pi-goal-list-loop-audit 0.29.16 → 0.29.17

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.
@@ -152,6 +152,11 @@ import {
152
152
  SettingsMenuComponent,
153
153
  type SettingsRow,
154
154
  } from "../settings-menu.js";
155
+ import {
156
+ buildModelPickItems,
157
+ ModelPickerComponent,
158
+ type ModelPickItem,
159
+ } from "../model-picker.js";
155
160
  import {
156
161
  applyMeasurement,
157
162
  applyMetriclessTick,
@@ -3943,19 +3948,37 @@ function getSessionThinkingLevel(): "off" | "minimal" | "low" | "medium" | "high
3943
3948
  function resolveAuditorModel(ctx: ExtensionContext, ref?: string): { model: any; error?: string; via?: string } {
3944
3949
  if (ref && ref.trim()) {
3945
3950
  const trimmed = ref.trim();
3951
+ // v0.29.17: an unavailable configured model (unknown id, or a provider
3952
+ // with no configured auth) falls back LOUDLY to the session model —
3953
+ // user request: "fall back to the session if unavailable". The v0.9.12
3954
+ // no-SILENT-substitution law stands: the fallback notifies + ledgers.
3955
+ // (Quota-exhausted keys stay on the quota-retry path — the model IS
3956
+ // available there; the key's window is the failure, not the model.)
3957
+ const fail = (reason: string) => {
3958
+ const sessionModel = ctx.model as any;
3959
+ if (sessionModel) {
3960
+ appendLedger(ctx.cwd, "auditor_model_fallback", { configured: trimmed, reason });
3961
+ ctx.ui.notify(`Auditor model "${trimmed}" is unavailable (${reason}) — falling back to the session model. Fix via /glla → Auditor model.`, "warning");
3962
+ return { model: sessionModel, via: "session-fallback" };
3963
+ }
3964
+ return { model: undefined, error: `${reason}: ${trimmed}` };
3965
+ };
3946
3966
  const slash = trimmed.indexOf("/");
3947
3967
  if (slash > 0) {
3948
3968
  const provider = trimmed.slice(0, slash);
3949
3969
  const id = trimmed.slice(slash + 1);
3950
3970
  const model = ctx.modelRegistry.find(provider, id);
3951
- return model ? { model, via: "setting" } : { model: undefined, error: `model not found: ${trimmed}` };
3971
+ if (!model) return fail("model not found");
3972
+ if (!ctx.modelRegistry.hasConfiguredAuth(model)) return fail(`no configured auth for ${provider}`);
3973
+ return { model, via: "setting" };
3952
3974
  }
3953
3975
  const matches = ctx.modelRegistry.getAvailable().filter((m: any) => m.id === trimmed || m.name === trimmed);
3954
- return matches[0] ? { model: matches[0], via: "setting" } : { model: undefined, error: `no available model matching: ${trimmed}` };
3976
+ if (matches[0]) return { model: matches[0], via: "setting" };
3977
+ return fail("no available model matching");
3955
3978
  }
3956
3979
  const sessionModel = ctx.model as any;
3957
3980
  if (sessionModel) return { model: sessionModel, via: "session" };
3958
- return { model: undefined, error: "no session model and no auditorModel configured — set one with /glla model=provider/id" };
3981
+ return { model: undefined, error: "no session model and no auditorModel configured — set one with /glla → Auditor model" };
3959
3982
  }
3960
3983
 
3961
3984
  // (v0.9.12) The auto-fallback apparatus was REMOVED: no tier ranking, no
@@ -4032,6 +4055,43 @@ async function promptSettingsMenu(
4032
4055
  */
4033
4056
  // v0.28.7 (T4): exported for the behavioral settings-editor tests
4034
4057
  // (tests/settings-editors.test.ts drives each editor class end-to-end).
4058
+ /**
4059
+ * v0.29.17: /model-style fuzzy picker for model-valued settings. Builds the
4060
+ * item list from the registry (configured-auth providers only — a pick
4061
+ * from this list can never be a dead provider) and hosts the picker via
4062
+ * ctx.ui.custom. Falls back to the typed input when the runtime has no
4063
+ * custom shard (headless) — typing stays the emergency hatch there.
4064
+ * Returns { kind: "session" } to clear the override, { kind: "ref" } with
4065
+ * provider/id, or undefined for cancel.
4066
+ */
4067
+ async function promptModelRef(
4068
+ ctx: ExtensionContext,
4069
+ title: string,
4070
+ emptyLabel: string,
4071
+ ): Promise<{ kind: "session" } | { kind: "ref"; ref: string } | undefined> {
4072
+ if (typeof (ctx.ui as { custom?: unknown }).custom !== "function" || !ctx.modelRegistry) {
4073
+ const v = await ctx.ui.input(title, "provider/model-id — empty keeps the default");
4074
+ if (v === undefined) return undefined;
4075
+ return v.trim() ? { kind: "ref", ref: v.trim() } : { kind: "session" };
4076
+ }
4077
+ const sessionModel = ctx.model as any;
4078
+ const sessionLabel = sessionModel ? `${sessionModel.provider}/${sessionModel.id}` : "pi session model";
4079
+ const models = ctx.modelRegistry
4080
+ .getAvailable()
4081
+ .filter((m: any) => ctx.modelRegistry.hasConfiguredAuth(m));
4082
+ const items = buildModelPickItems(models, sessionLabel);
4083
+ const pick = await ctx.ui.custom<ModelPickItem | undefined>((tui, theme, keybindings, done) => {
4084
+ return new ModelPickerComponent({ title, items }, () => tui.requestRender(), theme, keybindings, done);
4085
+ });
4086
+ if (!pick) return undefined;
4087
+ if (pick.kind === "session") return { kind: "session" };
4088
+ if (pick.kind === "model" && pick.ref) return { kind: "ref", ref: pick.ref };
4089
+ // manual escape hatch — typed provider/model, validated like before
4090
+ const v = await ctx.ui.input(title, emptyLabel);
4091
+ if (v === undefined) return undefined;
4092
+ return v.trim() ? { kind: "ref", ref: v.trim() } : { kind: "session" };
4093
+ }
4094
+
4035
4095
  export async function handleSettingChoice(id: string, ctx: ExtensionContext): Promise<void> {
4036
4096
  switch (id) {
4037
4097
  case "autoResume": {
@@ -4071,8 +4131,10 @@ export async function handleSettingChoice(id: string, ctx: ExtensionContext): Pr
4071
4131
  return;
4072
4132
  }
4073
4133
  case "auditorModel": {
4074
- const v = await ctx.ui.input("Auditor model override", "provider/model-id — empty keeps the pi session model");
4075
- if (v !== undefined) saveSettings("global", ctx.cwd, { auditorModel: v.trim() || undefined });
4134
+ const pick = await promptModelRef(ctx, "Auditor model override", "provider/model-id — empty keeps the pi session model");
4135
+ if (pick === undefined) return;
4136
+ saveSettings("global", ctx.cwd, { auditorModel: pick.kind === "session" ? undefined : pick.ref });
4137
+ if (pick.kind === "session") ctx.ui.notify("Auditor model override cleared — the auditor follows the pi session model.", "info");
4076
4138
  return;
4077
4139
  }
4078
4140
  case "auditorThinkingLevel": {
@@ -4177,15 +4239,14 @@ export async function handleSettingChoice(id: string, ctx: ExtensionContext): Pr
4177
4239
  case "subagentModelOverrides.Plan":
4178
4240
  case "subagentModelOverrides.general-purpose": {
4179
4241
  const agentType = id.slice("subagentModelOverrides.".length);
4180
- const v = await ctx.ui.input(`Model pin for ${agentType} subagents`, "provider/model-id e.g. minimax/MiniMax-M3 — always wins over strategy; empty = follow strategy");
4181
- if (v !== undefined) {
4182
- const current = loadSettings(ctx.cwd).subagentModelOverrides ?? {};
4183
- const next = { ...current };
4184
- if (v.trim()) next[agentType] = v.trim();
4185
- else delete next[agentType];
4186
- saveSettings("global", ctx.cwd, { subagentModelOverrides: Object.keys(next).length > 0 ? next : undefined });
4187
- ctx.ui.notify(`${agentType} model pin saved — applies to NEW pi sessions.`, "info");
4188
- }
4242
+ const pick = await promptModelRef(ctx, `Model pin for ${agentType} subagents`, "provider/model-id e.g. minimax/MiniMax-M3 — always wins over strategy; empty = follow strategy");
4243
+ if (pick === undefined) return;
4244
+ const current = loadSettings(ctx.cwd).subagentModelOverrides ?? {};
4245
+ const next = { ...current };
4246
+ if (pick.kind === "ref") next[agentType] = pick.ref;
4247
+ else delete next[agentType];
4248
+ saveSettings("global", ctx.cwd, { subagentModelOverrides: Object.keys(next).length > 0 ? next : undefined });
4249
+ ctx.ui.notify(`${agentType} model pin saved — applies to NEW pi sessions.`, "info");
4189
4250
  return;
4190
4251
  }
4191
4252
  case "subagentResolved":
@@ -0,0 +1,208 @@
1
+ // pi-goal-list-loop-audit — v0.29.17
2
+ // extensions/model-picker.ts
3
+ //
4
+ // A /model-style fuzzy picker for model-valued settings (Auditor model,
5
+ // subagent model pins). Why this exists:
6
+ // • ctx.ui.select renders EVERY option unsorted with no search — a full
7
+ // model registry is hundreds of rows; unusable (field: the auditor
8
+ // model was left on a quota-dead openrouter key partly because fixing
9
+ // it meant hand-typing provider/model into a bare input).
10
+ // • pi's own /model dialog (ModelSelectorComponent) needs ModelRuntime
11
+ // and SettingsManager internals that extensions never receive.
12
+ // So we rebuild the same interaction shape — a search line with a
13
+ // fuzzy-filtered list — from pi-tui primitives (fuzzyFilter) over
14
+ // ctx.modelRegistry, hosted via ctx.ui.custom, exactly like the v0.28.0
15
+ // settings table. Unit-testable via synthetic handleInput calls.
16
+ //
17
+ // Item order: "session model" (clear override) first, then every
18
+ // configured-auth model sorted by provider/id, then "type manually…" last.
19
+
20
+ import { fuzzyFilter, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
21
+ import type { SettingsMenuTheme, KeybindingsManagerLike } from "./settings-menu.ts";
22
+
23
+ export type ModelPickKind = "session" | "model" | "manual";
24
+
25
+ export interface ModelPickItem {
26
+ kind: ModelPickKind;
27
+ /** provider/model-id for kind === "model". */
28
+ ref?: string;
29
+ /** Row label shown in the list. */
30
+ label: string;
31
+ /** Text the fuzzy filter matches against. */
32
+ searchText: string;
33
+ }
34
+
35
+ export interface RegistryModelLike {
36
+ provider: string;
37
+ id: string;
38
+ name?: string;
39
+ }
40
+
41
+ /** Build the picker's static item list from registry models (already
42
+ * filtered to configured-auth providers by the caller). Session row first,
43
+ * manual-entry row last; models sorted by provider then id. */
44
+ export function buildModelPickItems(models: RegistryModelLike[], sessionLabel: string): ModelPickItem[] {
45
+ const sorted = [...models].sort((a, b) =>
46
+ a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider),
47
+ );
48
+ return [
49
+ {
50
+ kind: "session",
51
+ label: `session model (${sessionLabel}) — clear the override`,
52
+ searchText: "session model default clear override follow",
53
+ },
54
+ ...sorted.map((m) => {
55
+ const ref = `${m.provider}/${m.id}`;
56
+ return {
57
+ kind: "model" as const,
58
+ ref,
59
+ label: m.name && m.name !== m.id ? `${ref} — ${m.name}` : ref,
60
+ searchText: `${ref} ${m.name ?? ""}`,
61
+ };
62
+ }),
63
+ {
64
+ kind: "manual",
65
+ label: "type provider/model manually…",
66
+ searchText: "manual type custom provider model id",
67
+ },
68
+ ];
69
+ }
70
+
71
+ export interface ModelPickerFactoryDeps {
72
+ title: string;
73
+ items: ModelPickItem[];
74
+ /** Cap on visible list rows (window scrolls with the selection). */
75
+ maxVisibleRows?: number;
76
+ }
77
+
78
+ export class ModelPickerComponent {
79
+ private readonly title: string;
80
+ private readonly items: ModelPickItem[];
81
+ private readonly maxRows: number;
82
+ private readonly requestRender: () => void;
83
+ private readonly theme: SettingsMenuTheme;
84
+ private readonly keybindings: KeybindingsManagerLike;
85
+ private readonly done: (item: ModelPickItem | undefined) => void;
86
+
87
+ private query = "";
88
+ private selectedIdx = 0;
89
+
90
+ constructor(
91
+ deps: ModelPickerFactoryDeps,
92
+ requestRender: () => void,
93
+ theme: SettingsMenuTheme,
94
+ keybindings: KeybindingsManagerLike,
95
+ done: (item: ModelPickItem | undefined) => void,
96
+ ) {
97
+ this.title = deps.title;
98
+ this.items = deps.items;
99
+ this.maxRows = deps.maxVisibleRows ?? 12;
100
+ this.requestRender = requestRender;
101
+ this.theme = theme;
102
+ this.keybindings = keybindings;
103
+ this.done = done;
104
+ }
105
+
106
+ /** Current search query. Exposed for tests. */
107
+ getQuery(): string {
108
+ return this.query;
109
+ }
110
+
111
+ /** Index into the filtered list. Exposed for tests. */
112
+ getSelectedIdx(): number {
113
+ return this.selectedIdx;
114
+ }
115
+
116
+ /** Filtered items for the current query. Exposed for tests. */
117
+ filteredItems(): ModelPickItem[] {
118
+ if (!this.query.trim()) return this.items;
119
+ return fuzzyFilter(this.items, this.query.trim(), (it) => it.searchText);
120
+ }
121
+
122
+ private refresh(): void {
123
+ this.requestRender();
124
+ }
125
+
126
+ private move(delta: number): void {
127
+ const n = this.filteredItems().length;
128
+ if (n === 0) return;
129
+ this.selectedIdx = ((this.selectedIdx + delta) % n + n) % n;
130
+ this.refresh();
131
+ }
132
+
133
+ render(width: number): string[] {
134
+ const w = Math.max(20, width - 2);
135
+ const lines: string[] = [];
136
+ lines.push(this.theme.fg("accent", this.theme.bold(this.title)));
137
+ lines.push("");
138
+ const searchLine = `search: ${this.query}`;
139
+ lines.push(this.theme.fg("muted", truncateToWidth(searchLine, w, "…") + "▏"));
140
+ lines.push("");
141
+ const filtered = this.filteredItems();
142
+ if (filtered.length === 0) {
143
+ lines.push(this.theme.fg("warning", " no matches — keep typing, or Esc to cancel"));
144
+ } else {
145
+ const sel = Math.min(this.selectedIdx, filtered.length - 1);
146
+ const half = Math.floor(this.maxRows / 2);
147
+ const start = Math.max(0, Math.min(sel - half, filtered.length - this.maxRows));
148
+ const window = filtered.slice(start, start + this.maxRows);
149
+ if (start > 0) lines.push(this.theme.fg("dim", ` ↑ ${start} more`));
150
+ for (let i = 0; i < window.length; i++) {
151
+ const idx = start + i;
152
+ const it = window[i]!;
153
+ const row = truncateToWidth(it.label, w - 2, "…");
154
+ lines.push(idx === sel ? this.theme.fg("accent", `→ ${row}`) : ` ${row}`);
155
+ }
156
+ const remaining = filtered.length - (start + window.length);
157
+ if (remaining > 0) lines.push(this.theme.fg("dim", ` ↓ ${remaining} more`));
158
+ }
159
+ lines.push("");
160
+ lines.push(this.theme.fg("dim", "type to filter · ↑/↓ move · enter select · esc cancel"));
161
+ return lines;
162
+ }
163
+
164
+ handleInput(data: string): void {
165
+ if (this.keybindings.matches(data, "tui.select.confirm")) {
166
+ const it = this.filteredItems()[this.selectedIdx];
167
+ this.done(it);
168
+ return;
169
+ }
170
+ if (this.keybindings.matches(data, "tui.select.cancel") || data === "\x1b") {
171
+ this.done(undefined);
172
+ return;
173
+ }
174
+ if (this.keybindings.matches(data, "tui.select.up")) {
175
+ this.move(-1);
176
+ return;
177
+ }
178
+ if (this.keybindings.matches(data, "tui.select.down")) {
179
+ this.move(+1);
180
+ return;
181
+ }
182
+ if (data === "\x7f" || data === "\b") {
183
+ if (this.query.length > 0) {
184
+ this.query = this.query.slice(0, -1);
185
+ this.selectedIdx = 0;
186
+ this.refresh();
187
+ }
188
+ return;
189
+ }
190
+ // Printable input (single keystrokes and pasted runs alike). Ignore
191
+ // escape/CSI sequences — they start with \x1b and were handled above.
192
+ if (!data.startsWith("\x1b")) {
193
+ const printable = [...data].filter((ch) => ch >= " ").join("");
194
+ if (printable.length > 0) {
195
+ this.query += printable;
196
+ this.selectedIdx = 0;
197
+ this.refresh();
198
+ }
199
+ }
200
+ }
201
+
202
+ invalidate(): void {
203
+ // Stateless beyond the query/selection — nothing to clear.
204
+ }
205
+ }
206
+
207
+ // Re-export for callers that only need the width helper's type signature.
208
+ export { visibleWidth };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.29.16",
3
+ "version": "0.29.17",
4
4
  "description": "Goal. Loop. Audit. Done. \u2014 a pi-coding-agent extension that supervises long-running work, with isolated auditor on each completion. Beat bamboozling by design: the auditor runs in a fresh session with no extensions, no skills, no editor \u2014 only the read tools needed to verify your goal.",
5
5
  "license": "MIT",
6
6
  "author": "dracon",