pi-thinking-header 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,12 +4,15 @@ dsh 风格的 pi thinking 显示插件:带 token 计数的单行标题 + 折
4
4
 
5
5
  ```
6
6
  展开时: 折叠时(ctrl+t / 点击标题行):
7
- Thinking (~2.50k tokens) 流式中:Thinking (~2.50k tokens) · …尾随最新内容保持可见
8
- 模型思考全文… 结束后:Thinking (~2.50k tokens) · 第一行内容…
9
- 正式回复… 正式回复…
7
+ 流式中:Thinking… 流式中:Thinking (~2.50k tokens) · …尾随最新内容保持可见
8
+ 结束后:Thinking (~2.50k tokens) 结束后:Thinking (~2.50k tokens) · 第一行内容…
9
+ 模型思考全文…(流式中仅最后一行刷新) 正式回复…
10
+ 正式回复…
10
11
  ```
11
12
 
12
- - token 数量为本地估算:`ceil(chars / 4)`,流式输出时实时增长
13
+ - token 数量为本地估算:`ceil(chars / 4)`。折叠预览中流式实时增长;展开时标题行
14
+ 在流式期间保持稳定(只显示 `Thinking…`),块结束后才显示最终计数——pi 按整行连续
15
+ 区间重绘,标题行的计数若每个 chunk 都变化,会把前面的思考行与最新行一起擦除重绘
13
16
  - 小数点位数可配置(默认 2 位):`/thinking-header decimals <0-6>`,或直接编辑
14
17
  配置文件 `~/.pi/agent/thinking-header.json`(`{"decimals": 2}`)。例如默认效果
15
18
  `2.50k`;`decimals: 0` → `3k`;`decimals: 4` → `2.5000k`。两种模式均实时读取
@@ -39,17 +42,26 @@ pi install ./pi-thinking-header
39
42
  所有功能的统一入口:
40
43
 
41
44
  ```
42
- /thinking-header # 状态总览(模式/补丁状态/decimals)+ 用法
45
+ /thinking-header # 状态总览(模式/补丁状态/autoPatch/decimals)+ 用法
43
46
  /thinking-header patch # 应用/修复完整模式补丁(确认后执行)
47
+ /thinking-header autopatch # 查看启动自动补丁状态
48
+ /thinking-header autopatch on|off# 启动时自动检查并应用/迁移补丁(默认 on)
44
49
  /thinking-header decimals # 查看当前小数位
45
50
  /thinking-header decimals <0-6> # 设置小数位并持久化(默认 2)
46
51
  ```
47
52
 
53
+ 启动自动补丁(`autopatch`,默认开启):每次会话启动(`session_start`)自动探测磁盘上的
54
+ bundle——未打补丁时自动应用、检测到旧版本 patch 时自动迁移(installer 自带备份恢复逻辑),
55
+ 已是最新的则静默跳过。补丁写入磁盘后需重启 pi 才生效:当前会话仍用降级/旧渲染,notify
56
+ 会提示。因此 `pi update` 之后补丁丢失、或插件包更新带来新 patch 版本时,都无需手动
57
+ 处理,下次启动自动修复。关闭方式:`/thinking-header autopatch off`,或直接编辑配置文件
58
+ (`{"autoPatch": false}`)。
59
+
48
60
  补丁安装(`patch` 子命令,内部运行 `node install-patch.mjs`):
49
61
 
50
- - 已打补丁 → 提示 already applied(含当前 decimals)
51
- - 未打补丁(或补丁版本较旧)→ 确认后执行安装/迁移,输出结果并提醒重启 pi(当前会话仍是旧代码,重启后生效)
52
- - `pi update` 之后补丁丢失时,extension 自动进入降级模式,此时用该命令可一键修复
62
+ - 已是当前版本 → 提示 already applied(含当前 decimals)
63
+ - 未打补丁(或补丁版本较旧 → 迁移)→ 确认后执行安装/迁移,输出结果并提醒重启 pi(当前会话仍是旧代码,重启后生效)
64
+ - `pi update` 之后补丁丢失时,extension 自动进入降级模式;启动自动补丁(默认开)会在下次会话开始时自动修复,也可用该命令立即修复
53
65
 
54
66
  小数位配置(`decimals` 子命令):
55
67
 
@@ -64,7 +76,8 @@ pi install ./pi-thinking-header
64
76
 
65
77
  pi 的 extension API 无法接管内置 thinking 块的折叠渲染,完整效果需要附带
