@narumitw/pi-btw 0.59.0 → 0.60.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-btw",
3
- "version": "0.59.0",
3
+ "version": "0.60.0",
4
4
  "description": "Pi extension that adds a /btw side-question command.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/btw.ts CHANGED
@@ -357,19 +357,8 @@ async function showCommandMenuForBtw(
357
357
  ctx: ExtensionCommandContext,
358
358
  resumeThreads: readonly BtwResumeThreadSummary[],
359
359
  ): Promise<BtwCommandMenuResult> {
360
- const currentModel = ctx.model;
361
- const availableModels = ctx.modelRegistry.getAll();
362
- const currentThinkingLevel = pi.getThinkingLevel();
363
- const loaded = await readBtwSettings();
364
- const settings = loaded.kind === "loaded" ? loaded.settings : {};
365
- const configured = settings.model ? parseBtwModelReference(settings.model) : undefined;
366
- const configuredModel = configured
367
- ? availableModels.find((model) => model.provider === configured.provider && model.id === configured.modelId)
368
- : undefined;
369
- const model = configuredModel ?? currentModel;
370
360
  return showBtwCommandMenu(ctx, {
371
- currentThinkingLevel,
372
- availableThinkingLevels: model ? getSupportedThinkingLevels(model) : BTW_THINKING_LEVELS,
361
+ currentThinkingLevel: pi.getThinkingLevel(),
373
362
  resumeThreads,
374
363
  });
375
364
  }
