@andares/pi-toolkits 0.11.1 → 0.11.3

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
@@ -15,7 +15,7 @@
15
15
 
16
16
  In a nutshell: seven ready-to-use capabilities — **read-only consult mode** `ask`, **session-scoped boundary permission** `ask-perm`, **model favorites** `favorites`, **prompt stash** `stash`, **third-party code review** `third-review`, **dedicated compaction model** `compact-model` and **session startup automation** `autostart` — with more to come.
17
17
 
18
- **Requirements**: pi `>=0.99.0`. Every published pi release from 0.99.0 through 1.4.0 has been verified with the full gate (install → `tsc --noEmit` → full test suite → keybinding audit → hash compare of the host files this package patches), each in a sandbox with the whole `@earendil-works/*` family pinned to the version under test. Newer releases are verified as they ship and the floor moves only when the measurement says so; `pnpm check:compat` reproduces the result. Note: pi ≥ 0.99 hosts require host-provided packages to be declared in `peerDependencies` with a `"*"` range — `@earendil-works/pi-tui` and `typebox` (the `scratchpad` tool builds a TypeBox schema) are declared that way; an installed copy in `dependencies` would make pi warn.
18
+ **Requirements**: pi `>=0.99.0`. Every published pi release from 0.99.0 through 1.5.0 has been verified with the full gate (install → `tsc --noEmit` → full test suite → keybinding audit → hash compare of the host files this package patches), each in a sandbox with the whole `@earendil-works/*` family pinned to the version under test. Newer releases are verified as they ship and the floor moves only when the measurement says so; `pnpm check:compat` reproduces the result. Note: pi ≥ 0.99 hosts require host-provided packages to be declared in `peerDependencies` with a `"*"` range — `@earendil-works/pi-tui` and `typebox` (the `scratchpad` tool builds a TypeBox schema) are declared that way; an installed copy in `dependencies` would make pi warn.
19
19
 
20
20
  ---
21
21
 
@@ -287,7 +287,7 @@ pi's `/compact` and auto-compact summarize the context **with the current sessio
287
287
  | auto-compact threshold reached | summarized by the compaction model |
288
288
  | context overflow recovery | summarized by the compaction model, retry behavior unchanged |
289
289
 
290
- **Parts that are identical to pi's built-in behavior after takeover** (the built-in implementation is mirrored verbatim — copied from 0.87.1, byte-identical across 1.1.0 → 1.4.0 apart from the two 1.4.0 fixes adopted here: summaries request the model's output limit instead of a fixed fraction of `reserveTokens`, and `/compact <instructions>` also reaches the split-turn prefix — and only the executing model changes; the conversation serializer itself is imported from the host, so it follows whatever the running host does, e.g. 1.2.0 stops feeding assistant thinking back into the summarization request):
290
+ **Parts that are identical to pi's built-in behavior after takeover** (the built-in implementation is mirrored verbatim — copied from 0.87.1, byte-identical across 1.2.0 → 1.5.0 apart from the two 1.4.0 fixes adopted here: summaries request the model's output limit instead of a fixed fraction of `reserveTokens`, and `/compact <instructions>` also reaches the split-turn prefix, plus the 1.5.0 `cost.totalFromProvider` merge rule in the usage accounting — and only the executing model changes; the conversation serializer itself is imported from the host, so it follows whatever the running host does, e.g. 1.2.0 stops feeding assistant thinking back into the summarization request):
291
291
 
292
292
  - **Summary format** — the structured Goal / Progress / Key Decisions checkpoint; when a summary already exists, the iterative-update prompt is used so information carries over instead of being lost
293
293
  - **Split behavior** — the `keepRecentTokens` retention policy, and `firstKeptEntryId` / `tokensBefore` passed back unchanged
@@ -368,7 +368,7 @@ An **identical command line** declared by both sources runs once: a duplicate is
368
368
 
369
369
  **Key constraint: extension commands only**. Commands registered via `registerCommand` such as `/add-dir` (pi-add-dir) and `/cd` (pi-cd) work; built-in commands (`/compact`, `/reload`, `/resume`, …) are dispatched in the TUI layer and cannot be triggered from the extension layer; skill commands (`/skill:name`) are an expansion mechanism rather than an execution mechanism. The target extension you depend on (e.g. pi-add-dir) must be installed first.
370
370
 
371
- > **Implementation notes**: execution uses `sendUserMessage(cmd, { expandPromptTemplates: true })` — that option routes `/cmd args` into command dispatch instead of the model prompt path (the wrapper defaults to `false`; `sendUserMessage` and `_tryExecuteExtensionCommand` are byte-identical across 1.1.0–1.4.0, see the host-compatibility discipline in AGENTS.md). Pre-validation uses `pi.getCommands()` (accepting only `source: "extension"`). Depends on `smol-toml` (zero-dependency, ~10KB, TOML 1.0).
371
+ > **Implementation notes**: execution uses `sendUserMessage(cmd, { expandPromptTemplates: true })` — that option routes `/cmd args` into command dispatch instead of the model prompt path (the wrapper defaults to `false`; `sendUserMessage` and `_tryExecuteExtensionCommand` are byte-identical across 1.2.0–1.5.0, see the host-compatibility discipline in AGENTS.md). Pre-validation uses `pi.getCommands()` (accepting only `source: "extension"`). Depends on `smol-toml` (zero-dependency, ~10KB, TOML 1.0).
372
372
 
373
373
  ---
374
374
 
