@andares/pi-toolkits 0.3.1 → 0.4.1
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 +66 -8
- package/package.json +11 -5
- package/src/features/compact/compact.test.ts +661 -0
- package/src/features/compact/constants.ts +13 -0
- package/src/features/compact/index.ts +371 -0
- package/src/features/compact/prompt.ts +240 -0
- package/src/features/compact/store.ts +137 -0
- package/src/features/favorites/index.ts +5 -3
- package/src/features/favorites/patch-cycle.ts +60 -22
- package/src/features/favorites/patch-selector.ts +19 -20
- package/src/features/review/index.ts +68 -36
- package/src/features/review/review.test.ts +65 -10
- package/src/features/review/store.ts +0 -5
- package/src/index.ts +3 -0
- package/src/lib/model-config.ts +44 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compact-model feature — persisted compact-model config store.
|
|
3
|
+
*
|
|
4
|
+
* State lives in `<agent-dir>/pi-toolkits-compact.json` (agent-dir from
|
|
5
|
+
* getAgentDir(), same convention as the review store). The file holds a
|
|
6
|
+
* single model key:
|
|
7
|
+
*
|
|
8
|
+
* { "model": "google/gemini-2.5-flash" }
|
|
9
|
+
*
|
|
10
|
+
* Unset = feature disabled: every compaction (manual /compact, auto-compact
|
|
11
|
+
* threshold, overflow recovery) uses pi's default behavior with the current
|
|
12
|
+
* session model, zero overhead.
|
|
13
|
+
*
|
|
14
|
+
* Robustness mirrors the review store: corrupted/unreadable files fall back
|
|
15
|
+
* to "no model configured" with a console.warn; writes are atomic
|
|
16
|
+
* (tmp + rename) and never throw.
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
existsSync,
|
|
20
|
+
mkdirSync,
|
|
21
|
+
readFileSync,
|
|
22
|
+
renameSync,
|
|
23
|
+
writeFileSync,
|
|
24
|
+
} from "node:fs";
|
|
25
|
+
import { dirname, join } from "node:path";
|
|
26
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
27
|
+
import { COMPACT_FILE } from "./constants.js";
|
|
28
|
+
|
|
29
|
+
export class CompactStore {
|
|
30
|
+
private model: string | undefined;
|
|
31
|
+
private readonly file: string;
|
|
32
|
+
private loaded = false;
|
|
33
|
+
private readonly listeners = new Set<() => void>();
|
|
34
|
+
|
|
35
|
+
constructor(file: string = join(getAgentDir(), COMPACT_FILE)) {
|
|
36
|
+
this.file = file;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private ensureLoaded(): void {
|
|
40
|
+
if (this.loaded) return;
|
|
41
|
+
this.loaded = true;
|
|
42
|
+
try {
|
|
43
|
+
if (!existsSync(this.file)) return;
|
|
44
|
+
const raw = readFileSync(this.file, "utf8");
|
|
45
|
+
const parsed = JSON.parse(raw) as { model?: unknown };
|
|
46
|
+
if (
|
|
47
|
+
parsed &&
|
|
48
|
+
typeof parsed.model === "string" &&
|
|
49
|
+
parsed.model.includes("/")
|
|
50
|
+
) {
|
|
51
|
+
this.model = parsed.model;
|
|
52
|
+
}
|
|
53
|
+
} catch (error) {
|
|
54
|
+
// pi-lens-ignore: no-console-except-error,console-statement, — deliberate degradation log
|
|
55
|
+
console.warn(
|
|
56
|
+
"[pi-toolkits/compact] Failed to load compact-model config, starting unset:",
|
|
57
|
+
error instanceof Error ? error.message : String(error),
|
|
58
|
+
);
|
|
59
|
+
this.model = undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Atomically persist (tmp + rename). Never throws. */
|
|
64
|
+
save(): void {
|
|
65
|
+
try {
|
|
66
|
+
const dir = dirname(this.file);
|
|
67
|
+
if (!existsSync(dir)) {
|
|
68
|
+
mkdirSync(dir, { recursive: true });
|
|
69
|
+
}
|
|
70
|
+
const tmp = `${this.file}.tmp`;
|
|
71
|
+
writeFileSync(
|
|
72
|
+
tmp,
|
|
73
|
+
`${JSON.stringify({ model: this.model ?? null }, null, 2)}\n`,
|
|
74
|
+
"utf8",
|
|
75
|
+
);
|
|
76
|
+
renameSync(tmp, this.file);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
// pi-lens-ignore: no-console-except-error,console-statement, — deliberate degradation log
|
|
79
|
+
console.warn(
|
|
80
|
+
"[pi-toolkits/compact] Failed to save compact-model config:",
|
|
81
|
+
error instanceof Error ? error.message : String(error),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The configured compact model key ("provider/id"), or undefined. */
|
|
87
|
+
getModel(): string | undefined {
|
|
88
|
+
this.ensureLoaded();
|
|
89
|
+
return this.model;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Set (or clear with undefined) the compact model key and persist. */
|
|
93
|
+
setModel(key: string | undefined): void {
|
|
94
|
+
this.ensureLoaded();
|
|
95
|
+
this.model = key;
|
|
96
|
+
this.save();
|
|
97
|
+
this.notify();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
subscribe(listener: () => void): () => void {
|
|
101
|
+
this.listeners.add(listener);
|
|
102
|
+
return () => {
|
|
103
|
+
this.listeners.delete(listener);
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private notify(): void {
|
|
108
|
+
for (const listener of [...this.listeners]) {
|
|
109
|
+
try {
|
|
110
|
+
listener();
|
|
111
|
+
} catch {
|
|
112
|
+
// A broken listener must not break compact config handling.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Process-wide store shared by the session_before_compact handler and the
|
|
120
|
+
* /compact-model command.
|
|
121
|
+
*
|
|
122
|
+
* Accessed via getCompactStore() (lazy) so tests can swap in an isolated
|
|
123
|
+
* store via setSharedCompactStore().
|
|
124
|
+
*/
|
|
125
|
+
let sharedStore: CompactStore | undefined;
|
|
126
|
+
|
|
127
|
+
export function getCompactStore(): CompactStore {
|
|
128
|
+
if (!sharedStore) {
|
|
129
|
+
sharedStore = new CompactStore();
|
|
130
|
+
}
|
|
131
|
+
return sharedStore;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Test hook — replace the shared store (e.g. with one backed by a temp file). */
|
|
135
|
+
export function setSharedCompactStore(store: CompactStore): void {
|
|
136
|
+
sharedStore = store;
|
|
137
|
+
}
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
import type {
|
|
28
28
|
ExtensionAPI,
|
|
29
29
|
ExtensionContext,
|
|
30
|
+
ModelSelectEvent,
|
|
31
|
+
SessionStartEvent,
|
|
30
32
|
Theme,
|
|
31
33
|
} from "@earendil-works/pi-coding-agent";
|
|
32
34
|
import { FAVORITES_STATUS_KEY } from "./constants.js";
|
|
@@ -68,13 +70,13 @@ export function registerFavorites(pi: ExtensionAPI): void {
|
|
|
68
70
|
// selector. setStatus is idempotent and re-synced on every session_start.
|
|
69
71
|
let latestCtx: ExtensionContext | undefined;
|
|
70
72
|
|
|
71
|
-
pi.on("session_start", (_event, ctx) => {
|
|
73
|
+
pi.on("session_start", (_event: SessionStartEvent, ctx: ExtensionContext) => {
|
|
72
74
|
latestCtx = ctx;
|
|
73
75
|
captureTheme(ctx);
|
|
74
76
|
updateFooterStatus(ctx);
|
|
75
77
|
});
|
|
76
78
|
|
|
77
|
-
pi.on("model_select", (_event, ctx) => {
|
|
79
|
+
pi.on("model_select", (_event: ModelSelectEvent, ctx: ExtensionContext) => {
|
|
78
80
|
captureTheme(ctx);
|
|
79
81
|
updateFooterStatus(ctx);
|
|
80
82
|
});
|
|
@@ -90,7 +92,7 @@ export function registerFavorites(pi: ExtensionAPI): void {
|
|
|
90
92
|
|
|
91
93
|
pi.registerCommand("favorites", {
|
|
92
94
|
description: "List favorited models (add/remove with ctrl+F in /model)",
|
|
93
|
-
handler: async (_args, ctx) => {
|
|
95
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
94
96
|
captureTheme(ctx);
|
|
95
97
|
const list = getFavoritesStore().list();
|
|
96
98
|
if (list.length === 0) {
|
|
@@ -7,14 +7,16 @@
|
|
|
7
7
|
* one favorite exists, cycling is restricted to favorited models:
|
|
8
8
|
*
|
|
9
9
|
* - candidates come from the same source stock cycling would use (scoped
|
|
10
|
-
* models if any
|
|
10
|
+
* models if any — filtered to currently available ones like stock —
|
|
11
|
+
* else the available-model snapshot), filtered to favorites
|
|
11
12
|
* - if the current model is not a favorite: forward jumps to the first
|
|
12
13
|
* favorite, backward to the last (user-confirmed)
|
|
13
14
|
* - with exactly one favorite, already on it → undefined (stock "only one
|
|
14
15
|
* model available" feedback); otherwise jump to it
|
|
15
|
-
* - the apply logic mirrors
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* - the apply logic mirrors pi 0.87's _cycleScopedModel/_cycleAvailableModel
|
|
17
|
+
* (state swap, session persistence, default-model persistence only when
|
|
18
|
+
* `options.persist` is set, thinking-level re-clamp, model_select emit
|
|
19
|
+
* with source "cycle")
|
|
18
20
|
*
|
|
19
21
|
* Version guard: missing cycleModel → warn and skip; the patch is idempotent
|
|
20
22
|
* across /reload.
|
|
@@ -23,9 +25,17 @@ import {
|
|
|
23
25
|
AgentSession,
|
|
24
26
|
type ModelCycleResult,
|
|
25
27
|
} from "@earendil-works/pi-coding-agent";
|
|
26
|
-
import type { Model } from "@earendil-works/pi-ai";
|
|
27
28
|
import { getFavoritesStore, modelKey } from "./store.js";
|
|
28
29
|
|
|
30
|
+
/**
|
|
31
|
+
* pi's `Model<any>` — the exact type of `ModelCycleResult.model` in
|
|
32
|
+
* pi-coding-agent 0.87. Derived here instead of importing `Model` from
|
|
33
|
+
* @earendil-works/pi-ai, whose 0.87 index.d.ts re-exports it via an
|
|
34
|
+
* extensioned `"./types.ts"` specifier that not every TS resolver substitutes
|
|
35
|
+
* to `types.d.ts` (tsc 5.9 bundler does; other tsserver builds don't).
|
|
36
|
+
*/
|
|
37
|
+
type AnyModel = NonNullable<ModelCycleResult>["model"];
|
|
38
|
+
|
|
29
39
|
/**
|
|
30
40
|
* Structural view of the AgentSession internals the patch touches.
|
|
31
41
|
* AgentSession's members are TS-private (compile-time only), so this view is
|
|
@@ -33,16 +43,21 @@ import { getFavoritesStore, modelKey } from "./store.js";
|
|
|
33
43
|
*/
|
|
34
44
|
interface CycleSessionInternals {
|
|
35
45
|
// 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:
|
|
46
|
+
_scopedModels: ReadonlyArray<{ model: AnyModel; thinkingLevel?: string }>;
|
|
37
47
|
// pi-lens-ignore: no-any-type, — same as above
|
|
38
|
-
_modelRuntime: { getAvailableSnapshot():
|
|
48
|
+
_modelRuntime: { getAvailableSnapshot(): AnyModel[] };
|
|
39
49
|
// pi-lens-ignore: no-any-type, — same as above
|
|
40
|
-
model:
|
|
50
|
+
model: AnyModel;
|
|
41
51
|
agent: { state: { model: unknown } };
|
|
42
52
|
sessionManager: { appendModelChange(provider: string, id: string): void };
|
|
43
53
|
settingsManager: {
|
|
44
54
|
setDefaultModelAndProvider(provider: string, id: string): void;
|
|
45
55
|
};
|
|
56
|
+
/**
|
|
57
|
+
* pi 0.87: keeps a persisted default usable when cycling inside a
|
|
58
|
+
* non-empty --models scope.
|
|
59
|
+
*/
|
|
60
|
+
_addPersistedDefaultToNonEmptyScope(model: unknown): void;
|
|
46
61
|
setThinkingLevel(level: unknown): void;
|
|
47
62
|
_getThinkingLevelForModelSwitch(
|
|
48
63
|
explicitLevel?: string,
|
|
@@ -56,8 +71,7 @@ interface CycleSessionInternals {
|
|
|
56
71
|
}
|
|
57
72
|
|
|
58
73
|
interface CycleCandidate {
|
|
59
|
-
|
|
60
|
-
model: Model<any>;
|
|
74
|
+
model: AnyModel;
|
|
61
75
|
thinkingLevel?: string;
|
|
62
76
|
}
|
|
63
77
|
|
|
@@ -81,15 +95,24 @@ function nextCycleIndex(
|
|
|
81
95
|
: (currentIndex - 1 + len) % len;
|
|
82
96
|
}
|
|
83
97
|
|
|
98
|
+
/** Options accepted by pi 0.87's cycleModel (persist gates default-model persistence). */
|
|
99
|
+
interface CycleOptions {
|
|
100
|
+
persist?: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
84
103
|
let patched = false;
|
|
85
104
|
|
|
86
105
|
/** Restrict ctrl+p cycling to favorites. Idempotent; returns false if skipped. */
|
|
87
106
|
export function applyCyclePatch(): boolean {
|
|
88
107
|
if (patched) return true;
|
|
108
|
+
// SAFETY: AgentSession's members are TS-private (compile-time only), so a
|
|
109
|
+
// structural view of the runtime prototype is safe to cast to; guarded by
|
|
110
|
+
// the typeof check below before any patching happens.
|
|
89
111
|
const proto = AgentSession.prototype as unknown as {
|
|
90
112
|
cycleModel(
|
|
91
113
|
this: CycleSessionInternals,
|
|
92
114
|
direction?: "forward" | "backward",
|
|
115
|
+
options?: CycleOptions,
|
|
93
116
|
): Promise<ModelCycleResult | undefined>;
|
|
94
117
|
};
|
|
95
118
|
if (typeof proto.cycleModel !== "function") {
|
|
@@ -105,23 +128,34 @@ export function applyCyclePatch(): boolean {
|
|
|
105
128
|
proto.cycleModel = async function (
|
|
106
129
|
this: CycleSessionInternals,
|
|
107
130
|
direction: "forward" | "backward" = "forward",
|
|
131
|
+
options: CycleOptions = {},
|
|
108
132
|
): Promise<ModelCycleResult | undefined> {
|
|
109
133
|
if (
|
|
110
134
|
!getFavoritesStore().getConfig().cycleOnlyFavorites ||
|
|
111
135
|
!getFavoritesStore().hasAny()
|
|
112
136
|
) {
|
|
113
|
-
return original.call(this, direction);
|
|
137
|
+
return original.call(this, direction, options);
|
|
114
138
|
}
|
|
115
139
|
|
|
140
|
+
// Same source and availability filtering as pi 0.87's stock cycling.
|
|
116
141
|
const scoped = this._scopedModels.length > 0;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}))
|
|
122
|
-
: this._modelRuntime
|
|
142
|
+
let candidates: CycleCandidate[];
|
|
143
|
+
if (scoped) {
|
|
144
|
+
const availableIds = new Set(
|
|
145
|
+
this._modelRuntime
|
|
123
146
|
.getAvailableSnapshot()
|
|
124
|
-
.map((model) =>
|
|
147
|
+
.map((model) => `${model.provider}\0${model.id}`),
|
|
148
|
+
);
|
|
149
|
+
candidates = this._scopedModels.flatMap((sm) =>
|
|
150
|
+
availableIds.has(`${sm.model.provider}\0${sm.model.id}`)
|
|
151
|
+
? [{ model: sm.model, thinkingLevel: sm.thinkingLevel }]
|
|
152
|
+
: [],
|
|
153
|
+
);
|
|
154
|
+
} else {
|
|
155
|
+
candidates = this._modelRuntime
|
|
156
|
+
.getAvailableSnapshot()
|
|
157
|
+
.map((model) => ({ model, thinkingLevel: undefined }));
|
|
158
|
+
}
|
|
125
159
|
const favorites = candidates.filter((candidate) =>
|
|
126
160
|
getFavoritesStore().has(candidate.model),
|
|
127
161
|
);
|
|
@@ -148,10 +182,14 @@ export function applyCyclePatch(): boolean {
|
|
|
148
182
|
: this._getThinkingLevelForModelSwitch();
|
|
149
183
|
this.agent.state.model = next.model;
|
|
150
184
|
this.sessionManager.appendModelChange(next.model.provider, next.model.id);
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
185
|
+
// pi 0.87: only persist the default model when the caller asked for it.
|
|
186
|
+
if (options.persist) {
|
|
187
|
+
this.settingsManager.setDefaultModelAndProvider(
|
|
188
|
+
next.model.provider,
|
|
189
|
+
next.model.id,
|
|
190
|
+
);
|
|
191
|
+
this._addPersistedDefaultToNonEmptyScope(next.model);
|
|
192
|
+
}
|
|
155
193
|
this.setThinkingLevel(thinkingLevel);
|
|
156
194
|
await this._emitModelSelect(next.model, current, "cycle");
|
|
157
195
|
return {
|
|
@@ -58,6 +58,8 @@ interface SelectorPatchTarget {
|
|
|
58
58
|
handleInput(keyData: string): void;
|
|
59
59
|
updateList(): void;
|
|
60
60
|
filterModels(query: string): void;
|
|
61
|
+
/** pi 0.87: default-model badge source. */
|
|
62
|
+
isDefaultModel(model: ModelRef): boolean;
|
|
61
63
|
listContainer: ListContainerLike;
|
|
62
64
|
searchInput: SearchInputLike;
|
|
63
65
|
filteredModels: ModelItem[];
|
|
@@ -110,6 +112,9 @@ let patched = false;
|
|
|
110
112
|
/** Apply the three selector patches. Idempotent; returns false if skipped. */
|
|
111
113
|
export function applyModelSelectorPatches(): boolean {
|
|
112
114
|
if (patched) return true;
|
|
115
|
+
// SAFETY: ModelSelectorComponent's members are TS-private (compile-time
|
|
116
|
+
// only); the structural view matches pi 0.87's prototype shape and every
|
|
117
|
+
// touched method is existence-checked below before any patching happens.
|
|
113
118
|
const proto =
|
|
114
119
|
ModelSelectorComponent.prototype as unknown as SelectorPatchTarget;
|
|
115
120
|
if (
|
|
@@ -146,9 +151,10 @@ export function applyModelSelectorPatches(): boolean {
|
|
|
146
151
|
};
|
|
147
152
|
|
|
148
153
|
// ── 2. List rendering: hint row + bold bright yellow favorites ────────
|
|
149
|
-
// Replicates the built-in updateList layout (
|
|
150
|
-
//
|
|
151
|
-
//
|
|
154
|
+
// Replicates the built-in updateList layout (pi 0.87: fixed-width cursor
|
|
155
|
+
// and ✓ current-marker columns, provider badge, `· default` badge, scroll
|
|
156
|
+
// indicator, empty/error states, refresh status, Model Name footer) with
|
|
157
|
+
// one change: favorited model ids render in the favorite style.
|
|
152
158
|
proto.updateList = function (this: SelectorPatchTarget): void {
|
|
153
159
|
const theme = themeProvider();
|
|
154
160
|
if (!theme) {
|
|
@@ -178,24 +184,17 @@ export function applyModelSelectorPatches(): boolean {
|
|
|
178
184
|
const isCurrent =
|
|
179
185
|
this.currentModel !== undefined &&
|
|
180
186
|
sameModel(item.model, this.currentModel);
|
|
187
|
+
const isDefault = this.isDefaultModel(item.model);
|
|
181
188
|
const isFavorite = getFavoritesStore().has(item.model);
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
}
|
|
189
|
+
const defaultBadge = isDefault
|
|
190
|
+
? theme.fg("muted", " · default")
|
|
191
|
+
: "";
|
|
192
|
+
const cursor = isSelected ? theme.fg("accent", "→ ") : " ";
|
|
193
|
+
const currentMarker = isCurrent ? theme.fg("accent", "✓ ") : " ";
|
|
194
|
+
const baseText = isSelected ? theme.fg("accent", item.id) : item.id;
|
|
195
|
+
const modelText = isFavorite ? styleFavorite(item.id) : baseText;
|
|
196
|
+
const providerBadge = theme.fg("muted", `[${item.provider}]`);
|
|
197
|
+
const line = `${cursor}${currentMarker}${modelText} ${providerBadge}${defaultBadge}`;
|
|
199
198
|
list.addChild(new Text(line, 0, 0));
|
|
200
199
|
}
|
|
201
200
|
if (startIndex > 0 || endIndex < this.filteredModels.length) {
|
|
@@ -11,11 +11,14 @@
|
|
|
11
11
|
* model as ordinary text, so an accidental send can never start a review.)
|
|
12
12
|
*
|
|
13
13
|
* Review-model config lives in `<agent-dir>/pi-toolkits-review.json`
|
|
14
|
-
* ("provider/id", see store.ts)
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
14
|
+
* ("provider/id", see store.ts) and doubles as the picker default. Every
|
|
15
|
+
* /third-review run opens the model picker (ctx.ui.select, same
|
|
16
|
+
* getAvailable() source as /model) with the configured model as the FIRST
|
|
17
|
+
* row — one Enter accepts it and starts the review. Picking a different
|
|
18
|
+
* model persists it immediately (disk + in-memory) as the new default for
|
|
19
|
+
* next time; cancelling aborts the review without changing anything. When
|
|
20
|
+
* the configured model is gone/unavailable (e.g. deleted from pi's
|
|
21
|
+
* models.json) the user is warned and the picker opens on the plain list.
|
|
19
22
|
*
|
|
20
23
|
* Guards: never fires while the agent is streaming (the review needs the
|
|
21
24
|
* just-finished work as context) and never runs two reviews concurrently.
|
|
@@ -23,39 +26,32 @@
|
|
|
23
26
|
import type {
|
|
24
27
|
ExtensionAPI,
|
|
25
28
|
ExtensionContext,
|
|
29
|
+
SessionStartEvent,
|
|
26
30
|
Theme,
|
|
27
31
|
} from "@earendil-works/pi-coding-agent";
|
|
28
|
-
import
|
|
32
|
+
import {
|
|
33
|
+
modelKey,
|
|
34
|
+
resolveConfiguredModel,
|
|
35
|
+
type AvailableModel,
|
|
36
|
+
} from "../../lib/model-config.js";
|
|
29
37
|
import { REVIEW_STATUS_KEY } from "./constants.js";
|
|
30
38
|
import { REVIEW_PROMPT } from "./prompt.js";
|
|
31
|
-
import { getReviewStore,
|
|
32
|
-
|
|
33
|
-
/** Resolve the configured review model against the available model list. */
|
|
34
|
-
export function resolveConfiguredModel(
|
|
35
|
-
ctx: ExtensionContext,
|
|
36
|
-
key: string | undefined,
|
|
37
|
-
): Model<Api> | undefined {
|
|
38
|
-
if (!key) return undefined;
|
|
39
|
-
const slash = key.indexOf("/");
|
|
40
|
-
if (slash <= 0 || slash === key.length - 1) return undefined;
|
|
41
|
-
const provider = key.slice(0, slash);
|
|
42
|
-
const id = key.slice(slash + 1);
|
|
43
|
-
return ctx.modelRegistry
|
|
44
|
-
.getAvailable()
|
|
45
|
-
.find((m) => m.provider === provider && m.id === id);
|
|
46
|
-
}
|
|
39
|
+
import { getReviewStore, type ReviewStore } from "./store.js";
|
|
47
40
|
|
|
48
41
|
/**
|
|
49
42
|
* Let the user pick a review model from the available list and persist the
|
|
50
43
|
* choice. Returns the picked model, or undefined when cancelled/unavailable.
|
|
51
44
|
*
|
|
52
|
-
* The
|
|
53
|
-
*
|
|
45
|
+
* The configured model is placed FIRST: the select dialog always highlights
|
|
46
|
+
* its first row, so one Enter accepts the current default. Picking any other
|
|
47
|
+
* model persists it (disk + in-memory) as the new default. The title shows
|
|
48
|
+
* the current config, so /review-model doubles as a view-and-change entry
|
|
49
|
+
* point; cancelling leaves the config untouched.
|
|
54
50
|
*/
|
|
55
51
|
export async function pickReviewModel(
|
|
56
52
|
ctx: ExtensionContext,
|
|
57
53
|
store: ReviewStore,
|
|
58
|
-
): Promise<
|
|
54
|
+
): Promise<AvailableModel | undefined> {
|
|
59
55
|
// Dialog-capable UI exists in TUI and RPC modes (hasUI); json/print and
|
|
60
56
|
// headless contexts have no dialogs, so fall back to a manual-config hint.
|
|
61
57
|
if (!ctx.hasUI) {
|
|
@@ -74,21 +70,45 @@ export async function pickReviewModel(
|
|
|
74
70
|
return undefined;
|
|
75
71
|
}
|
|
76
72
|
const configured = store.getModel();
|
|
73
|
+
const configuredModel = resolveConfiguredModel(ctx, configured);
|
|
74
|
+
// Configured model first → highlighted by default (Enter accepts it);
|
|
75
|
+
// the rest keep the registry order.
|
|
76
|
+
const ordered: AvailableModel[] = configuredModel
|
|
77
|
+
? [
|
|
78
|
+
configuredModel,
|
|
79
|
+
...available.filter(
|
|
80
|
+
(m) => modelKey(m) !== modelKey(configuredModel),
|
|
81
|
+
),
|
|
82
|
+
]
|
|
83
|
+
: available;
|
|
77
84
|
// Same display source as the /model selector: name + [provider]/id.
|
|
78
|
-
const options =
|
|
79
|
-
|
|
80
|
-
configured
|
|
81
|
-
? `选择评审模型 (当前: ${configured})`
|
|
82
|
-
: "选择评审模型 (third-review)",
|
|
83
|
-
options,
|
|
85
|
+
const options = ordered.map(
|
|
86
|
+
(m: AvailableModel) => `${m.name} (${m.provider}/${m.id})`,
|
|
84
87
|
);
|
|
88
|
+
// Title reflects what Enter actually accepts: the configured model when it
|
|
89
|
+
// is available, a clear "unavailable" marker otherwise.
|
|
90
|
+
let title = "选择评审模型 (third-review)";
|
|
91
|
+
if (configuredModel) {
|
|
92
|
+
title = `选择评审模型 (当前: ${configured},回车使用)`;
|
|
93
|
+
} else if (configured) {
|
|
94
|
+
title = `选择评审模型 (当前: ${configured} 不可用)`;
|
|
95
|
+
}
|
|
96
|
+
const choice = await ctx.ui.select(title, options);
|
|
85
97
|
if (choice === undefined) {
|
|
86
98
|
ctx.ui.notify("third-review: 已取消,评审模型配置未变更。", "info");
|
|
87
99
|
return undefined;
|
|
88
100
|
}
|
|
89
|
-
const model =
|
|
101
|
+
const model = ordered[options.indexOf(choice)];
|
|
90
102
|
if (!model) return undefined;
|
|
91
|
-
|
|
103
|
+
const key = modelKey(model);
|
|
104
|
+
if (key !== configured) {
|
|
105
|
+
// Persist immediately (disk + in-memory + footer) — next run defaults here.
|
|
106
|
+
store.setModel(key);
|
|
107
|
+
ctx.ui.notify(
|
|
108
|
+
`third-review: 评审模型已保存为「${model.name}」,下次默认使用。`,
|
|
109
|
+
"info",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
92
112
|
return model;
|
|
93
113
|
}
|
|
94
114
|
|
|
@@ -128,8 +148,20 @@ export function createReviewRunner(
|
|
|
128
148
|
: "third-review: 尚未配置评审模型,请选择。",
|
|
129
149
|
"warning",
|
|
130
150
|
);
|
|
151
|
+
}
|
|
152
|
+
if (ctx.hasUI) {
|
|
153
|
+
// Always show the picker: the configured model is the first row
|
|
154
|
+
// (one Enter accepts it); picking another persists it as default.
|
|
131
155
|
model = await pickReviewModel(ctx, store);
|
|
132
156
|
if (!model) return;
|
|
157
|
+
} else if (!model) {
|
|
158
|
+
// No dialogs (json/print/headless): the configured-model path keeps
|
|
159
|
+
// working; without one, point at the config file.
|
|
160
|
+
ctx.ui.notify(
|
|
161
|
+
"third-review: 当前环境不支持模型选择对话框,请编辑 <agent-dir>/pi-toolkits-review.json 手动配置。",
|
|
162
|
+
"error",
|
|
163
|
+
);
|
|
164
|
+
return;
|
|
133
165
|
}
|
|
134
166
|
const switched = await pi.setModel(model);
|
|
135
167
|
if (!switched) {
|
|
@@ -159,7 +191,7 @@ export function registerReview(pi: ExtensionAPI): void {
|
|
|
159
191
|
pi.registerCommand("third-review", {
|
|
160
192
|
description:
|
|
161
193
|
"Summon the configured review model to review the just-completed work (check + fix)",
|
|
162
|
-
handler: async (_args, ctx) => {
|
|
194
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
163
195
|
await runReview(ctx);
|
|
164
196
|
},
|
|
165
197
|
});
|
|
@@ -170,7 +202,7 @@ export function registerReview(pi: ExtensionAPI): void {
|
|
|
170
202
|
pi.registerCommand("review-model", {
|
|
171
203
|
description:
|
|
172
204
|
"Set or change the review model used by third-review (picker shows the current one)",
|
|
173
|
-
handler: async (_args, ctx) => {
|
|
205
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
174
206
|
await pickReviewModel(ctx, getReviewStore());
|
|
175
207
|
},
|
|
176
208
|
});
|
|
@@ -188,7 +220,7 @@ export function registerReview(pi: ExtensionAPI): void {
|
|
|
188
220
|
);
|
|
189
221
|
};
|
|
190
222
|
|
|
191
|
-
pi.on("session_start", (_event, ctx) => {
|
|
223
|
+
pi.on("session_start", (_event: SessionStartEvent, ctx: ExtensionContext) => {
|
|
192
224
|
latestCtx = ctx;
|
|
193
225
|
try {
|
|
194
226
|
const t = ctx.ui.theme;
|