package/src/menu.ts CHANGED
@@ -1,4 +1,10 @@
1
- import type { ExtensionCommandContext, KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
1
+ import { type Api, getSupportedThinkingLevels, type Model } from "@earendil-works/pi-ai";
2
+ import {
3
+ BorderedLoader,
4
+ type ExtensionCommandContext,
5
+ type KeybindingsManager,
6
+ type Theme,
7
+ } from "@earendil-works/pi-coding-agent";
2
8
  import type { Component, TUI } from "@earendil-works/pi-tui";
3
9
  import type { MenuContext, RunMenuResult } from "@narumitw/pi-tui-kit";
4
10
  import {
@@ -14,6 +20,7 @@ import {
14
20
  btwSettingsPath,
15
21
  effectiveFullscreenCopyOnSelect,
16
22
  effectiveRememberThinkingLevelChanges,
23
+ parseBtwModelReference,
17
24
  readBtwSettings,
18
25
  type UpdateBtwSettingsOptions,
19
26
  updateBtwSettings,
@@ -35,7 +42,11 @@ export interface BtwResumeThreadSummary {
35
42
 
36
43
  export interface ShowBtwCommandMenuOptions {
37
44
  currentThinkingLevel: BtwThinkingLevel;
38
- availableThinkingLevels: readonly BtwThinkingLevel[];
45
+ /** Deterministic test override; production derives levels from each selected model. */
46
+ availableThinkingLevels?: readonly BtwThinkingLevel[];
47
+ availableModels?: readonly Model<Api>[];
48
+ currentModel?: Model<Api>;
49
+ scopedModels?: ExtensionCommandContext["scopedModels"];
39
50
  resumeThreads?: readonly BtwResumeThreadSummary[];
40
51
  settingsPath?: string;
41
52
  readSettings?: typeof readBtwSettings;
@@ -44,11 +55,13 @@ export interface ShowBtwCommandMenuOptions {
44
55
 
45
56
  export type BtwCommandMenuResult = "start" | "tree" | "closed" | { kind: "resume"; threadId: string };
46
57
 
47
- type BtwMenuScreen = "main" | "resume" | "settings" | "invalid" | "shortcut" | "shortcut-input";
58
+ type BtwMenuScreen = "main" | "resume" | "settings" | "model" | "invalid" | "shortcut" | "shortcut-input";
48
59
  type BtwMenuAction =
49
60
  | "start"
50
61
  | "start-tree"
51
62
  | "resume"
63
+ | "open-model"
64
+ | "set-model"
52
65
  | "set-thinking"
53
66
  | "set-remember"
54
67
  | "set-fullscreen-copy"
@@ -70,15 +83,32 @@ export async function showBtwCommandMenu(
70
83
  options: ShowBtwCommandMenuOptions,
71
84
  ): Promise<BtwCommandMenuResult> {
72
85
  if (ctx.mode !== "tui") return "closed";
73
- const { defineMenu, runMenu } = await import("@narumitw/pi-tui-kit");
86
+ const { defineMenu, runMenu, sanitizeTerminalText } = await import("@narumitw/pi-tui-kit");
74
87
  if (ctx.signal?.aborted) return "closed";
75
88
  const settingsPath = options.settingsPath ?? btwSettingsPath();
76
89
  const readSettings = options.readSettings ?? readBtwSettings;
77
90
  const updateSettings = options.updateSettings ?? updateBtwSettings;
78
- const levels =
79
- options.availableThinkingLevels.length > 0
80
- ? [...options.availableThinkingLevels]
81
- : (["off"] satisfies BtwThinkingLevel[]);
91
+ const getAvailable = ctx.modelRegistry.getAvailable;
92
+ const allAvailableModels = deduplicateModels(
93
+ options.availableModels ??
94
+ (typeof getAvailable === "function" ? getAvailable.call(ctx.modelRegistry) : ctx.modelRegistry.getAll()),
95
+ );
96
+ const currentModel = options.currentModel ?? ctx.model;
97
+ const scopedModels = options.scopedModels ?? ctx.scopedModels ?? [];
98
+ const selectableModels = availableModelsInScope(allAvailableModels, scopedModels);
99
+ const modelItemIds = new Map(selectableModels.map((model, index) => [model, `btw-settings-model:${index}`]));
100
+ const modelsByItemId = new Map(selectableModels.map((model) => [modelItemIds.get(model) as string, model]));
101
+ const safeModelMetadata = (value: string, fallback: string): string => {
102
+ const safe = sanitizeTerminalText(value).trim() || fallback;
103
+ return [...safe].slice(0, 512).join("");
104
+ };
105
+ const displayModelReference = (model: Pick<Model<Api>, "provider" | "id">): string =>
106
+ `${safeModelMetadata(model.id, "unknown model")} [${safeModelMetadata(model.provider, "unknown provider")}]`;
107
+ const rawModelReference = (model: Pick<Model<Api>, "provider" | "id">): string | undefined => {
108
+ const reference = `${model.provider}/${model.id}`;
109
+ const parsed = parseBtwModelReference(reference);
110
+ return parsed?.provider === model.provider && parsed.modelId === model.id ? reference : undefined;
111
+ };
82
112
  const displaySettingsPath = sanitizeSingleLine(settingsPath);
83
113
  const resumeThreads = options.resumeThreads ?? [];
84
114
  let startSelected = false;
@@ -139,6 +169,53 @@ export async function showBtwCommandMenu(
139
169
  }
140
170
  };
141
171
 
172
+ const saveModel = async (
173
+ model: Model<Api> | undefined,
174
+ signal: AbortSignal,
175
+ ): Promise<{ kind: "saved" } | { kind: "cancelled" } | { kind: "failed"; error: unknown }> => {
176
+ const modelReference = model ? rawModelReference(model) : undefined;
177
+ if (model && !modelReference) return { kind: "cancelled" };
178
+ const result = await ctx.ui.custom<{ kind: "saved" } | { kind: "cancelled" } | { kind: "failed"; error: unknown }>(
179
+ (tui, theme, _keybindings, done) => {
180
+ const loader = new BorderedLoader(tui, theme, "Saving Pi BTW model...");
181
+ const ownerController = new AbortController();
182
+ const saveSignal = AbortSignal.any([signal, loader.signal, ownerController.signal]);
183
+ let settled = false;
184
+ const finish = (value: { kind: "saved" } | { kind: "cancelled" } | { kind: "failed"; error: unknown }) => {
185
+ if (settled) return;
186
+ settled = true;
187
+ done(value);
188
+ };
189
+ const cancel = () => finish({ kind: "cancelled" });
190
+ loader.onAbort = cancel;
191
+ saveSignal.addEventListener("abort", cancel, { once: true });
192
+ queueMicrotask(() => {
193
+ void (async () => {
194
+ // Let the host mount the loader before a synchronous test double or cached write can settle it.
195
+ await Promise.resolve();
196
+ if (saveSignal.aborted) return;
197
+ try {
198
+ await updateSettings({ model: modelReference }, { settingsPath, signal: saveSignal });
199
+ finish({ kind: "saved" });
200
+ } catch (error) {
201
+ finish(saveSignal.aborted ? { kind: "cancelled" } : { kind: "failed", error });
202
+ }
203
+ })();
204
+ });
205
+ return {
206
+ render: (width: number) => loader.render(width),
207
+ invalidate: () => loader.invalidate(),
208
+ handleInput: (data: string) => loader.handleInput(data),
209
+ dispose() {
210
+ ownerController.abort(new DOMException("Pi BTW model save disposed", "AbortError"));
211
+ loader.dispose();
212
+ },
213
+ };
214
+ },
215
+ );
216
+ return result ?? { kind: "cancelled" };
217
+ };
218
+
142
219
  const loadState = async (): Promise<BtwMenuState> => {
143
220
  const loaded = await readSettings(settingsPath);
144
221
  if (loaded.kind === "invalid") {
@@ -146,19 +223,104 @@ export async function showBtwCommandMenu(
146
223
  }
147
224
  return { kind: "valid", settings: loaded.kind === "loaded" ? loaded.settings : {} };
148
225
  };
149
- const currentMainThinkingLevel = clampToAvailableThinkingLevel(options.currentThinkingLevel, levels);
226
+ const configuredModel = (settings: BtwSettings): Model<Api> | undefined => {
227
+ if (!settings.model) return undefined;
228
+ const reference = parseBtwModelReference(settings.model);
229
+ return reference
230
+ ? allAvailableModels.find((model) => model.provider === reference.provider && model.id === reference.modelId)
231
+ : undefined;
232
+ };
233
+ const selectableConfiguredModel = (settings: BtwSettings): Model<Api> | undefined => {
234
+ const configured = configuredModel(settings);
235
+ return configured ? selectableModels.find((model) => sameModel(model, configured)) : undefined;
236
+ };
237
+ const thinkingLevels = (settings: BtwSettings): BtwThinkingLevel[] => {
238
+ const overridden = options.availableThinkingLevels;
239
+ const effectiveModel = configuredModel(settings) ?? currentModel;
240
+ const available =
241
+ overridden && overridden.length > 0
242
+ ? overridden
243
+ : effectiveModel
244
+ ? getSupportedThinkingLevels(effectiveModel)
245
+ : BTW_THINKING_LEVELS;
246
+ return available.length > 0 ? [...available] : ["off"];
247
+ };
248
+ const currentMainThinkingLevel = (settings: BtwSettings): BtwThinkingLevel =>
249
+ clampToAvailableThinkingLevel(options.currentThinkingLevel, thinkingLevels(settings));
150
250
  const displayThinkingLevel = (settings: BtwSettings): string =>
151
251
  settings.thinkingLevel === undefined
152
252
  ? SAME_AS_MAIN_THREAD
153
- : clampToAvailableThinkingLevel(settings.thinkingLevel, levels);
253
+ : clampToAvailableThinkingLevel(settings.thinkingLevel, thinkingLevels(settings));
154
254
  const displayThinkingSummary = (settings: BtwSettings): string =>
155
255
  settings.thinkingLevel === undefined
156
- ? `${SAME_AS_MAIN_THREAD} (currently ${currentMainThinkingLevel})`
256
+ ? `${SAME_AS_MAIN_THREAD} (currently ${currentMainThinkingLevel(settings)})`
157
257
  : displayThinkingLevel(settings);
158
258
  const displayRememberSummary = (settings: BtwSettings): string => {
159
259
  const value = effectiveRememberThinkingLevelChanges(settings) ? "On" : "Off";
160
260
  return settings.thinkingLevel === undefined ? `${value} (fixed levels only)` : value;
161
261
  };
262
+ const displayModelValue = (settings: BtwSettings): string => {
263
+ if (!settings.model) {
264
+ return currentModel ? `${SAME_AS_MAIN_THREAD} (${displayModelReference(currentModel)})` : SAME_AS_MAIN_THREAD;
265
+ }
266
+ const available = configuredModel(settings);
267
+ if (!available) return `${SAME_AS_MAIN_THREAD} · ${safeModelMetadata(settings.model, "unknown model")} unavailable`;
268
+ const reference = displayModelReference(available);
269
+ return selectableConfiguredModel(settings) ? reference : `${reference} · outside current scope`;
270
+ };
271
+ const modelItems = (settings: BtwSettings) => {
272
+ const configured = configuredModel(settings);
273
+ const selectable = selectableConfiguredModel(settings);
274
+ const retained = settings.model && !selectable;
275
+ return [
276
+ {
277
+ id: "same-as-main",
278
+ label: SAME_AS_MAIN_THREAD,
279
+ description: currentModel
280
+ ? `Currently ${displayModelReference(currentModel)}`
281
+ : "Use the main thread model when /btw starts.",
282
+ },
283
+ ...selectableModels.map((model) => {
284
+ const reference = displayModelReference(model);
285
+ const name = safeModelMetadata(model.name ?? "", "");
286
+ const validReference = rawModelReference(model);
287
+ return {
288
+ id: modelItemIds.get(model) as string,
289
+ label: reference,
290
+ ...(name ? { details: [`Model Name: ${name}`] } : {}),
291
+ searchText: [reference, name].filter(Boolean).join(" "),
292
+ ...(!validReference
293
+ ? {
294
+ disabled: true,
295
+ disabledReason: "This model identity cannot be stored in pi-btw.json.",
296
+ }
297
+ : {}),
298
+ };
299
+ }),
300
+ ...(retained
301
+ ? [
302
+ {
303
+ id: "configured-model",
304
+ label: configured
305
+ ? displayModelReference(configured)
306
+ : safeModelMetadata(settings.model as string, "unknown model"),
307
+ ...(configured
308
+ ? { description: "Configured outside the current model scope; retained until changed." }
309
+ : {
310
+ disabled: true,
311
+ disabledReason: "Configured model is unavailable; /btw falls back to the main model.",
312
+ }),
313
+ },
314
+ ]
315
+ : []),
316
+ ];
317
+ };
318
+ const selectedModelItemId = (settings: BtwSettings): string => {
319
+ const selected = selectableConfiguredModel(settings);
320
+ return selected ? (modelItemIds.get(selected) as string) : settings.model ? "configured-model" : "same-as-main";
321
+ };
322
+ const currentModelItemId = (settings: BtwSettings): string =>
323
+ settings.model && configuredModel(settings) ? selectedModelItemId(settings) : "same-as-main";
162
324
 
163
325
  const menu = defineMenu<BtwMenuState, BtwMenuScreen, BtwMenuAction, MenuContext>({
164
326
  start: "main",
@@ -167,6 +329,7 @@ export async function showBtwCommandMenu(
167
329
  kind: "actions",
168
330
  title: "Pi BTW",
169
331
  lines: [
332
+ `Model: ${displayModelValue(state.settings)}`,
170
333
  `Thinking: ${displayThinkingSummary(state.settings)} · Remember changes: ${displayRememberSummary(state.settings)}`,
171
334
  `Copy on select: ${effectiveFullscreenCopyOnSelect(state.settings) ? "On" : "Off"}`,
172
335
  ],
@@ -196,7 +359,7 @@ export async function showBtwCommandMenu(
196
359
  {
197
360
  id: "settings",
198
361
  label: "Settings",
199
- description: "Choose thinking, keybindings, and selection copying",
362
+ description: "Choose model, thinking, keybindings, and selection copying",
200
363
  to: state.kind === "invalid" ? "invalid" : "settings",
201
364
  },
202
365
  ],
@@ -220,12 +383,19 @@ export async function showBtwCommandMenu(
220
383
  title: "Pi BTW Settings",
221
384
  lines: [`User settings · ${displaySettingsPath}`],
222
385
  items: [
386
+ {
387
+ id: "model",
388
+ label: "Model",
389
+ description: "Choose the model for future pi-btw side threads without changing the main session.",
390
+ currentValue: displayModelValue(state.settings),
391
+ action: "open-model",
392
+ },
223
393
  {
224
394
  id: "thinkingLevel",
225
395
  label: "Thinking level",
226
- description: `Set the starting level for future pi-btw side threads. Currently ${currentMainThinkingLevel}.`,
396
+ description: `Set the starting level for future pi-btw side threads. Currently ${currentMainThinkingLevel(state.settings)}.`,
227
397
  currentValue: displayThinkingLevel(state.settings),
228
- values: [SAME_AS_MAIN_THREAD, ...levels],
398
+ values: [SAME_AS_MAIN_THREAD, ...thinkingLevels(state.settings)],
229
399
  action: "set-thinking",
230
400
  },
231
401
  {
@@ -253,6 +423,23 @@ export async function showBtwCommandMenu(
253
423
  })),
254
424
  ],
255
425
  }),
426
+ model: ({ state }) => ({
427
+ kind: "choice",
428
+ title: "Pi BTW Model",
429
+ lines: [
430
+ "Same as main thread is the default and fallback when a configured model is unavailable.",
431
+ ...(state.settings.model && !selectableConfiguredModel(state.settings)
432
+ ? [`Configured: ${displayModelValue(state.settings)}`]
433
+ : []),
434
+ ],
435
+ items: modelItems(state.settings),
436
+ action: "set-model",
437
+ currentItemId: currentModelItemId(state.settings),
438
+ initialItemId: selectedModelItemId(state.settings),
439
+ enableSearch: true,
440
+ viewportSize: 10,
441
+ hint: "back",
442
+ }),
256
443
  shortcut: ({ state }) => ({
257
444
  kind: "actions",
258
445
  title: shortcutLabels[shortcut],
@@ -289,6 +476,26 @@ export async function showBtwCommandMenu(
289
476
  },
290
477
  "save-shortcut": ({ state, value, signal }) => saveShortcut(state, value?.trim() ?? "", signal),
291
478
  "reset-shortcut": ({ state, signal }) => saveShortcut(state, undefined, signal),
479
+ "open-model": async () => ({ kind: "to", screen: "model" }),
480
+ "set-model": async ({ state, itemId, signal }) => {
481
+ if (itemId === "configured-model" && configuredModel(state.settings)) {
482
+ return { kind: "back" };
483
+ }
484
+ const model = itemId ? modelsByItemId.get(itemId) : undefined;
485
+ if (itemId !== "same-as-main" && (!model || !rawModelReference(model))) return { kind: "rejected" };
486
+ const result = await saveModel(model, signal);
487
+ if (result.kind === "failed") {
488
+ notifySaveFailure(ctx, result.error);
489
+ return { kind: "rejected" };
490
+ }
491
+ if (result.kind === "cancelled" || signal.aborted) return { kind: "close" };
492
+ notifySafely(
493
+ ctx,
494
+ model ? `Pi BTW model: ${displayModelReference(model)}.` : `Pi BTW model: ${SAME_AS_MAIN_THREAD}.`,
495
+ "info",
496
+ );
497
+ return { kind: "back" };
498
+ },
292
499
  start: async () => {
293
500
  startSelected = true;
294
501
  return { kind: "close" };
@@ -304,8 +511,9 @@ export async function showBtwCommandMenu(
304
511
  resumedThreadId = itemId;
305
512
  return { kind: "close" } as const;
306
513
  },
307
- "set-thinking": async ({ value, signal }) => {
514
+ "set-thinking": async ({ state, value, signal }) => {
308
515
  if (!value) return { kind: "rejected" };
516
+ const levels = thinkingLevels(state.settings);
309
517
  const patch =
310
518
  value === SAME_AS_MAIN_THREAD
311
519
  ? ({ thinkingLevel: undefined } satisfies BtwSettingsPatch)
@@ -429,6 +637,27 @@ export async function runBtwMenuPreservingEditor(
429
637
  return result;
430
638
  }
431
639
 
640
+ function deduplicateModels(models: readonly Model<Api>[]): Model<Api>[] {
641
+ return models.filter((model, index) => models.findIndex((candidate) => sameModel(candidate, model)) === index);
642
+ }
643
+
644
+ function availableModelsInScope(
645
+ availableModels: readonly Model<Api>[],
646
+ scopedModels: ExtensionCommandContext["scopedModels"],
647
+ ): Model<Api>[] {
648
+ if (scopedModels.length === 0) return [...availableModels];
649
+ return deduplicateModels(
650
+ scopedModels.flatMap((entry) => {
651
+ const available = availableModels.find((model) => sameModel(model, entry.model));
652
+ return available ? [available] : [];
653
+ }),
654
+ );
655
+ }
656
+
657
+ function sameModel(left: Pick<Model<Api>, "provider" | "id">, right: Pick<Model<Api>, "provider" | "id">): boolean {
658
+ return left.provider === right.provider && left.id === right.id;
659
+ }
660
+
432
661
  function clampToAvailableThinkingLevel(
433
662
  requested: BtwThinkingLevel,
434
663
  available: readonly BtwThinkingLevel[],
package/src/settings.ts CHANGED
@@ -26,6 +26,7 @@ export type BtwSettingsLoadResult =
26
26
 
27
27
  export interface BtwSettingsPatch {
28
28
  keybindings?: BtwKeybindingOverrides;
29
+ model?: string;
29
30
  thinkingLevel?: BtwThinkingLevel;
30
31
  rememberThinkingLevelChanges?: boolean;
31
32
  fullscreenCopyOnSelect?: boolean;
@@ -254,6 +255,10 @@ function applyBtwSettingsPatch(current: SettingsDocument, patch: BtwSettingsPatc
254
255
  if (Object.keys(keys).length) updated.keybindings = keys;
255
256
  else delete updated.keybindings;
256
257
  }
258
+ if (Object.hasOwn(patch, "model")) {
259
+ if (patch.model === undefined) delete updated.model;
260
+ else updated.model = patch.model;
261
+ }
257
262
  if (Object.hasOwn(patch, "thinkingLevel")) {
258
263
  if (patch.thinkingLevel === undefined) delete updated.thinkingLevel;
259
264
  else updated.thinkingLevel = patch.thinkingLevel;