package/README.zh-CN.md CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  一句话介绍:目前包含七个开箱即用的功能——**只读咨询模式** `ask`、**会话级边界权限** `ask-perm`、**模型收藏** `favorites`、**提示词暂存** `stash`、**第三方代码评审** `third-review`、**独立压缩模型** `compact-model`、**会话启动自动化** `autostart`,后续持续追加。
17
17
 
18
- **版本要求**:pi `>=0.99.0`。0.99.0 到 1.4.0 的每一个已发布版本都用完整门禁实测过(install → `tsc --noEmit` → 全量测试 → 键位审计 → 对 patch 目标宿主文件做哈希比对),每个版本都在沙箱中把整个 `@earendil-works/*` 家族钉在同一版本。更新的版本随发布逐个验证,下限只在实测结果允许时移动;`pnpm check:compat` 可复现该结论。注:pi ≥ 0.99 的宿主要求「宿主提供的包」一律声明为 peerDependencies `"*"` —— `@earendil-works/pi-tui` 与 `typebox`(ask 的 `scratchpad` 用它构造参数 schema)都按此声明;若放进 `dependencies`,pi 会告警。
18
+ **版本要求**:pi `>=0.99.0`。0.99.0 到 1.5.0 的每一个已发布版本都用完整门禁实测过(install → `tsc --noEmit` → 全量测试 → 键位审计 → 对 patch 目标宿主文件做哈希比对),每个版本都在沙箱中把整个 `@earendil-works/*` 家族钉在同一版本。更新的版本随发布逐个验证,下限只在实测结果允许时移动;`pnpm check:compat` 可复现该结论。注:pi ≥ 0.99 的宿主要求「宿主提供的包」一律声明为 peerDependencies `"*"` —— `@earendil-works/pi-tui` 与 `typebox`(ask 的 `scratchpad` 用它构造参数 schema)都按此声明;若放进 `dependencies`,pi 会告警。
19
19
 
20
20
  ---
21
21
 
@@ -287,7 +287,7 @@ pi 的 `/compact` 与 auto-compact 默认**用当前会话模型**做上下文
287
287
  | auto-compact 阈值触发 | 压缩模型总结 |
288
288
  | context overflow 恢复 | 压缩模型总结,重试行为不变 |
289
289
 
290
- **接管后与 pi 内建行为完全一致的部分**(逐字同步 pi 内建实现,复制自 0.87.1、在 1.1.0 → 1.4.0 内字节级不变 —— 除了此处采纳的两项 1.4.0 修正:摘要请求使用模型自身的输出上限而非 `reserveTokens` 的固定比例,以及 `/compact <instructions>` 也会进入 split-turn 前缀;只换执行模型;对话序列化函数(`serializeConversation`)是直接从宿主导入的,因此跟随宿主行为 —— 例如 1.2.0 起不再把 assistant thinking 回填进摘要请求):
290
+ **接管后与 pi 内建行为完全一致的部分**(逐字同步 pi 内建实现,复制自 0.87.1、在 1.2.0 → 1.5.0 内字节级不变 —— 除了此处采纳的两项 1.4.0 修正:摘要请求使用模型自身的输出上限而非 `reserveTokens` 的固定比例,以及 `/compact <instructions>` 也会进入 split-turn 前缀,外加 1.5.0 的 `cost.totalFromProvider` 合并规则(用量统计);只换执行模型;对话序列化函数(`serializeConversation`)是直接从宿主导入的,因此跟随宿主行为 —— 例如 1.2.0 起不再把 assistant thinking 回填进摘要请求):
291
291
 
292
292
  - **摘要格式** —— Goal / Progress / Key Decisions 结构化 checkpoint;已有摘要时用迭代更新版 prompt,信息延续不丢
293
293
  - **切分行为** —— 保留最近 `keepRecentTokens` 的策略、`firstKeptEntryId` / `tokensBefore` 原样回传,不动
@@ -368,7 +368,7 @@ run = [
368
368
 
369
369
  **关键约束:只支持扩展命令**。`/add-dir`(pi-add-dir)、`/cd`(pi-cd)等 `registerCommand` 注册的命令可以;内置命令(`/compact`、`/reload`、`/resume`…)在 TUI 层分发,扩展层触发不了;skill 命令(`/skill:name`)是展开机制而非执行机制。依赖的目标扩展(如 pi-add-dir)需要先安装。
370
370
 
371
- > **实现说明**:执行用 `sendUserMessage(cmd, { expandPromptTemplates: true })` —— 该选项把 `/cmd args` 路由到命令分发而不是模型 prompt 路径(包装层默认 `false`,`sendUserMessage` 与 `_tryExecuteExtensionCommand` 在 1.1.0–1.4.0 全区间字节级一致,见 AGENTS.md 宿主兼容纪律)。预校验用 `pi.getCommands()`(只认 `source: "extension"`)。依赖 `smol-toml`(零依赖、~10KB、TOML 1.0)。
371
+ > **实现说明**:执行用 `sendUserMessage(cmd, { expandPromptTemplates: true })` —— 该选项把 `/cmd args` 路由到命令分发而不是模型 prompt 路径(包装层默认 `false`,`sendUserMessage` 与 `_tryExecuteExtensionCommand` 在 1.2.0–1.5.0 全区间字节级一致,见 AGENTS.md 宿主兼容纪律)。预校验用 `pi.getCommands()`(只认 `source: "extension"`)。依赖 `smol-toml`(零依赖、~10KB、TOML 1.0)。
372
372
 
373
373
  ---
374
374
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andares/pi-toolkits",
3
- "version": "0.11.1",
3
+ "version": "0.11.3",
4
4
  "description": "Integrated toolkit extension for pi — ask mode (read-only Q&A), session-scoped permission checks, model favorites, prompt stash, third-party code review, session autostart, a dedicated compaction model, and an agent memory framework (/ask-init)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -47,9 +47,9 @@
47
47
  "smol-toml": "^1.9.0"
48
48
  },
