@andares/pi-toolkits 0.1.0 → 0.3.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.
@@ -0,0 +1,437 @@
1
+ /**
2
+ * favorites feature — unit tests.
3
+ *
4
+ * Covers the logic that drives the patches:
5
+ * - store: toggle/has/count + JSON persistence round-trip + corruption fallback
6
+ * - cycle patch: favorites-only cycling (fallback to stock when disabled or
7
+ * no favorites; favorites-only pick; single-favorite jump; not-a-favorite
8
+ * → forward first / backward last)
9
+ * - selector patches: ctrl+j favorites-only filter, ctrl+f toggle, hint
10
+ * injection, idempotent apply
11
+ *
12
+ * The patches read the shared store via getFavoritesStore(); each test gets an
13
+ * isolated store backed by a temp file via setSharedFavoritesStore(). The
14
+ * selector fake objects inherit the patched ModelSelectorComponent.prototype
15
+ * (setPrototypeOf) so the patched methods resolve with `this` = the fake.
16
+ */
17
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import {
21
+ AgentSession,
22
+ initTheme,
23
+ ModelSelectorComponent,
24
+ type Theme,
25
+ } from "@earendil-works/pi-coding-agent";
26
+ import { afterAll, beforeEach, beforeAll, describe, expect, it } from "vitest";
27
+ import {
28
+ FavoritesStore,
29
+ getFavoritesStore,
30
+ modelKey,
31
+ setSharedFavoritesStore,
32
+ } from "./store.js";
33
+ import { applyCyclePatch } from "./patch-cycle.js";
34
+ import {
35
+ applyModelSelectorPatches,
36
+ setSelectorThemeProvider,
37
+ } from "./patch-selector.js";
38
+
39
+ // ─── setup ───────────────────────────────────────────────────────
40
+
41
+ let tmpDir: string;
42
+ let storeSeq = 0;
43
+
44
+ beforeAll(() => {
45
+ // Initialize pi's live theme proxy so rawKeyHint()/theme.fg() work in the
46
+ // replicated updateList paths.
47
+ initTheme("dark");
48
+ // Fake theme for the selector patch's own rendering (only fg() is used).
49
+ setSelectorThemeProvider(
50
+ () => ({ fg: (_c: string, t: string) => t }) as unknown as Theme,
51
+ );
52
+ tmpDir = mkdtempSync(join(tmpdir(), "pi-toolkits-fav-"));
53
+ });
54
+
55
+ beforeEach(() => {
56
+ storeSeq += 1;
57
+ setSharedFavoritesStore(
58
+ new FavoritesStore(join(tmpDir, `fav-${storeSeq}.json`)),
59
+ );
60
+ });
61
+
62
+ afterAll(() => {
63
+ rmSync(tmpDir, { recursive: true, force: true });
64
+ });
65
+
66
+ const models = [
67
+ { provider: "anthropic", id: "claude-sonnet-4" },
68
+ { provider: "anthropic", id: "claude-opus-4" },
69
+ { provider: "openrouter", id: "openai/gpt-5" },
70
+ ];
71
+
72
+ function freshStoreFile(): string {
73
+ storeSeq += 1;
74
+ return join(tmpDir, `fav-${storeSeq}.json`);
75
+ }
76
+
77
+ // ─── store ───────────────────────────────────────────────────────
78
+
79
+ describe("FavoritesStore", () => {
80
+ it("toggles, counts and keys by provider/id", () => {
81
+ const store = new FavoritesStore(freshStoreFile());
82
+ expect(store.has(models[0])).toBe(false);
83
+ expect(store.toggle(models[0])).toBe(true);
84
+ expect(store.has(models[0])).toBe(true);
85
+ expect(store.count()).toBe(1);
86
+ expect(store.toggle(models[0])).toBe(false);
87
+ expect(store.count()).toBe(0);
88
+ });
89
+
90
+ it("persists across instances (file round-trip)", () => {
91
+ const file = freshStoreFile();
92
+ const a = new FavoritesStore(file);
93
+ a.toggle(models[0]);
94
+ a.toggle(models[1]);
95
+ const b = new FavoritesStore(file);
96
+ expect(b.has(models[0])).toBe(true);
97
+ expect(b.has(models[1])).toBe(true);
98
+ expect(b.has(models[2])).toBe(false);
99
+ expect(b.count()).toBe(2);
100
+ });
101
+
102
+ it("falls back to empty + defaults on a corrupted file", () => {
103
+ const file = freshStoreFile();
104
+ // Intentionally write invalid JSON — the READ side must tolerate it.
105
+ try {
106
+ writeFileSync(file, "{ not json", "utf8");
107
+ } catch {
108
+ // A failed write still leaves the store to hit the read-error path.
109
+ }
110
+ const store = new FavoritesStore(file);
111
+ expect(store.count()).toBe(0);
112
+ expect(store.getConfig().cycleOnlyFavorites).toBe(true);
113
+ });
114
+
115
+ it("loads cycleOnlyFavorites config from disk", () => {
116
+ const file = freshStoreFile();
117
+ writeFileSync(
118
+ file,
119
+ JSON.stringify({
120
+ favorites: ["anthropic/claude-sonnet-4"],
121
+ config: { cycleOnlyFavorites: false },
122
+ }),
123
+ "utf8",
124
+ );
125
+ const store = new FavoritesStore(file);
126
+ expect(store.has(models[0])).toBe(true);
127
+ expect(store.getConfig().cycleOnlyFavorites).toBe(false);
128
+ });
129
+
130
+ it("writes a valid JSON file", () => {
131
+ const file = freshStoreFile();
132
+ const store = new FavoritesStore(file);
133
+ store.toggle(models[0]);
134
+ const raw = JSON.parse(readFileSync(file, "utf8")) as {
135
+ favorites: string[];
136
+ };
137
+ expect(raw.favorites).toEqual([modelKey(models[0])]);
138
+ });
139
+ });
140
+
141
+ // ─── cycle patch ─────────────────────────────────────────────────
142
+
143
+ interface FakeCycleSession {
144
+ _scopedModels: Array<{
145
+ model: (typeof models)[number];
146
+ thinkingLevel?: string;
147
+ }>;
148
+ _modelRuntime: { getAvailableSnapshot(): (typeof models)[number][] };
149
+ model: (typeof models)[number];
150
+ agent: { state: { model: unknown } };
151
+ sessionManager: {
152
+ appendModelChange(provider: string, id: string): void;
153
+ calls: Array<[string, string]>;
154
+ };
155
+ settingsManager: {
156
+ setDefaultModelAndProvider(provider: string, id: string): void;
157
+ calls: Array<[string, string]>;
158
+ };
159
+ setThinkingLevel(level: unknown): void;
160
+ _getThinkingLevelForModelSwitch(explicit?: string): string;
161
+ _cycleAvailableModel(direction: "forward" | "backward"): Promise<unknown>;
162
+ _cycleScopedModel(direction: "forward" | "backward"): Promise<unknown>;
163
+ _emitModelSelect(
164
+ next: unknown,
165
+ previous: unknown,
166
+ source: "cycle",
167
+ ): Promise<void>;
168
+ thinkingLevel: string;
169
+ emitted: Array<{ next: unknown; previous: unknown; source: string }>;
170
+ }
171
+
172
+ type ModelLike = (typeof models)[number];
173
+
174
+ /** Stock cycle algorithm used by the real AgentSession (mirrored for the fake). */
175
+ function stockAvailableCycle(
176
+ this: FakeCycleSession,
177
+ direction: "forward" | "backward",
178
+ ): Promise<unknown> {
179
+ const available = this._modelRuntime.getAvailableSnapshot();
180
+ if (available.length <= 1) return Promise.resolve(undefined);
181
+ const current = this.model;
182
+ let idx = available.findIndex(
183
+ (m) => m.provider === current.provider && m.id === current.id,
184
+ );
185
+ if (idx === -1) idx = 0;
186
+ const len = available.length;
187
+ const nextIdx =
188
+ direction === "forward" ? (idx + 1) % len : (idx - 1 + len) % len;
189
+ const next = available[nextIdx];
190
+ this.agent.state.model = next;
191
+ this.sessionManager.appendModelChange(next.provider, next.id);
192
+ this.settingsManager.setDefaultModelAndProvider(next.provider, next.id);
193
+ const level = this._getThinkingLevelForModelSwitch();
194
+ this.setThinkingLevel(level);
195
+ return Promise.resolve({
196
+ model: next,
197
+ thinkingLevel: level,
198
+ isScoped: false,
199
+ });
200
+ }
201
+
202
+ function fakeSession(
203
+ overrides: Partial<FakeCycleSession> = {},
204
+ ): FakeCycleSession {
205
+ const session = {
206
+ _scopedModels: [],
207
+ _modelRuntime: { getAvailableSnapshot: () => models },
208
+ model: models[0],
209
+ agent: { state: { model: models[0] } },
210
+ sessionManager: {
211
+ calls: [] as Array<[string, string]>,
212
+ appendModelChange(
213
+ this: { calls: Array<[string, string]> },
214
+ provider: string,
215
+ id: string,
216
+ ) {
217
+ this.calls.push([provider, id]);
218
+ },
219
+ },
220
+ settingsManager: {
221
+ calls: [] as Array<[string, string]>,
222
+ setDefaultModelAndProvider(
223
+ this: { calls: Array<[string, string]> },
224
+ provider: string,
225
+ id: string,
226
+ ) {
227
+ this.calls.push([provider, id]);
228
+ },
229
+ },
230
+ setThinkingLevel(level: unknown) {
231
+ (this as unknown as FakeCycleSession).thinkingLevel = level as string;
232
+ },
233
+ _getThinkingLevelForModelSwitch: (_explicit?: string) => "low",
234
+ _cycleAvailableModel: stockAvailableCycle,
235
+ _cycleScopedModel: stockAvailableCycle,
236
+ _emitModelSelect(next: unknown, previous: unknown, source: "cycle") {
237
+ this.emitted.push({ next, previous, source });
238
+ return Promise.resolve();
239
+ },
240
+ thinkingLevel: "low",
241
+ emitted: [] as FakeCycleSession["emitted"],
242
+ } satisfies FakeCycleSession;
243
+ return { ...session, ...overrides } as FakeCycleSession;
244
+ }
245
+
246
+ type PatchedCycle = (
247
+ this: FakeCycleSession,
248
+ direction?: "forward" | "backward",
249
+ ) => Promise<
250
+ { model: ModelLike; thinkingLevel: string; isScoped: boolean } | undefined
251
+ >;
252
+
253
+ describe("cycle patch", () => {
254
+ const originalCycleModel = AgentSession.prototype.cycleModel;
255
+
256
+ it("applies once and wraps the original", () => {
257
+ expect(applyCyclePatch()).toBe(true);
258
+ expect(applyCyclePatch()).toBe(true); // idempotent
259
+ expect(AgentSession.prototype.cycleModel).not.toBe(originalCycleModel);
260
+ });
261
+
262
+ it("falls back to stock cycling when no favorites exist", async () => {
263
+ const patched = AgentSession.prototype
264
+ .cycleModel as unknown as PatchedCycle;
265
+ const result = await patched.call(fakeSession(), "forward");
266
+ // Stock: sonnet-4 → opus-4 (next available model).
267
+ expect(result?.model.id).toBe("claude-opus-4");
268
+ });
269
+
270
+ it("cycles only among favorites once favorites exist", async () => {
271
+ getFavoritesStore().toggle(models[0]);
272
+ getFavoritesStore().toggle(models[1]); // favorites: sonnet-4, opus-4 (gpt-5 excluded)
273
+ const patched = AgentSession.prototype
274
+ .cycleModel as unknown as PatchedCycle;
275
+ const session = fakeSession({ model: models[1] }); // current = opus-4
276
+ const result = await patched.call(session, "forward");
277
+ expect(result?.model.id).toBe("claude-sonnet-4"); // wraps within favorites
278
+ expect(session.emitted[0]?.source).toBe("cycle");
279
+ expect(result?.isScoped).toBe(false);
280
+ });
281
+
282
+ it("jumps to first favorite forward / last backward when current is not a favorite", async () => {
283
+ getFavoritesStore().toggle(models[1]);
284
+ getFavoritesStore().toggle(models[2]); // favorites: opus-4, gpt-5; current = sonnet-4 (not fav)
285
+ const patched = AgentSession.prototype
286
+ .cycleModel as unknown as PatchedCycle;
287
+
288
+ const forward = await patched.call(
289
+ fakeSession({ model: models[0] }),
290
+ "forward",
291
+ );
292
+ expect(forward?.model.id).toBe("claude-opus-4"); // first favorite
293
+
294
+ const backward = await patched.call(
295
+ fakeSession({ model: models[0] }),
296
+ "backward",
297
+ );
298
+ expect(backward?.model.id).toBe("openai/gpt-5"); // last favorite
299
+ });
300
+
301
+ it("returns undefined when already on the only favorite", async () => {
302
+ getFavoritesStore().toggle(models[0]);
303
+ const patched = AgentSession.prototype
304
+ .cycleModel as unknown as PatchedCycle;
305
+ expect(
306
+ await patched.call(fakeSession({ model: models[0] }), "forward"),
307
+ ).toBeUndefined();
308
+ });
309
+
310
+ it("respects cycleOnlyFavorites=false (falls back to stock)", async () => {
311
+ const file = freshStoreFile();
312
+ writeFileSync(
313
+ file,
314
+ JSON.stringify({
315
+ favorites: [modelKey(models[0])],
316
+ config: { cycleOnlyFavorites: false },
317
+ }),
318
+ "utf8",
319
+ );
320
+ setSharedFavoritesStore(new FavoritesStore(file));
321
+ getFavoritesStore().toggle(models[1]); // ensure favorites exist
322
+ const patched = AgentSession.prototype
323
+ .cycleModel as unknown as PatchedCycle;
324
+ const result = await patched.call(
325
+ fakeSession({ model: models[0] }),
326
+ "forward",
327
+ );
328
+ // Stock behavior: next available model, not favorites-only.
329
+ expect(result?.model.id).toBe("claude-opus-4");
330
+ });
331
+ });
332
+
333
+ // ─── selector patches ────────────────────────────────────────────
334
+
335
+ interface FakeSelector {
336
+ children: unknown[];
337
+ listContainer: { clear(): void; addChild(_c: unknown): void };
338
+ searchInput: { getValue(): string; handleInput(_d: string): void };
339
+ filteredModels: Array<{ provider: string; id: string; model: ModelLike }>;
340
+ activeModels: Array<{ provider: string; id: string; model: ModelLike }>;
341
+ selectedIndex: number;
342
+ currentModel: ModelLike | undefined;
343
+ tui: { requestRender(): void };
344
+ __favoritesOnly?: boolean;
345
+ __favoritesHint?: { setText(_t: string): void };
346
+ handleInput(d: string): void;
347
+ updateList(): void;
348
+ filterModels(q: string): void;
349
+ }
350
+
351
+ function item(model: ModelLike) {
352
+ return { provider: model.provider, id: model.id, model };
353
+ }
354
+
355
+ function fakeSelector(overrides: Partial<FakeSelector> = {}): FakeSelector {
356
+ const selector = {
357
+ children: [] as unknown[],
358
+ listContainer: { clear() {}, addChild() {} },
359
+ searchInput: { getValue: () => "", handleInput: () => {} },
360
+ filteredModels: [] as FakeSelector["filteredModels"],
361
+ activeModels: [] as FakeSelector["activeModels"],
362
+ selectedIndex: 0,
363
+ currentModel: undefined,
364
+ tui: { requestRender() {} },
365
+ ...overrides,
366
+ };
367
+ // Route methods through the patched prototype so the real patched
368
+ // implementations run with `this` = the fake. IMPORTANT: the fake must NOT
369
+ // have own handleInput/updateList/filterModels stubs — own properties
370
+ // shadow the patched prototype methods.
371
+ return Object.setPrototypeOf(
372
+ selector,
373
+ ModelSelectorComponent.prototype,
374
+ ) as unknown as FakeSelector;
375
+ }
376
+
377
+ describe("selector patches", () => {
378
+ it("applies once and is idempotent", () => {
379
+ expect(applyModelSelectorPatches()).toBe(true);
380
+ expect(applyModelSelectorPatches()).toBe(true);
381
+ });
382
+
383
+ it("ctrl+j toggles the favorites-only filter and filters activeModels", () => {
384
+ getFavoritesStore().toggle(models[0]);
385
+ const selector = fakeSelector({
386
+ activeModels: models.map(item),
387
+ filteredModels: models.map(item),
388
+ });
389
+ selector.handleInput("\x0a"); // ctrl+j
390
+ expect(selector.__favoritesOnly).toBe(true);
391
+ // Original filterModels ran on the favorites subset:
392
+ expect(selector.filteredModels.map((m) => m.id)).toEqual([
393
+ "claude-sonnet-4",
394
+ ]);
395
+ selector.handleInput("\x0a"); // toggle off
396
+ expect(selector.__favoritesOnly).toBe(false);
397
+ expect(selector.filteredModels).toHaveLength(3);
398
+ });
399
+
400
+ it("ctrl+f toggles favorite for the selected model", () => {
401
+ const selector = fakeSelector({
402
+ filteredModels: models.map(item),
403
+ selectedIndex: 1, // opus-4
404
+ activeModels: models.map(item),
405
+ });
406
+ selector.handleInput("\x06"); // ctrl+f
407
+ expect(getFavoritesStore().has(models[1])).toBe(true);
408
+ selector.handleInput("\x06");
409
+ expect(getFavoritesStore().has(models[1])).toBe(false);
410
+ });
411
+
412
+ it("injects the hint row once (children.length grows by exactly one)", () => {
413
+ const selector = fakeSelector({
414
+ children: [1, 2, 3, 4] as unknown[],
415
+ activeModels: [],
416
+ filteredModels: [],
417
+ });
418
+ selector.updateList(); // ensureFavoritesHint runs on first updateList
419
+ selector.updateList();
420
+ expect(selector.children).toHaveLength(5); // 4 + exactly 1 hint
421
+ expect(selector.__favoritesHint).toBeDefined();
422
+ });
423
+
424
+ it("keeps non-favorites filter behavior when favorites-only is off", () => {
425
+ getFavoritesStore().toggle(models[0]);
426
+ const selector = fakeSelector({
427
+ activeModels: models.map(item),
428
+ filteredModels: models.map(item),
429
+ });
430
+ // No ctrl+j pressed: filterModels with a query keeps all models (stock path).
431
+ selector.filterModels("claude");
432
+ expect(selector.filteredModels.map((m) => m.id)).toEqual([
433
+ "claude-sonnet-4",
434
+ "claude-opus-4",
435
+ ]);
436
+ });
437
+ });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * favorites feature — model favorites for pi.
3
+ *
4
+ * What it does:
5
+ * - Inside the /model selector (opened via /model or ctrl+l):
6
+ * * a hint row at the top shows the keys and the favorites-only state
7
+ * * ctrl+f toggles favorite on the currently selected model
8
+ * * ctrl+j toggles the "only favorites" filter
9
+ * * favorited model ids render in bold bright yellow
10
+ * The keys are consumed by the selector component only, so pi's editor
11
+ * bindings (ctrl+f cursor-right, ctrl+j newline) are untouched elsewhere.
12
+ * - ctrl+p / ctrl+shift+p keep their bindings but, once favorites exist
13
+ * (and cycleOnlyFavorites is not false), cycle only among favorited
14
+ * models instead of all models. ctrl+l / /model remain the escape hatch
15
+ * to pick any model.
16
+ * - Favorites persist to <agent-dir>/pi-toolkits-favorites.json (global
17
+ * across projects and sessions).
18
+ *
19
+ * Implementation: pi's extension API has no hook to render inside the
20
+ * built-in model selector and cannot rebind the reserved ctrl+p actions, so
21
+ * the feature patches two built-in prototypes at runtime:
22
+ * - ModelSelectorComponent (hint row, ctrl+f/ctrl+j, styling, filter)
23
+ * - AgentSession.cycleModel (favorites-only cycling)
24
+ * Both patches are version-guarded (skip + warn if the API changed) and
25
+ * idempotent across /reload.
26
+ */
27
+ import type {
28
+ ExtensionAPI,
29
+ ExtensionContext,
30
+ Theme,
31
+ } from "@earendil-works/pi-coding-agent";
32
+ import { FAVORITES_STATUS_KEY } from "./constants.js";
33
+ import { applyCyclePatch } from "./patch-cycle.js";
34
+ import {
35
+ applyModelSelectorPatches,
36
+ setSelectorThemeProvider,
37
+ } from "./patch-selector.js";
38
+ import { getFavoritesStore } from "./store.js";
39
+
40
+ export function registerFavorites(pi: ExtensionAPI): void {
41
+ // Live theme proxy shared with the built-in components (see theme.ts:
42
+ // `theme` is a Proxy reading the current theme off globalThis). Captured
43
+ // lazily from the first context; unavailable in non-TUI modes.
44
+ let theme: Theme | undefined;
45
+
46
+ const captureTheme = (ctx: ExtensionContext): void => {
47
+ if (theme) return;
48
+ try {
49
+ const t = ctx.ui.theme;
50
+ if (t) {
51
+ theme = t;
52
+ setSelectorThemeProvider(() => theme);
53
+ }
54
+ } catch {
55
+ // No theme in this mode — selector styling degrades to stock.
56
+ }
57
+ };
58
+
59
+ const updateFooterStatus = (ctx: ExtensionContext): void => {
60
+ const count = getFavoritesStore().count();
61
+ ctx.ui.setStatus(
62
+ FAVORITES_STATUS_KEY,
63
+ count > 0 && theme ? theme.fg("accent", `★ ${count}`) : undefined,
64
+ );
65
+ };
66
+
67
+ // Latest context for live footer updates when favorites change in the
68
+ // selector. setStatus is idempotent and re-synced on every session_start.
69
+ let latestCtx: ExtensionContext | undefined;
70
+
71
+ pi.on("session_start", (_event, ctx) => {
72
+ latestCtx = ctx;
73
+ captureTheme(ctx);
74
+ updateFooterStatus(ctx);
75
+ });
76
+
77
+ pi.on("model_select", (_event, ctx) => {
78
+ captureTheme(ctx);
79
+ updateFooterStatus(ctx);
80
+ });
81
+
82
+ getFavoritesStore().subscribe(() => {
83
+ if (latestCtx) updateFooterStatus(latestCtx);
84
+ });
85
+
86
+ // Patch built-in prototypes (each is internally version-guarded and
87
+ // idempotent). Order doesn't matter — they touch different classes.
88
+ applyModelSelectorPatches();
89
+ applyCyclePatch();
90
+
91
+ pi.registerCommand("favorites", {
92
+ description: "List favorited models (add/remove with ctrl+F in /model)",
93
+ handler: async (_args, ctx) => {
94
+ captureTheme(ctx);
95
+ const list = getFavoritesStore().list();
96
+ if (list.length === 0) {
97
+ ctx.ui.notify(
98
+ "No favorited models. Open /model and press ctrl+F on a model to favorite it.",
99
+ "info",
100
+ );
101
+ return;
102
+ }
103
+ const cycle = getFavoritesStore().getConfig().cycleOnlyFavorites
104
+ ? "on"
105
+ : "off";
106
+ ctx.ui.notify(
107
+ `Favorites (${list.length}): ${list.join(", ")} — ctrl+p cycling: ${cycle}`,
108
+ "info",
109
+ );
110
+ },
111
+ });
112
+ }