@yinan_chen/pi-claude-code-ui 0.0.0-stage → 0.2.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.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +44 -2
  3. package/package.json +34 -5
  4. package/src/claude-tools.ts +274 -0
  5. package/src/claude-view.ts +775 -0
  6. package/src/index.ts +566 -0
  7. package/src/pi-runtime.ts +232 -0
  8. package/src/preview.ts +147 -0
  9. package/src/running-effect.ts +29 -0
  10. package/src/skill-block-recollapse.ts +362 -0
  11. package/src/sticky-first-message.ts +1114 -0
  12. package/src/subagent-panel.ts +144 -0
  13. package/src/vendor/ATTRIBUTION.md +26 -0
  14. package/src/vendor/ansi-color.ts +44 -0
  15. package/src/vendor/ansi-text.ts +47 -0
  16. package/src/vendor/diff/ATTRIBUTION.md +28 -0
  17. package/src/vendor/diff/ansi-utils.ts +113 -0
  18. package/src/vendor/diff/diff-component.ts +125 -0
  19. package/src/vendor/diff/diff-edit-render.ts +237 -0
  20. package/src/vendor/diff/diff-header.ts +137 -0
  21. package/src/vendor/diff/diff-highlight.ts +115 -0
  22. package/src/vendor/diff/diff-inline.ts +324 -0
  23. package/src/vendor/diff/diff-layout.ts +762 -0
  24. package/src/vendor/diff/diff-limits.ts +229 -0
  25. package/src/vendor/diff/diff-palette.ts +410 -0
  26. package/src/vendor/diff/diff-parse.ts +535 -0
  27. package/src/vendor/diff/diff-presentation.ts +70 -0
  28. package/src/vendor/diff/diff-renderer.ts +23 -0
  29. package/src/vendor/diff/diff-text.ts +45 -0
  30. package/src/vendor/diff/diff-write-render.ts +572 -0
  31. package/src/vendor/diff/index.ts +126 -0
  32. package/src/vendor/diff/line-width-safety.ts +92 -0
  33. package/src/vendor/diff/shiki-highlight.ts +92 -0
  34. package/src/vendor/diff/write-execution.ts +113 -0
  35. package/src/vendor/diff-config.ts +30 -0
  36. package/src/vendor/show-more-hint.ts +38 -0
  37. package/src/vendor/tool-result-sanitize.ts +24 -0
  38. package/src/working-message.ts +140 -0
