@bachi/pi-coder 1.0.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/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/config/AGENTS.md +100 -0
- package/config/pi-statusline.json +140 -0
- package/config/settings.json +38 -0
- package/config/web-search.json +5 -0
- package/docs/README.md +14 -0
- package/docs/configuration.md +123 -0
- package/docs/development.md +177 -0
- package/docs/extensions.md +292 -0
- package/docs/handbook.zh.md +432 -0
- package/docs/installation.md +124 -0
- package/docs/themes.md +107 -0
- package/extensions/ask-user-question/answers.test.ts +104 -0
- package/extensions/ask-user-question/answers.ts +72 -0
- package/extensions/ask-user-question/dialog.test.ts +180 -0
- package/extensions/ask-user-question/dialog.ts +102 -0
- package/extensions/ask-user-question/index.ts +253 -0
- package/extensions/ask-user-question/model.test.ts +275 -0
- package/extensions/ask-user-question/model.ts +259 -0
- package/extensions/ask-user-question/schema.ts +49 -0
- package/extensions/ask-user-question/types.ts +86 -0
- package/extensions/ask-user-question/validate.test.ts +183 -0
- package/extensions/ask-user-question/validate.ts +110 -0
- package/extensions/ask-user-question/view.ts +262 -0
- package/extensions/auto-default-model/default-model.test.ts +268 -0
- package/extensions/auto-default-model/index.ts +87 -0
- package/extensions/bash-command-collapse.ts +1476 -0
- package/extensions/below-editor-after-statusline.ts +118 -0
- package/extensions/clear-command.ts +29 -0
- package/extensions/cwd-statusline.ts +39 -0
- package/extensions/exit-command.ts +59 -0
- package/extensions/fenceless-code-block/index.test.ts +208 -0
- package/extensions/fenceless-code-block/index.ts +28 -0
- package/extensions/fenceless-code-block/render.test.ts +177 -0
- package/extensions/fenceless-code-block/render.ts +142 -0
- package/extensions/folder-history.ts +197 -0
- package/extensions/init-command.ts +163 -0
- package/extensions/prompt-editor/bash-prompt.test.ts +94 -0
- package/extensions/prompt-editor/bash-prompt.ts +59 -0
- package/extensions/prompt-editor/render.test.ts +283 -0
- package/extensions/prompt-editor.ts +212 -0
- package/extensions/read-path-collapse.ts +474 -0
- package/extensions/recap/index.test.ts +348 -0
- package/extensions/recap/index.ts +462 -0
- package/extensions/recap/subagents.test.ts +144 -0
- package/extensions/recap/subagents.ts +128 -0
- package/extensions/rewind/README.md +229 -0
- package/extensions/rewind/checkpoints.test.ts +560 -0
- package/extensions/rewind/checkpoints.ts +820 -0
- package/extensions/rewind/flow.test.ts +756 -0
- package/extensions/rewind/flow.ts +362 -0
- package/extensions/rewind/index.ts +400 -0
- package/extensions/rewind/picker.ts +135 -0
- package/extensions/rewind/viewport.test.ts +76 -0
- package/extensions/rewind/viewport.ts +48 -0
- package/extensions/simple-task/gap.test.ts +147 -0
- package/extensions/simple-task/gap.ts +122 -0
- package/extensions/simple-task/index.ts +439 -0
- package/extensions/simple-task/types.ts +53 -0
- package/extensions/simple-task/widget.ts +86 -0
- package/extensions/startup-logo/header-guard.test.ts +274 -0
- package/extensions/startup-logo/header-guard.ts +166 -0
- package/extensions/startup-logo/index.test.ts +305 -0
- package/extensions/startup-logo/index.ts +194 -0
- package/extensions/startup-logo/loaded-sections.test.ts +257 -0
- package/extensions/startup-logo/loaded-sections.ts +267 -0
- package/extensions/startup-logo/logo.test.ts +124 -0
- package/extensions/startup-logo/logo.ts +124 -0
- package/extensions/statusline/footer-guard.test.ts +273 -0
- package/extensions/statusline/footer-guard.ts +171 -0
- package/extensions/statusline/git.test.ts +174 -0
- package/extensions/statusline/git.ts +142 -0
- package/extensions/statusline/index.ts +294 -0
- package/extensions/statusline/line.test.ts +316 -0
- package/extensions/statusline/line.ts +201 -0
- package/extensions/subagent-log-guard/filter.test.ts +85 -0
- package/extensions/subagent-log-guard/filter.ts +32 -0
- package/extensions/subagent-log-guard/index.ts +112 -0
- package/extensions/theme-command.ts +263 -0
- package/extensions/thinking-collapse/window.test.ts +321 -0
- package/extensions/thinking-collapse/window.ts +354 -0
- package/extensions/thinking-collapse.ts +60 -0
- package/extensions/tool-diff/title-row.test.ts +254 -0
- package/extensions/tool-diff/title-row.ts +191 -0
- package/extensions/tool-diff.ts +1276 -0
- package/extensions/working-indicator/bash-spinner.test.ts +135 -0
- package/extensions/working-indicator/bash-spinner.ts +114 -0
- package/extensions/working-indicator/index.test.ts +579 -0
- package/extensions/working-indicator/index.ts +940 -0
- package/extensions/working-indicator/spinner-frames.test.ts +219 -0
- package/extensions/working-indicator/spinner-frames.ts +156 -0
- package/extensions/working-indicator/summary-request.test.ts +195 -0
- package/extensions/working-indicator/summary-request.ts +207 -0
- package/extensions/working-indicator/working-summary.test.ts +499 -0
- package/extensions/working-indicator/working-summary.ts +375 -0
- package/package.json +71 -0
- package/themes/ayu.json +97 -0
- package/themes/catppuccin.json +103 -0
- package/themes/summer-night.json +87 -0
|
@@ -0,0 +1,940 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* working-indicator.ts — 语义化 working spinner 文案 + 运行时长读秒 + bash 执行中行尾 ●
|
|
3
|
+
*
|
|
4
|
+
* pi 内置的 working loader 在流式期间只显示一句固定的 "Working",看不出当前
|
|
5
|
+
* 在做什么、已经跑了多久。这个扩展用 `ctx.ui.setWorkingMessage()` 把它换成
|
|
6
|
+
* 随状态变化的文案,并在尾部挂一个每秒读秒的运行时长。
|
|
7
|
+
*
|
|
8
|
+
* 文案规则:
|
|
9
|
+
* bash 工具 → Tools Calling (↓ N tokens · D)
|
|
10
|
+
* edit 工具 → Editing (↓ N tokens · D)
|
|
11
|
+
* write 工具 → Writing (↓ N tokens · D)
|
|
12
|
+
* read 工具 → Reading (↓ N tokens · D)
|
|
13
|
+
* 模型推理中 → Thinking (↓ N tokens · D)
|
|
14
|
+
* 其他(含未知工具) → Working (D)
|
|
15
|
+
*
|
|
16
|
+
* 配色:**标签用正文色(正常白),后面的统计段保持 pi 默认的 muted 灰**。
|
|
17
|
+
* 详见 `formatWorkingMessage` 的注释 —— 颜色必须拼进文案字符串里,因为 pi 只按
|
|
18
|
+
* 「整条文案」上色,没有分段着色的入口。
|
|
19
|
+
*
|
|
20
|
+
* 时长格式:<60s 用 `42s`;≥60s 用 `1m 23s`;≥1h 用 `1h 23m 32s`。
|
|
21
|
+
*
|
|
22
|
+
* token 口径说明:`↓ N tokens` 是**当前这一小段的产出量**,不是回合累加值。每次推理段
|
|
23
|
+
* (`thinking_start`)、每次工具参数段(`toolcall_start`)都从 0 重新开始数 —— 否则一个
|
|
24
|
+
* 回合里跑几次工具,数字会一路涨上去,看不出「这次 bash 命令有多大」。实现上按段类型
|
|
25
|
+
* 分成独立的计数器(推理段 / 工具参数段各一份),显示的数跟着当前文案走:`Thinking` 配
|
|
26
|
+
* 推理段的数、`Tools Calling` / `Editing` / `Writing` / `Reading` 配工具参数段的数。
|
|
27
|
+
* 正文段(`text_delta`)刻意不计:它从不显示,累加只会污染后面几段的数。
|
|
28
|
+
*
|
|
29
|
+
* 四个实测得来的实现前提(都验证过):
|
|
30
|
+
* 1. **`usage.output` 在流式期间恒为 0**(probe 实测:thinking_start /
|
|
31
|
+
* toolcall_start / 多个 text_delta 时点全是 0),所以 token 数只能从
|
|
32
|
+
* 流式字符估算,不能读 usage。
|
|
33
|
+
* 2. **`toolcall_start` 的 `partial.content[contentIndex].name` 已经带
|
|
34
|
+
* 工具名**(probe 实测 `block.type=toolCall block.name=bash`),所以
|
|
35
|
+
* 参数还在流式生成时就能显示工具态,不必等到 `tool_execution_start`。
|
|
36
|
+
* 3. 现有扩展没人用过 `setWorkingMessage` / `setWorkingIndicator`(grep
|
|
37
|
+
* 0 命中;`setStatus` 只被 simple-task / cwd-statusline 用在 footer),
|
|
38
|
+
* 所以不存在同名覆盖冲突。
|
|
39
|
+
* 4. **`toolcall_checkpoint` 不在扩展能看到的事件联合里** —— pi-ai 的
|
|
40
|
+
* `AssistantMessageEvent` 只有 start / text_* / thinking_* /
|
|
41
|
+
* toolcall_{start,delta,end} / done / error;checkpoint 是 TUI / session
|
|
42
|
+
* 编码器内部用的 `MessageFrame`,在事件流下游。所以每个参数 delta 都会
|
|
43
|
+
* 以 `toolcall_delta` 到达扩展,段级计数本身就是完整的,不需要为恢复会话的
|
|
44
|
+
* catch-up 补一段(补了反而会和 delta 重复计数)。
|
|
45
|
+
*
|
|
46
|
+
* 时长口径说明:秒数是**回合级**时钟(从 `agent_start` 起算),不是每个活动
|
|
47
|
+
* 各自计时。这样 `Working (42s)` 和 `Tools Calling (… · 42s)` 是同一个数,
|
|
48
|
+
* 不会因为状态切换而跳回 0 —— 切换活动时时钟若重置,读秒就失去了"这个回合
|
|
49
|
+
* 已经花了多久"的意义。**token 数正好相反,是段级的**:时长答"这个回合跑了多久",
|
|
50
|
+
* token 答"当前这一段产出了多少",两个口径刻意不同。
|
|
51
|
+
*
|
|
52
|
+
* 行尾还有一段**右对齐的提示词摘要**(`working-summary.ts` 算布局):当前回合
|
|
53
|
+
* 用户输入**压平成一行**后的前排字符,放不下就截断补 `…`,形如
|
|
54
|
+
*
|
|
55
|
+
* ⠧ Working (23s) ✦ /init 我要优化…
|
|
56
|
+
*
|
|
57
|
+
* 用来在长任务跑起来之后提醒「这一轮到底在干什么」。压平规则见 `flattenPrompt`:
|
|
58
|
+
* 多行折成一句(行间按两侧字符决定补不补空格,CJK 折行处不插空格)、缩进 / 制表符 /
|
|
59
|
+
* 连续空格折叠、ANSI 转义与零宽字符剥掉、Markdown 排版噪声(列表符号 / 引用 / 标题 /
|
|
60
|
+
* 代码围栏标记行)剥掉 —— 截断针对整段而不只是第一行。几个要点:
|
|
61
|
+
* - 提示词从 `input` 事件捕获("Fired when user input is received, before
|
|
62
|
+
* agent processing"):普通消息就是用户敲的原文,skill / 模板命令是展开前的
|
|
63
|
+
* 原始命令行。**扩展命令(如 /init)例外** —— pi 在 `input` 事件之前就把
|
|
64
|
+
* 原始命令行消费掉了(`agent-session.prompt()` 先走
|
|
65
|
+
* `_tryExecuteExtensionCommand`,命中即 return),扩展能看到的只有命令
|
|
66
|
+
* handler 经 `sendUserMessage` 发出的那段提示词,所以 /init 显示的是它
|
|
67
|
+
* 构造的提示词压平后的前排字符而不是 "/init …"。
|
|
68
|
+
* - 压平在 `input` 事件里**算一次并缓存**(`promptText`),不在每次 refresh 里重算 ——
|
|
69
|
+
* refresh 每个流式 delta 都会跑,几十 KB 的粘贴不能每次都重新扫一遍。
|
|
70
|
+
* - 流式中 steer / followUp 也会触发 `input`,摘要跟着更新成最新一条指令。
|
|
71
|
+
* - **长提示词会异步请模型压成一句话**(`summary-request.ts` 决策 / 提示词 / 清洗,
|
|
72
|
+
* 这里只负责发请求与回包护栏):原文超过可用宽度 `PI_WORKING_SUMMARY_TRIGGER` 倍
|
|
73
|
+
* (默认 1.2,即稍微放不下就压)时,拿**同一个可用宽度**算出一个目标列数,让
|
|
74
|
+
* 模型按「一句话说清这段提示词要干什么」返回;请求与主回合并行,不 `await`、不阻
|
|
75
|
+
* 塞任何渲染路径;回来了就顶掉原文,没回来 / 超时 / 报错 / 返回空串就一直显示截断
|
|
76
|
+
* 后的原文。**每个提示词只请求一次**,摘要回来超长也不重试 —— 按可见宽度正常截断
|
|
77
|
+
* 就行(两次请求只会让这一格来回跳)。压平后的原文也参与判断(不像旧实现那样只
|
|
78
|
+
* 看第一行):只要整段超出阈值就请求,所以多行粘贴的长提示词同样能拿到摘要。
|
|
79
|
+
* 代价:本机网关给这些模型路由在 `extra_body` 里**写死了 thinking**
|
|
80
|
+
* (`thinking: {type: enabled}` + `reasoning_effort: max`,客户端关不掉),所以这次
|
|
81
|
+
* 请求一定带着思考:实测 3-20s、600-1400 个 thinking token。默认用当前会话模型,
|
|
82
|
+
* 想省钱 / 提速可以 `PI_WORKING_SUMMARY_MODEL=provider/modelId` 换成便宜快的模型,
|
|
83
|
+
* 或者 `PI_WORKING_SUMMARY_LLM=off` 整个关掉。
|
|
84
|
+
* - 右对齐靠「把整行可见宽度恰好凑满预算」实现:pi 的 working message 走
|
|
85
|
+
* Text 的 word-wrap,只有不超宽才单行原样输出(详见 working-summary.ts
|
|
86
|
+
* 头注释)。预算 = 终端列数 − 4(spinner "⠧ " 2 列 + Text paddingX 左右
|
|
87
|
+
* 各 1 列),终端宽度每次刷新现读 `process.stdout.columns`,resize 后
|
|
88
|
+
* 下一个 1s tick 自愈。
|
|
89
|
+
* - 摘要段另有**整段宽度上限 = 终端宽度的一半**(`floor(cols / 2)`,含
|
|
90
|
+
* `✦ ` 前缀):宽终端上剩余空间再多,摘要也不会超过半屏,免得长提示词
|
|
91
|
+
* 把左边的状态文案挤得只剩几列;上限收窄出的富余并进 gap,右对齐不变。
|
|
92
|
+
* 请求目标长度用的是同一个 `availableSummaryTextWidth`,所以「要求模型写多长」
|
|
93
|
+
* 与实际能显示多少列是同一份数学。
|
|
94
|
+
* - 只对**独立 spinner 行**(statusContainer)精确成立。若 working 指示器被
|
|
95
|
+
* 嵌进编辑器上边框(内置编辑器的 embedWorkingStatus 模式;本机装的
|
|
96
|
+
* prompt-editor.ts 没开嵌入,所以走独立行),边框渲染会把超宽部分截掉、
|
|
97
|
+
* 摘要自动退化为不显示(折行发生在 gap 的空格处,第一行仍是原文案),
|
|
98
|
+
* 不会渲染出半截摘要。
|
|
99
|
+
* - 配色:`✦` 用 syntaxKeyword(语法高亮的关键字色,在各行主题里都够显眼又不刺眼),
|
|
100
|
+
* 摘要正文用 muted(与统计段一致,不跟标签抢注意力)。
|
|
101
|
+
* - 开关:`PI_WORKING_SUMMARY=off` 整段关闭;`PI_WORKING_SUMMARY_LLM=off` 只关掉
|
|
102
|
+
* 模型压缩(退回「压平 + 截断」);`PI_WORKING_SUMMARY_TRIGGER=<n>` 调触发倍数
|
|
103
|
+
* (默认 1.2);`PI_WORKING_SUMMARY_MODEL=provider/modelId` 指定摘要模型(缺省用
|
|
104
|
+
* 当前会话模型,也可以用便宜快的模型,摘要只是个附带请求);
|
|
105
|
+
* `PI_WORKING_SUMMARY_GAP=<n>` 调整左段与摘要之间的最小空隙(默认 1 列;
|
|
106
|
+
* 右对齐时实际空隙通常远大于它,这个值只是窄终端下的下限)。
|
|
107
|
+
*
|
|
108
|
+
* ## spinner 幻彩帧(`spinner-frames.ts`)
|
|
109
|
+
*
|
|
110
|
+
* pi 的 working spinner 是十帧盲文 + 单色 accent。本扩展把它换成**同族盲文按调色板轮换颜色**
|
|
111
|
+
* 的幻彩帧:accent → success → warning → syntaxKeyword → toolDiffAdded → toolDiffRemoved →
|
|
112
|
+
* toolTitle,每色 ≈1.5s(19 帧 × 80ms = 1520ms;1500ms 不是 80ms 的整数倍,取最近的 19 帧,
|
|
113
|
+
* 差 1.3%;初版 400ms → 800ms → 1520ms,一路按需求放慢),旋转速度与 pi 默认完全一致
|
|
114
|
+
* (同一套十帧盲文、同一个 80ms 帧间隔),所以看着只是「那只熟悉的 spinner 在变色」。
|
|
115
|
+
*
|
|
116
|
+
* 帧表只能**预烘颜色**:`ctx.ui.setWorkingIndicator({ frames })` 的自定义帧是 verbatim 渲染的
|
|
117
|
+
* (pi 的 Loader 里 `renderIndicatorVerbatim` 为真时不套 `spinnerColorFn`),颜色得自己带在帧
|
|
118
|
+
* 字符串里。于是换主题后必须重装帧表 —— `refresh()` 每秒读主题时顺手比一下色板指纹(同主题
|
|
119
|
+
* 稳定、换肤必变),变了才重装(重装会把动画相位复位,所以不能进每秒 tick 里无脑调)。
|
|
120
|
+
* 帧表怎么排、色槽撞车怎么去重、单色主题怎么降级,全在 `spinner-frames.ts` 头注释里(纯模块,
|
|
121
|
+
* 12 条 `node --test` 用例)。
|
|
122
|
+
*
|
|
123
|
+
* 三个装帧表的时机:
|
|
124
|
+
* - `agent_start`:回合开始、spinner 还没渲染,相位复位看不见;同时把「上一回合被
|
|
125
|
+
* `ask_user_question` 顶掉」与「换会话时 pi 的 `resetExtensionUI()` 把 indicator 还原成
|
|
126
|
+
* 默认帧」这两种情况一并补回来。
|
|
127
|
+
* - `refresh()` 里的指纹比对:`/theme` 换肤后一秒内自愈。
|
|
128
|
+
* - `ask_user_question` 的 `tool_execution_end`:那个扩展在问卷期间冻结 spinner(单帧 ⠿),
|
|
129
|
+
* 退出时无参 `setWorkingIndicator()` 恢复的是 **pi 的默认帧**(它不知道本扩展的彩帧),
|
|
130
|
+
* 所以问卷一结束就补装,免得这一回合剩下的时间掉回单色。
|
|
131
|
+
*
|
|
132
|
+
* 七个色槽在当前主题里可能撞色(实测 summer-night 的 success == toolDiffAdded、warning ==
|
|
133
|
+
* toolTitle),撞车的只留一个;如果最后只剩一种颜色(单色主题 / `NO_COLOR` / 恒等 `fg`),
|
|
134
|
+
* 干脆不下发帧表、保持 pi 默认 spinner —— 装一张每帧长得都一样的「动画」表没有意义。
|
|
135
|
+
* 开关:`PI_SPINNER_RAINBOW=off` 回到单色 accent;`PI_SPINNER_COLOR_HOLD=<帧数>` 调每种
|
|
136
|
+
* 颜色持续多久(默认 19 帧 ≈ 1.5s;1 = 每帧一换,在 80ms 帧率下是频闪,不是幻彩)。
|
|
137
|
+
*
|
|
138
|
+
* ## bash 执行中 `●`(working 行尾)
|
|
139
|
+
*
|
|
140
|
+
* 需求原文:把 `●` 挂在 `⠼ Tools Calling (13m 59s)` 后面,bash 执行时长超过 1 秒后出现、
|
|
141
|
+
* 执行结束后消失;多个 bash 同时在执行时,任意一个超过 1s 就闪,**所有** bash 都结束后才
|
|
142
|
+
* 消失。这是退役的 bash 首行 `$ ` → `● ` spinner 的落点迁移(命令块里不再出现 `●`):
|
|
143
|
+
* 门槛 1s、亮 / 灭各 500ms、`PI_BASH_SPINNER=off` 关闭,都与旧版同契约。
|
|
144
|
+
*
|
|
145
|
+
* 实现分两半:纯逻辑在伴生模块 `bash-spinner.ts`(形态 / 门槛 / 并发登记簿,不 import pi,
|
|
146
|
+
* `node --test` 用例在 `bash-spinner.test.ts`),定时器在 `index.ts`:
|
|
147
|
+
* - `tool_execution_start`(toolName=bash)落账、`tool_execution_end` 销账,`toolCallId`
|
|
148
|
+
* 为键,天然支持并行多 bash;
|
|
149
|
+
* - 每次 `refresh()` 都跑 `driveBashSpinner(now)`:已跨过门槛 → 起 500ms 节拍(翻相位 +
|
|
150
|
+
* 重绘);还没跨过 → 按**最早**那个执行的截止点排一次性定时器,让 `●` 准时出现(非流式
|
|
151
|
+
* 命令执行期间没有上游事件,不自驱就永不重绘);
|
|
152
|
+
* - 标记段拼在左段(标签 + 统计段)末尾、摘要布局之前,恒 2 列(亮 `" ●"` / 灭两格空格),
|
|
153
|
+
* 所以右对齐的提示词摘要不随亮灭左右蹦;
|
|
154
|
+
* - 停表三处:`agent_settled` / `session_shutdown`(回合结束、会话替换)、旧 ctx 的
|
|
155
|
+
* try/catch(`stopActivity`)、以及 `refresh()` 早退分支(不在回合内)。
|
|
156
|
+
* - 标签口径顺带修了一处:`executingTool` 从单字符串改成 `toolCallId → 工具名` 的 Map ——
|
|
157
|
+
* 并行批次里先结束的工具不再把还在跑的工具名清掉(否则长 bash 的 `●` 会挂在 `Working`
|
|
158
|
+
* 而不是 `Tools Calling` 后面)。标签仍取最后启动的那个,优先级语义不变。
|
|
159
|
+
*/
|
|
160
|
+
|
|
161
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
162
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
163
|
+
import {
|
|
164
|
+
BASH_SPINNER_BLINK_MS,
|
|
165
|
+
BASH_SPINNER_COLOR,
|
|
166
|
+
BashRunTracker,
|
|
167
|
+
bashSpinnerSuffix,
|
|
168
|
+
} from "./bash-spinner.ts";
|
|
169
|
+
import {
|
|
170
|
+
DEFAULT_MIN_GAP,
|
|
171
|
+
SUMMARY_MARKER,
|
|
172
|
+
availableSummaryTextWidth,
|
|
173
|
+
flattenPrompt,
|
|
174
|
+
layoutPromptSummary,
|
|
175
|
+
} from "./working-summary.ts";
|
|
176
|
+
import {
|
|
177
|
+
DEFAULT_FRAMES_PER_COLOR,
|
|
178
|
+
SPINNER_INTERVAL_MS,
|
|
179
|
+
buildSpinnerPalette,
|
|
180
|
+
type SpinnerPalette,
|
|
181
|
+
} from "./spinner-frames.ts";
|
|
182
|
+
import {
|
|
183
|
+
DEFAULT_TRIGGER_RATIO,
|
|
184
|
+
buildSummaryRequestPrompt,
|
|
185
|
+
cleanSummaryText,
|
|
186
|
+
planSummaryRequest,
|
|
187
|
+
} from "./summary-request.ts";
|
|
188
|
+
|
|
189
|
+
/** 工具名 → 文案。没列进来的工具一律回落到 Working(按需求「其他情况都显示 Working」)。 */
|
|
190
|
+
const TOOL_LABELS: Record<string, string> = {
|
|
191
|
+
bash: "Tools Calling",
|
|
192
|
+
edit: "Editing",
|
|
193
|
+
write: "Writing",
|
|
194
|
+
read: "Reading",
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const THINKING_LABEL = "Thinking";
|
|
198
|
+
const DEFAULT_LABEL = "Working";
|
|
199
|
+
/**
|
|
200
|
+
* 问卷工具名(`ask-user-question/` 扩展注册的)。它在问卷期间把 working indicator 冻结成单帧、
|
|
201
|
+
* 退出时无参恢复成 pi 的**默认**帧(见文件头「spinner 幻彩帧」),所以本扩展要在它结束时补装彩帧。
|
|
202
|
+
*/
|
|
203
|
+
const ASK_USER_QUESTION_TOOL = "ask_user_question";
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 文案两个色段。标签用 `text`(正文正常色,深色主题下就是那条浅白),统计段用
|
|
207
|
+
* `muted` —— 和 pi 给整条 working message 上的默认色**同一个槽位**,所以那截灰
|
|
208
|
+
* 渲染出来和改动前一致(只是转义符的位置从整条包一层变成统计段自己带前缀)。
|
|
209
|
+
*/
|
|
210
|
+
const LABEL_COLOR = "text";
|
|
211
|
+
const STATS_COLOR = "muted";
|
|
212
|
+
/** 摘要标记 `✦` 的颜色:语法高亮的关键字色,作行尾的视觉锚点。 */
|
|
213
|
+
const SUMMARY_MARKER_COLOR = "syntaxKeyword";
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* spinner("⠧ " 2 列)+ Text 组件 paddingX(左右各 1 列)占掉的列数:
|
|
217
|
+
* working message 的可见宽度预算 = 终端列数 − 4(见文件头注释)。
|
|
218
|
+
*/
|
|
219
|
+
const SPINNER_AND_PADDING_COLUMNS = 4;
|
|
220
|
+
|
|
221
|
+
const SUMMARY_ENABLED = (process.env.PI_WORKING_SUMMARY ?? "").toLowerCase() !== "off";
|
|
222
|
+
/**
|
|
223
|
+
* 提示词太长时请模型压成一句话的开关(`PI_WORKING_SUMMARY_LLM=off` 关掉,退回「只截断」)。
|
|
224
|
+
* `PI_WORKING_SUMMARY=off` 时整段摘要都不显示,自然也不用请求。
|
|
225
|
+
*/
|
|
226
|
+
const SUMMARY_LLM_ENABLED = SUMMARY_ENABLED && (process.env.PI_WORKING_SUMMARY_LLM ?? "").toLowerCase() !== "off";
|
|
227
|
+
/**
|
|
228
|
+
* 超过可用宽度的多少倍才值得请求摘要(`PI_WORKING_SUMMARY_TRIGGER`,默认 1.2)。
|
|
229
|
+
* 1 = 只要放不下就请求;1.2 = 截断会丢掉近两成原文就请求;2 = 丢掉一半以上才请求。
|
|
230
|
+
* 小于 1 的值无意义(退回默认)。
|
|
231
|
+
*/
|
|
232
|
+
const SUMMARY_TRIGGER_RATIO = (() => {
|
|
233
|
+
const parsed = Number.parseFloat(process.env.PI_WORKING_SUMMARY_TRIGGER ?? "");
|
|
234
|
+
return Number.isFinite(parsed) && parsed >= 1 ? parsed : DEFAULT_TRIGGER_RATIO;
|
|
235
|
+
})();
|
|
236
|
+
/**
|
|
237
|
+
* 摘要请求用哪个模型(`PI_WORKING_SUMMARY_MODEL=provider/modelId`,如 `litellm-any/deepseek-flash`)。
|
|
238
|
+
* 缺省 / 解析不到时用当前会话模型 —— 摘要只是个附带请求,不该因为配置写错就消失。
|
|
239
|
+
*/
|
|
240
|
+
const SUMMARY_MODEL_SPEC = (process.env.PI_WORKING_SUMMARY_MODEL ?? "").trim();
|
|
241
|
+
/** 摘要请求的硬超时,到点 abort。本机这些路由都带着强制 thinking,实测一次「压一句话」
|
|
242
|
+
* 要 3-20s(qwen3.8-flash 在 max 档上偏慢),所以比 recap 的 30s 再宽一点;回合结束时
|
|
243
|
+
* 还会提前 abort(那时摘要已经没人看)。超时只是白花一次请求,不影响主任务。 */
|
|
244
|
+
const SUMMARY_TIMEOUT_MS = 45_000;
|
|
245
|
+
/**
|
|
246
|
+
* 摘要请求的 maxTokens。**必须给 thinking 留足份额**:本机网关给这些路由强制
|
|
247
|
+
* `thinking: {type: enabled}` + `reasoning_effort: max`(`gateway/config.yaml` 的
|
|
248
|
+
* `extra_body`),客户端关不掉;实测「压一句话」光思考就要 600-1400 token,预算给小了
|
|
249
|
+
* 思考会把额度吃光、正文一个字都不剩(实测 80 / 512 / 2048 全被 thinking 吃满)。
|
|
250
|
+
* 1024 在 deepseek-flash / qwen3.8-flash / deepseek-flash-qd 上都稳定留下正文。
|
|
251
|
+
*
|
|
252
|
+
* 另:**不要传 `temperature: 0`** —— 这几个 max 档路由在 0 温度下会退化成停不下来的
|
|
253
|
+
* 长思考(2048 额度也被吃光且零正文),默认温度下 3s 就正常返回。
|
|
254
|
+
*/
|
|
255
|
+
const SUMMARY_MAX_TOKENS = 1024;
|
|
256
|
+
/**
|
|
257
|
+
* 估算「左段(标签 + 统计段 + `●`)占多宽」的余量,只用于请求模型时算目标长度。
|
|
258
|
+
* 请求发生在 `input` 事件里,那时这一回合还没开始、真实左段不存在,只能按工具执行态
|
|
259
|
+
* (最长的 `Tools Calling (↓ N tokens · D)` 加行尾 `●`)估一个偏保守的值:宽终端上真正
|
|
260
|
+
* 起作用的是半屏上限,这个估计只在窄终端里生效,估小了反而会让摘要被截。
|
|
261
|
+
*/
|
|
262
|
+
const SUMMARY_LEFT_WIDTH_ESTIMATE = 34;
|
|
263
|
+
/**
|
|
264
|
+
* bash 执行中 `●` 的开关。env 名沿用退役的 bash 首行 spinner(`PI_BASH_SPINNER`):
|
|
265
|
+
* 功能只是把落点从命令块挪到了 working 行尾。
|
|
266
|
+
*/
|
|
267
|
+
const BASH_SPINNER_ENABLED = (process.env.PI_BASH_SPINNER ?? "").toLowerCase() !== "off";
|
|
268
|
+
/**
|
|
269
|
+
* 幻彩 spinner 开关(`PI_SPINNER_RAINBOW=off` 回到 pi 默认的单色 accent 十帧)。
|
|
270
|
+
*/
|
|
271
|
+
const SPINNER_RAINBOW_ENABLED = (process.env.PI_SPINNER_RAINBOW ?? "").toLowerCase() !== "off";
|
|
272
|
+
/**
|
|
273
|
+
* 幻彩里每种颜色持续的**帧数**(`PI_SPINNER_COLOR_HOLD`,默认 19 帧 × 80ms = 1520ms ≈ 1.5s
|
|
274
|
+
* 一换色;1500ms 不是 80ms 的整数倍,取最近帧数)。解析不到 / 小于 1 时退回默认;
|
|
275
|
+
* `spinner-frames.ts` 里还会再兜一次非有限值。
|
|
276
|
+
*/
|
|
277
|
+
const SPINNER_COLOR_HOLD = (() => {
|
|
278
|
+
const parsed = Number.parseInt(process.env.PI_SPINNER_COLOR_HOLD ?? "", 10);
|
|
279
|
+
return Number.isFinite(parsed) && parsed >= 1 ? parsed : DEFAULT_FRAMES_PER_COLOR;
|
|
280
|
+
})();
|
|
281
|
+
const SUMMARY_MIN_GAP = (() => {
|
|
282
|
+
const parsed = Number.parseInt(process.env.PI_WORKING_SUMMARY_GAP ?? "", 10);
|
|
283
|
+
return Number.isFinite(parsed) && parsed >= 1 ? parsed : DEFAULT_MIN_GAP;
|
|
284
|
+
})();
|
|
285
|
+
|
|
286
|
+
/** 终端列数;拿不到(非 TTY)或异常窄时退回 80。每次刷新现读,resize 自愈。 */
|
|
287
|
+
function terminalWidth(): number {
|
|
288
|
+
const cols = process.stdout.columns;
|
|
289
|
+
return Number.isFinite(cols) && cols >= 20 ? Math.floor(cols) : 80;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** 只用到 `fg` 的主题接口 —— 探针 / 单测里可以喂一个只有 `fg` 的假对象。 */
|
|
293
|
+
export interface WorkingMessageTheme {
|
|
294
|
+
fg(color: string, text: string): string;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 组装 spinner 文案:标签(`Tools Calling` / `Editing` / …)走正文色,后面的统计段
|
|
299
|
+
* (`(↓ 70 tokens · 10s)`)走 muted 灰。
|
|
300
|
+
*
|
|
301
|
+
* 为什么要自己上色:pi 的 `WorkingStatusIndicator` 把整条 working message 交给
|
|
302
|
+
* `theme.fg("muted", message)`(非嵌入态;嵌入编辑器边框时是边框色),没有分段着色的
|
|
303
|
+
* 入口,`setWorkingMessage()` 收的就是一个字符串。好在 `Theme.fg` 只重置前景色
|
|
304
|
+
* (结尾是 `\x1b[39m` 而不是 `\x1b[0m`),于是「标签染 text + 统计段再染 muted」的
|
|
305
|
+
* 拼接结果是稳定的:标签的重置符止于标签末尾,统计段自己带上 muted 前缀,最外层的
|
|
306
|
+
* 那次 muted 只是多包了一层、渲染上无副作用。
|
|
307
|
+
*
|
|
308
|
+
* 统计段的 muted **必须显式写出来**,不能指望外层那次 `fg("muted")` —— 标签里的
|
|
309
|
+
* `\x1b[39m` 已经把外层的前景色重置成终端默认色了,不重新上色的话那截灰会变白。
|
|
310
|
+
*
|
|
311
|
+
* 拿不到主题时(理论上只有非 TUI 上下文)退回纯文本:外层仍按 pi 的默认色渲染,
|
|
312
|
+
* 只是标签不再是白的。
|
|
313
|
+
*/
|
|
314
|
+
export function formatWorkingMessage(
|
|
315
|
+
theme: WorkingMessageTheme | undefined,
|
|
316
|
+
label: string,
|
|
317
|
+
stats: string | null,
|
|
318
|
+
): string {
|
|
319
|
+
if (theme === undefined) return stats === null ? label : `${label} ${stats}`;
|
|
320
|
+
const styledLabel = theme.fg(LABEL_COLOR, label);
|
|
321
|
+
// 分隔空格放进统计段一起染 muted,免得它孤零零地留在终端默认色上。
|
|
322
|
+
return stats === null ? styledLabel : `${styledLabel}${theme.fg(STATS_COLOR, ` ${stats}`)}`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* 在左段(标签+统计)之后补空隙、接上右对齐的提示词摘要。放不下(终端太窄 /
|
|
327
|
+
* 左段太宽 / 提示词为空)时原样返回左段 —— 摘要永远是增强,不挤掉原有信息。
|
|
328
|
+
*
|
|
329
|
+
* 传入的 `summaryText` 是 `input` 事件里已经压平缓存好的整段提示词(见文件头注释),
|
|
330
|
+
* 这里只做布局。宽度计算用 pi-tui 的 `visibleWidth`(与渲染器折行同一套实现),布局数学在
|
|
331
|
+
* `working-summary.ts`(纯函数,有单测)。gap 空格夹在两段各自的颜色重置之后、
|
|
332
|
+
* 之前,不归属任何色段(空格本来就没有前景色)。
|
|
333
|
+
*/
|
|
334
|
+
function appendPromptSummary(
|
|
335
|
+
theme: WorkingMessageTheme | undefined,
|
|
336
|
+
left: string,
|
|
337
|
+
summaryText: string | null,
|
|
338
|
+
): string {
|
|
339
|
+
if (summaryText === null || summaryText === "") return left;
|
|
340
|
+
const termWidth = terminalWidth();
|
|
341
|
+
const layout = layoutPromptSummary({
|
|
342
|
+
promptText: summaryText,
|
|
343
|
+
leftWidth: visibleWidth(left),
|
|
344
|
+
totalWidth: termWidth - SPINNER_AND_PADDING_COLUMNS,
|
|
345
|
+
// 摘要段(含 `✦ `)不超过半屏,免得长提示词把左侧状态文案挤没。
|
|
346
|
+
maxWidth: Math.floor(termWidth / 2),
|
|
347
|
+
widthOf: visibleWidth,
|
|
348
|
+
minGap: SUMMARY_MIN_GAP,
|
|
349
|
+
});
|
|
350
|
+
if (layout === null) return left;
|
|
351
|
+
const styledSummary =
|
|
352
|
+
theme === undefined
|
|
353
|
+
? `${SUMMARY_MARKER} ${layout.summaryText}`
|
|
354
|
+
: `${theme.fg(SUMMARY_MARKER_COLOR, SUMMARY_MARKER)}${theme.fg(STATS_COLOR, ` ${layout.summaryText}`)}`;
|
|
355
|
+
return `${left}${" ".repeat(layout.gap)}${styledSummary}`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** 摘要用哪个模型:`PI_WORKING_SUMMARY_MODEL` 解析得到就用它,否则用当前会话模型。 */
|
|
359
|
+
function resolveSummaryModel(ctx: ExtensionContext): NonNullable<ExtensionContext["model"]> | undefined {
|
|
360
|
+
if (SUMMARY_MODEL_SPEC !== "") {
|
|
361
|
+
const slash = SUMMARY_MODEL_SPEC.indexOf("/");
|
|
362
|
+
if (slash > 0) {
|
|
363
|
+
const found = ctx.modelRegistry.find(SUMMARY_MODEL_SPEC.slice(0, slash), SUMMARY_MODEL_SPEC.slice(slash + 1));
|
|
364
|
+
if (found !== undefined) return found;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return ctx.model;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* 请求模型摘要时假定的可用宽度(列)。与渲染共用 `availableSummaryTextWidth`,只是左段
|
|
372
|
+
* 宽度是估计值 —— 请求发生在 `input` 事件里,这一回合还没开始,真实左段还不存在
|
|
373
|
+
* (见 `SUMMARY_LEFT_WIDTH_ESTIMATE`)。
|
|
374
|
+
*/
|
|
375
|
+
function summaryRequestBudgetWidth(): number {
|
|
376
|
+
const termWidth = terminalWidth();
|
|
377
|
+
return availableSummaryTextWidth({
|
|
378
|
+
leftWidth: SUMMARY_LEFT_WIDTH_ESTIMATE,
|
|
379
|
+
totalWidth: termWidth - SPINNER_AND_PADDING_COLUMNS,
|
|
380
|
+
maxWidth: Math.floor(termWidth / 2),
|
|
381
|
+
widthOf: visibleWidth,
|
|
382
|
+
minGap: SUMMARY_MIN_GAP,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** 从 assistant 响应里抽文本(只走了 thinking 的空回复会得到空串 → 放弃这次摘要)。 */
|
|
387
|
+
function textFromAssistant(message: unknown): string {
|
|
388
|
+
const content = (message as { content?: unknown } | undefined)?.content;
|
|
389
|
+
if (!Array.isArray(content)) return "";
|
|
390
|
+
return content
|
|
391
|
+
.filter((block) => (block as { type?: unknown; text?: unknown })?.type === "text")
|
|
392
|
+
.map((block) => (block as { text?: unknown }).text)
|
|
393
|
+
.filter((text): text is string => typeof text === "string")
|
|
394
|
+
.join("\n");
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** 读秒间隔。1s 足够 —— 显示精度就是秒,更密的 tick 只是白做渲染。 */
|
|
398
|
+
const TICK_MS = 1000;
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* 时长格式化:<60s → `42s`;≥60s → `1m 23s`;≥1h → `1h 23m 32s`。
|
|
402
|
+
* 分钟段在超过 1 小时后仍然保留(不折算成纯分钟),符合 `1h 23m 32s` 的形状。
|
|
403
|
+
*
|
|
404
|
+
* 导出是为了可测:小时档要真跑一小时才能触发,所以三个分支用单测覆盖,
|
|
405
|
+
* 分钟档另外用一次真实的长任务抓帧验证(见 README)。
|
|
406
|
+
*/
|
|
407
|
+
export function formatDuration(ms: number): string {
|
|
408
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
409
|
+
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
410
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
411
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
412
|
+
const seconds = totalSeconds % 60;
|
|
413
|
+
if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
|
|
414
|
+
return `${minutes}m ${seconds}s`;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export default function (pi: ExtensionAPI) {
|
|
418
|
+
/** 回合起始时刻;null 表示当前不在回合内(不渲染、不起定时器)。 */
|
|
419
|
+
let turnStartedAt: number | null = null;
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* 当前回合的用户输入**压平后**的原文(`input` 事件捕获后立刻 `flattenPrompt`
|
|
423
|
+
* 一次并缓存,见文件头注释);null = 还没捕获到(恢复的会话自动继续等),
|
|
424
|
+
* 此时不显示摘要。刻意不存原文再每次 refresh 重算 —— refresh 每个流式 delta 都跑。
|
|
425
|
+
*/
|
|
426
|
+
let promptText: string | null = null;
|
|
427
|
+
/**
|
|
428
|
+
* 模型压出来的一句话(异步到达;null = 还没到 / 没请求 / 请求失败)。显示时优先于
|
|
429
|
+
* `promptText`:到了就换上摘要,一直到回合结束都不会再变(**只请求一次**,回来的
|
|
430
|
+
* 摘要再长也只截断、不重试)。
|
|
431
|
+
*/
|
|
432
|
+
let promptSummaryText: string | null = null;
|
|
433
|
+
/**
|
|
434
|
+
* 每次 `input` 递增的序号。模型回包可能比提示词换得慢(用户已经开始下一轮 / 流中
|
|
435
|
+
* steer),回包拿它对一下:不是自己那一次就丢掉,绝不把旧提示词的摘要贴到新的上面。
|
|
436
|
+
*/
|
|
437
|
+
let promptSeq = 0;
|
|
438
|
+
/** 上一次 `input` 的原文,用来吃掉同一条消息的重复 `input` 事件(见 `input` 处理里)。 */
|
|
439
|
+
let lastInputText: string | null = null;
|
|
440
|
+
/** 在飞的摘要请求;`abort()` 停掉它(新提示词 / 回合结束 / 会话关掉)。 */
|
|
441
|
+
let summaryAbort: AbortController | null = null;
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* 按段类型分开的 token 估算计数器:宽字符(CJK,终端占 2 列)按 1 token 计,
|
|
445
|
+
* 窄字符按 1/4 token 计(下方 `wide` 从 pi-tui 的 `visibleWidth` 反推,不是自己维护码点表)。
|
|
446
|
+
*
|
|
447
|
+
* 以前这个口径与 `tool-diff.ts` 的 `estimateTokens` 共用 —— 那边标题行后来改成显示
|
|
448
|
+
* `(+N -M)` 行数而不是 token 数,函数已经删掉,所以估算只留这里一份。
|
|
449
|
+
*
|
|
450
|
+
* 每段各自一份、段开始时清零,所以显示出来的数永远是「当前这一小段」的量。
|
|
451
|
+
* 分开存而不是共用一个累加值,还有一个原因:工具执行期间上游可能还在推 reasoning 帧,
|
|
452
|
+
* 共用一个累加值会把推理字符算进 bash 命令的数里。
|
|
453
|
+
*
|
|
454
|
+
* 这里做**增量累加**而不是每秒重算整串:宽/窄字符数是可加的,所以
|
|
455
|
+
* 逐 delta 累加与对完整文本一次性计算等价,但代价从 O(全文) 降到 O(delta)。
|
|
456
|
+
*
|
|
457
|
+
* 正文段(text)不在表里:它的 token 数从不显示,累加只会污染其它段。
|
|
458
|
+
*/
|
|
459
|
+
type CounterKind = "thinking" | "toolcall";
|
|
460
|
+
const counters: Record<CounterKind, { wide: number; narrow: number }> = {
|
|
461
|
+
thinking: { wide: 0, narrow: 0 },
|
|
462
|
+
toolcall: { wide: 0, narrow: 0 },
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
/** 新一小段开始:该段的计数器清零,从头重新数。 */
|
|
466
|
+
function resetCounter(kind: CounterKind): void {
|
|
467
|
+
counters[kind].wide = 0;
|
|
468
|
+
counters[kind].narrow = 0;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** 逐 delta 累加宽/窄字符数到对应段。 */
|
|
472
|
+
function accumulate(kind: CounterKind, text: string): void {
|
|
473
|
+
if (text === "") return;
|
|
474
|
+
const points = [...text].length; // 按码点计,代理对不会算成 2 个字符
|
|
475
|
+
const wide = Math.max(0, visibleWidth(text) - points);
|
|
476
|
+
counters[kind].wide += wide;
|
|
477
|
+
counters[kind].narrow += Math.max(0, points - wide);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function tokenEstimate(kind: CounterKind): number {
|
|
481
|
+
const counter = counters[kind];
|
|
482
|
+
return Math.ceil(counter.wide + counter.narrow / 4);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* 正在执行的工具:`toolCallId → 工具名`(`tool_execution_start` 落账、`_end` 销账)。
|
|
487
|
+
* 一个批次里可能**并行**执行多个工具(pi 的 parallel tool mode),只留一个字符串会在
|
|
488
|
+
* 先结束的那个上把名字清掉 —— 长 bash 还在跑、文案却掉回 `Working`,行尾的 `●` 就挂在
|
|
489
|
+
* 了错的标签后面。标签取**最后启动**的那个(沿用原语义:执行态压过推理态,看启动序)。
|
|
490
|
+
*/
|
|
491
|
+
const executingTools = new Map<string, string>();
|
|
492
|
+
|
|
493
|
+
/** 最后启动且仍在执行的工具名;没有则 null。 */
|
|
494
|
+
function executingToolName(): string | null {
|
|
495
|
+
let name: string | null = null;
|
|
496
|
+
for (const toolName of executingTools.values()) name = toolName;
|
|
497
|
+
return name;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* 并发 bash 执行的登记簿(见 `bash-spinner.ts`):门槛 / 粘性 / 消失条件都在它里面,
|
|
502
|
+
* 这里只管「什么时候闪」的定时器。
|
|
503
|
+
*/
|
|
504
|
+
const bashRuns = new BashRunTracker();
|
|
505
|
+
/** 标记的亮 / 灭相位(`bashSpinnerSuffix` 用它出两态)。 */
|
|
506
|
+
let bashSpinOn = false;
|
|
507
|
+
let bashBlinkTimer: ReturnType<typeof setInterval> | null = null;
|
|
508
|
+
let bashArmTimer: ReturnType<typeof setTimeout> | null = null;
|
|
509
|
+
/** 参数正在流式生成的工具(`toolcall_start` → `toolcall_end`)。 */
|
|
510
|
+
let streamingTool: string | null = null;
|
|
511
|
+
/** 是否处于推理段(`thinking_start` → `thinking_end`)。 */
|
|
512
|
+
let thinking = false;
|
|
513
|
+
|
|
514
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
515
|
+
let ctxRef: ExtensionContext | null = null;
|
|
516
|
+
let lastMessage: string | null = null;
|
|
517
|
+
/**
|
|
518
|
+
* 上一次装上去的色板指纹(`spinner-frames.ts` 的 `signature`):`refresh()` 每秒拿当前主题
|
|
519
|
+
* 现算一次,指纹变了才重装帧表 —— `/theme` 换肤后一秒内自愈,且相位只在真的换色时复位。
|
|
520
|
+
*/
|
|
521
|
+
let spinnerSignature: string | null = null;
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* 当前该显示哪个文案,以及该配哪一段的 token 计数;返回 null 表示回落到
|
|
525
|
+
* Working(无 token 段)。
|
|
526
|
+
*
|
|
527
|
+
* 优先级:正在执行 > 参数流式中 > 推理中。执行态压过推理态是有意的 ——
|
|
528
|
+
* 工具执行期间上游可能还在推 reasoning 帧,但用户关心的是"命令在跑"。
|
|
529
|
+
* 未列进 TOOL_LABELS 的工具直接回落 Working,不再往下走推理态(沿用原语义)。
|
|
530
|
+
*/
|
|
531
|
+
function currentActivity(): { label: string; counter: CounterKind } | null {
|
|
532
|
+
const executing = executingToolName();
|
|
533
|
+
if (executing !== null) {
|
|
534
|
+
const label = TOOL_LABELS[executing];
|
|
535
|
+
return label === undefined ? null : { label, counter: "toolcall" };
|
|
536
|
+
}
|
|
537
|
+
if (streamingTool !== null) {
|
|
538
|
+
const label = TOOL_LABELS[streamingTool];
|
|
539
|
+
return label === undefined ? null : { label, counter: "toolcall" };
|
|
540
|
+
}
|
|
541
|
+
if (thinking) return { label: THINKING_LABEL, counter: "thinking" };
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** 当前文案拆成「标签」与「统计段」两截 —— 两截颜色不同,得分开上色。 */
|
|
546
|
+
function buildParts(now: number): { label: string; stats: string | null } {
|
|
547
|
+
const duration = formatDuration(now - (turnStartedAt ?? now));
|
|
548
|
+
const activity = currentActivity();
|
|
549
|
+
if (activity === null) return { label: DEFAULT_LABEL, stats: `(${duration})` };
|
|
550
|
+
return {
|
|
551
|
+
label: activity.label,
|
|
552
|
+
stats: `(↓ ${tokenEstimate(activity.counter)} tokens · ${duration})`,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** 当前主题下的幻彩色板(现算;`theme.fg` 是 live proxy,换肤后同一调用就能拿到新色)。 */
|
|
557
|
+
function spinnerPalette(ctx: ExtensionContext): SpinnerPalette {
|
|
558
|
+
return buildSpinnerPalette((token, text) => ctx.ui.theme.fg(token, text), {
|
|
559
|
+
framesPerColor: SPINNER_COLOR_HOLD,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* 把色板装到 pi 上(不判重、不兜异常)。`frames === null`(当前主题挑不出两种可区分的颜色)
|
|
565
|
+
* 时退回 pi 默认帧。
|
|
566
|
+
*/
|
|
567
|
+
function applySpinnerPalette(ctx: ExtensionContext, palette: SpinnerPalette): void {
|
|
568
|
+
spinnerSignature = palette.signature;
|
|
569
|
+
if (palette.frames === null) {
|
|
570
|
+
ctx.ui.setWorkingIndicator();
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
ctx.ui.setWorkingIndicator({ frames: palette.frames, intervalMs: SPINNER_INTERVAL_MS });
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* 无条件装幻彩帧表。失败静默:调用点都是「旧 ctx 也不该掀翻宿主」的位置(`agent_start` /
|
|
578
|
+
* `tool_execution_end`)—— 旧 ctx 会抛 `This extension ctx is stale…`,那种情况下什么都没必要做。
|
|
579
|
+
*/
|
|
580
|
+
function installRainbowSpinner(ctx: ExtensionContext): void {
|
|
581
|
+
if (!SPINNER_RAINBOW_ENABLED) return;
|
|
582
|
+
try {
|
|
583
|
+
applySpinnerPalette(ctx, spinnerPalette(ctx));
|
|
584
|
+
} catch {
|
|
585
|
+
// 旧 ctx:不去碰 UI。
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* 只在主题指纹变了时重装(`refresh()` 每秒调一次)。重装会把动画相位复位,所以不能无脑调 ——
|
|
591
|
+
* 换肤那一瞬间本来就要全屏重绘,那一下复位看不见。
|
|
592
|
+
*/
|
|
593
|
+
function syncRainbowSpinner(ctx: ExtensionContext): void {
|
|
594
|
+
if (!SPINNER_RAINBOW_ENABLED) return;
|
|
595
|
+
const palette = spinnerPalette(ctx);
|
|
596
|
+
if (palette.signature === spinnerSignature) return;
|
|
597
|
+
applySpinnerPalette(ctx, palette);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* 刷新文案。只在字符串真的变了时才调 `setWorkingMessage`,避免每秒无谓重渲染。
|
|
602
|
+
*
|
|
603
|
+
* `ctx.ui` 的读取(取主题、写文案)包在 try/catch 里:会话被替换或 `/reload` 之后,
|
|
604
|
+
* 旧 ctx 会抛 `This extension ctx is stale after session replacement or reload`,
|
|
605
|
+
* 而这个异常发生在**读 `ctx.ui` 这一步**(比组件 render 更早),漏网的定时器回调
|
|
606
|
+
* 足以掀翻宿主进程 —— recap / simple-task 都踩过同一个坑。
|
|
607
|
+
*
|
|
608
|
+
* 主题每次现读(`/theme` 换主题后一秒内自愈),刻意不订阅主题变更事件。
|
|
609
|
+
*/
|
|
610
|
+
function refresh(): void {
|
|
611
|
+
const ctx = ctxRef;
|
|
612
|
+
if (ctx === null || turnStartedAt === null) {
|
|
613
|
+
stopBashSpinner();
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
let message: string;
|
|
617
|
+
try {
|
|
618
|
+
const now = Date.now();
|
|
619
|
+
driveBashSpinner(now);
|
|
620
|
+
const parts = buildParts(now);
|
|
621
|
+
let left = formatWorkingMessage(ctx.ui.theme, parts.label, parts.stats);
|
|
622
|
+
left += bashSpinnerText(ctx.ui.theme);
|
|
623
|
+
// 模型摘要到达后顶掉原文;没到 / 失败就是截断后的原文。
|
|
624
|
+
const summarySource = promptSummaryText ?? promptText;
|
|
625
|
+
message = SUMMARY_ENABLED ? appendPromptSummary(ctx.ui.theme, left, summarySource) : left;
|
|
626
|
+
// 主题指纹变了就重装幻彩帧表(换肤后一秒内自愈);指纹没变这里什么都不做。
|
|
627
|
+
syncRainbowSpinner(ctx);
|
|
628
|
+
} catch {
|
|
629
|
+
// 旧 ctx:停掉定时器,别再拿它去碰 UI。
|
|
630
|
+
stopActivity();
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
if (message === lastMessage) return;
|
|
634
|
+
lastMessage = message;
|
|
635
|
+
try {
|
|
636
|
+
ctx.ui.setWorkingMessage(message);
|
|
637
|
+
} catch {
|
|
638
|
+
stopActivity();
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function startTimer(ctx: ExtensionContext): void {
|
|
643
|
+
stopTimer();
|
|
644
|
+
ctxRef = ctx;
|
|
645
|
+
timer = setInterval(refresh, TICK_MS);
|
|
646
|
+
// unref:读秒定时器不该把进程吊住(退出时不必等它)。
|
|
647
|
+
timer.unref?.();
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function stopTimer(): void {
|
|
651
|
+
if (timer !== null) {
|
|
652
|
+
clearInterval(timer);
|
|
653
|
+
timer = null;
|
|
654
|
+
}
|
|
655
|
+
ctxRef = null;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** 停掉 `●` 的节拍 / 排程定时器并回到灭态(幂等)。 */
|
|
659
|
+
function stopBashSpinner(): void {
|
|
660
|
+
if (bashBlinkTimer !== null) {
|
|
661
|
+
clearInterval(bashBlinkTimer);
|
|
662
|
+
bashBlinkTimer = null;
|
|
663
|
+
}
|
|
664
|
+
if (bashArmTimer !== null) {
|
|
665
|
+
clearTimeout(bashArmTimer);
|
|
666
|
+
bashArmTimer = null;
|
|
667
|
+
}
|
|
668
|
+
bashSpinOn = false;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* 亮灭节拍:`●` 亮起后每 500ms 翻相位 + 重绘(与 tool-diff 的 `edit` / `write` 同节拍)。
|
|
673
|
+
* 幂等:已在闪就不动(相位不重置,多 bash 并发时节拍连续)。
|
|
674
|
+
*/
|
|
675
|
+
function startBashBlink(): void {
|
|
676
|
+
if (bashBlinkTimer !== null) return;
|
|
677
|
+
bashSpinOn = true; // 首次亮相:到门槛那一刻就是亮的,不留 500ms 空窗
|
|
678
|
+
bashBlinkTimer = setInterval(() => {
|
|
679
|
+
bashSpinOn = !bashSpinOn;
|
|
680
|
+
refresh();
|
|
681
|
+
}, BASH_SPINNER_BLINK_MS);
|
|
682
|
+
bashBlinkTimer.unref?.();
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* 门槛未到:起一次性定时器,到点重绘一次让 `●` **准时**出现(否则要等下一次别的重绘 ——
|
|
687
|
+
* 非流式命令执行期间可能一个上游事件都没有)。幂等(已有排程 / 已在闪都直接返回)。
|
|
688
|
+
*/
|
|
689
|
+
function armBashSpinner(delayMs: number): void {
|
|
690
|
+
if (bashArmTimer !== null || bashBlinkTimer !== null) return;
|
|
691
|
+
bashArmTimer = setTimeout(() => {
|
|
692
|
+
bashArmTimer = null;
|
|
693
|
+
refresh();
|
|
694
|
+
}, Math.max(0, delayMs));
|
|
695
|
+
bashArmTimer.unref?.();
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* 「这一批 bash 有没有谁跨过门槛」+ 定时器驱动,每次 refresh 都跑一遍。
|
|
700
|
+
* 已跨过 → 停排程、起节拍;还没跨过 → 按最早截止点排一次性定时器;没有执行 / 开关关了
|
|
701
|
+
* → 全部停掉。
|
|
702
|
+
*/
|
|
703
|
+
function driveBashSpinner(now: number): void {
|
|
704
|
+
if (!BASH_SPINNER_ENABLED || bashRuns.size === 0) {
|
|
705
|
+
stopBashSpinner();
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
if (bashRuns.markCrossed(now)) {
|
|
709
|
+
if (bashArmTimer !== null) {
|
|
710
|
+
clearTimeout(bashArmTimer);
|
|
711
|
+
bashArmTimer = null;
|
|
712
|
+
}
|
|
713
|
+
startBashBlink();
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
const delay = bashRuns.armDelay(now);
|
|
717
|
+
if (delay !== null) armBashSpinner(delay);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* 行尾标记段(含前导空格,**恒 2 列**;不显示时是空串)。上色在这里做:`Theme.fg`
|
|
722
|
+
* 只重置前景色,拼在左段末尾不影响前面已有的色段。
|
|
723
|
+
*/
|
|
724
|
+
function bashSpinnerText(theme: WorkingMessageTheme | undefined): string {
|
|
725
|
+
if (!BASH_SPINNER_ENABLED || !bashRuns.shown) return "";
|
|
726
|
+
const text = bashSpinnerSuffix(bashSpinOn);
|
|
727
|
+
return theme === undefined ? text : theme.fg(BASH_SPINNER_COLOR, text);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** 旧 ctx(会话被替换 / `/reload`):停掉所有定时器、清掉账本,别再拿它去碰 UI。 */
|
|
731
|
+
function stopActivity(): void {
|
|
732
|
+
stopTimer();
|
|
733
|
+
stopBashSpinner();
|
|
734
|
+
bashRuns.clear();
|
|
735
|
+
cancelSummaryRequest();
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** 从 `toolcall_start` 的 partial 里取工具名。 */
|
|
739
|
+
function toolNameFromPartial(event: { contentIndex?: number; partial?: unknown }): string | null {
|
|
740
|
+
const partial = event.partial as { content?: unknown[] } | undefined;
|
|
741
|
+
const index = event.contentIndex;
|
|
742
|
+
if (!Array.isArray(partial?.content) || typeof index !== "number") return null;
|
|
743
|
+
const block = partial.content[index] as { name?: unknown } | undefined;
|
|
744
|
+
return typeof block?.name === "string" ? block.name : null;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** 停掉在飞的摘要请求(幂等)。它只是一个附带请求,停了也不会影响主任务。 */
|
|
748
|
+
function cancelSummaryRequest(): void {
|
|
749
|
+
if (summaryAbort !== null) {
|
|
750
|
+
summaryAbort.abort();
|
|
751
|
+
summaryAbort = null;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* 提示词太长时**异步**让模型压一句话(决策 / 提示词 / 清洗都在 `summary-request.ts`)。
|
|
757
|
+
* 发出去就返回,不 `await`:主回合跟这个请求完全并行,请求慢 / 失败只意味着这一格继续
|
|
758
|
+
* 显示截断后的原文。回包三重护栏:abort 过的、序号换了的、提示词已经清掉的(回合结束)
|
|
759
|
+
* 一律丢弃。
|
|
760
|
+
*/
|
|
761
|
+
function requestPromptSummary(ctx: ExtensionContext, prompt: string, seq: number): void {
|
|
762
|
+
if (ctx.mode !== "tui") return; // print / json / rpc 没有 working 行,白花请求。
|
|
763
|
+
const model = resolveSummaryModel(ctx);
|
|
764
|
+
if (model === undefined) return;
|
|
765
|
+
|
|
766
|
+
const plan = planSummaryRequest({
|
|
767
|
+
promptWidth: visibleWidth(prompt),
|
|
768
|
+
budgetWidth: summaryRequestBudgetWidth(),
|
|
769
|
+
triggerRatio: SUMMARY_TRIGGER_RATIO,
|
|
770
|
+
});
|
|
771
|
+
if (!plan.needed) return;
|
|
772
|
+
|
|
773
|
+
const controller = new AbortController();
|
|
774
|
+
summaryAbort = controller;
|
|
775
|
+
const timeout = setTimeout(() => controller.abort(), SUMMARY_TIMEOUT_MS);
|
|
776
|
+
timeout.unref?.();
|
|
777
|
+
|
|
778
|
+
void (async () => {
|
|
779
|
+
try {
|
|
780
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
781
|
+
if (!auth.ok || controller.signal.aborted) return;
|
|
782
|
+
const response = await ctx.modelRegistry.complete(
|
|
783
|
+
model,
|
|
784
|
+
{
|
|
785
|
+
messages: [
|
|
786
|
+
{
|
|
787
|
+
role: "user",
|
|
788
|
+
content: [{ type: "text", text: buildSummaryRequestPrompt(prompt, plan.targetWidth) }],
|
|
789
|
+
timestamp: Date.now(),
|
|
790
|
+
},
|
|
791
|
+
],
|
|
792
|
+
},
|
|
793
|
+
{
|
|
794
|
+
apiKey: auth.apiKey,
|
|
795
|
+
headers: auth.headers,
|
|
796
|
+
signal: controller.signal,
|
|
797
|
+
maxTokens: SUMMARY_MAX_TOKENS,
|
|
798
|
+
// 刻意**不**传 temperature:0 会让这些 max 档路由退化成停不下来的长思考(见
|
|
799
|
+
// `SUMMARY_MAX_TOKENS` 注释),默认温度反而稳。
|
|
800
|
+
cacheRetention: "none",
|
|
801
|
+
},
|
|
802
|
+
);
|
|
803
|
+
if (controller.signal.aborted || seq !== promptSeq || promptText === null) return;
|
|
804
|
+
const text = cleanSummaryText(textFromAssistant(response));
|
|
805
|
+
if (text === "") return;
|
|
806
|
+
promptSummaryText = text;
|
|
807
|
+
refresh();
|
|
808
|
+
} catch {
|
|
809
|
+
// 超时 / 中断 / 网络错误 / 模型不可用:静默放弃,行尾继续显示截断后的原文。
|
|
810
|
+
} finally {
|
|
811
|
+
clearTimeout(timeout);
|
|
812
|
+
if (summaryAbort === controller) summaryAbort = null;
|
|
813
|
+
}
|
|
814
|
+
})().catch(() => {});
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
pi.on("input", async (event, ctx) => {
|
|
818
|
+
// 在 agent 处理前触发:普通消息是用户原文,skill/模板命令是展开前的原始
|
|
819
|
+
// 命令行,扩展命令的 sendUserMessage 也会再触发一次(source=extension)。
|
|
820
|
+
// 流式中的 steer / followUp 同样走这里,摘要随之更新成最新指令。
|
|
821
|
+
// 压平在这里算一次并缓存(refresh 每个 delta 都跑,不能每次重扫整段)。
|
|
822
|
+
//
|
|
823
|
+
// 同一条文本重复触发时直接复用:扩展命令的 sendUserMessage 会让同一条消息
|
|
824
|
+
// 走两次 `input`,第二次不该把在飞的摘要请求作废重来。
|
|
825
|
+
if (event.text === lastInputText) return;
|
|
826
|
+
lastInputText = event.text;
|
|
827
|
+
promptSeq += 1;
|
|
828
|
+
cancelSummaryRequest();
|
|
829
|
+
promptSummaryText = null;
|
|
830
|
+
promptText = SUMMARY_ENABLED ? flattenPrompt(event.text) : null;
|
|
831
|
+
if (SUMMARY_LLM_ENABLED && promptText !== null && promptText !== "") {
|
|
832
|
+
requestPromptSummary(ctx, promptText, promptSeq);
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
837
|
+
turnStartedAt = Date.now();
|
|
838
|
+
resetCounter("thinking");
|
|
839
|
+
resetCounter("toolcall");
|
|
840
|
+
executingTools.clear();
|
|
841
|
+
bashRuns.clear();
|
|
842
|
+
stopBashSpinner();
|
|
843
|
+
streamingTool = null;
|
|
844
|
+
thinking = false;
|
|
845
|
+
lastMessage = null;
|
|
846
|
+
// print / json 模式没有 working loader 行,起定时器只是白跑。
|
|
847
|
+
if (ctx.hasUI) {
|
|
848
|
+
// 回合开始时装帧表:此刻 spinner 还没渲染(流式开始才出现),相位复位看不见;
|
|
849
|
+
// 换会话后 pi 的 resetExtensionUI() 会把 indicator 还原成默认帧,这里一并补回来。
|
|
850
|
+
installRainbowSpinner(ctx);
|
|
851
|
+
startTimer(ctx);
|
|
852
|
+
refresh();
|
|
853
|
+
}
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
pi.on("message_update", async (event) => {
|
|
857
|
+
const streamEvent = event.assistantMessageEvent as
|
|
858
|
+
| { type?: string; delta?: string; contentIndex?: number; partial?: unknown }
|
|
859
|
+
| undefined;
|
|
860
|
+
switch (streamEvent?.type) {
|
|
861
|
+
// 三个 *_start 都是一小段的起点:该段计数器清零,从头重新数。
|
|
862
|
+
case "thinking_start":
|
|
863
|
+
thinking = true;
|
|
864
|
+
resetCounter("thinking");
|
|
865
|
+
break;
|
|
866
|
+
case "thinking_end":
|
|
867
|
+
thinking = false;
|
|
868
|
+
break;
|
|
869
|
+
// 文本段开始 = 推理段结束(正常顺序里 thinking_end 会先到,这里是兜底)。
|
|
870
|
+
// text_delta 本身不计数,所以 text_start 不需要重置任何计数器。
|
|
871
|
+
case "text_start":
|
|
872
|
+
thinking = false;
|
|
873
|
+
break;
|
|
874
|
+
case "toolcall_start": {
|
|
875
|
+
const name = toolNameFromPartial(streamEvent as { contentIndex?: number; partial?: unknown });
|
|
876
|
+
if (name !== null) streamingTool = name;
|
|
877
|
+
resetCounter("toolcall");
|
|
878
|
+
break;
|
|
879
|
+
}
|
|
880
|
+
case "toolcall_end":
|
|
881
|
+
streamingTool = null;
|
|
882
|
+
// 刻意不清零 toolcall 计数器:`tool_execution_start` 紧接着就到,执行期间
|
|
883
|
+
// 显示的仍然是这条命令的量;清零会让整个执行期显示 `↓ 0 tokens`。
|
|
884
|
+
break;
|
|
885
|
+
// delta 按自己的类型归到对应段,于是推理帧不会算进工具命令的数里。
|
|
886
|
+
case "thinking_delta":
|
|
887
|
+
accumulate("thinking", typeof streamEvent.delta === "string" ? streamEvent.delta : "");
|
|
888
|
+
break;
|
|
889
|
+
case "toolcall_delta":
|
|
890
|
+
accumulate("toolcall", typeof streamEvent.delta === "string" ? streamEvent.delta : "");
|
|
891
|
+
break;
|
|
892
|
+
// text_delta 刻意不计:正文段的 token 数从不显示,累加只会污染后面几段的数。
|
|
893
|
+
}
|
|
894
|
+
refresh();
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
pi.on("tool_execution_start", async (event) => {
|
|
898
|
+
executingTools.set(event.toolCallId, event.toolName);
|
|
899
|
+
if (event.toolName === "bash") bashRuns.start(event.toolCallId, Date.now());
|
|
900
|
+
// 参数流式阶段可能没拿到名字(partial 缺块),执行态是权威来源,这里补上。
|
|
901
|
+
streamingTool = null;
|
|
902
|
+
refresh();
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
pi.on("tool_execution_end", async (event, ctx) => {
|
|
906
|
+
executingTools.delete(event.toolCallId);
|
|
907
|
+
bashRuns.end(event.toolCallId);
|
|
908
|
+
// 问卷退出时会把 indicator 恢复成 pi 的默认帧(它不知道本扩展的彩帧),这里补装回去。
|
|
909
|
+
if (event.toolName === ASK_USER_QUESTION_TOOL) installRainbowSpinner(ctx);
|
|
910
|
+
refresh();
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* `agent_settled` 才是回合真正结束(`agent_end` 之后还可能自动重试、自动压缩
|
|
915
|
+
* 或继续排队的后续消息 —— 那些阶段 spinner 仍在转,所以不能提前停表)。
|
|
916
|
+
*/
|
|
917
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
918
|
+
stopActivity();
|
|
919
|
+
turnStartedAt = null;
|
|
920
|
+
lastMessage = null;
|
|
921
|
+
promptText = null;
|
|
922
|
+
promptSummaryText = null;
|
|
923
|
+
lastInputText = null;
|
|
924
|
+
try {
|
|
925
|
+
ctx.ui.setWorkingMessage(); // 无参 = 恢复 pi 默认文案
|
|
926
|
+
} catch {
|
|
927
|
+
// 旧 ctx,忽略
|
|
928
|
+
}
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
pi.on("session_shutdown", async () => {
|
|
932
|
+
stopActivity();
|
|
933
|
+
turnStartedAt = null;
|
|
934
|
+
promptText = null;
|
|
935
|
+
promptSummaryText = null;
|
|
936
|
+
// 去重键也要清:新会话里用户可能又敲一条与上一会话最后一条完全相同的提示词,
|
|
937
|
+
// 留着旧值会把它当成「重复的 input」直接跳过。
|
|
938
|
+
lastInputText = null;
|
|
939
|
+
});
|
|
940
|
+
}
|