@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,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* favorites feature — AgentSession.cycleModel patch.
|
|
3
|
+
*
|
|
4
|
+
* ctrl+p / ctrl+shift+p keep their keybindings (they are reserved built-ins
|
|
5
|
+
* that extensions cannot rebind); instead we change the semantics of the
|
|
6
|
+
* cycle itself. When `cycleOnlyFavorites` (default true) is set AND at least
|
|
7
|
+
* one favorite exists, cycling is restricted to favorited models:
|
|
8
|
+
*
|
|
9
|
+
* - candidates come from the same source stock cycling would use (scoped
|
|
10
|
+
* models if any, else the available-model snapshot), filtered to favorites
|
|
11
|
+
* - if the current model is not a favorite: forward jumps to the first
|
|
12
|
+
* favorite, backward to the last (user-confirmed)
|
|
13
|
+
* - with exactly one favorite, already on it → undefined (stock "only one
|
|
14
|
+
* model available" feedback); otherwise jump to it
|
|
15
|
+
* - the apply logic mirrors _cycleAvailableModel (state swap, session +
|
|
16
|
+
* settings persistence, thinking-level re-clamp, model_select emit with
|
|
17
|
+
* source "cycle")
|
|
18
|
+
*
|
|
19
|
+
* Version guard: missing cycleModel → warn and skip; the patch is idempotent
|
|
20
|
+
* across /reload.
|
|
21
|
+
*/
|
|
22
|
+
import {
|
|
23
|
+
AgentSession,
|
|
24
|
+
type ModelCycleResult,
|
|
25
|
+
} from "@earendil-works/pi-coding-agent";
|
|
26
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
27
|
+
import { getFavoritesStore, modelKey } from "./store.js";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Structural view of the AgentSession internals the patch touches.
|
|
31
|
+
* AgentSession's members are TS-private (compile-time only), so this view is
|
|
32
|
+
* safe to use as the patched method's `this` type at runtime.
|
|
33
|
+
*/
|
|
34
|
+
interface CycleSessionInternals {
|
|
35
|
+
// pi-lens-ignore: no-any-type, — pi's Model generic requires `any` (constraint is Api); pi itself uses Model<any> everywhere
|
|
36
|
+
_scopedModels: ReadonlyArray<{ model: Model<any>; thinkingLevel?: string }>;
|
|
37
|
+
// pi-lens-ignore: no-any-type, — same as above
|
|
38
|
+
_modelRuntime: { getAvailableSnapshot(): Model<any>[] };
|
|
39
|
+
// pi-lens-ignore: no-any-type, — same as above
|
|
40
|
+
model: Model<any>;
|
|
41
|
+
agent: { state: { model: unknown } };
|
|
42
|
+
sessionManager: { appendModelChange(provider: string, id: string): void };
|
|
43
|
+
settingsManager: {
|
|
44
|
+
setDefaultModelAndProvider(provider: string, id: string): void;
|
|
45
|
+
};
|
|
46
|
+
setThinkingLevel(level: unknown): void;
|
|
47
|
+
_getThinkingLevelForModelSwitch(
|
|
48
|
+
explicitLevel?: string,
|
|
49
|
+
): ModelCycleResult["thinkingLevel"] | undefined;
|
|
50
|
+
_emitModelSelect(
|
|
51
|
+
next: unknown,
|
|
52
|
+
previous: unknown,
|
|
53
|
+
source: "cycle",
|
|
54
|
+
): Promise<void>;
|
|
55
|
+
thinkingLevel: ModelCycleResult["thinkingLevel"];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface CycleCandidate {
|
|
59
|
+
// pi-lens-ignore: no-any-type, — matches pi's Model<any> API
|
|
60
|
+
model: Model<any>;
|
|
61
|
+
thinkingLevel?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Index of the next favorite to cycle to.
|
|
66
|
+
*
|
|
67
|
+
* When the current model is not a favorite (`currentIndex === -1`), forward
|
|
68
|
+
* jumps to the first favorite and backward to the last (user-confirmed
|
|
69
|
+
* behavior); otherwise standard wrap-around modulo arithmetic applies.
|
|
70
|
+
*/
|
|
71
|
+
function nextCycleIndex(
|
|
72
|
+
currentIndex: number,
|
|
73
|
+
direction: "forward" | "backward",
|
|
74
|
+
len: number,
|
|
75
|
+
): number {
|
|
76
|
+
if (currentIndex === -1) {
|
|
77
|
+
return direction === "forward" ? 0 : len - 1;
|
|
78
|
+
}
|
|
79
|
+
return direction === "forward"
|
|
80
|
+
? (currentIndex + 1) % len
|
|
81
|
+
: (currentIndex - 1 + len) % len;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let patched = false;
|
|
85
|
+
|
|
86
|
+
/** Restrict ctrl+p cycling to favorites. Idempotent; returns false if skipped. */
|
|
87
|
+
export function applyCyclePatch(): boolean {
|
|
88
|
+
if (patched) return true;
|
|
89
|
+
const proto = AgentSession.prototype as unknown as {
|
|
90
|
+
cycleModel(
|
|
91
|
+
this: CycleSessionInternals,
|
|
92
|
+
direction?: "forward" | "backward",
|
|
93
|
+
): Promise<ModelCycleResult | undefined>;
|
|
94
|
+
};
|
|
95
|
+
if (typeof proto.cycleModel !== "function") {
|
|
96
|
+
// pi-lens-ignore: no-console-except-error,console-statement, — intentional degradation log
|
|
97
|
+
console.warn(
|
|
98
|
+
"[pi-toolkits/favorites] AgentSession.cycleModel missing; cycle patch skipped.",
|
|
99
|
+
);
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
patched = true;
|
|
103
|
+
const original = proto.cycleModel;
|
|
104
|
+
|
|
105
|
+
proto.cycleModel = async function (
|
|
106
|
+
this: CycleSessionInternals,
|
|
107
|
+
direction: "forward" | "backward" = "forward",
|
|
108
|
+
): Promise<ModelCycleResult | undefined> {
|
|
109
|
+
if (
|
|
110
|
+
!getFavoritesStore().getConfig().cycleOnlyFavorites ||
|
|
111
|
+
!getFavoritesStore().hasAny()
|
|
112
|
+
) {
|
|
113
|
+
return original.call(this, direction);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const scoped = this._scopedModels.length > 0;
|
|
117
|
+
const candidates: CycleCandidate[] = scoped
|
|
118
|
+
? this._scopedModels.map((sm) => ({
|
|
119
|
+
model: sm.model,
|
|
120
|
+
thinkingLevel: sm.thinkingLevel,
|
|
121
|
+
}))
|
|
122
|
+
: this._modelRuntime
|
|
123
|
+
.getAvailableSnapshot()
|
|
124
|
+
.map((model) => ({ model, thinkingLevel: undefined }));
|
|
125
|
+
const favorites = candidates.filter((candidate) =>
|
|
126
|
+
getFavoritesStore().has(candidate.model),
|
|
127
|
+
);
|
|
128
|
+
if (favorites.length === 0) {
|
|
129
|
+
// No favorites among the current source — fall back to stock cycling.
|
|
130
|
+
return original.call(this, direction);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const current = this.model;
|
|
134
|
+
const currentIndex = favorites.findIndex(
|
|
135
|
+
(candidate) => modelKey(candidate.model) === modelKey(current),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// Already on the only favorite — nothing to cycle to.
|
|
139
|
+
if (currentIndex !== -1 && favorites.length === 1) {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
const nextIndex = nextCycleIndex(currentIndex, direction, favorites.length);
|
|
143
|
+
|
|
144
|
+
const next = favorites[nextIndex];
|
|
145
|
+
const thinkingLevel =
|
|
146
|
+
scoped && next.thinkingLevel !== undefined
|
|
147
|
+
? this._getThinkingLevelForModelSwitch(next.thinkingLevel)
|
|
148
|
+
: this._getThinkingLevelForModelSwitch();
|
|
149
|
+
this.agent.state.model = next.model;
|
|
150
|
+
this.sessionManager.appendModelChange(next.model.provider, next.model.id);
|
|
151
|
+
this.settingsManager.setDefaultModelAndProvider(
|
|
152
|
+
next.model.provider,
|
|
153
|
+
next.model.id,
|
|
154
|
+
);
|
|
155
|
+
this.setThinkingLevel(thinkingLevel);
|
|
156
|
+
await this._emitModelSelect(next.model, current, "cycle");
|
|
157
|
+
return {
|
|
158
|
+
model: next.model,
|
|
159
|
+
thinkingLevel: this.thinkingLevel,
|
|
160
|
+
isScoped: scoped,
|
|
161
|
+
};
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* favorites feature — ModelSelectorComponent patches.
|
|
3
|
+
*
|
|
4
|
+
* pi's extension API has no hook to render inside the built-in model
|
|
5
|
+
* selector, so we patch the component prototype at runtime. Three hooks:
|
|
6
|
+
*
|
|
7
|
+
* 1. handleInput — intercept ctrl+f (toggle favorite for the selected
|
|
8
|
+
* model) and ctrl+j (toggle "only favorites" filter)
|
|
9
|
+
* BEFORE the original forwards them to the search box.
|
|
10
|
+
* Scoped to the selector: outside it, ctrl+f/ctrl+j keep
|
|
11
|
+
* pi's default editor bindings untouched.
|
|
12
|
+
* 2. updateList — inject a hint row at the top of the selector and render
|
|
13
|
+
* favorited model ids in bold bright yellow.
|
|
14
|
+
* 3. filterModels — when "only favorites" is on, temporarily swap
|
|
15
|
+
* activeModels for the favorites subset so the original
|
|
16
|
+
* fuzzy-filter/selection logic runs on it (zero
|
|
17
|
+
* duplication); every path (search typing, scope toggle,
|
|
18
|
+
* catalog refresh) converges here.
|
|
19
|
+
*
|
|
20
|
+
* Version guard: if the prototype no longer has the expected methods (pi
|
|
21
|
+
* upgraded internals), patches are skipped with a warning — the extension
|
|
22
|
+
* keeps loading and ctrl+p falls back to stock behavior.
|
|
23
|
+
*/
|
|
24
|
+
import {
|
|
25
|
+
ModelSelectorComponent,
|
|
26
|
+
rawKeyHint,
|
|
27
|
+
type Theme,
|
|
28
|
+
} from "@earendil-works/pi-coding-agent";
|
|
29
|
+
import { matchesKey, Spacer, Text } from "@earendil-works/pi-tui";
|
|
30
|
+
import {
|
|
31
|
+
FAVORITES_ONLY_KEY,
|
|
32
|
+
FAVORITE_TOGGLE_KEY,
|
|
33
|
+
styleFavorite,
|
|
34
|
+
} from "./constants.js";
|
|
35
|
+
import { getFavoritesStore } from "./store.js";
|
|
36
|
+
|
|
37
|
+
/** Minimal structural views of the built-in internals we touch. */
|
|
38
|
+
interface ModelRef {
|
|
39
|
+
provider: string;
|
|
40
|
+
id: string;
|
|
41
|
+
name?: string;
|
|
42
|
+
}
|
|
43
|
+
interface ModelItem {
|
|
44
|
+
provider: string;
|
|
45
|
+
id: string;
|
|
46
|
+
model: ModelRef;
|
|
47
|
+
}
|
|
48
|
+
interface SearchInputLike {
|
|
49
|
+
getValue(): string;
|
|
50
|
+
}
|
|
51
|
+
interface ListContainerLike {
|
|
52
|
+
clear(): void;
|
|
53
|
+
addChild(child: unknown): void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The patched prototype surface (public structural view, no private clash). */
|
|
57
|
+
interface SelectorPatchTarget {
|
|
58
|
+
handleInput(keyData: string): void;
|
|
59
|
+
updateList(): void;
|
|
60
|
+
filterModels(query: string): void;
|
|
61
|
+
listContainer: ListContainerLike;
|
|
62
|
+
searchInput: SearchInputLike;
|
|
63
|
+
filteredModels: ModelItem[];
|
|
64
|
+
activeModels: ModelItem[];
|
|
65
|
+
selectedIndex: number;
|
|
66
|
+
currentModel: ModelRef | undefined;
|
|
67
|
+
errorMessage?: string;
|
|
68
|
+
refreshStatusMessage?: string;
|
|
69
|
+
refreshStatusSuccess?: boolean;
|
|
70
|
+
tui: { requestRender(): void };
|
|
71
|
+
children: unknown[];
|
|
72
|
+
// favorites state injected onto instances
|
|
73
|
+
__favoritesOnly?: boolean;
|
|
74
|
+
__favoritesHint?: Text;
|
|
75
|
+
ensureFavoritesHint(): void;
|
|
76
|
+
handleFavoriteToggle(): void;
|
|
77
|
+
toggleFavoritesOnly(): void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let themeProvider: () => Theme | undefined = () => undefined;
|
|
81
|
+
|
|
82
|
+
/** Called by registerFavorites once the live theme proxy is available. */
|
|
83
|
+
export function setSelectorThemeProvider(
|
|
84
|
+
provider: () => Theme | undefined,
|
|
85
|
+
): void {
|
|
86
|
+
themeProvider = provider;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function sameModel(a: ModelRef, b: ModelRef): boolean {
|
|
90
|
+
return a.provider === b.provider && a.id === b.id;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function buildHintText(state: SelectorPatchTarget): string {
|
|
94
|
+
const theme = themeProvider();
|
|
95
|
+
if (!theme) return "";
|
|
96
|
+
const keys = `${rawKeyHint(FAVORITE_TOGGLE_KEY, "favorite")} · ${rawKeyHint(FAVORITES_ONLY_KEY, "only-favorites")}`;
|
|
97
|
+
const mode = state.__favoritesOnly
|
|
98
|
+
? theme.fg("success", " [ON]")
|
|
99
|
+
: theme.fg("muted", " [OFF]");
|
|
100
|
+
const count = getFavoritesStore().count();
|
|
101
|
+
const countText = theme.fg(
|
|
102
|
+
"dim",
|
|
103
|
+
count > 0 ? ` · ${count} favorited` : " · none favorited",
|
|
104
|
+
);
|
|
105
|
+
return theme.fg("muted", "Favorites: ") + keys + mode + countText;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let patched = false;
|
|
109
|
+
|
|
110
|
+
/** Apply the three selector patches. Idempotent; returns false if skipped. */
|
|
111
|
+
export function applyModelSelectorPatches(): boolean {
|
|
112
|
+
if (patched) return true;
|
|
113
|
+
const proto =
|
|
114
|
+
ModelSelectorComponent.prototype as unknown as SelectorPatchTarget;
|
|
115
|
+
if (
|
|
116
|
+
typeof proto.handleInput !== "function" ||
|
|
117
|
+
typeof proto.updateList !== "function" ||
|
|
118
|
+
typeof proto.filterModels !== "function"
|
|
119
|
+
) {
|
|
120
|
+
// pi-lens-ignore: no-console-except-error,console-statement, — deliberate degradation log
|
|
121
|
+
console.warn(
|
|
122
|
+
"[pi-toolkits/favorites] ModelSelectorComponent API changed; model selector patches skipped.",
|
|
123
|
+
);
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
patched = true;
|
|
127
|
+
|
|
128
|
+
const originalHandleInput = proto.handleInput;
|
|
129
|
+
const originalUpdateList = proto.updateList;
|
|
130
|
+
const originalFilterModels = proto.filterModels;
|
|
131
|
+
|
|
132
|
+
// ── 1. Key interception (ctrl+f / ctrl+j) ─────────────────────────────
|
|
133
|
+
proto.handleInput = function (
|
|
134
|
+
this: SelectorPatchTarget,
|
|
135
|
+
keyData: string,
|
|
136
|
+
): void {
|
|
137
|
+
if (matchesKey(keyData, FAVORITE_TOGGLE_KEY)) {
|
|
138
|
+
this.handleFavoriteToggle();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (matchesKey(keyData, FAVORITES_ONLY_KEY)) {
|
|
142
|
+
this.toggleFavoritesOnly();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
originalHandleInput.call(this, keyData);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// ── 2. List rendering: hint row + bold bright yellow favorites ────────
|
|
149
|
+
// Replicates the built-in updateList layout (current-model check, selected
|
|
150
|
+
// row, scroll indicator, empty/error states, refresh status) with one
|
|
151
|
+
// change: favorited model ids render in the favorite style.
|
|
152
|
+
proto.updateList = function (this: SelectorPatchTarget): void {
|
|
153
|
+
const theme = themeProvider();
|
|
154
|
+
if (!theme) {
|
|
155
|
+
// Degraded path (theme not captured yet / non-TUI): stock rendering.
|
|
156
|
+
originalUpdateList.call(this);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
this.ensureFavoritesHint();
|
|
160
|
+
const list = this.listContainer;
|
|
161
|
+
list.clear();
|
|
162
|
+
const maxVisible = 10;
|
|
163
|
+
const startIndex = Math.max(
|
|
164
|
+
0,
|
|
165
|
+
Math.min(
|
|
166
|
+
this.selectedIndex - Math.floor(maxVisible / 2),
|
|
167
|
+
this.filteredModels.length - maxVisible,
|
|
168
|
+
),
|
|
169
|
+
);
|
|
170
|
+
const endIndex = Math.min(
|
|
171
|
+
startIndex + maxVisible,
|
|
172
|
+
this.filteredModels.length,
|
|
173
|
+
);
|
|
174
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
175
|
+
const item = this.filteredModels[i];
|
|
176
|
+
if (!item) continue;
|
|
177
|
+
const isSelected = i === this.selectedIndex;
|
|
178
|
+
const isCurrent =
|
|
179
|
+
this.currentModel !== undefined &&
|
|
180
|
+
sameModel(item.model, this.currentModel);
|
|
181
|
+
const isFavorite = getFavoritesStore().has(item.model);
|
|
182
|
+
let line: string;
|
|
183
|
+
if (isSelected) {
|
|
184
|
+
const prefix = theme.fg("accent", "→ ");
|
|
185
|
+
const modelText = isFavorite
|
|
186
|
+
? styleFavorite(item.id)
|
|
187
|
+
: theme.fg("accent", item.id);
|
|
188
|
+
const providerBadge = theme.fg("muted", `[${item.provider}]`);
|
|
189
|
+
const checkmark = isCurrent ? theme.fg("success", " ✓") : "";
|
|
190
|
+
line = `${prefix + modelText} ${providerBadge}${checkmark}`;
|
|
191
|
+
} else {
|
|
192
|
+
const modelText = isFavorite
|
|
193
|
+
? ` ${styleFavorite(item.id)}`
|
|
194
|
+
: ` ${item.id}`;
|
|
195
|
+
const providerBadge = theme.fg("muted", `[${item.provider}]`);
|
|
196
|
+
const checkmark = isCurrent ? theme.fg("success", " ✓") : "";
|
|
197
|
+
line = `${modelText} ${providerBadge}${checkmark}`;
|
|
198
|
+
}
|
|
199
|
+
list.addChild(new Text(line, 0, 0));
|
|
200
|
+
}
|
|
201
|
+
if (startIndex > 0 || endIndex < this.filteredModels.length) {
|
|
202
|
+
const scrollInfo = theme.fg(
|
|
203
|
+
"muted",
|
|
204
|
+
` (${this.selectedIndex + 1}/${this.filteredModels.length})`,
|
|
205
|
+
);
|
|
206
|
+
list.addChild(new Text(scrollInfo, 0, 0));
|
|
207
|
+
}
|
|
208
|
+
if (this.errorMessage) {
|
|
209
|
+
for (const line of this.errorMessage.split("\n")) {
|
|
210
|
+
list.addChild(new Text(theme.fg("error", line), 0, 0));
|
|
211
|
+
}
|
|
212
|
+
} else if (this.filteredModels.length === 0) {
|
|
213
|
+
list.addChild(new Text(theme.fg("muted", " No matching models"), 0, 0));
|
|
214
|
+
} else {
|
|
215
|
+
const selected = this.filteredModels[this.selectedIndex];
|
|
216
|
+
list.addChild(new Spacer(1));
|
|
217
|
+
list.addChild(
|
|
218
|
+
new Text(
|
|
219
|
+
theme.fg("muted", ` Model Name: ${selected.model.name}`),
|
|
220
|
+
0,
|
|
221
|
+
0,
|
|
222
|
+
),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
if (this.refreshStatusMessage) {
|
|
226
|
+
list.addChild(new Spacer(1));
|
|
227
|
+
list.addChild(
|
|
228
|
+
new Text(
|
|
229
|
+
theme.fg(
|
|
230
|
+
this.refreshStatusSuccess ? "success" : "muted",
|
|
231
|
+
` ${this.refreshStatusMessage}`,
|
|
232
|
+
),
|
|
233
|
+
0,
|
|
234
|
+
0,
|
|
235
|
+
),
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
// ── 3. "Only favorites" filter ────────────────────────────────────────
|
|
241
|
+
// Swap activeModels for the favorites subset around the original call so
|
|
242
|
+
// the built-in fuzzy-filter + selection-index logic runs on the subset.
|
|
243
|
+
proto.filterModels = function (
|
|
244
|
+
this: SelectorPatchTarget,
|
|
245
|
+
query: string,
|
|
246
|
+
): void {
|
|
247
|
+
if (this.__favoritesOnly) {
|
|
248
|
+
const saved = this.activeModels;
|
|
249
|
+
this.activeModels = saved.filter((item) =>
|
|
250
|
+
getFavoritesStore().has(item.model),
|
|
251
|
+
);
|
|
252
|
+
try {
|
|
253
|
+
originalFilterModels.call(this, query);
|
|
254
|
+
} finally {
|
|
255
|
+
this.activeModels = saved;
|
|
256
|
+
}
|
|
257
|
+
} else {
|
|
258
|
+
originalFilterModels.call(this, query);
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// ── helpers injected onto the prototype ───────────────────────────────
|
|
263
|
+
proto.ensureFavoritesHint = function (this: SelectorPatchTarget): void {
|
|
264
|
+
if (this.__favoritesHint) return;
|
|
265
|
+
const theme = themeProvider();
|
|
266
|
+
if (!theme) return;
|
|
267
|
+
const hint = new Text(buildHintText(this), 0, 0);
|
|
268
|
+
// children: [0]=top border, [1]=spacer, then provider/scope hint rows…
|
|
269
|
+
this.children.splice(2, 0, hint);
|
|
270
|
+
this.__favoritesHint = hint;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
proto.handleFavoriteToggle = function (this: SelectorPatchTarget): void {
|
|
274
|
+
const selected = this.filteredModels[this.selectedIndex];
|
|
275
|
+
if (!selected) return;
|
|
276
|
+
const nowFavorite = getFavoritesStore().toggle(selected.model);
|
|
277
|
+
if (this.__favoritesHint) {
|
|
278
|
+
this.__favoritesHint.setText(buildHintText(this));
|
|
279
|
+
}
|
|
280
|
+
if (this.__favoritesOnly && !nowFavorite) {
|
|
281
|
+
// Un-favorited while the favorites-only filter is on: drop it from
|
|
282
|
+
// the list immediately.
|
|
283
|
+
this.filterModels(this.searchInput.getValue());
|
|
284
|
+
} else {
|
|
285
|
+
this.updateList();
|
|
286
|
+
}
|
|
287
|
+
this.tui.requestRender();
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
proto.toggleFavoritesOnly = function (this: SelectorPatchTarget): void {
|
|
291
|
+
this.__favoritesOnly = !this.__favoritesOnly;
|
|
292
|
+
if (this.__favoritesHint) {
|
|
293
|
+
this.__favoritesHint.setText(buildHintText(this));
|
|
294
|
+
}
|
|
295
|
+
this.filterModels(this.searchInput.getValue());
|
|
296
|
+
this.tui.requestRender();
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* favorites feature — persisted favorites store.
|
|
3
|
+
*
|
|
4
|
+
* State lives in `<agent-dir>/pi-toolkits-favorites.json` (agent-dir from
|
|
5
|
+
* getAgentDir(), same convention as @cnife/pi-auto-naming-session's config
|
|
6
|
+
* file). Favorites are global across projects and sessions.
|
|
7
|
+
*
|
|
8
|
+
* File shape:
|
|
9
|
+
* {
|
|
10
|
+
* "favorites": ["anthropic/claude-sonnet-4", "openrouter/openai/gpt-5"],
|
|
11
|
+
* "config": { "cycleOnlyFavorites": true }
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* `cycleOnlyFavorites` (default true) gates the ctrl+p favorites-only
|
|
15
|
+
* cycling behavior — set it to false in the JSON to restore full cycling.
|
|
16
|
+
*
|
|
17
|
+
* Robustness: corrupted/unreadable files fall back to an empty set + default
|
|
18
|
+
* config with a console.warn; writes are atomic (tmp file + rename) and
|
|
19
|
+
* failures are logged, never thrown.
|
|
20
|
+
*/
|
|
21
|
+
import {
|
|
22
|
+
existsSync,
|
|
23
|
+
mkdirSync,
|
|
24
|
+
readFileSync,
|
|
25
|
+
renameSync,
|
|
26
|
+
writeFileSync,
|
|
27
|
+
} from "node:fs";
|
|
28
|
+
import { dirname, join } from "node:path";
|
|
29
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
30
|
+
import { FAVORITES_FILE } from "./constants.js";
|
|
31
|
+
|
|
32
|
+
export interface FavoritesConfig {
|
|
33
|
+
/** When true (default), ctrl+p / ctrl+shift+p cycle only favorited models. */
|
|
34
|
+
cycleOnlyFavorites: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface FavoritesSnapshot {
|
|
38
|
+
favorites: string[];
|
|
39
|
+
config: FavoritesConfig;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DEFAULT_CONFIG: FavoritesConfig = { cycleOnlyFavorites: true };
|
|
43
|
+
|
|
44
|
+
/** Model identity used as the favorites key: "provider/id". */
|
|
45
|
+
export function modelKey(model: { provider: string; id: string }): string {
|
|
46
|
+
return `${model.provider}/${model.id}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class FavoritesStore {
|
|
50
|
+
private favorites = new Set<string>();
|
|
51
|
+
private config: FavoritesConfig = { ...DEFAULT_CONFIG };
|
|
52
|
+
private readonly file: string;
|
|
53
|
+
private loaded = false;
|
|
54
|
+
private readonly listeners = new Set<() => void>();
|
|
55
|
+
|
|
56
|
+
constructor(file: string = join(getAgentDir(), FAVORITES_FILE)) {
|
|
57
|
+
this.file = file;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private ensureLoaded(): void {
|
|
61
|
+
if (this.loaded) return;
|
|
62
|
+
this.loaded = true;
|
|
63
|
+
try {
|
|
64
|
+
if (!existsSync(this.file)) {
|
|
65
|
+
this.save();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const raw = readFileSync(this.file, "utf8");
|
|
69
|
+
const parsed = JSON.parse(raw) as Partial<FavoritesSnapshot>;
|
|
70
|
+
if (parsed && Array.isArray(parsed.favorites)) {
|
|
71
|
+
for (const id of parsed.favorites) {
|
|
72
|
+
if (typeof id === "string" && id.includes("/")) {
|
|
73
|
+
this.favorites.add(id);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (parsed && parsed.config && typeof parsed.config === "object") {
|
|
78
|
+
const cfg = parsed.config as Partial<FavoritesConfig>;
|
|
79
|
+
if (typeof cfg.cycleOnlyFavorites === "boolean") {
|
|
80
|
+
this.config.cycleOnlyFavorites = cfg.cycleOnlyFavorites;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch (error) {
|
|
84
|
+
// pi-lens-ignore: no-console-except-error,console-statement, — deliberate degradation log
|
|
85
|
+
console.warn(
|
|
86
|
+
"[pi-toolkits/favorites] Failed to load favorites file, starting empty:",
|
|
87
|
+
error instanceof Error ? error.message : String(error),
|
|
88
|
+
);
|
|
89
|
+
this.favorites.clear();
|
|
90
|
+
this.config = { ...DEFAULT_CONFIG };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Atomically persist (tmp + rename). Never throws. */
|
|
95
|
+
save(): void {
|
|
96
|
+
try {
|
|
97
|
+
const dir = dirname(this.file);
|
|
98
|
+
if (!existsSync(dir)) {
|
|
99
|
+
mkdirSync(dir, { recursive: true });
|
|
100
|
+
}
|
|
101
|
+
const tmp = `${this.file}.tmp`;
|
|
102
|
+
const data: FavoritesSnapshot = {
|
|
103
|
+
// Explicit comparator: keys are "provider/id" strings, sorted
|
|
104
|
+
// lexicographically for a deterministic state file.
|
|
105
|
+
favorites: [...this.favorites].sort((a, b) => a.localeCompare(b)),
|
|
106
|
+
config: this.config,
|
|
107
|
+
};
|
|
108
|
+
writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
|
109
|
+
renameSync(tmp, this.file);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
// pi-lens-ignore: no-console-except-error,console-statement, — deliberate degradation log
|
|
112
|
+
console.warn(
|
|
113
|
+
"[pi-toolkits/favorites] Failed to save favorites file:",
|
|
114
|
+
error instanceof Error ? error.message : String(error),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
has(model: { provider: string; id: string }): boolean {
|
|
120
|
+
this.ensureLoaded();
|
|
121
|
+
return this.favorites.has(modelKey(model));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
hasAny(): boolean {
|
|
125
|
+
this.ensureLoaded();
|
|
126
|
+
return this.favorites.size > 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
count(): number {
|
|
130
|
+
this.ensureLoaded();
|
|
131
|
+
return this.favorites.size;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
list(): string[] {
|
|
135
|
+
this.ensureLoaded();
|
|
136
|
+
return [...this.favorites].sort((a, b) => a.localeCompare(b));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Toggle favorite for a model. Returns true if it is now favorited. */
|
|
140
|
+
toggle(model: { provider: string; id: string }): boolean {
|
|
141
|
+
this.ensureLoaded();
|
|
142
|
+
const key = modelKey(model);
|
|
143
|
+
let nowFavorite: boolean;
|
|
144
|
+
if (this.favorites.has(key)) {
|
|
145
|
+
this.favorites.delete(key);
|
|
146
|
+
nowFavorite = false;
|
|
147
|
+
} else {
|
|
148
|
+
this.favorites.add(key);
|
|
149
|
+
nowFavorite = true;
|
|
150
|
+
}
|
|
151
|
+
this.save();
|
|
152
|
+
this.notify();
|
|
153
|
+
return nowFavorite;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
getConfig(): FavoritesConfig {
|
|
157
|
+
this.ensureLoaded();
|
|
158
|
+
return { ...this.config };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
subscribe(listener: () => void): () => void {
|
|
162
|
+
this.listeners.add(listener);
|
|
163
|
+
return () => {
|
|
164
|
+
this.listeners.delete(listener);
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private notify(): void {
|
|
169
|
+
for (const listener of [...this.listeners]) {
|
|
170
|
+
try {
|
|
171
|
+
listener();
|
|
172
|
+
} catch {
|
|
173
|
+
// A broken listener must not break favorites handling.
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Process-wide store shared by the selector/cycle patches and commands.
|
|
181
|
+
*
|
|
182
|
+
* Accessed via getFavoritesStore() (lazy) so tests can swap in an isolated
|
|
183
|
+
* store via setSharedFavoritesStore().
|
|
184
|
+
*/
|
|
185
|
+
let sharedStore: FavoritesStore | undefined;
|
|
186
|
+
|
|
187
|
+
export function getFavoritesStore(): FavoritesStore {
|
|
188
|
+
if (!sharedStore) {
|
|
189
|
+
sharedStore = new FavoritesStore();
|
|
190
|
+
}
|
|
191
|
+
return sharedStore;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Test hook — replace the shared store (e.g. with one backed by a temp file). */
|
|
195
|
+
export function setSharedFavoritesStore(store: FavoritesStore): void {
|
|
196
|
+
sharedStore = store;
|
|
197
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* review feature — constants.
|
|
3
|
+
*
|
|
4
|
+
* The review-model config lives in `<agent-dir>/pi-toolkits-review.json`
|
|
5
|
+
* (same agent-dir convention as the favorites store) and holds a single
|
|
6
|
+
* model key `"provider/id"` — see store.ts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Config file name inside the pi agent dir. */
|
|
10
|
+
export const REVIEW_FILE = "pi-toolkits-review.json";
|
|
11
|
+
|
|
12
|
+
/** Footer status key shown while a review model is configured. */
|
|
13
|
+
export const REVIEW_STATUS_KEY = "review";
|