@@ -0,0 +1,1114 @@
1
+ /**
2
+ * sticky-first-message — 聊天视口顶部的吸附用户消息泡(CSS position:sticky 语义)
3
+ *
4
+ * fullscreen 模式(tuiMode: "fullscreen")下,向 root VStack 首位插入动态高度 StickyBar:
5
+ * - 视口第一行内容属于某条用户消息的回复(其原文首行已滚出顶部)→ 顶部吸附一条
6
+ * 与消息泡同色的泡,显示该消息原文的第一条非空行(带 " ❯ " 前缀,3 列对齐;
7
+ * 不改写文字本体;原位消息泡已顶格,不再用同款前缀);首行超宽且含
8
+ * 空格(无换行长消息在聊天区 word-wrap 成多行)时显示其第一个视觉行,仅超长
9
+ * 无空格 token(超长 URL 等)才中段省略保句尾。
10
+ * - 原文就在视口内(scrollTop == 消息起始行或更早)→ 泡消失(原位与吸附二选一)。
11
+ * - 点击泡 → transcript.scrollTo(消息起始行):原文完整回到视口顶部,自动脱离 follow-end。
12
+ * - 流式回复期间视口跟随底部,顶部始终吸附「当前这轮在回答什么」。
13
+ * - 识别范围:UserMessageComponent、SkillInvocationMessageComponent(/skill:xxx,文案取
14
+ * skillBlock.userMessage 首行,否则 `/skill:<name>`);CustomMessageComponent(扩展注入 /
15
+ * 跨会话转发的旁路内容)默认不吸附,PI_STICKY_INCLUDE_CUSTOM=1 选择加入。
16
+ *
17
+ * 实现:布局层 measureHeight 直接以 component.render(width) 行数为 intrinsic,StickyBar 输出
18
+ * 1 行即挤压 transcript 一行,输出 0 行即让位。
19
+ * 行分布来自 documentContainer/chatContainer 的 mouseLayout(Container.render 自动构建;
20
+ * tidy-anytool 的 render wrapper 也会重建)。布局引擎每帧顺序:root VStack 先对各 entry
21
+ * measureHeight(此时调 StickyBar.render),之后才布局 scroll 节点(渲染 doc、写 contentHeight、
22
+ * clamp scrollTop)。因此 StickyBar.render 读到的 mouseLayout / scrollTop / contentHeight 都是
23
+ * 上一帧的、彼此一致的数据——直接复用,不重复渲染 doc;只在宽度变化(resize)或尚无布局时
24
+ * 才回退到主动 doc.render(contentWidth)(该回退的布局是本帧的,与上一帧的 scrollTop 可能不一致,
25
+ * 由监控的瞬态判据吸收)。
26
+ *
27
+ * ── 反馈环与自洽求解(F1,v2.1)────────────────────────────────────────────
28
+ * transcript 是 ScrollView(follow:"end"),且 root VStack 里只有它 grow>0。于是泡高度 h∈{0,1}
29
+ * 会挤掉 transcript 一行视口,而 ScrollView.updateLayout 在 followingEnd 时把
30
+ * scrollTop 重写为 contentHeight - viewportHeight —— h 反过来决定了下一帧判定用的 scrollTop:
31
+ * h=0 → scrollTop = base h=1 → scrollTop = base + 1 (base = contentHeight - 无泡视口)
32
+ * 而「该不该吸附」在下一条用户消息首行 L 处不单调:f(L-1)=1(吸附前一条)、f(L)=0(原文可见)、
33
+ * f(L+1)=1(吸附它自己)。当 base = L-1 时两者互锁 → 每帧 0/1 翻转 → 泡无限闪烁
34
+ * (症状:泡正下方紧贴着下一条消息的泡)。
35
+ * 修法两步:
36
+ * 1. 判定坐标系改用「无泡视口」的顶行 base(= contentHeight - (viewportHeight + 上一帧泡高)),
37
+ * 输入不再由自己的输出决定 → 环断开;
38
+ * 2. 对 h∈{0,1} 做自洽求解:f(base)=0 ⇒ 稳定无泡;否则 f(base+1)=1 ⇒ 稳定有泡(按 base+1 判定,
39
+ * 即泡下方真正的顶行);两者都不自洽 ⇒ 取无泡(next-anchor-adjacent:下一条消息的原文
40
+ * 就紧贴在泡下面,吸附上一条已无意义)。
41
+ * 非 followingEnd 时 scrollTop 与泡高无关(updateLayout 的 clamp 只减不增,天然收敛),直接单次判定。
42
+ *
43
+ * 多模块实例:/reload 会把扩展重新 import 为新模块实例(新 stickyBar、新 live)。prototype 补丁
44
+ * 只包一层(Symbol.for FLAG),wrapper 经 globalThis slot 间接调用「最新模块」的 installInto;
45
+ * 旧模块留在 root 里的泡被「中和」(render→[]、handleMouse→undefined,仍留在 root),不驱逐——
46
+ * 旧版 wrapper 每个事件都检查自己的泡在不在 root,驱逐会导致新拔旧插抖动。
47
+ *
48
+ * 健康监控(默认关闭,PI_STICKY_HEALTH=1 开启):数据目录 ~/.pi/agent/extension-data/sticky-first-message/(PI_STICKY_DATA_DIR 可覆盖)
49
+ * 下 health.log + snapshots/(保留最近 30 份,异步写入)。正常态零噪音;异常须持续 ≥300ms 或
50
+ * ≥10 帧才记录+快照;tui.contentHeight 与本帧布局总行数不一致 ⇒ 布局引擎尚未跟上 ⇒ 瞬态跳过。
51
+ * 另有振荡探测器:泡高度在 2s 窗口内翻转 ≥8 次即记 OSCILLATION + 快照(60s 冷却)——上述
52
+ * 反馈环类问题的两个状态各自都「正常」,异常状态机永远抓不到,必须独立监控。
53
+ *
54
+ * 前提:tuiMode "fullscreen"(TuiAltScreen/ViewportTUI)。regular 模式下 fullscreenLayoutRoot
55
+ * 不被消费,扩展自动不生效、无副作用。
56
+ *
57
+ * kill switch: PI_STICKY_FIRST_MESSAGE=0 debug: PI_STICKY_DEBUG=1 health log: PI_STICKY_HEALTH=1
58
+ */
59
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
60
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
61
+ import { promises as fsp } from "node:fs";
62
+ import { homedir } from "node:os";
63
+ import { basename, join } from "node:path";
64
+ import { getPiRuntime, resolvePkgRoot } from "./pi-runtime.ts";
65
+
66
+ const VERSION = "2.2.1-20260910";
67
+
68
+ const env = (k: string) => (process.env[k] || "").trim();
69
+ const killed = () => env("PI_STICKY_FIRST_MESSAGE") === "0";
70
+ const includeCustom = () => env("PI_STICKY_INCLUDE_CUSTOM") === "1";
71
+ /** 健康日志 + 快照落盘开关;默认关闭,不产生任何文件(状态机仍运行,仅不写盘) */
72
+ const healthEnabled = () => env("PI_STICKY_HEALTH") === "1";
73
+
74
+ // ── 样式 ─────────────────────────────────────────────────────────────
75
+ // 主题优先:底色 = 主题 userMessageBg × SHADE(0.78)同族降调:与聊天区消息泡同族但降一档
76
+ // 亮度——悬浮条是首条消息泡的「驻留影子」,视觉权重应低于原泡,同色会混消、异色则割裂;
77
+ // 前景 = 主题 userMessageText,深字浅字随主题自动适配。
78
+ // 次要文字 = userMessageText 色 + ANSI dim 修饰(\x1b[2m):亮底自动「深字变灰」、暗底
79
+ // 自动「亮字变灰」,比 dim token 在明暗两种底色下都可靠。
80
+ // 拿不到主题(session_start 前 / 非 TUI)时退回 github-dark 系的固定值 #1C2128 / #9EA7B3。
81
+ // 主题经 ctx.ui.setWidget 的零高探针捕获(widget 工厂拿到 (tui, theme)),或复用 lean-tidy 捕获的。
82
+ const RESET = "\x1b[0m";
83
+ const BG_FALLBACK = "\x1b[48;2;28;33;40m"; // #1C2128
84
+ const FG_FALLBACK = "\x1b[38;2;158;167;179m"; // #9EA7B3
85
+ const SHADE = 0.78; // 泡底色向暗收的系数:同族降调,与原泡保持可辨的层级差
86
+ let THEME: any = (globalThis as any).__stickyTheme ?? (globalThis as any).__leanTidyTheme;
87
+ function captureTheme(t: any): void {
88
+ if (t && typeof t.fg === "function") {
89
+ THEME = t;
90
+ (globalThis as any).__stickyTheme = t;
91
+ }
92
+ }
93
+ const rgbOf = (ansi: string, kind: "38" | "48"): [number, number, number] | undefined => {
94
+ const m = ansi.match(new RegExp(`${kind};2;(\\d+);(\\d+);(\\d+)m`));
95
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined;
96
+ };
97
+ function barColors(): { bg: string; fg: string; dim: string } {
98
+ if (THEME) {
99
+ try {
100
+ const umbg = rgbOf(THEME.bg("userMessageBg", "x"), "48");
101
+ const bg = umbg ? `\x1b[48;2;${Math.round(umbg[0] * SHADE)};${Math.round(umbg[1] * SHADE)};${Math.round(umbg[2] * SHADE)}m` : BG_FALLBACK;
102
+ const fgAnsi = THEME.fg("userMessageText", "x").replace(/x\x1b\[39m$/, "");
103
+ const dimAnsi = fgAnsi ? `\x1b[2m${fgAnsi}` : "\x1b[2m";
104
+ return { bg, fg: fgAnsi || FG_FALLBACK, dim: dimAnsi };
105
+ } catch {}
106
+ }
107
+ return { bg: BG_FALLBACK, fg: FG_FALLBACK, dim: "\x1b[2m" };
108
+ }
109
+ /** 中段省略:保住句尾约 60%,首部 40%(宽字符安全)。 */
110
+ function truncMiddle(s: string, max: number): string {
111
+ if (visibleWidth(s) <= max) return s;
112
+ if (max <= 1) return "…";
113
+ const chars = [...s];
114
+ const tailMax = Math.min(Math.floor(max * 0.6), max - 1);
115
+ let tail = "";
116
+ let w = 0;
117
+ for (let i = chars.length - 1; i >= 0; i--) {
118
+ const cw = visibleWidth(chars[i]);
119
+ if (w + cw > tailMax) break;
120
+ tail = chars[i] + tail;
121
+ w += cw;
122
+ }
123
+ const head = truncateToWidth(s, Math.max(0, max - w - 1), "").replace(/\x1b\[0m/g, ""); // 剥掉 truncateToWidth 无条件追加的 \x1b[0m:泡整行自带底色/前景(bg fg … RESET),行中混入的重置码会把底色提前清掉 → 视觉上「背景只渲染了一半」
124
+ return `${head}…${tail}`;
125
+ }
126
+
127
+ /**
128
+ * 泡文案定稿:原文首行超宽时的兜底。
129
+ * 「中段省略」只适合无空格的长 token(超长 URL/代码):对含空格的文本——典型是粘贴的
130
+ * 无换行长消息,聊天区里 word-wrap 成多行(视觉多行、逻辑一行,firstNonEmptyLine 拿到
131
+ * 整条)——head…tail 会把首尾各保一段、中间大段消失,路径/句子两头残缺,看起来像
132
+ * 「泡只渲染了半行」。改为取第一个视觉行(wrapTextWithAnsi 与聊天区 Markdown 的断行
133
+ * 同语义),泡显示的就是聊天区里消息的第一行。
134
+ */
135
+ const barText = (raw: string, avail: number): string => {
136
+ if (vis(raw) <= avail) return raw;
137
+ if (raw.includes(" ")) {
138
+ const first = wrapTextWithAnsi(raw, avail)[0];
139
+ if (first && first.trim() !== "") return first.trim();
140
+ }
141
+ return truncMiddle(raw, avail);
142
+ };
143
+
144
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
145
+ const OSC_RE = /\x1b\][^\x07]*\x07/g;
146
+ const vis = (s: string) => visibleWidth(String(s).replace(ANSI_RE, "").replace(OSC_RE, ""));
147
+
148
+ const diag = (...args: unknown[]) => {
149
+ if (env("PI_STICKY_DEBUG") === "1") {
150
+ console.error("[sticky-first-message]", ...args);
151
+ }
152
+ };
153
+
154
+ // ── 常驻健康监控(无需复现):异常态自动记录 + 自动抓现场快照 ──
155
+ // 数据目录:PI_STICKY_DATA_DIR > $PI_CODING_AGENT_DIR/extension-data/sticky-first-message
156
+ // > ~/.pi/agent/extension-data/sticky-first-message
157
+ const resolveDataDir = (): string => {
158
+ const override = env("PI_STICKY_DATA_DIR");
159
+ if (override) return override;
160
+ const agentDir = env("PI_CODING_AGENT_DIR");
161
+ const base = agentDir ? agentDir.replace(/^~(?=$|\/)/, homedir()) : join(homedir(), ".pi", "agent");
162
+ return join(base, "extension-data", "sticky-first-message");
163
+ };
164
+ let dataDir = resolveDataDir();
165
+ const healthLogPath = () => join(dataDir, "health.log");
166
+ const snapshotsDir = () => join(dataDir, "snapshots");
167
+ const MAX_SNAPSHOTS = 30;
168
+ const SNAPSHOT_RE = /^snapshot-(\d+)-(\d+)\.txt$/; // snapshot-<pid>-<ms>.txt(只轮转本插件自己的文件)
169
+
170
+ /** 单调时钟(测试可注入);日志时间戳始终用真实时钟 */
171
+ let nowFn: () => number = () => Date.now();
172
+ const ts = () => new Date().toISOString();
173
+
174
+ // 串行异步 IO 队列:fire-and-forget、保序、永不抛错;测试用 flushIO() 等待落盘
175
+ let ioQueue: Promise<void> = Promise.resolve();
176
+ const enqueueIO = (job: () => Promise<void>): Promise<void> => {
177
+ ioQueue = ioQueue.then(job).catch(() => {});
178
+ return ioQueue;
179
+ };
180
+ let dirsReady: Promise<void> | undefined;
181
+ const ensureDirs = (): Promise<void> => {
182
+ if (!dirsReady) {
183
+ dirsReady = fsp
184
+ .mkdir(snapshotsDir(), { recursive: true })
185
+ .then(() => {})
186
+ .catch((e) => {
187
+ dirsReady = undefined; // 下次重试
188
+ throw e;
189
+ });
190
+ }
191
+ return dirsReady;
192
+ };
193
+
194
+ /** 每行日志前缀:时间 pid 进程运行秒 cwd 最后一段——7 个 pi 共写一份日志时可归属 */
195
+ const logPrefix = () => {
196
+ const cwd = process.cwd();
197
+ return `${ts()} pid=${process.pid} up=${Math.round(process.uptime())} cwd=${basename(cwd) || cwd}`;
198
+ };
199
+ /** 轮转阈值与旧档名:只保留一份 health.log.1(rename 覆盖,POSIX 原子),日志本来只在排障时看,多留无益 */
200
+ const HEALTH_LOG_MAX_BYTES = 256 * 1024;
201
+ const HEALTH_LOG_OLD = "health.log.1";
202
+ let healthLogBytes = -1; // 进程内累计写入字节数;-1 = 未校准(首条日志 stat 一次现有文件,之后不再 stat)
203
+ const healthLog = (line: string): void => {
204
+ diag("health:", line);
205
+ if (!healthEnabled()) return;
206
+ const text = `${logPrefix()} ${line}\n`;
207
+ const bytes = Buffer.byteLength(text);
208
+ enqueueIO(async () => {
209
+ await ensureDirs();
210
+ try {
211
+ if (healthLogBytes < 0) {
212
+ try {
213
+ healthLogBytes = (await fsp.stat(healthLogPath())).size;
214
+ } catch {
215
+ healthLogBytes = 0; // 尚无日志文件
216
+ }
217
+ }
218
+ if (healthLogBytes + bytes > HEALTH_LOG_MAX_BYTES) {
219
+ // 轮转与追加都在 ioQueue 内串行执行,天然无竞态;rename 失败则降级为继续追加(宁超限不丢日志)
220
+ await fsp.rename(healthLogPath(), join(dataDir, HEALTH_LOG_OLD));
221
+ healthLogBytes = 0;
222
+ }
223
+ await fsp.appendFile(healthLogPath(), text);
224
+ healthLogBytes += bytes;
225
+ } catch {
226
+ healthLogBytes = 0;
227
+ try {
228
+ await fsp.appendFile(healthLogPath(), text);
229
+ } catch {
230
+ /* 外层 ioQueue 已兜底吞错 */
231
+ }
232
+ }
233
+ });
234
+ };
235
+
236
+ /** 正常无泡(原文可见/顶部区域/空对话/顶部无前置用户消息)——不算失效 */
237
+ const NORMAL_KEYS = new Set([
238
+ "user-msg-in-view",
239
+ "target-in-view",
240
+ "top-in-header",
241
+ "empty-chat",
242
+ "no-user-msg-before",
243
+ "next-anchor-adjacent",
244
+ ]);
245
+
246
+ // 瞬态过滤:异常须持续 ≥300ms 或 ≥10 帧;布局引擎尚未跟上(contentHeight 与本帧布局不一致)
247
+ // 的帧不记录——除非这种不一致伴随同一异常长期持续(逃生口,防止判据失效时永远静默)
248
+ const ANOMALY_MIN_MS = 300;
249
+ const ANOMALY_MIN_FRAMES = 10;
250
+ const MISMATCH_ESCAPE_MS = 2000;
251
+ const MISMATCH_ESCAPE_FRAMES = 60;
252
+
253
+ interface Pending {
254
+ key: string;
255
+ firstAt: number;
256
+ frames: number;
257
+ detail: string;
258
+ }
259
+ let pending: Pending | null = null;
260
+ /** 已写入日志、尚未恢复的异常码(与 pending 分离,保证 anomaly→normal→active 路径也有 recovered) */
261
+ let lastAnomalyKey = "";
262
+
263
+ const clearAnomaly = (to: string) => {
264
+ pending = null;
265
+ if (lastAnomalyKey !== "") {
266
+ healthLog(`recovered (was: ${lastAnomalyKey}) -> ${to}`);
267
+ diag("recovered:", lastAnomalyKey, "->", to);
268
+ lastAnomalyKey = "";
269
+ }
270
+ };
271
+ const noteNormal = (key: string, detail = "") => {
272
+ clearAnomaly(key);
273
+ diag("no-bubble(normal):", key, detail);
274
+ };
275
+ const noteActive = () => clearAnomaly("active");
276
+ const noteAnomaly = (key: string, detail: string, layoutConsistent: boolean, snap?: SnapshotCtx) => {
277
+ const now = nowFn();
278
+ if (!pending || pending.key !== key) pending = { key, firstAt: now, frames: 0, detail };
279
+ pending.frames++;
280
+ pending.detail = detail;
281
+ if (key === lastAnomalyKey) return; // 已记录,持续中
282
+ const elapsed = now - pending.firstAt;
283
+ if (!(elapsed >= ANOMALY_MIN_MS || pending.frames >= ANOMALY_MIN_FRAMES)) return; // 未达持续阈值
284
+ if (!layoutConsistent && !(elapsed >= MISMATCH_ESCAPE_MS && pending.frames >= MISMATCH_ESCAPE_FRAMES)) {
285
+ return; // 布局引擎尚未跟上本帧内容 ⇒ 瞬态
286
+ }
287
+ const was = lastAnomalyKey ? ` (was: ${lastAnomalyKey})` : "";
288
+ lastAnomalyKey = key;
289
+ const tag = layoutConsistent ? "" : " layout-mismatch-persistent";
290
+ healthLog(`NO-BUBBLE ${key} ${detail} [frames=${pending.frames} ms=${elapsed}${tag}]${was}`);
291
+ diag("NO-BUBBLE:", key, detail);
292
+ dumpSnapshot(key, detail, snap);
293
+ };
294
+
295
+ /** 快照上下文:computeState 算到哪一步就带哪些字段 */
296
+ interface SnapshotCtx {
297
+ entries?: Entry[];
298
+ focusIdx?: number; // 视口顶行所在 entry(-1 = 范围外)
299
+ headerHeight?: number;
300
+ scrollTop?: number; // 实际用于判定的顶行(与 focusIdx 对应),贴底时 = base / base+1
301
+ decision?: string; // F1 决策路径
302
+ barHeight?: number; // 上一帧泡高(base 计算基准)
303
+ contentWidth?: number;
304
+ layoutSource?: string;
305
+ docTotal?: number;
306
+ layoutConsistent?: boolean;
307
+ }
308
+
309
+ let lastDumpAt = 0;
310
+ const rotateSnapshots = async (): Promise<void> => {
311
+ const dir = snapshotsDir();
312
+ const names = (await fsp.readdir(dir)).filter((n) => SNAPSHOT_RE.test(n));
313
+ if (names.length <= MAX_SNAPSHOTS) return;
314
+ const stamp = (n: string) => Number(n.match(SNAPSHOT_RE)?.[2] ?? 0);
315
+ names.sort((a, b) => stamp(a) - stamp(b) || a.localeCompare(b));
316
+ const doomed = names.slice(0, names.length - MAX_SNAPSHOTS);
317
+ await Promise.all(doomed.map((n) => fsp.unlink(join(dir, n)).catch(() => {})));
318
+ };
319
+ /** 失效现场快照(瘦身版):头信息 + constructor.name 直方图 + 视口附近 ±20 个 entries */
320
+ const dumpSnapshot = (reason: string, detail: string, ctx?: SnapshotCtx) => {
321
+ if (!healthEnabled()) return;
322
+ try {
323
+ const now = nowFn();
324
+ if (now - lastDumpAt < 5000) {
325
+ healthLog("snapshot skipped (throttled 5s)");
326
+ return;
327
+ }
328
+ lastDumpAt = now;
329
+ const { tui, document: doc, chat } = live;
330
+ const nameOf = (c: any) => {
331
+ if (c === undefined || c === null) return String(c);
332
+ return ctorName(c) || typeof c;
333
+ };
334
+ const textOf = (c: any) => {
335
+ const t = labelOf(c) || (typeof c?.text === "string" ? c.text : "");
336
+ return t ? ` text="${t.replace(/\s+/g, " ").slice(0, 60)}"` : "";
337
+ };
338
+ const out: string[] = [];
339
+ out.push(
340
+ `=== sticky snapshot @ ${logPrefix()} version=${VERSION} reason=${reason} ${detail} ===`,
341
+ );
342
+ out.push(
343
+ `tui: scrollTop=${tui?.scrollTop} viewportHeight=${tui?.viewportHeight} contentHeight=${tui?.contentHeight} scrollbar=${tui?.scrollbar} followingEnd=${tui?.isFollowingEnd}`,
344
+ );
345
+ out.push(
346
+ `layout: source=${ctx?.layoutSource ?? "n/a"} contentWidth=${ctx?.contentWidth ?? "n/a"} headerHeight=${ctx?.headerHeight ?? "n/a"} docTotal=${ctx?.docTotal ?? "n/a"} consistent=${ctx?.layoutConsistent ?? "n/a"} includeCustom=${includeCustom()}`,
347
+ );
348
+ out.push(
349
+ `sticky: decision=${ctx?.decision ?? "n/a"} probeScrollTop=${ctx?.scrollTop ?? "n/a"} lastBarHeight=${ctx?.barHeight ?? F.lastBarHeight} flipsInWindow=${F.flipTimes.length}`,
350
+ );
351
+ const dl = doc?.mouseLayout;
352
+ out.push(`doc.mouseLayout: width=${dl?.width} children=${dl?.children?.length ?? "n/a"}`);
353
+ for (const c of dl?.children ?? []) {
354
+ out.push(` doc-child ${nameOf(c?.component)} h=${c?.height}${c?.component === chat ? " (chat)" : ""}`);
355
+ }
356
+ const cl = chat?.mouseLayout;
357
+ const inDoc = Array.isArray(doc?.children) && doc.children.includes(chat);
358
+ out.push(
359
+ `chat: realChildren=${chat?.children?.length ?? "n/a"} mouseLayoutChildren=${cl?.children?.length ?? "n/a"} mouseLayoutWidth=${cl?.width} chatInDocChildren=${inDoc}`,
360
+ );
361
+ // entries:优先用本帧算好的;否则从 chat.mouseLayout 兜底重建
362
+ let entries = ctx?.entries;
363
+ if (!entries) {
364
+ entries = [];
365
+ let acc = ctx?.headerHeight ?? 0;
366
+ for (const c of cl?.children ?? []) {
367
+ const h = Math.max(0, c?.height ?? 0);
368
+ entries.push({ component: c?.component, start: acc, height: h, kind: anchorKind(c?.component) });
369
+ acc += h;
370
+ }
371
+ }
372
+ const hist = new Map<string, number>();
373
+ const kinds = { user: 0, skill: 0, custom: 0, unrecognized: 0 };
374
+ for (const e of entries) {
375
+ const n = nameOf(e.component);
376
+ hist.set(n, (hist.get(n) ?? 0) + 1);
377
+ if (e.kind) kinds[e.kind]++;
378
+ else if (n === "UserMessageComponent") kinds.unrecognized++;
379
+ }
380
+ const histText = [...hist]
381
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
382
+ .map(([k, v]) => `${k}=${v}`)
383
+ .join(" ");
384
+ out.push(`histogram(n=${entries.length}): ${histText}`);
385
+ out.push(
386
+ `anchors: user=${kinds.user} skill=${kinds.skill} custom=${kinds.custom} unrecognized-user-msg=${kinds.unrecognized}`,
387
+ );
388
+ const focus = ctx?.focusIdx ?? -1;
389
+ const center = focus >= 0 ? focus : entries.length - 1;
390
+ const lo = Math.max(0, center - 20);
391
+ const hi = Math.min(entries.length - 1, center + 20);
392
+ out.push(`entries[${lo}..${hi}] (focus=${focus} scrollTop=${ctx?.scrollTop ?? tui?.scrollTop}):`);
393
+ for (let i = lo; i <= hi; i++) {
394
+ const e = entries[i];
395
+ const kind = e.kind ? ` <${e.kind}>` : "";
396
+ out.push(`${i === focus ? ">" : " "} [${i}] start=${e.start} h=${e.height} ${nameOf(e.component)}${kind}${textOf(e.component)}`);
397
+ }
398
+ const file = join(snapshotsDir(), `snapshot-${process.pid}-${now}.txt`);
399
+ const body = out.join("\n") + "\n";
400
+ enqueueIO(async () => {
401
+ await ensureDirs();
402
+ await fsp.writeFile(file, body);
403
+ await rotateSnapshots();
404
+ });
405
+ healthLog(`snapshot -> ${file}`);
406
+ } catch (e: any) {
407
+ diag("snapshot error:", e?.message);
408
+ }
409
+ };
410
+
411
+ // ── 振荡探测器(F2):泡高度在短窗口内反复翻转 ⇒ 布局反馈环─────────────────
412
+ // 反馈环的两个状态(active / 某个 normal)各自都「正常」,上面的异常状态机永远抓不到;
413
+ // 只有监控「输出本身的翻转频率」才能发现。不依赖任何布局内部判据,故不会随它们一起失效。
414
+ const FLIP_WINDOW_MS = 2000;
415
+ const FLIP_THRESHOLD = 8;
416
+ const FLIP_REPORT_COOLDOWN_MS = 60_000;
417
+ /** 上一帧泡实际输出的行数(F1 的 base 计算需要它把自己占的行加回去) */
418
+ /** F1/F2 监控状态装箱(resetState 整体清零;测试经 getState 读取) */
419
+ const F = { lastBarHeight: 0, flipTimes: [] as number[], lastFlipReportAt: 0, lastNoteAt: -1 };
420
+
421
+ /** 每帧渲染末尾调用:记住泡高(供下一帧算 base)+ 翻转频率监控 */
422
+ const noteBarHeight = (h: number): void => {
423
+ const now = nowFn();
424
+ if (h !== F.lastBarHeight) {
425
+ F.lastBarHeight = h;
426
+ // 同一毫秒内的多次 render(布局引擎 measure + layout 两次调用)只算一次翻转
427
+ if (now === F.lastNoteAt) {
428
+ F.flipTimes[F.flipTimes.length - 1] = now;
429
+ } else {
430
+ F.flipTimes.push(now);
431
+ }
432
+ F.lastNoteAt = now;
433
+ F.flipTimes = F.flipTimes.filter((t) => now - t <= FLIP_WINDOW_MS);
434
+ if (F.flipTimes.length >= FLIP_THRESHOLD && now - F.lastFlipReportAt >= FLIP_REPORT_COOLDOWN_MS) {
435
+ F.lastFlipReportAt = now;
436
+ const span = now - F.flipTimes[0];
437
+ const t = live.tui;
438
+ const detail = `flips=${F.flipTimes.length} within=${span}ms scrollTop=${t?.scrollTop} viewportHeight=${t?.viewportHeight} contentHeight=${t?.contentHeight} followingEnd=${t?.isFollowingEnd}`;
439
+ healthLog(`OSCILLATION ${detail}`);
440
+ diag("OSCILLATION:", detail);
441
+ dumpSnapshot("bar-oscillation", detail);
442
+ }
443
+ } else if (F.flipTimes.length > 0 && now - F.flipTimes[F.flipTimes.length - 1] > FLIP_WINDOW_MS) {
444
+ F.flipTimes = []; // 稳定超过一个窗口 ⇒ 丢弃历史
445
+ }
446
+ };
447
+
448
+ /** 用户消息原文 → 泡文案:第一条非空行 */
449
+ const firstNonEmptyLine = (text: string): string =>
450
+ String(text ?? "").split(/\r?\n/).find((l) => l.trim() !== "") ?? "";
451
+
452
+ // ── 运行时上下文(由 InteractiveMode patch 捕获)───────────────────
453
+ interface StickyCtx {
454
+ tui: any; // transcriptScrollView (ScrollView): scrollTop / scrollTo / getContentWidth / contentHeight
455
+ document: any; // documentContainer (Container)
456
+ chat: any; // chatContainer (Container)
457
+ }
458
+ const live: Partial<StickyCtx> = {};
459
+
460
+ // ── 消息组件识别(duck-typing 为主,constructor.name 兜底;bundle 未混淆类名)──
461
+ const ctorName = (c: any): string => {
462
+ try {
463
+ return typeof c?.constructor?.name === "string" ? c.constructor.name : "";
464
+ } catch {
465
+ return "";
466
+ }
467
+ };
468
+ /** UserMessageComponent:render + rebuild + text:string */
469
+ const isUserMsgComponent = (c: any): boolean =>
470
+ !!c &&
471
+ typeof c.render === "function" &&
472
+ typeof c.rebuild === "function" &&
473
+ typeof c.text === "string";
474
+ /** SkillInvocationMessageComponent(extends Box):setExpanded + updateDisplay + skillBlock.name */
475
+ const isSkillInvocation = (c: any): boolean =>
476
+ !!c &&
477
+ !!c.skillBlock &&
478
+ typeof c.skillBlock.name === "string" &&
479
+ ((typeof c.setExpanded === "function" && typeof c.updateDisplay === "function") ||
480
+ ctorName(c) === "SkillInvocationMessageComponent");
481
+ /** CustomMessageComponent(extends Container):message.customType + rebuild + setExpanded */
482
+ const isCustomMsg = (c: any): boolean =>
483
+ !!c &&
484
+ !!c.message &&
485
+ typeof c.message.customType === "string" &&
486
+ ((typeof c.rebuild === "function" && typeof c.setExpanded === "function") ||
487
+ ctorName(c) === "CustomMessageComponent");
488
+
489
+ type AnchorKind = "user" | "skill" | "custom";
490
+ const anchorKind = (c: any): AnchorKind | null =>
491
+ isUserMsgComponent(c) ? "user" : isSkillInvocation(c) ? "skill" : isCustomMsg(c) ? "custom" : null;
492
+ /** 是否参与吸附目标判定(custom 需 PI_STICKY_INCLUDE_CUSTOM=1) */
493
+ const isAnchor = (kind: AnchorKind | null): boolean =>
494
+ kind === "user" || kind === "skill" || (kind === "custom" && includeCustom());
495
+
496
+ const customText = (m: any): string => {
497
+ if (typeof m?.content === "string") return m.content;
498
+ if (Array.isArray(m?.content)) {
499
+ return m.content
500
+ .filter((p: any) => p?.type === "text")
501
+ .map((p: any) => String(p?.text ?? ""))
502
+ .join("\n");
503
+ }
504
+ return "";
505
+ };
506
+ /** 组件 → 泡文案(非锚点组件返回空串) */
507
+ const labelOf = (c: any): string => {
508
+ switch (anchorKind(c)) {
509
+ case "user":
510
+ return firstNonEmptyLine(c.text);
511
+ case "skill": {
512
+ // 与 skill-block-recollapse 的列表预览同一格式:[skill] name — 参数首行
513
+ const name = c.skillBlock?.name ?? "";
514
+ const msg = firstNonEmptyLine(c.skillBlock?.userMessage ?? "");
515
+ return msg ? `[skill] ${name} — ${msg}` : `[skill] ${name}`;
516
+ }
517
+ case "custom":
518
+ return `[${c.message.customType}] ${firstNonEmptyLine(customText(c.message))}`.trimEnd();
519
+ default:
520
+ return "";
521
+ }
522
+ };
523
+
524
+ /** 泡文案 memo:labelOf+barText 会对整条消息 split/wrap,是 StickyBar.render 每帧的唯一重活。
525
+ * 布局引擎的 renderCache 每帧重建帮不上,这里以组件对象为键(WeakMap,随组件回收)自缓存;
526
+ * 命中条件 = 源引用未变(user 的 text / skill 的 skillBlock / custom 的 message——三者都是
527
+ * 「消息内容不变则引用不变」,内容真变了组件也会整个重建换新键)+ 可用宽度相同。
528
+ * 命中路径仅一次 WeakMap.get + 两次 ===,零分配、零 ANSI/宽度计算;文本与宽度都稳定时
529
+ * (流式期间的常态)每帧全命中。无吸附目标时 render 里 st.target 判空在前,根本不进这里。 */
530
+ type LabelMemo = { kind: AnchorKind; src: unknown; avail: number; out: string };
531
+ const labelMemo = new WeakMap<object, LabelMemo>();
532
+ const labelSrcOf = (c: any, kind: AnchorKind): unknown =>
533
+ kind === "user" ? c.text : kind === "skill" ? c.skillBlock : c.message;
534
+ const barLabel = (c: any, avail: number): string => {
535
+ const memo = labelMemo.get(c);
536
+ if (memo && memo.avail === avail && memo.src === labelSrcOf(c, memo.kind)) return memo.out;
537
+ const kind = anchorKind(c);
538
+ if (!kind) return "";
539
+ const out = barText(labelOf(c), avail);
540
+ labelMemo.set(c, { kind, src: labelSrcOf(c, kind), avail, out });
541
+ return out;
542
+ };
543
+
544
+ interface Entry {
545
+ component: any;
546
+ start: number; // 内容流起始行(含 header 偏移)
547
+ height: number;
548
+ kind: AnchorKind | null;
549
+ }
550
+
551
+ /** 吸附判定的决策路径(F1):direct = 非贴底直接用 tui.scrollTop */
552
+ type Decision = "direct" | "stable-0" | "stable-1" | "tie-break";
553
+
554
+ interface StickyState {
555
+ entries: Entry[];
556
+ headerHeight: number;
557
+ scrollTop: number; // tui 报告的真实 scrollTop(上一帧末)
558
+ probeScrollTop: number; // 实际用于吸附判定的视口顶行(贴底时为 base 或 base+1)
559
+ decision: Decision;
560
+ target: Entry | null; // 当前应吸附的用户消息
561
+ layoutSource: "cached" | "rendered";
562
+ layoutConsistent: boolean;
563
+ }
564
+
565
+ /** resolveTarget 的结果:纯数据,日志留给调用方在最终决策后写一次 */
566
+ interface Resolution {
567
+ target: Entry | null;
568
+ focusIdx: number;
569
+ normal: [string, string] | null;
570
+ anomaly: [string, string] | null;
571
+ }
572
+
573
+ /**
574
+ * 纯函数:给定 entries 与视口顶行 scrollTop,算出应吸附谁。无副作用、不写日志
575
+ * ——F1 的自洽求解要对同一帧调它两次(h=0 / h=1 两个候选坐标),不能有副作用。
576
+ */
577
+ const resolveTarget = (entries: Entry[], headerHeight: number, scrollTop: number): Resolution => {
578
+ // 视口顶行所在 entry:只认实际贡献了行的 entry(h>0);零高 entry 与后继同 start,不参与命中
579
+ const focusIdx = entries.findIndex((e) => e.height > 0 && scrollTop >= e.start && scrollTop < e.start + e.height);
580
+ let target: Entry | null = null;
581
+ let normal: [string, string] | null = null;
582
+ let anomaly: [string, string] | null = null;
583
+ if (focusIdx === -1) {
584
+ // scrollTop 落在 entries 覆盖范围外:顶部 header 区域=正常;超出内容末尾=异常
585
+ const last = entries[entries.length - 1];
586
+ const contentEnd = last.start + last.height;
587
+ if (scrollTop < headerHeight) {
588
+ normal = ["top-in-header", `scrollTop=${scrollTop} headerHeight=${headerHeight}`];
589
+ } else {
590
+ anomaly = [
591
+ "scroll-outside-entries",
592
+ `scrollTop=${scrollTop} headerHeight=${headerHeight} contentEnd=${contentEnd} n=${entries.length}`,
593
+ ];
594
+ }
595
+ } else {
596
+ const hit = entries[focusIdx];
597
+ if (isAnchor(hit.kind)) {
598
+ // 视口顶行落在用户消息块上:原文首行已滚出(scrollTop > start)才吸附
599
+ if (scrollTop > hit.start) target = hit;
600
+ else normal = ["user-msg-in-view", `start=${hit.start} scrollTop=${scrollTop}`];
601
+ } else {
602
+ // 视口顶行是回复/工具/其他:吸附它所属的最近一条用户消息
603
+ for (let j = focusIdx - 1; j >= 0; j--) {
604
+ if (isAnchor(entries[j].kind)) {
605
+ target = entries[j];
606
+ break;
607
+ }
608
+ }
609
+ if (!target) {
610
+ normal = ["no-user-msg-before", `idx=${focusIdx}/${entries.length} scrollTop=${scrollTop}`];
611
+ } else if (scrollTop <= target.start) {
612
+ normal = ["target-in-view", `start=${target.start} scrollTop=${scrollTop}`];
613
+ target = null;
614
+ }
615
+ }
616
+ }
617
+ return { target, focusIdx, normal, anomaly };
618
+ };
619
+
620
+ /**
621
+ * F1 第 1 步:贴底(ScrollView follow:"end" 且当前 followingEnd)时的「无泡视口顶行」base。
622
+ * base = contentHeight - (viewportHeight + 上一帧泡高):把泡自己占掉的行加回去,得到一个
623
+ * 与本模块输出无关的判定坐标。非贴底 / 数据不可用时返回 null(调用方退回 tui.scrollTop)。
624
+ */
625
+ const followEndBase = (tui: any, docTotal: number): number | null => {
626
+ if (tui?.isFollowingEnd !== true) return null;
627
+ const vh = tui.viewportHeight;
628
+ if (typeof vh !== "number" || !Number.isFinite(vh)) return null;
629
+ const ch = typeof tui.contentHeight === "number" && Number.isFinite(tui.contentHeight) ? tui.contentHeight : docTotal;
630
+ const viewportFull = Math.max(1, Math.trunc(vh) + F.lastBarHeight);
631
+ return Math.max(0, Math.trunc(ch) - viewportFull);
632
+ };
633
+
634
+ const freshLayout = (l: any, width: number): boolean => !!l && l.width === width && Array.isArray(l.children);
635
+
636
+ /**
637
+ * 计算当前帧的吸附状态。
638
+ * 优先复用布局引擎上一帧写好的 doc/chat.mouseLayout(与 scrollTop、contentHeight 同帧一致),
639
+ * 宽度不符或尚无布局时才主动 doc.render(contentWidth)。失败一律返回 null(无泡),绝不抛错干扰渲染。
640
+ */
641
+ const computeState = (width: number): StickyState | null => {
642
+ const { tui, document: doc, chat } = live;
643
+ if (!tui || !doc || !chat) {
644
+ noteAnomaly("no-live-refs", `tui=${!!tui} doc=${!!doc} chat=${!!chat}`, true);
645
+ return null;
646
+ }
647
+ try {
648
+ const w = Math.max(1, Math.trunc(width));
649
+ const contentWidth = Math.max(1, Math.trunc(tui.getContentWidth(w)));
650
+
651
+ // P1:复用已算好的 mouseLayout;仅 resize 后首帧 / 尚无布局时回退主动渲染
652
+ let layoutSource: "cached" | "rendered" = "cached";
653
+ if (!freshLayout(doc.mouseLayout, contentWidth) || !freshLayout(chat.mouseLayout, contentWidth)) {
654
+ doc.render(contentWidth);
655
+ layoutSource = "rendered";
656
+ }
657
+ const snap: SnapshotCtx = { contentWidth, layoutSource };
658
+ const docLayout = doc.mouseLayout;
659
+ if (!freshLayout(docLayout, contentWidth)) {
660
+ noteAnomaly("doc-layout-stale", `docLayout.width=${docLayout?.width} contentWidth=${contentWidth}`, true, snap);
661
+ return null;
662
+ }
663
+ let headerHeight = 0;
664
+ let docTotal = 0;
665
+ let sawChat = false;
666
+ for (const c of docLayout.children) {
667
+ const h = Math.max(0, c?.height ?? 0);
668
+ if (!sawChat) {
669
+ if (c?.component === chat) sawChat = true;
670
+ else headerHeight += h;
671
+ }
672
+ docTotal += h;
673
+ }
674
+ // 布局引擎是否已跟上:ScrollView.contentHeight 由上一帧 updateLayout 写入,应等于 doc 总行数;
675
+ // 不等说明本帧数据跨帧(内容刚增减),此时的异常判为瞬态
676
+ const layoutConsistent = typeof tui.contentHeight !== "number" || tui.contentHeight === docTotal;
677
+ snap.headerHeight = headerHeight;
678
+ snap.docTotal = docTotal;
679
+ snap.layoutConsistent = layoutConsistent;
680
+ if (!sawChat) {
681
+ noteAnomaly("chat-not-in-doc", `docChildren=${docLayout.children.length}`, layoutConsistent, snap);
682
+ return null;
683
+ }
684
+
685
+ const chatLayout = chat.mouseLayout;
686
+ if (!freshLayout(chatLayout, contentWidth)) {
687
+ noteAnomaly(
688
+ "chat-layout-stale",
689
+ `chatLayout.width=${chatLayout?.width} contentWidth=${contentWidth}`,
690
+ layoutConsistent,
691
+ snap,
692
+ );
693
+ return null;
694
+ }
695
+ const entries: Entry[] = [];
696
+ let acc = headerHeight;
697
+ let unrecognized = 0;
698
+ for (const c of chatLayout.children) {
699
+ const comp = c?.component;
700
+ const h = Math.max(0, c?.height ?? 0);
701
+ const kind = anchorKind(comp);
702
+ if (kind !== "user" && ctorName(comp) === "UserMessageComponent") unrecognized++; // M3
703
+ entries.push({ component: comp, start: acc, height: h, kind });
704
+ acc += h;
705
+ }
706
+
707
+ const scrollTop = Math.max(0, Math.trunc(tui.scrollTop ?? 0));
708
+ const state: StickyState = {
709
+ entries,
710
+ headerHeight,
711
+ scrollTop,
712
+ probeScrollTop: scrollTop,
713
+ decision: "direct",
714
+ target: null,
715
+ layoutSource,
716
+ layoutConsistent,
717
+ };
718
+ snap.entries = entries;
719
+ snap.scrollTop = scrollTop;
720
+ snap.focusIdx = -1;
721
+ if (entries.length === 0) {
722
+ noteNormal("empty-chat", `scrollTop=${scrollTop} headerHeight=${headerHeight}`);
723
+ return state;
724
+ }
725
+
726
+ // F1:贴底时先换到「无泡视口」坐标系 base(断开 泡高↔视口高↔scrollTop 反馈环),
727
+ // 再对 h∈{0,1} 求自洽解;非贴底时 scrollTop 与泡高无关,直接单次判定。
728
+ const base = followEndBase(tui, docTotal);
729
+ let res: Resolution;
730
+ if (base === null) {
731
+ res = resolveTarget(entries, headerHeight, scrollTop);
732
+ } else {
733
+ const zero = resolveTarget(entries, headerHeight, base); // 候选 h=0:视口顶行 = base
734
+ if (!zero.target) {
735
+ res = zero; // f(base)=0 ⇒ 无泡自洽(优先:原文可见就不吸附)
736
+ state.decision = "stable-0";
737
+ state.probeScrollTop = base;
738
+ } else {
739
+ const one = resolveTarget(entries, headerHeight, base + 1); // 候选 h=1:泡下方顶行 = base+1
740
+ if (one.target) {
741
+ res = one; // f(base+1)=1 ⇒ 有泡自洽
742
+ state.decision = "stable-1";
743
+ state.probeScrollTop = base + 1;
744
+ } else {
745
+ // 两解都不自洽(base+1 恰是下一条消息首行)⇒ 取无泡:它的原文就紧贴在泡下面
746
+ res = {
747
+ target: null,
748
+ focusIdx: zero.focusIdx,
749
+ normal: ["next-anchor-adjacent", `base=${base} nextAnchorStart=${base + 1}`],
750
+ anomaly: one.anomaly ?? zero.anomaly,
751
+ };
752
+ state.decision = "tie-break";
753
+ state.probeScrollTop = base;
754
+ }
755
+ }
756
+ }
757
+ snap.focusIdx = res.focusIdx;
758
+ snap.scrollTop = state.probeScrollTop;
759
+ snap.decision = state.decision;
760
+ snap.barHeight = F.lastBarHeight;
761
+ state.target = res.target;
762
+ // M3:存在真·UserMessageComponent 却识别不出 ⇒ 识别范围失效,优先记录(泡照常按现有识别显示)
763
+ const anomaly: [string, string] | null =
764
+ unrecognized > 0 ? ["unrecognized-user-msg", `count=${unrecognized} n=${entries.length}`] : res.anomaly;
765
+ if (anomaly) noteAnomaly(anomaly[0], anomaly[1], layoutConsistent, snap);
766
+ else if (res.target) noteActive();
767
+ else if (res.normal) noteNormal(res.normal[0], res.normal[1]);
768
+ return state;
769
+ } catch (e: any) {
770
+ noteAnomaly("computeState-throw", String(e?.message ?? e), true);
771
+ return null;
772
+ }
773
+ };
774
+
775
+ // ── StickyBar 组件(动态 0/1 行)──────────────────────────────────
776
+ export const stickyBar: any = {
777
+ __isStickyFirstMessageBar: true,
778
+ __stickyVersion: VERSION,
779
+ render(width: number): string[] {
780
+ if (killed()) return []; // 完全不参与(不记泡高、不计翻转)
781
+ let out: string[] = [];
782
+ try {
783
+ // 低频(每 2s)核对 live 引用是否仍指向当前 InteractiveMode 的容器:只比对/更新引用,
784
+ // 绝不动 root.entries/children——布局引擎此刻正在遍历该数组(挂载只走事件路径)。
785
+ const now = nowFn();
786
+ if (liveSelf && now - lastReassertAt > 2000) {
787
+ lastReassertAt = now;
788
+ const r = syncRefs(liveSelf);
789
+ if (r.status === "changed") healthLog(`SELF-HEAL ${r.detail}`);
790
+ }
791
+ const w = Math.max(1, Math.trunc(width));
792
+ const st = computeState(w);
793
+ if (st?.target) {
794
+ // " ❯ " 3 列前缀(❯ 与正文同色,user/skill/custom 三类锚点统一加;原位消息泡已顶格,
795
+ // 仅悬浮泡保留此前缀)+ 中段省略保句尾 + 右侧 dim ↑ 暗示可点回跳
796
+ const c = barColors();
797
+ const arrow = w >= 12 ? " ↑" : "";
798
+ const pad = Math.min(3, Math.max(0, w - 1)); // 宽 ≥4 时完整 " ❯ ",极窄退化
799
+ const prefix = " ❯ ".slice(0, pad);
800
+ const avail = Math.max(1, w - pad - visibleWidth(arrow));
801
+ const text = barLabel(st.target.component, avail);
802
+ const gap = Math.max(0, w - pad - visibleWidth(text) - visibleWidth(arrow));
803
+ out = [`${c.bg}${c.fg}${prefix}${text}${" ".repeat(gap)}${arrow ? `${c.dim}${arrow}` : ""}${RESET}`];
804
+ }
805
+ } catch (e: any) {
806
+ noteAnomaly("render-throw", String(e?.message ?? e), true);
807
+ out = [];
808
+ }
809
+ noteBarHeight(out.length); // F1 下一帧的 base 基准 + F2 翻转监控
810
+ return out;
811
+ },
812
+ handleMouse(event: any): any {
813
+ try {
814
+ if (event?.type !== "click" || event?.button !== "left") return undefined;
815
+ const st = computeState(Math.max(1, Math.trunc(event?.width ?? 0)));
816
+ if (!st?.target) return undefined;
817
+ live.tui?.scrollTo?.(st.target.start);
818
+ diag("jump to line", st.target.start);
819
+ return { handled: true };
820
+ } catch (e: any) {
821
+ diag("handleMouse error:", e?.message);
822
+ return undefined;
823
+ }
824
+ },
825
+ invalidate(): void {},
826
+ };
827
+
828
+ // ── InteractiveMode patch:捕获实例 + 向 root VStack 插入 StickyBar ──
829
+ /** 跨模块实例共享(Symbol.for):prototype 只包一层;wrapper 经 slot 调最新模块 */
830
+ const FLAG = Symbol.for("sticky-first-message/patched");
831
+ const SLOT = Symbol.for("sticky-first-message/slot");
832
+ interface Slot {
833
+ installInto: (self: any) => void;
834
+ stickyBar: any;
835
+ version: string;
836
+ }
837
+ const claimSlot = (): void => {
838
+ const slot: Slot = { installInto, stickyBar, version: VERSION };
839
+ (globalThis as any)[SLOT] = slot;
840
+ };
841
+ const currentSlot = (): Slot | undefined => (globalThis as any)[SLOT];
842
+
843
+ /** 最近一次见到的 InteractiveMode 实例(渲染帧低频核对 live 引用用) */
844
+ let liveSelf: any = undefined;
845
+ let lastReassertAt = 0;
846
+ /** 本模块实例的泡是否曾成功挂载(区分 INSTALL 与 bar-missing 自愈) */
847
+ let everMounted = false;
848
+
849
+ function looksLikeInteractiveMode(proto: any): boolean {
850
+ return (
851
+ !!proto &&
852
+ typeof proto.renderInitialMessages === "function" &&
853
+ typeof proto.addMessageToChat === "function"
854
+ );
855
+ }
856
+
857
+ interface RefsResult {
858
+ status: "same" | "first" | "changed" | "unavailable";
859
+ detail: string;
860
+ }
861
+ /** 比对并更新 live 引用;不写日志(由调用方决定如何归类) */
862
+ function syncRefs(self: any): RefsResult {
863
+ const tui = self?.transcriptScrollView;
864
+ const doc = self?.documentContainer;
865
+ const chat = self?.chatContainer;
866
+ if (!tui || !doc || !chat) return { status: "unavailable", detail: "" };
867
+ if (live.tui === tui && live.document === doc && live.chat === chat) return { status: "same", detail: "" };
868
+ const first = !live.tui && !live.document && !live.chat;
869
+ const detail = `refs(tui=${live.tui === tui},doc=${live.document === doc},chat=${live.chat === chat},chatChildren=${
870
+ (live.chat as any)?.children?.length ?? "?"
871
+ }→${chat.children?.length ?? "?"})`;
872
+ live.tui = tui;
873
+ live.document = doc;
874
+ live.chat = chat;
875
+ return { status: first ? "first" : "changed", detail };
876
+ }
877
+
878
+ /**
879
+ * H1 中和外来泡:其他模块实例(/reload 前的旧模块)留在 root 里的 stickyBar 不驱逐、不移位,
880
+ * 只把 render 换成空输出、handleMouse 换成无操作。旧 wrapper 每个事件检查「我的泡在不在 root」
881
+ * 仍通过,因而安静;旧 computeState 不再运行,不产生旧版日志。返回本次新中和的个数。
882
+ */
883
+ function neutralizeForeignBars(root: any): number {
884
+ let n = 0;
885
+ const seen = new Set<any>();
886
+ const visit = (c: any) => {
887
+ if (!c || typeof c !== "object" || c === stickyBar || seen.has(c)) return;
888
+ seen.add(c);
889
+ if (c.__isStickyFirstMessageBar !== true || c.__stickyNeutralized === true) return;
890
+ try {
891
+ c.render = () => [];
892
+ c.handleMouse = () => undefined;
893
+ c.__stickyNeutralized = true;
894
+ c.__stickyNeutralizedBy = VERSION;
895
+ n++;
896
+ } catch (e: any) {
897
+ diag("neutralize error:", e?.message);
898
+ }
899
+ };
900
+ for (const e of root.entries) visit(e?.component);
901
+ for (const c of root.children) visit(c);
902
+ return n;
903
+ }
904
+
905
+ /**
906
+ * 幂等安装 + 自愈(事件路径专用;渲染路径不得调用——见 stickyBar.render):
907
+ * 每次实际校验「stickyBar 在 root」与「live 指向 self 容器」,偏差即修复。
908
+ * 日志归类:live 为空 ⇒ INSTALL(首次安装,附中和的外来泡数);live 非空且引用变化 ⇒ SELF-HEAL refs;
909
+ * 泡曾挂载而丢失 ⇒ SELF-HEAL bar-missing。
910
+ */
911
+ function installInto(self: any): void {
912
+ try {
913
+ if (!self) return;
914
+ liveSelf = self;
915
+ const root = self.fullscreenLayoutRoot;
916
+ if (!root || !Array.isArray(root.entries) || !Array.isArray(root.children)) return;
917
+ const refs = syncRefs(self);
918
+ if (refs.status === "unavailable") return;
919
+
920
+ const foreign = neutralizeForeignBars(root);
921
+ const inEntries = root.entries.some((e: any) => e?.component === stickyBar);
922
+ const inChildren = root.children.includes(stickyBar);
923
+ const mounted = inEntries && inChildren;
924
+ if (mounted && refs.status === "same" && foreign === 0) return; // 一切正常,零开销通过
925
+
926
+ const why: string[] = [];
927
+ if (!mounted) {
928
+ // 清理半插状态(如在 children 不在 entries),再整体重插首位
929
+ if (inChildren) {
930
+ const i = root.children.indexOf(stickyBar);
931
+ if (i !== -1) root.children.splice(i, 1);
932
+ }
933
+ if (inEntries) {
934
+ const i = root.entries.findIndex((e: any) => e?.component === stickyBar);
935
+ if (i !== -1) root.entries.splice(i, 1);
936
+ }
937
+ const before = `entries=${root.entries.length},children=${root.children.length}${inEntries !== inChildren ? ",half" : ""}`;
938
+ root.entries.unshift({ component: stickyBar, basis: "auto", grow: 0, shrink: 0, minSize: 0 });
939
+ root.children.unshift(stickyBar);
940
+ if (everMounted) why.push(`bar-missing(${before})`);
941
+ everMounted = true;
942
+ }
943
+ if (refs.status === "changed") why.push(refs.detail);
944
+ if (foreign > 0 && refs.status !== "first") why.push(`neutralized(${foreign})`);
945
+
946
+ if (refs.status === "first") {
947
+ healthLog(`INSTALL version=${VERSION} foreignBars=${foreign} entries=${root.entries.length}`);
948
+ diag("installed; foreign bars neutralized:", foreign);
949
+ } else if (why.length > 0) {
950
+ healthLog(`SELF-HEAL ${why.join("+")}`);
951
+ diag("self-healed:", why.join("+"));
952
+ }
953
+ } catch (e: any) {
954
+ diag("install error:", e?.message);
955
+ }
956
+ }
957
+
958
+ function patchInteractiveMode(cls: any): boolean {
959
+ const proto = cls?.prototype;
960
+ if (!proto || !looksLikeInteractiveMode(proto)) return false;
961
+ if (proto[FLAG]) return true; // 任一模块实例已包过:不叠层,wrapper 经 slot 调最新模块
962
+ proto[FLAG] = VERSION;
963
+ // 三入口兜底(同 tidy-anytool 先例):历史渲染、流式消息、每事件必经
964
+ for (const method of ["renderInitialMessages", "addMessageToChat", "handleEvent"] as const) {
965
+ const original = proto[method];
966
+ if (typeof original !== "function") continue;
967
+ proto[method] = function patchedStickyHook(this: any, ...args: any[]) {
968
+ try {
969
+ currentSlot()?.installInto?.(this);
970
+ } catch {}
971
+ return original.call(this, ...args);
972
+ };
973
+ }
974
+ return true;
975
+ }
976
+
977
+ /** 类来源由 _shared/pi-runtime.ts 提供(进程内只扫一次;bundle 运行时 = 包导出类 + 主 chunk) */
978
+ async function patchAllCandidates(): Promise<number> {
979
+ let patched = 0;
980
+ const rt = await getPiRuntime();
981
+ diag("pi runtime:", rt.pkgRoot, rt.piVersion, `cacheHit=${rt.cacheHit}`, `${rt.ms}ms`);
982
+ for (const { where, mod } of rt.modules) {
983
+ for (const key of Object.keys(mod)) {
984
+ let cls: any;
985
+ try {
986
+ cls = mod[key];
987
+ } catch {
988
+ continue;
989
+ }
990
+ if ((key.includes("InteractiveMode") || looksLikeInteractiveMode(cls?.prototype)) && patchInteractiveMode(cls)) {
991
+ patched++;
992
+ diag("patched interactive mode:", where, key);
993
+ }
994
+ }
995
+ }
996
+ return patched;
997
+ }
998
+
999
+ // ── 测试钩子(不影响运行时;pi 加载只执行 default export)──────────
1000
+ export const __stickyTest = {
1001
+ VERSION,
1002
+ FLAG,
1003
+ SLOT,
1004
+ NORMAL_KEYS,
1005
+ MAX_SNAPSHOTS,
1006
+ ANOMALY_MIN_MS,
1007
+ ANOMALY_MIN_FRAMES,
1008
+ MISMATCH_ESCAPE_MS,
1009
+ MISMATCH_ESCAPE_FRAMES,
1010
+ FLIP_WINDOW_MS,
1011
+ FLIP_THRESHOLD,
1012
+ FLIP_REPORT_COOLDOWN_MS,
1013
+ computeState,
1014
+ resolveTarget,
1015
+ followEndBase,
1016
+ noteBarHeight,
1017
+ installInto,
1018
+ syncRefs,
1019
+ neutralizeForeignBars,
1020
+ patchInteractiveMode,
1021
+ claimSlot,
1022
+ currentSlot,
1023
+ resolvePkgRoot,
1024
+ firstNonEmptyLine,
1025
+ labelOf,
1026
+ barText,
1027
+ barLabel,
1028
+ labelSrcOf,
1029
+ truncMiddle,
1030
+ anchorKind,
1031
+ isUserMsgComponent,
1032
+ isSkillInvocation,
1033
+ isCustomMsg,
1034
+ dumpSnapshot,
1035
+ healthLog,
1036
+ vis,
1037
+ stickyBar,
1038
+ setLive(ctx: Partial<StickyCtx>) {
1039
+ Object.assign(live, ctx);
1040
+ },
1041
+ clearLive() {
1042
+ live.tui = undefined;
1043
+ live.document = undefined;
1044
+ live.chat = undefined;
1045
+ },
1046
+ getLive: () => ({ ...live }),
1047
+ /** 注入单调时钟(瞬态/持续阈值、快照节流、渲染帧核对间隔都用它) */
1048
+ setNow(fn: (() => number) | null) {
1049
+ nowFn = fn ?? (() => Date.now());
1050
+ },
1051
+ /** 重定向数据目录(health.log + snapshots/);轮转计数随之重新校准 */
1052
+ setDataDir(dir: string | null) {
1053
+ dataDir = dir ?? resolveDataDir();
1054
+ dirsReady = undefined;
1055
+ healthLogBytes = -1;
1056
+ },
1057
+ getDataDir: () => dataDir,
1058
+ healthLogPath,
1059
+ snapshotsDir,
1060
+ /** 等待所有已排队的日志/快照写入落盘 */
1061
+ flushIO: () => ioQueue,
1062
+ /** 清空全部模块内状态(live/liveSelf/异常状态机/挂载标记/节流) */
1063
+ resetState() {
1064
+ live.tui = undefined;
1065
+ live.document = undefined;
1066
+ live.chat = undefined;
1067
+ liveSelf = undefined;
1068
+ lastReassertAt = 0;
1069
+ lastDumpAt = 0;
1070
+ pending = null;
1071
+ lastAnomalyKey = "";
1072
+ everMounted = false;
1073
+ F.lastBarHeight = 0;
1074
+ F.flipTimes = [];
1075
+ F.lastFlipReportAt = 0;
1076
+ F.lastNoteAt = -1;
1077
+ },
1078
+ getState: () => ({
1079
+ lastAnomalyKey,
1080
+ pending,
1081
+ everMounted,
1082
+ liveSelf,
1083
+ lastReassertAt,
1084
+ lastBarHeight: F.lastBarHeight,
1085
+ flips: F.flipTimes.length,
1086
+ lastFlipReportAt: F.lastFlipReportAt,
1087
+ }),
1088
+ };
1089
+
1090
+ export default async function stickyFirstMessage(pi: ExtensionAPI): Promise<void> {
1091
+ // 主题探针:零高 widget,只为拿到 (tui, theme) 里的 theme(泡的底色/前景跟随主题)
1092
+ pi.on("session_start", async (_e, ctx: any) => {
1093
+ if (!ctx?.hasUI || typeof ctx.ui?.setWidget !== "function") return;
1094
+ try {
1095
+ ctx.ui.setWidget("sticky-first-message:theme-probe", (_tui: any, theme: any) => {
1096
+ captureTheme(theme);
1097
+ return { render: () => [], invalidate() {} };
1098
+ });
1099
+ } catch (e: any) {
1100
+ diag("theme probe failed:", e?.message);
1101
+ }
1102
+ });
1103
+ try {
1104
+ if (killed()) {
1105
+ diag("disabled via PI_STICKY_FIRST_MESSAGE=0");
1106
+ return;
1107
+ }
1108
+ claimSlot(); // 本模块成为已有 wrapper 的间接调用目标(覆盖旧模块)
1109
+ const n = await patchAllCandidates();
1110
+ diag("patched classes:", n, "version:", VERSION);
1111
+ } catch (e: any) {
1112
+ diag("entry error:", e?.message);
1113
+ }
1114
+ }