66
78
  的 bundle patch。推荐直接在 pi 会话内运行 `/thinking-header patch`
67
- (自动定位安装位置);也可手动执行:
79
+ (自动定位安装位置);也可什么都不做——默认开启的启动自动补丁会在首次
80
+ 会话开始时自动应用。手动执行:
68
81
 
69
82
  ```bash
70
83
  # npm 安装:~/.pi/agent/npm/pi-thinking-header/install-patch.mjs
@@ -73,13 +86,17 @@ node <包目录>/install-patch.mjs
73
86
  ```
74
87
 
75
88
  - 每条消息独立的 token 计数与预览(含历史消息)
89
+ - 展开态标题行流式期间字节级稳定(`Thinking…`):折叠更新只重绘最后一行,
90
+ 之前的思考行不会被一起擦除重绘;块结束后标题行才出现最终计数(回复流式时
91
+ 已结束的思考块直接显示最终计数)
76
92
  - 折叠态严格单行、宽度感知截断、点击切换
77
93
  - 流式期间折叠预览滚动跟随最新行行尾(dsh 同款),结束后回到第一行
78
94
  - 小数点位数渲染时实时读取配置文件(mtime 缓存),与降级模式共享同一份配置
79
- - 幂等;自动从 v1/v2/v3/v3.1 patch 迁移(恢复原始备份后重新应用;早期 v3
95
+ - 幂等;自动从 v1/v2/v3/v3.1/v3.2 patch 迁移(恢复原始备份后重新应用;早期 v3
80
96
  折叠组件缺 `invalidate()`、退出/切换模式时触发 `this.child.invalidate is not
81
- a function` 崩溃且无备份时,会先原地热修复);`pi update` 后重跑一次即可;
82
- 插件包更新后也建议重跑 `/thinking-header patch` 使 patch 与 extension 版本一致
97
+ a function` 崩溃且无备份时,会先原地热修复);`pi update` 后无需手动处理——
98
+ 启动自动补丁(默认开)会在下次会话开始时重新应用/迁移;插件包更新后同样如此
99
+ (patch 版本随插件更新时自动迁移到新版)
83
100
  - 回滚:
84
101
  ```bash
85
102
  cp <chunk路径>.pre-thinking-header.bak <chunk路径>
@@ -89,11 +106,13 @@ node <包目录>/install-patch.mjs
89
106
  ### 纯插件模式:extension 自动降级
90
107
 
91
108
  extension 启动时探测运行中的 pi bundle:**检测到任意版本的 patch 时**(含旧版本)
92
- extension 保持惰性(避免双标题),请运行 `/thinking-header patch` 迁移到当前 patch 版本;
93
- **未检测到 patch 时**自动进入降级模式:
109
+ extension 保持惰性(避免双标题;旧版本会在下次会话启动时被自动迁移到当前版本,
110
+ 或运行 `/thinking-header patch` 立即迁移);**未检测到 patch 时**自动进入降级模式,
111
+ 并在会话启动时自动应用补丁(默认行为,可关闭):
94
112
 
95
113
  - 展开的 thinking:通过 markdown transformer 前置 `Thinking (~N tokens)` 标题行
96
- (按块正确计数,`pi update` 后依然有效,无需维护)
114
+ (按块正确计数,`pi update` 后依然有效,无需维护)。流式期间同样保持稳定
115
+ (`Thinking…`),块结束后显示最终计数,避免正文旧行被一起重绘
97
116
  - 折叠的 thinking:pi 的 extension API 只暴露一个全局 label,插件会在每条
98
117
  assistant 消息结束后将其更新为最新消息的 `计数 · 预览`。局限:转录里更早的
99
118
  折叠块也会显示最新 label —— 需要按消息独立显示请打 bundle patch
@@ -104,7 +123,7 @@ extension 保持惰性(避免双标题),请运行 `/thinking-header patch`
104
123
  | 文件 | 说明 |
105
124
  |------|------|
106
125
  | `index.js` | pi extension(模式检测 + 降级实现,含流式行尾跟随 label) |
107
- | `install-patch.mjs` | 完整模式安装器(v3.2 bundle patch,渲染时读取 decimals 配置) |
126
+ | `install-patch.mjs` | 完整模式安装器(v3.3 bundle patch,渲染时读取 decimals 配置) |
108
127
  | `package.json` | pi 包清单(`pi.extensions`) |
109
128
 
110
129
  ## License
package/index.js CHANGED
@@ -7,7 +7,11 @@
7
7
  *
8
8
  * Thinking (~2.50k tokens)
9
9
  *
10
- * - Expanded: header line + full thinking text
10
+ * - Expanded: header line + full thinking text; while the block streams the
11
+ * header line stays byte-stable ("Thinking…") — pi's TUI redraws whole
12
+ * lines over the contiguous changed range, so any header change would
13
+ * redraw every earlier thinking line together with the latest one. The
14
+ * token count appears once the block settles.
11
15
  * - Collapsed: single line — header + a preview of the thinking content that
12
16
  * follows dsh's ReasoningRow: while streaming it shows the LATEST line with
13
17
  * the window pinned to its tail (leading ellipsis — the line "scrolls" as
@@ -23,13 +27,23 @@
23
27
  * 2. FALLBACK MODE (pure extension API) — used when the bundle is unpatched:
24
28
  * - Expanded thinking: prepends a `Thinking (~N tokens)` header via a
25
29
  * markdown transformer (correct per-block counts, works everywhere,
26
- * survives pi updates).
30
+ * survives pi updates). Same streaming-stable header as full mode: while
31
+ * `ctx.isStreaming` the header stays "Thinking…" and only the final
32
+ * count is shown once the block settles.
27
33
  * - Collapsed thinking: updates the global hidden-thinking label on each
28
34
  * assistant message with the latest count + preview. Limitation: pi's
29
35
  * extension API only exposes ONE global label, so collapsed blocks in
30
36
  * older transcript messages will show the most recent label. Run
31
37
  * /thinking-header patch for per-message fidelity.
32
38
  *
39
+ * Startup auto-patch (autoPatch, default on): every session_start re-probes
40
+ * the bundle — unpatched → apply the patch, older family version → migrate —
41
+ * so a lost patch after `pi update` (or a newer extension release) heals on
42
+ * the next launch with zero manual steps. The patched file matters for the
43
+ * NEXT restart; the running session keeps its fallback (or old-patch)
44
+ * renderer and a notify explains that. Disable: /thinking-header autopatch
45
+ * off, or {"autoPatch": false} in the config file.
46
+ *
33
47
  * Token counts are local estimates: ceil(chars / 4). The k-token decimal places
34
48
  * are configurable (default 2): /thinking-header decimals <0-6>, persisted to
35
49
  * <agentDir>/thinking-header.json ({"decimals": 2}). Both modes re-read the
@@ -50,9 +64,16 @@ const INSTALLER_PATH = fileURLToPath(new URL("./install-patch.mjs", import.meta.
50
64
 
51
65
  // Family-wide marker: ANY patch version owns header rendering end-to-end, so
52
66
  // the extension must stay inert for all of them (avoids double headers). The
53
- // /thinking-header patch migrates older patch versions to the current one.
67
+ // /thinking-header patch (and the startup auto-patch) migrates older patch
68
+ // versions to the current one.
54
69
  const MARKER = "pi-thinking-header";
55
70
 
71
+ // Current patch version — keep in sync with CURRENT_MARKER in
72
+ // install-patch.mjs. A disk bundle carrying only an older family version is
73
+ // "patched" (extension stays inert) but not "current" (auto-migrates).
74
+ const PATCH_VERSION = "v3.3";
75
+ const CURRENT_MARKER = MARKER + ":" + PATCH_VERSION;
76
+
56
77
  // ---------------- decimals config (shared with the bundle patch) -------------
57
78
  // <agentDir>/thinking-header.json: { "decimals": 2 } — k-token decimal places.
58
79
  // The bundle patch reads the same file at render time (mtime-cached), so both
@@ -94,7 +115,10 @@ const getDecimals = () => {
94
115
  return typeof d === "number" && Number.isInteger(d) && d >= 0 && d <= 6 ? d : DEFAULT_DECIMALS;
95
116
  };
96
117
 
97
- function saveDecimals(n) {
118
+ // Startup auto-patch: only an explicit `false` disables it (absent key = on).
119
+ const getAutoPatch = () => loadConfig().autoPatch !== false;
120
+
121
+ function saveConfigKey(key, value) {
98
122
  let cfg = {};
99
123
  try {
100
124
  const parsed = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
@@ -102,7 +126,7 @@ function saveDecimals(n) {
102
126
  } catch {
103
127
  // no/invalid config → start fresh, keep nothing
104
128
  }
105
- cfg.decimals = n;
129
+ cfg[key] = value;
106
130
  mkdirSync(dirname(CONFIG_PATH), { recursive: true });
107
131
  writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
108
132
  configCache.mtime = -1n; // force re-read on next loadConfig()
@@ -113,7 +137,9 @@ const COMPONENT_ANCHOR =
113
137
 
114
138
  /**
115
139
  * Locate the running pi's bundle chunk that contains AssistantMessageComponent.
116
- * Returns { found: true, patched: boolean } or { found: false }.
140
+ * Returns { found: true, patched, current } or { found: false }:
141
+ * - patched: ANY pi-thinking-header family version is on disk
142
+ * - current: exactly the version this extension ships (PATCH_VERSION)
117
143
  */
118
144
  function probeBundle() {
119
145
  try {
@@ -128,7 +154,11 @@ function probeBundle() {
128
154
  const path = join(chunksDir, file);
129
155
  const src = readFileSync(path, "utf8");
130
156
  if (!src.includes(COMPONENT_ANCHOR)) continue;
131
- return { found: true, patched: src.includes(MARKER) };
157
+ return {
158
+ found: true,
159
+ patched: src.includes(MARKER),
160
+ current: src.includes(CURRENT_MARKER),
161
+ };
132
162
  }
133
163
  } catch {
134
164
  // pi binary not resolvable — fall through
@@ -204,10 +234,58 @@ const thinkingLabel = (blocks, running) => {
204
234
  return label;
205
235
  };
206
236
 
237
+ /**
238
+ * Startup auto-patch: on session_start, re-probe the bundle and run the
239
+ * installer when the disk is unpatched (apply) or carries an older family
240
+ * version (migrate). The patched file only matters for the NEXT pi start —
241
+ * the running session keeps whichever renderer it booted with — so the
242
+ * notify says so. Best-effort by design: failures degrade to the manual
243
+ * /thinking-header patch command, never to a broken startup.
244
+ */
245
+ function registerAutoPatch(pi) {
246
+ pi.on("session_start", async (_event, ctx) => {
247
+ try {
248
+ if (!getAutoPatch()) return;
249
+ const before = probeBundle();
250
+ if (!before.found || before.current) return; // up to date / not resolvable
251
+ const migrating = before.patched; // older family version on disk
252
+ const { stdout, stderr } = await execFileAsync(
253
+ process.execPath,
254
+ [INSTALLER_PATH],
255
+ { timeout: 30_000, windowsHide: true, maxBuffer: 1024 * 1024 },
256
+ );
257
+ if (probeBundle().current) {
258
+ ctx.ui.notify(
259
+ `pi-thinking-header: bundle patch ${migrating ? "migrated to" : "applied"} (${PATCH_VERSION}) at startup — ` +
260
+ "effective after the next restart; this session keeps its current renderer.",
261
+ "info",
262
+ );
263
+ } else {
264
+ const out = (stdout + "\n" + stderr).trim();
265
+ ctx.ui.notify(
266
+ "pi-thinking-header: startup auto-patch had no effect.\n" +
267
+ (out || "(no installer output)") +
268
+ "\nRun /thinking-header patch for details.",
269
+ "warning",
270
+ );
271
+ }
272
+ } catch (err) {
273
+ const detail = [err?.message, err?.stdout, err?.stderr].filter(Boolean).join("\n");
274
+ ctx.ui.notify(
275
+ "pi-thinking-header: startup auto-patch failed.\n" +
276
+ detail +
277
+ "\nRun /thinking-header patch to apply manually.",
278
+ "warning",
279
+ );
280
+ }
281
+ });
282
+ }
283
+
207
284
  export default function (pi) {
208
285
  // /thinking-header — single entry point for everything this package does:
209
286
  // /thinking-header status overview + usage
210
287
  // /thinking-header patch apply/repair the full-mode bundle patch
288
+ // /thinking-header autopatch on|off startup auto-check+apply (default on)
211
289
  // /thinking-header decimals show the k-token decimal places
212
290
  // /thinking-header decimals <0-6> set + persist them (0-6, default 2)
213
291
  // Registered in BOTH modes. Fallback mode re-reads the config per label; the
@@ -217,21 +295,38 @@ export default function (pi) {
217
295
  "usage:\n" +
218
296
  " /thinking-header status overview\n" +
219
297
  " /thinking-header patch apply/repair the full-mode patch\n" +
298
+ " /thinking-header autopatch on|off startup auto-check+apply (default on)\n" +
220
299
  " /thinking-header decimals show k-token decimal places\n" +
221
300
  " /thinking-header decimals <0-6> set them (persisted, applies live)";
222
301
 
223
302
  pi.registerCommand("thinking-header", {
224
- description:
225
- "pi-thinking-header: status / `patch` apply-repair full-mode patch / `decimals <0-6>` token-count decimals",
303
+ description: "Thinking display settings: status, patch, autopatch, decimals",
226
304
  getArgumentCompletions: (prefix) => {
227
305
  const p = (prefix ?? "").trimStart();
228
- if (p.includes(" ")) return null;
229
- const items = [
230
- { value: "patch", label: "apply/repair the full-mode bundle patch" },
231
- { value: "decimals", label: "k-token decimal places (0-6, default 2)" },
232
- ];
233
- const filtered = items.filter((i) => i.value.startsWith(p));
234
- return filtered.length > 0 ? filtered : null;
306
+ const tokens = p.split(/\s+/);
307
+ // Fully typed token → close the menu so Enter SUBMITS the command:
308
+ // pi's editor consumes Enter to accept an open completion (argument
309
+ // prefixes don't start with "/", so accept-without-submit applies), and
310
+ // an exact-match item would re-open the menu on every re-query — Enter
311
+ // would be swallowed repeatedly and the command would never run.
312
+ if (tokens.length === 1) {
313
+ const items = [
314
+ { value: "patch", label: "apply/repair the full-mode bundle patch" },
315
+ { value: "autopatch", label: "startup auto-apply/migrate the patch (on/off)" },
316
+ { value: "decimals", label: "k-token decimal places (0-6, default 2)" },
317
+ ];
318
+ const filtered = items.filter((i) => i.value.startsWith(tokens[0]));
319
+ if (filtered.length === 0) return null;
320
+ if (filtered.length === 1 && filtered[0].value === tokens[0]) return null;
321
+ return filtered;
322
+ }
323
+ if (tokens.length === 2 && tokens[0] === "autopatch") {
324
+ const opts = ["on", "off"].filter((o) => o.startsWith(tokens[1]));
325
+ if (opts.length === 0) return null;
326
+ if (opts.length === 1 && opts[0] === tokens[1]) return null;
327
+ return opts.map((o) => ({ value: o, label: `startup auto-patch: ${o}` }));
328
+ }
329
+ return null; // decimals <0-6>: single-digit values never need completion
235
330
  },
236
331
  handler: async (args, ctx) => {
237
332
  const argv = (args ?? "").trim().split(/\s+/).filter(Boolean);
@@ -254,7 +349,7 @@ export default function (pi) {
254
349
  return;
255
350
  }
256
351
  try {
257
- saveDecimals(n);
352
+ saveConfigKey("decimals", n);
258
353
  } catch (err) {
259
354
  ctx.ui.notify(`pi-thinking-header: failed to save ${CONFIG_PATH}: ${err?.message ?? err}`, "error");
260
355
  return;
@@ -267,6 +362,42 @@ export default function (pi) {
267
362
  return;
268
363
  }
269
364
 
365
+ // ---- autopatch subcommand: startup auto-check+apply toggle --------
366
+ if (argv[0] === "autopatch") {
367
+ const raw = argv[1];
368
+ if (raw === undefined) {
369
+ ctx.ui.notify(
370
+ `pi-thinking-header: autoPatch = ${getAutoPatch() ? "on" : "off"} (default on)\n` +
371
+ "session-start check: applies the patch when missing, migrates old versions\n" +
372
+ `config: ${CONFIG_PATH}\n` +
373
+ "usage: /thinking-header autopatch on|off",
374
+ "info",
375
+ );
376
+ return;
377
+ }
378
+ if (raw !== "on" && raw !== "off") {
379
+ ctx.ui.notify('pi-thinking-header: autopatch expects "on" or "off"', "error");
380
+ return;
381
+ }
382
+ try {
383
+ saveConfigKey("autoPatch", raw === "on");
384
+ } catch (err) {
385
+ ctx.ui.notify(
386
+ `pi-thinking-header: failed to save ${CONFIG_PATH}: ${err?.message ?? err}`,
387
+ "error",
388
+ );
389
+ return;
390
+ }
391
+ ctx.ui.notify(
392
+ `pi-thinking-header: autoPatch = ${raw} → saved to ${CONFIG_PATH}\n` +
393
+ (raw === "on"
394
+ ? "the next session start applies/migrates the patch automatically"
395
+ : "startup auto-patch disabled — use /thinking-header patch to apply manually"),
396
+ "info",
397
+ );
398
+ return;
399
+ }
400
+
270
401
  // ---- patch subcommand: apply/repair the full-mode bundle patch ----
271
402
  if (argv[0] === "patch") {
272
403
  try {
@@ -278,9 +409,9 @@ export default function (pi) {
278
409
  );
279
410
  return;
280
411
  }
281
- if (state.patched) {
412
+ if (state.current) {
282
413
  ctx.ui.notify(
283
- `pi-thinking-header: full-mode patch already applied (v3.2) — decimals = ${getDecimals()}`,
414
+ `pi-thinking-header: full-mode patch already applied (${PATCH_VERSION}) — decimals = ${getDecimals()}`,
284
415
  "info",
285
416
  );
286
417
  return;
@@ -316,12 +447,15 @@ export default function (pi) {
316
447
  const state = probeBundle();
317
448
  const mode = !state.found
318
449
  ? "fallback (pi installation not detected)"
319
- : state.patched
320
- ? "full (bundle patch v3.2 applied)"
321
- : "fallback (bundle unpatched — /thinking-header patch enables full mode)";
450
+ : state.current
451
+ ? `full (bundle patch ${PATCH_VERSION} applied)`
452
+ : state.patched
453
+ ? "full (older patch on disk — auto-migrates at next session start, or /thinking-header patch now)"
454
+ : "fallback (bundle unpatched — auto-patches at next session start, or /thinking-header patch now)";
322
455
  ctx.ui.notify(
323
456
  "pi-thinking-header\n" +
324
457
  `mode: ${mode}\n` +
458
+ `autoPatch: ${getAutoPatch() ? "on" : "off"} (default on)\n` +
325
459
  `decimals: ${getDecimals()} (default ${DEFAULT_DECIMALS})\n` +
326
460
  `config: ${CONFIG_PATH}\n` +
327
461
  usage,
@@ -331,20 +465,37 @@ export default function (pi) {
331
465
  });
332
466
 
333
467
  const bundle = probeBundle();
468
+ if (bundle.found && bundle.current) {
469
+ // FULL MODE, up to date: the bundle patch owns rendering end-to-end
470
+ // (per-message labels, width-aware single-line previews). Stay inert — a
471
+ // global label update here would corrupt the patch's per-block counts.
472
+ return;
473
+ }
474
+
475
+ // Not current on disk (unpatched, or an older family version): register the
476
+ // startup auto-patch. It lands on disk for the NEXT restart; this session
477
+ // keeps whichever renderer it booted with.
478
+ registerAutoPatch(pi);
479
+
334
480
  if (bundle.found && bundle.patched) {
335
- // FULL MODE: the bundle patch owns rendering end-to-end (per-message labels,
336
- // width-aware single-line previews). Stay inert — a global label update here
337
- // would corrupt the patch's per-block counts.
481
+ // An older family patch still owns rendering for THIS session → stay
482
+ // inert to avoid double headers; the auto-patch migrates it (above).
338
483
  return;
339
484
  }
340
485
 
341
486
  // ---------------- FALLBACK MODE (pure extension API) ----------------
342
487
 
343
- // Expanded thinking: per-block header with token count.
488
+ // Expanded thinking: per-block header with token count. While the block is
489
+ // still streaming the header MUST stay byte-stable ("Thinking…"): pi's TUI
490
+ // diffs whole lines and rewrites the contiguous [firstChanged..lastChanged]
491
+ // range, so any header change on every chunk would erase+redraw every
492
+ // earlier thinking line together with the latest one. The token count
493
+ // appears once the block settles (isStreaming flips to false).
344
494
  pi.registerMarkdownTransformer((markdown, ctx) => {
345
495
  if (ctx?.messageType !== "assistant-thinking") return markdown;
346
496
  const text = markdown.trim();
347
497
  if (!text) return markdown;
498
+ if (ctx.isStreaming) return `*Thinking…*\n\n${markdown}`;
348
499
  return `*Thinking (~${formatTokens(Math.ceil(text.length / 4))} tokens)*\n\n${markdown}`;
349
500
  });
350
501
 
@@ -354,7 +505,10 @@ export default function (pi) {
354
505
  // once the message settles (message_end) it returns to the first line.
355
506
  // Limitation: pi's extension API only exposes ONE global label, so collapsed
356
507
  // blocks in older transcript messages will show the most recent label. Run
357
- // /thinking-header patch for per-message fidelity.
508
+ // /thinking-header patch for per-message fidelity. Re-setting the SAME label
509
+ // is skipped: pi pushes the global label into every message component on
510
+ // each set, so a no-op set would still rebuild all of them.
511
+ let lastLabel = null;
358
512
  const updateLabel = async (message, ctx, running) => {
359
513
  try {
360
514
  if (!message || message.role !== "assistant") return;
@@ -362,7 +516,10 @@ export default function (pi) {
362
516
  (c) => c?.type === "thinking" && typeof c.thinking === "string" && c.thinking.trim(),
363
517
  );
364
518
  if (blocks.length === 0) return;
365
- ctx.ui.setHiddenThinkingLabel(thinkingLabel(blocks, running));
519
+ const label = thinkingLabel(blocks, running);
520
+ if (label === lastLabel) return;
521
+ lastLabel = label;
522
+ ctx.ui.setHiddenThinkingLabel(label);
366
523
  } catch {
367
524
  // display-only; never break the session over a label
368
525
  }
package/install-patch.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * pi-thinking-header — FULL MODE installer (bundle patch, v3.2).
3
+ * pi-thinking-header — FULL MODE installer (bundle patch, v3.3).
4
4
  *
5
5
  * Patches pi's AssistantMessageComponent for dsh-style thinking rendering:
6
6
  *
@@ -46,8 +46,12 @@ const MD_TAIL =
46
46
  ",this.outputPad,0,this.markdownTheme,{color:text=>theme.fg(\"thinkingText\",text),italic:!0}," +
47
47
  '{transform:createMarkdownTransform("assistant-thinking",this.isStreaming,this.markdownTransformers)})';
48
48
 
49
+ // hasVisibleContentAfter is in scope at the patch site (updateContent): a
50
+ // thinking block is "still streaming" iff the message is streaming AND nothing
51
+ // visible follows it — after the reply starts, earlier blocks show their final
52
+ // count instead of staying on "Thinking…".
49
53
  const HEADER_TEXT =
50
- 'new Text(theme.italic(theme.fg("thinkingText",this.thinkingLabelText(thinkingBlocks))),this.outputPad,0)';
54
+ 'new Text(theme.italic(theme.fg("thinkingText",this.thinkingHeaderLabel(thinkingBlocks,this.isStreaming&&!hasVisibleContentAfter))),this.outputPad,0)';
51
55
 
52
56
  const ANCHOR2 =
53
57
  'thinkingComponent=hidden?new Text(theme.italic(theme.fg("thinkingText",this.hiddenThinkingLabel)),this.outputPad,0):new Markdown(thinkingBlocks.' +
@@ -58,11 +62,11 @@ const ANCHOR2 =
58
62
  // ---------- injected code ----------
59
63
  // Current patch version; also the key for mode detection (family-wide) and
60
64
  // migration ("any older family member → restore pristine backup, re-apply").
61
- const CURRENT_MARKER = "pi-thinking-header:v3.2";
65
+ const CURRENT_MARKER = "pi-thinking-header:v3.3";
62
66
 
63
67
  // Marker comment doubles as the extension's mode-detection key.
64
68
  const HELPER =
65
- "/*pi-thinking-header:v3.2*/" +
69
+ "/*pi-thinking-header:v3.3*/" +
66
70
  // decimals config: per-instance cache keyed on the config file's mtime
67
71
  // (mtimeNs BigInt — ns precision beats same-ms collisions). Lazy ??= init
68
72
  // (no class fields needed) + a reader that resolves
@@ -73,14 +77,26 @@ const HELPER =
73
77
  'p=(process.env.PI_CODING_AGENT_DIR||os.homedir()+"/.pi/agent")+"/thinking-header.json",st=fs.statSync(p,{bigint:!0});' +
74
78
  'if(st.mtimeNs!==this._pthT){let c={};try{c=JSON.parse(fs.readFileSync(p,"utf8"))}catch{}' +
75
79
  "this._pthT=st.mtimeNs,this._pthC=c}}catch{this._pthC=null}return this._pthC||{}}" +
76
- // One-line label: "Thinking (~2.50k tokens)" (prefix honours a custom label, minus trailing dots)
77
- // Decimals come from _piCfg(): integer 0–6 wins, anything else → 2. No
78
- // trailing-zero stripping: the setting is authoritative (2.50k stays 2.50k).
79
- 'thinkingLabelText(blocks){let chars=0;for(let b of blocks)chars+=b.length;' +
80
+ // Token count: local estimate ceil(chars / 4). Decimals come from _piCfg():
81
+ // integer 0–6 wins, anything else → 2. No trailing-zero stripping: the
82
+ // setting is authoritative (2.50k stays 2.50k).
83
+ '_tokensLabel(blocks){let chars=0;for(let b of blocks)chars+=b.length;' +
80
84
  "let tokens=Math.ceil(chars/4),c=this._piCfg()," +
81
- 'd=typeof c.decimals=="number"&&Number.isInteger(c.decimals)&&c.decimals>=0&&c.decimals<=6?c.decimals:2,' +
82
- 'count=tokens>=1000?(tokens/1000).toFixed(d)+"k":""+tokens;' +
83
- 'return this.hiddenThinkingLabel.replace(/\\.{3}$/,"")+" (~"+count+" tokens)"}' +
85
+ 'd=typeof c.decimals=="number"&&Number.isInteger(c.decimals)&&c.decimals>=0&&c.decimals<=6?c.decimals:2;' +
86
+ 'return(tokens>=1000?(tokens/1000).toFixed(d)+"k":""+tokens)+" tokens"}' +
87
+ // Live one-line label: "Thinking (~2.50k tokens)" (prefix honours a custom
88
+ // label, minus trailing dots). Used by the collapsed row ONLY — that row is
89
+ // a single screen line, so a live count redraws just that one line.
90
+ 'thinkingLabelText(blocks){return this.hiddenThinkingLabel.replace(/\\.{3}$/,"")+" (~"+this._tokensLabel(blocks)+")"}' +
91
+ // Expanded header. While the block is ACTIVELY streaming (it is the message's
92
+ // last content and chunks still arrive) it renders the bare prefix + "…" and
93
+ // MUST stay byte-stable: pi's TUI diffs whole lines and rewrites the
94
+ // contiguous [firstChanged..lastChanged] range, so changing the header on
95
+ // every chunk would erase+redraw every earlier thinking line together with
96
+ // the latest one. The final count appears once the block settles (stream
97
+ // ends or later content starts), when the block no longer re-renders per
98
+ // chunk and no redraw cascade is possible.
99
+ 'thinkingHeaderLabel(blocks,streaming){let p=this.hiddenThinkingLabel.replace(/\\.{3}$/,"");if(streaming)return p+"…";return p+" (~"+this._tokensLabel(blocks)+")"}' +
84
100
  // Collapsed state: plain-object component rendering ONE line:
85
101
  // label + " · " + preview, truncated to the render width (CJK-aware).
86
102
  // dsh ReasoningRow parity: running → latest non-empty line with the window
@@ -163,7 +179,7 @@ const TAIL_BROKEN = "padTotal)]}}}";
163
179
  const TAIL_FIXED = "padTotal)]},invalidate(){}}}";
164
180
 
165
181
  if (src.includes(CURRENT_MARKER) && src.includes(TAIL_FIXED)) {
166
- console.log(`Already patched (v3.2): ${chunkPath}`);
182
+ console.log(`Already patched (v3.3): ${chunkPath}`);
167
183
  process.exit(0);
168
184
  }
169
185
 
@@ -181,7 +197,7 @@ if (src.includes("pi-thinking-header") || src.includes("thinkingLabelText(")) {
181
197
  writeFileSync(chunkPath, src);
182
198
  console.log("Hotfixed (early v3 → v3.1): added missing invalidate() to the collapsed-thinking component");
183
199
  console.log(`File: ${chunkPath}`);
184
- console.log("Re-run the installer to update the patch to v3.2 (configurable decimals).");
200
+ console.log("Re-run the installer to update the patch to the latest version.");
185
201
  process.exit(0);
186
202
  }
187
203
  if (!existsSync(backup)) {
@@ -205,6 +221,6 @@ if (!src.includes(CURRENT_MARKER) || !src.includes("piThinkingCollapsed(") || !s
205
221
 
206
222
  verifyESM(src);
207
223
  writeFileSync(chunkPath, src);
208
- console.log(`Patched (v3.2): ${chunkPath}`);
224
+ console.log(`Patched (v3.3): ${chunkPath}`);
209
225
  console.log(`Backup: ${backup}`);
210
226
  console.log("Revert with: cp '" + backup + "' '" + chunkPath + "'");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-thinking-header",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "dsh-style thinking display for pi: one-line header with token count (configurable decimals, default 2); collapsed thinking shows a single-line content preview",
5
5
  "keywords": [
6
6
  "pi-package",