49
49
  "devDependencies": {
50
- "@earendil-works/pi-ai": "^1.4.0",
51
- "@earendil-works/pi-coding-agent": "^1.4.0",
52
- "@earendil-works/pi-tui": "^1.4.0",
50
+ "@earendil-works/pi-ai": "^1.5.0",
51
+ "@earendil-works/pi-coding-agent": "^1.5.0",
52
+ "@earendil-works/pi-tui": "^1.5.0",
53
53
  "@types/node": "^26.6.2",
54
54
  "typebox": "^1.3.27",
55
55
  "typescript": "^5.6.0",
@@ -2,7 +2,7 @@
2
2
  * ask feature — actionable rejection messages.
3
3
  *
4
4
  * The `tool_call` hook's `reason` is delivered to the model as the tool result
5
- * (verified against pi 1.4.0: `extensions/runner.js` returns the `{block,
5
+ * (verified against pi 1.5.0: `extensions/runner.js` returns the `{block,
6
6
  * reason}` result unchanged and pi-agent-core's `agent-loop.js` turns it into
7
7
  * `createErrorToolResult(reason)`; both paths are byte-identical to 1.2.0), so
8
8
  * it is the best place to name the nearest allowed alternative and stop the model
@@ -48,7 +48,7 @@ export const RUNNABLE_REASONS: ReadonlySet<string> = new Set([
48
48
  * `-r` (`--resume`), `--session <path>` and `--fork <path>` all open an
49
49
  * existing session *before* the first runtime exists, and that runtime is
50
50
  * created without an explicit session_start event, so extensions see pi's
51
- * default reason — `startup` — not `resume` (verified on pi 1.1.0–1.4.0:
51
+ * default reason — `startup` — not `resume` (verified on pi 1.2.0–1.5.0:
52
52
  * `main.js` builds the initial session manager from those flags and calls
53
53
  * `createAgentSessionRuntime` with no `sessionStartEvent`;
54
54
  * `agent-session.js` defaults it to `{ reason: "startup" }`). `resume` is only
@@ -27,7 +27,7 @@
27
27
  * that flag is what routes "/cmd args" through _tryExecuteExtensionCommand
28
28
  * instead of the model prompt path. The wrapper defaults it to false in this
29
29
  * package (each byte-identical across every published version of the supported
30
- * window, 1.1.0 → 1.4.0; see AGENTS.md host-compat discipline for the version
30
+ * window, 1.2.0 → 1.5.0; see AGENTS.md host-compat discipline for the version
31
31
  * matrix).
32
32
  *
33
33
  * Only extension commands can run this way. Built-in commands (/compact,
@@ -525,6 +525,61 @@ describe("session_before_compact handler", () => {
525
525
  expect(compaction.usage?.input).toBe(107);
526
526
  });
527
527
 
528
+ it("keeps the cost provider-reported only when both split-turn parts report it", async () => {
529
+ // 1.5.0's combineUsage rule end-to-end: the merged compaction usage, not
530
+ // just the helper. A part that reports no provider cost is enough to make
531
+ // the sum "not provider-reported"; only both parts reporting it flips it
532
+ // to true (asserted below on a second run). The flag is cost provenance,
533
+ // not the token sum.
534
+ getCompactStore().setModel("google/gemini-2.5-flash");
535
+ const { handler } = registeredHandler();
536
+ const withFlag = (flag: boolean | undefined) => {
537
+ const usage = fakeUsage();
538
+ if (flag !== undefined) usage.cost.totalFromProvider = flag;
539
+ return usage;
540
+ };
541
+ const { ctx } = fakeCtx({
542
+ complete: vi.fn(async (_model, context) => {
543
+ const text = (context.messages[0]?.content as Array<{ text?: string }>)[0]
544
+ ?.text;
545
+ return text?.includes("# Instructions")
546
+ ? fakeResponse("TURN PREFIX", { usage: withFlag(true) })
547
+ : fakeResponse("HISTORY", { usage: withFlag(undefined) });
548
+ }),
549
+ });
550
+ const event = fakeEvent({
551
+ turnPrefixMessages: [userMessage("prefix msg")],
552
+ isSplitTurn: true,
553
+ });
554
+ const result = (await handler(
555
+ event as never,
556
+ ctx,
557
+ )) as SessionBeforeCompactResult | undefined;
558
+ // Read through the structural view, not the host's `CompleteUsage`:
559
+ // `cost.totalFromProvider` only exists on pi ≥ 1.5.0's types, so reading
560
+ // it directly from the host type breaks typecheck on the 1.2–1.4 window
561
+ // even though the runtime field we write is the same.
562
+ const mergedUsage = result!.compaction!.usage as unknown as UsageLike;
563
+ expect(mergedUsage.cost.totalFromProvider).toBe(false);
564
+
565
+ // Same split turn, but both summaries report the cost themselves → true.
566
+ const both = fakeCtx({
567
+ complete: vi.fn(async (_model, context) => {
568
+ const text = (context.messages[0]?.content as Array<{ text?: string }>)[0]
569
+ ?.text;
570
+ return text?.includes("# Instructions")
571
+ ? fakeResponse("TURN PREFIX", { usage: withFlag(true) })
572
+ : fakeResponse("HISTORY", { usage: withFlag(true) });
573
+ }),
574
+ });
575
+ const bothResult = (await handler(
576
+ event as never,
577
+ both.ctx,
578
+ )) as SessionBeforeCompactResult | undefined;
579
+ const bothUsage = bothResult!.compaction!.usage as unknown as UsageLike;
580
+ expect(bothUsage.cost.totalFromProvider).toBe(true);
581
+ });
582
+
528
583
  it("keeps the previous summary as history text on split turns with no messages", async () => {
529
584
  getCompactStore().setModel("google/gemini-2.5-flash");
530
585
  const { handler } = registeredHandler();
@@ -704,4 +759,23 @@ describe("combineUsage", () => {
704
759
  const none = combineUsage(fakeUsage(), fakeUsage());
705
760
  expect(none.cacheWrite1h).toBeUndefined();
706
761
  });
762
+
763
+ it("marks the cost provider-reported only when every part is (pi 1.5.0)", () => {
764
+ const withFlag = (flag?: boolean): UsageLike => {
765
+ const usage = fakeUsage();
766
+ if (flag !== undefined) usage.cost.totalFromProvider = flag;
767
+ return usage;
768
+ };
769
+ expect(
770
+ combineUsage(withFlag(true), withFlag(true)).cost.totalFromProvider,
771
+ ).toBe(true);
772
+ expect(
773
+ combineUsage(withFlag(true), withFlag(false)).cost.totalFromProvider,
774
+ ).toBe(false);
775
+ // Absent on either side is not "provider-reported"; the field is always
776
+ // written as an explicit boolean, exactly like the built-in.
777
+ expect(
778
+ combineUsage(withFlag(), withFlag(true)).cost.totalFromProvider,
779
+ ).toBe(false);
780
+ });
707
781
  });
@@ -12,8 +12,10 @@
12
12
  * - prompts, message assembly, split-turn handling, summary budgets, and
13
13
  * the summary file-list tail are verbatim pi built-in behavior (copied
14
14
  * from 0.87.1; `core/compaction/compaction.js` stayed byte-identical
15
- * 1.0.0 → 1.3.0, and 1.4.0 changed only the summary output budget and
16
- * the split-turn focus handling mirrored below) — only the executing
15
+ * 1.0.0 → 1.3.0, 1.4.0 changed only the summary output budget and
16
+ * the split-turn focus handling mirrored below, and 1.5.0 left it
17
+ * untouched; the only mirrored helper that moved after that span is
18
+ * `combineUsage`, see prompt.ts) — only the executing
17
19
  * model differs. The one exception is `serializeConversation`, which is
18
20
  * imported from the host instead of mirrored, so it tracks whatever the
19
21
  * running host does (1.2.0 stopped feeding assistant thinking into the
@@ -133,7 +135,7 @@ async function completeSummary(
133
135
  * over).
134
136
  *
135
137
  * 1.4.0 changed two things this mirror covers, and we follow 1.4.0 for every
136
- * host in the supported window (1.1.0 → 1.4.0) because both are bug fixes
138
+ * host in the supported window (1.2.0 → 1.5.0) because both are bug fixes
137
139
  * rather than behavior we could sensibly keep per version:
138
140
  * - summaries no longer cap maxTokens at 0.8/0.5 × reserveTokens; the
139
141
  * request omits maxTokens so the provider uses the model's output limit
@@ -6,14 +6,16 @@
6
6
  * summaries produced by the configured compact model are formatted exactly
7
7
  * like pi's own compaction output. Only the executing model differs.
8
8
  * Verified byte-identical against every published version of the supported
9
- * window (1.1.0 → 1.4.0): core/usage-totals.js is unchanged across that
10
- * span, the only utils.js change (1.2.0 dropping assistant thinking from
11
- * serializeConversation) does not touch the strings mirrored here — that
12
- * serializer is imported from the host, not mirrored — and compaction.js
13
- * changed once, in 1.4.0, in the output budget and the split-turn focus
14
- * handling (both adopted here, see buildTurnPrefixPromptText). 0.87.0
15
- * shipped a different split-turn prefix prompt; the text mirrored here is
16
- * 0.87.1's fix, and the mirror is the only variant any supported host gets
9
+ * window (1.2.0 → 1.5.0): the only utils.js change (1.2.0 dropping assistant
10
+ * thinking from serializeConversation) does not touch the strings mirrored
11
+ * here — that serializer is imported from the host, not mirrored —
12
+ * compaction.js last changed in 1.4.0, in the output budget and the
13
+ * split-turn focus handling (both adopted here, see
14
+ * buildTurnPrefixPromptText), and core/usage-totals.js changed once past
15
+ * that point, in 1.5.0, which only added the `cost.totalFromProvider` merge
16
+ * rule mirrored below (the numeric fields are untouched). 0.87.0 shipped a
17
+ * different split-turn prefix prompt; the text mirrored here is 0.87.1's
18
+ * fix, and the mirror is the only variant any supported host gets
17
19
  * — intentional.
18
20
  *
19
21
  * Update these strings whenever pi changes its built-in prompts (diff
@@ -213,9 +215,12 @@ export function formatFileOperations(
213
215
 
214
216
  /**
215
217
  * Combine two provider usage records field-wise (pi 0.87.1 compaction.js
216
- * combineUsage, moved verbatim to core/usage-totals.js in 0.99.0 and
217
- * unchanged through 1.4.0): used when
218
- * a split turn produces two summarization calls.
218
+ * combineUsage, moved verbatim to core/usage-totals.js in 0.99.0; the
219
+ * numeric fields are unchanged through 1.5.0, which added the
220
+ * `cost.totalFromProvider` rule at the end of the cost block — a sum counts
221
+ * as provider-reported only when every part does, and the field is written
222
+ * as an explicit boolean either way): used when a split turn produces two
223
+ * summarization calls.
219
224
  */
220
225
  export function combineUsage<T extends UsageLike>(first: T, second: T): T {
221
226
  return {
@@ -239,6 +244,10 @@ export function combineUsage<T extends UsageLike>(first: T, second: T): T {
239
244
  cacheRead: first.cost.cacheRead + second.cost.cacheRead,
240
245
  cacheWrite: first.cost.cacheWrite + second.cost.cacheWrite,
241
246
  total: first.cost.total + second.cost.total,
247
+ // A sum is provider-reported only when every part is (pi 1.5.0).
248
+ totalFromProvider:
249
+ first.cost.totalFromProvider === true &&
250
+ second.cost.totalFromProvider === true,
242
251
  },
243
252
  } as T;
244
253
  }
@@ -258,5 +267,7 @@ export interface UsageLike {
258
267
  cacheRead: number;
259
268
  cacheWrite: number;
260
269
  total: number;
270
+ /** pi 1.5.0: true only when the provider reported the cost itself. */
271
+ totalFromProvider?: boolean;
261
272
  };
262
273
  }
@@ -9,9 +9,9 @@
9
9
  * keybinding conflicts arise.
10
10
  *
11
11
  * Verified against every published version of the supported range
12
- * (0.99.0 → 1.4.0) keybinding
13
- * tables + pi-tui input dispatch on WSL2 + Windows Terminal; re-run
14
- * `pnpm check:keybindings` after every host upgrade before trusting this.
12
+ * (0.99.0 → 1.5.0) keybinding tables + pi-tui input dispatch on WSL2 +
13
+ * Windows Terminal; re-run `pnpm check:keybindings` after every host upgrade
14
+ * before trusting this.
15
15
  */
16
16
  export const FAVORITES_STATUS_KEY = "favorites";
17
17
 
@@ -29,13 +29,19 @@ export const FAVORITES_FILE = "pi-toolkits-favorites.json";
29
29
  * (0.87–0.99 defaulted to `regular`, where the binding is inert). On
30
30
  * Windows/WSL `tui.altScreen.search` resolves to ctrl+f, and
31
31
  * `TuiAltScreen.handleViewportInput()` checks it before
32
- * `shouldDeferViewportInputToOverlay()`, so the keystroke opens transcript
33
- * search and the selector never sees it (verified in pi-tui 1.4.0
34
- * `dist/tui-alt-screen.js`: the search check is the first key branch and
35
- * returns `{ consume: true }` unconditionally; the 1.4.0 changes to that
36
- * file are exit/teardown only).
32
+ * `shouldDeferViewportInput()` (named `shouldDeferViewportInputToOverlay()`
33
+ * through 1.4.0), so the keystroke opens transcript
34
+ * search and the selector never sees it (verified in pi-tui 1.5.0
35
+ * `dist/tui-alt-screen.js`: the search check is still the first key branch
36
+ * and still returns `{ consume: true }` unconditionally; the 1.5.0 changes to
37
+ * that file are exit/teardown, word-selection joiners, and a rename of
38
+ * `shouldDeferViewportInputToOverlay()` to `shouldDeferViewportInput()` —
39
+ * which now also defers viewport keys to a focused component that declares
40
+ * `capturesViewportInput`, but that guard sits BELOW the search branch, so
41
+ * it does not rescue ctrl+f for the selector even though 1.5.0 gives the
42
+ * selector `capturesViewportInput = true`).
37
43
  *
38
- * Why ctrl+alt+f was discarded: clean at the pi layer across 1.1–1.4 (only
44
+ * Why ctrl+alt+f was discarded: clean at the pi layer across 1.1–1.5 (only
39
45
  * ctrl+alt+] is taken from that family) and byte-distinguishable without
40
46
  * kitty — but grabbed at the OS/app layer on the author's machine by a
41
47
  * Windows screenshot tool, so it never reached the terminal. Lesson: a key
@@ -39,6 +39,7 @@ import {
39
39
  setSharedFavoritesStore,
40
40
  } from "./store.js";
41
41
  import { applyCyclePatch } from "./patch-cycle.js";
42
+ import { styleFavorite } from "./constants.js";
42
43
  import {
43
44
  applyModelSelectorPatches,
44
45
  setSelectorThemeProvider,
@@ -464,4 +465,81 @@ describe("selector patches", () => {
464
465
  "claude-opus-4",
465
466
  ]);
466
467
  });
468
+
469
+ it("mirrors pi 1.5.0's disambiguating model name after the id", () => {
470
+ // The name is shown only when it adds words the id does not convey
471
+ // (pi's getDistinctModelName rule, copied — it is not exported).
472
+ type LineItem = {
473
+ provider: string;
474
+ id: string;
475
+ model: { provider: string; id: string; name?: string };
476
+ };
477
+ const redundant: LineItem = {
478
+ provider: "anthropic",
479
+ id: "claude-sonnet-4",
480
+ model: { provider: "anthropic", id: "claude-sonnet-4", name: "Claude Sonnet 4" },
481
+ };
482
+ const renamed: LineItem = {
483
+ provider: "openrouter",
484
+ id: "minimax/minimax-m3",
485
+ model: {
486
+ provider: "openrouter",
487
+ id: "minimax/minimax-m3",
488
+ name: "MiniMax M3 [rasmus@openrouter, parasail]",
489
+ },
490
+ };
491
+ const lines: string[] = [];
492
+ const selector = fakeSelector({
493
+ activeModels: [],
494
+ filteredModels: [redundant, renamed] as unknown as FakeSelector["filteredModels"],
495
+ listContainer: {
496
+ clear() {},
497
+ addChild: (child: unknown) =>
498
+ lines.push((child as { text?: string }).text ?? ""),
499
+ },
500
+ });
501
+ selector.updateList();
502
+ const redundantLine = lines.find((l) => l.includes("claude-sonnet-4"));
503
+ const renamedLine = lines.find((l) => l.includes("minimax/minimax-m3"));
504
+ expect(redundantLine).toBeDefined();
505
+ expect(redundantLine).not.toContain("(Claude Sonnet 4)");
506
+ expect(renamedLine).toContain(
507
+ "(MiniMax M3 [rasmus@openrouter, parasail])",
508
+ );
509
+ // The full line pins the 1.5.0 column order (id → name → provider badge)
510
+ // and the favorite highlight not leaking ANSI codes into the name. The
511
+ // two spaces after → are the (empty) current-marker column; the fake
512
+ // selector has no currentModel, so no ✓ is drawn.
513
+ expect(redundantLine).toBe("→ claude-sonnet-4 [anthropic]");
514
+ expect(renamedLine).toBe(
515
+ " minimax/minimax-m3 (MiniMax M3 [rasmus@openrouter, parasail]) [openrouter]",
516
+ );
517
+ });
518
+
519
+ it("keeps the favorite highlight while appending the disambiguating name", () => {
520
+ // Regression guard: the 1.5.0 name text must land AFTER the styled id
521
+ // (and outside its ANSI reset), or favorited ids would lose their color.
522
+ const favoriteModel = {
523
+ provider: "anthropic",
524
+ id: "claude-opus-4",
525
+ name: "Claude Opus 4 (max)",
526
+ };
527
+ getFavoritesStore().toggle(favoriteModel);
528
+ const lines: string[] = [];
529
+ const selector = fakeSelector({
530
+ activeModels: [],
531
+ filteredModels: [
532
+ { provider: favoriteModel.provider, id: favoriteModel.id, model: favoriteModel },
533
+ ] as unknown as FakeSelector["filteredModels"],
534
+ listContainer: {
535
+ clear() {},
536
+ addChild: (child: unknown) =>
537
+ lines.push((child as { text?: string }).text ?? ""),
538
+ },
539
+ });
540
+ selector.updateList();
541
+ expect(lines[0]).toBe(
542
+ `→ ${styleFavorite("claude-opus-4")} (Claude Opus 4 (max)) [anthropic]`,
543
+ );
544
+ });
467
545
  });
@@ -15,8 +15,9 @@
15
15
  * model available" feedback); otherwise jump to it
16
16
  * - the apply logic mirrors pi's _cycleScopedModel/_cycleAvailableModel
17
17
  * (byte-identical across every published version in the supported window
18
- * 1.1.0 → 1.4.0 — `cycleModel` itself is untouched by 1.4.0's recovery and
19
- * tool-restore rewrites:
18
+ * 1.2.0 → 1.5.0 — `cycleModel` itself is untouched by 1.4.0's recovery and
19
+ * tool-restore rewrites and by 1.5.0's auth-routing/loadout-ordering
20
+ * changes:
20
21
  * state swap, session persistence, default-model persistence only when
21
22
  * `options.persist` is set, thinking-level re-clamp, model_select emit
22
23
  * with source "cycle")
@@ -32,9 +33,9 @@ import { getFavoritesStore, modelKey } from "./store.js";
32
33
 
33
34
  /**
34
35
  * pi's `Model<any>` — the exact type of `ModelCycleResult.model`
35
- * (pi-coding-agent 1.1–1.4; the field is unchanged across that span).
36
+ * (pi-coding-agent 1.2–1.5; the field is unchanged across that span).
36
37
  * Derived here instead of importing `Model` from @earendil-works/pi-ai,
37
- * whose index.d.ts (1.1 through 1.4.0 alike) re-exports it via an
38
+ * whose index.d.ts (1.2 through 1.5.0 alike) re-exports it via an
38
39
  * extensioned `"./types.ts"` specifier that not every TS resolver substitutes
39
40
  * to `types.d.ts` (tsc 5.9 bundler does; other tsserver builds don't).
40
41
  */
@@ -58,7 +59,7 @@ interface CycleSessionInternals {
58
59
  setDefaultModelAndProvider(provider: string, id: string): void;
59
60
  };
60
61
  /**
61
- * pi 1.0–1.3: keeps a persisted default usable when cycling inside a
62
+ * pi 1.0–1.5: keeps a persisted default usable when cycling inside a
62
63
  * non-empty --models scope.
63
64
  */
64
65
  _addPersistedDefaultToNonEmptyScope(model: unknown): void;
@@ -141,7 +142,7 @@ export function applyCyclePatch(): boolean {
141
142
  return original.call(this, direction, options);
142
143
  }
143
144
 
144
- // Same source and availability filtering as pi's stock cycling (1.0–1.3).
145
+ // Same source and availability filtering as pi's stock cycling (1.0–1.5).
145
146
  const scoped = this._scopedModels.length > 0;
146
147
  let candidates: CycleCandidate[];
147
148
  if (scoped) {
@@ -186,7 +187,7 @@ export function applyCyclePatch(): boolean {
186
187
  : this._getThinkingLevelForModelSwitch();
187
188
  this.agent.state.model = next.model;
188
189
  this.sessionManager.appendModelChange(next.model.provider, next.model.id);
189
- // pi 1.0–1.3: only persist the default model when the caller asked for it.
190
+ // pi 1.0–1.5: only persist the default model when the caller asked for it.
190
191
  if (options.persist) {
191
192
  this.settingsManager.setDefaultModelAndProvider(
192
193
  next.model.provider,
@@ -21,8 +21,10 @@
21
21
  * upgraded internals), patches are skipped with a warning — the extension
22
22
  * keeps loading and ctrl+p falls back to stock behavior. The prototype
23
23
  * shape touched here (handleInput/updateList/filterModels and the instance
24
- * fields below) is byte-identical across the mandatory window
25
- * pi 1.1.0 → 1.4.0 (and exercised by the 0.99.0 → 1.0.4 backward probes).
24
+ * fields below) is unchanged across the mandatory window
25
+ * pi 1.2.0 → 1.5.0 (and exercised by the 0.99.0 → 1.1.0 backward probes).
26
+ * updateList gained one layout change in 1.5.0 — the disambiguating model
27
+ * name after the id — mirrored here; see distinctModelName below.
26
28
  */
27
29
  import {
28
30
  ModelSelectorComponent,
@@ -61,7 +63,7 @@ interface SelectorPatchTarget {
61
63
  handleInput(keyData: string): void;
62
64
  updateList(): void;
63
65
  filterModels(query: string): void;
64
- /** pi 1.0–1.3: default-model badge source. */
66
+ /** pi 1.0–1.5: default-model badge source. */
65
67
  isDefaultModel(model: ModelRef): boolean;
66
68
  listContainer: ListContainerLike;
67
69
  searchInput: SearchInputLike;
@@ -95,6 +97,37 @@ function sameModel(a: ModelRef, b: ModelRef): boolean {
95
97
  return a.provider === b.provider && a.id === b.id;
96
98
  }
97
99
 
100
+ /** Lowercase a label and split it into word tokens (pi's splitModelLabel). */
101
+ function splitModelLabel(value: string): string[] {
102
+ return value
103
+ .toLowerCase()
104
+ .split(/[^\p{L}\p{N}]+/u)
105
+ .filter(Boolean);
106
+ }
107
+
108
+ /**
109
+ * Replica of pi 1.5.0's internal `getDistinctModelName`
110
+ * (`dist/modes/interactive/model-search.js`). It is not reachable from the
111
+ * package's `.` entry, so the rule is copied instead of imported: show the
112
+ * model name only when it has words the id does not already convey, which
113
+ * keeps renamed overrides visible without repeating `claude-sonnet-4` after
114
+ * `claude-sonnet-4-20250514`. Mirrored for every host in the window — the
115
+ * model `name` field exists across it, and one rendering rule is better than
116
+ * a version branch.
117
+ */
118
+ function distinctModelName(id: string, name?: string): string | undefined {
119
+ const trimmed = name?.trim();
120
+ if (!trimmed) return undefined;
121
+ const idWords = splitModelLabel(id);
122
+ const idTokens = new Set(idWords);
123
+ const compactId = idWords.join("");
124
+ // Single-character words must match a whole id token: "4" is not covered
125
+ // by "mistral-small-2603".
126
+ const isCovered = (word: string): boolean =>
127
+ idTokens.has(word) || (word.length > 1 && compactId.includes(word));
128
+ return splitModelLabel(trimmed).every(isCovered) ? undefined : trimmed;
129
+ }
130
+
98
131
  function buildHintText(state: SelectorPatchTarget): string {
99
132
  const theme = themeProvider();
100
133
  if (!theme) return "";
@@ -116,7 +149,7 @@ let patched = false;
116
149
  export function applyModelSelectorPatches(): boolean {
117
150
  if (patched) return true;
118
151
  // SAFETY: ModelSelectorComponent's members are TS-private (compile-time
119
- // only); the structural view matches pi 1.0–1.3's prototype shape
152
+ // only); the structural view matches pi 1.0–1.5's prototype shape
120
153
  // (byte-identical across that span) and every touched method is
121
154
  // existence-checked below before any patching happens.
122
155
  const proto =
@@ -155,10 +188,11 @@ export function applyModelSelectorPatches(): boolean {
155
188
  };
156
189
 
157
190
  // ── 2. List rendering: hint row + bold bright yellow favorites ────────
158
- // Replicates the built-in updateList layout (pi 1.0–1.3: fixed-width cursor
159
- // and ✓ current-marker columns, provider badge, `· default` badge, scroll
160
- // indicator, empty/error states, refresh status, Model Name footer) with
161
- // one change: favorited model ids render in the favorite style.
191
+ // Replicates the built-in updateList layout (fixed-width cursor and ✓
192
+ // current-marker columns, the 1.5.0 disambiguating model name, provider
193
+ // badge, `· default` badge, scroll indicator, empty/error states, refresh
194
+ // status, Model Name footer) with one change: favorited model ids render
195
+ // in the favorite style.
162
196
  proto.updateList = function (this: SelectorPatchTarget): void {
163
197
  const theme = themeProvider();
164
198
  if (!theme) {
@@ -197,8 +231,10 @@ export function applyModelSelectorPatches(): boolean {
197
231
  const currentMarker = isCurrent ? theme.fg("accent", "✓ ") : " ";
198
232
  const baseText = isSelected ? theme.fg("accent", item.id) : item.id;
199
233
  const modelText = isFavorite ? styleFavorite(item.id) : baseText;
234
+ const name = distinctModelName(item.id, item.model.name);
235
+ const nameText = name ? theme.fg("muted", ` (${name})`) : "";
200
236
  const providerBadge = theme.fg("muted", `[${item.provider}]`);
201
- const line = `${cursor}${currentMarker}${modelText} ${providerBadge}${defaultBadge}`;
237
+ const line = `${cursor}${currentMarker}${modelText}${nameText} ${providerBadge}${defaultBadge}`;
202
238
  list.addChild(new Text(line, 0, 0));
203
239
  }
204
240
  if (startIndex > 0 || endIndex < this.filteredModels.length) {
@@ -11,6 +11,10 @@ import {
11
11
  scanRefs,
12
12
  } from "./detect.js";
13
13
  import { project, remove } from "./test-helpers.js";
14
+ // Static import (not `await import` inside the test): the per-test timeout
15
+ // must not have to cover transforming/loading the deterministic half of the
16
+ // feature — that is what made this test flake under matrix load.
17
+ import { stateToToml } from "./apply.js";
14
18
 
15
19
  describe("isBareRef", () => {
16
20
  const text = "see memory/DESIGN.md, .agents/memory/X.md, my-memory/y, ~/.pi/agent/memory/z";
@@ -207,8 +211,7 @@ describe("review regressions", () => {
207
211
  expect(countBareRefs(text, "implementations/")).toBe(1);
208
212
  });
209
213
 
210
- it("round-trips custom budgets through the state TOML keys", async () => {
211
- const { stateToToml } = await import("./apply.js");
214
+ it("round-trips custom budgets through the state TOML keys", () => {
212
215
  const parsed = parseState(
213
216
  stateToToml({
214
217
  version: 1,
@@ -21,7 +21,7 @@ import type { KeyId } from "@earendil-works/pi-tui";
21
21
  /**
22
22
  * Stash hotkey.
23
23
  *
24
- * Compatibility rationale (verified against pi 0.99.0–1.4.0 keybindings, live
24
+ * Compatibility rationale (verified against pi 0.99.0–1.5.0 keybindings, live
25
25
  * matchesKey probes and terminal/OS behavior):
26
26
  * - pi: no default binding for ctrl+alt+<letter> (only ctrl+alt+] is taken);
27
27
  * extension shortcuts dispatch first while the editor is focused
package/src/lib/gates.ts CHANGED
@@ -18,9 +18,9 @@
18
18
  * promise. The queue serializes our own presentations; the host slot is
19
19
  * still shared with other extensions (pi does not arbitrate globally).
20
20
  * pi 1.4.0 stacks extension dialogs so a second one no longer strands the
21
- * first (#11166), but the 1.1–1.3 hosts in the supported window still
21
+ * first (#11166), but the 1.2–1.3 hosts in the supported window still
22
22
  * replace it — the queue stays, and it also keeps one confirmation visible
23
- * at a time instead of a pile of stacked dialogs on 1.4.0.
23
+ * at a time instead of a pile of stacked dialogs on later hosts.
24
24
  * - A gate that throws blocks the call (fail closed), matching pi's own
25
25
  * `tool_call` handler contract.
26
26
  *
@@ -4,8 +4,8 @@
4
4
  * (review, compact-model — favorites keeps its own store-local key logic).
5
5
  *
6
6
  * Types are derived from pi-coding-agent's own exports (ModelRegistry)
7
- * rather than imported from @earendil-works/pi-ai: pi-ai's index.d.ts (1.1.0
8
- * through 1.4.0 alike) re-exports `Model`/`Api` via extensioned
7
+ * rather than imported from @earendil-works/pi-ai: pi-ai's index.d.ts (1.2.0
8
+ * through 1.5.0 alike) re-exports `Model`/`Api` via extensioned
9
9
  * `./types.ts` specifiers, which tsc 5.9 bundler resolution substitutes to
10
10
  * `.d.ts` but some tsserver builds do not. Deriving from ModelRegistry keeps
11
11
  * every resolver in agreement with the exact type
@@ -9,11 +9,12 @@
9
9
  *
10
10
  * The component is driven through a small ModelRuntime facade backed by
11
11
  * ctx.modelRegistry (pi's extension-facing wrapper). SAFETY: verified
12
- * against pi 1.1.0–1.4.0 dist (modes/interactive/components/model-selector.js +
13
- * model-catalog-refresh.js — byte-identical across that span) — the
14
- * component only calls getAvailableSnapshot, getModel, getError and refresh
15
- * on modelRuntime, and ModelRegistry provides exactly those
16
- * (getAvailable/find/getError/refresh).
12
+ * against pi 1.2.0–1.5.0 dist (modes/interactive/components/model-selector.js +
13
+ * model-catalog-refresh.js) — the component only calls getAvailableSnapshot,
14
+ * getModel and getError on modelRuntime (plus refresh, which ModelRegistry
15
+ * provides) across that whole span. 1.5.0 changed the selector's rendering
16
+ * and key handling (page keys, disambiguating model names, viewport-input
17
+ * capture) but not the runtime surface this facade implements.
17
18
  *
18
19
  * `current` is passed as the component's currentModel: the built-in selector
19
20
  * preselects, ✓-marks and sorts-first its currentModel, so one Enter accepts
@@ -42,9 +43,10 @@ function modelRuntimeFacade(ctx: ExtensionContext): ModelRuntime {
42
43
  refresh: (options?: { signal?: AbortSignal }) =>
43
44
  ctx.modelRegistry.refresh(options),
44
45
  };
45
- // SAFETY: the selector only touches the four members above (pi 1.0–1.3
46
- // model-selector.js); everything else on ModelRuntime is never reached
47
- // from this component.
46
+ // SAFETY: the selector only touches the four members above (pi 1.0–1.5
47
+ // model-selector.js; the 1.5.0 rendering/page-key changes add no runtime
48
+ // call); everything else on ModelRuntime is never reached from this
49
+ // component.
48
50
  return facade as unknown as ModelRuntime;
49
51
  }
50
52