@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.
- package/README.md +203 -24
- package/package.json +7 -3
- package/src/features/favorites/constants.ts +31 -0
- package/src/features/favorites/favorites.test.ts +437 -0
- package/src/features/favorites/index.ts +112 -0
- package/src/features/favorites/patch-cycle.ts +165 -0
- package/src/features/favorites/patch-selector.ts +300 -0
- package/src/features/favorites/store.ts +197 -0
- package/src/features/review/constants.ts +13 -0
- package/src/features/review/index.ts +232 -0
- package/src/features/review/prompt.ts +37 -0
- package/src/features/review/review.test.ts +443 -0
- package/src/features/review/store.ts +137 -0
- package/src/features/stash/index.ts +29 -0
- package/src/features/stash/stash.test.ts +101 -0
- package/src/features/stash/stash.ts +83 -0
- package/src/index.ts +9 -0
|
@@ -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
|
+
}
|