@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/README.md CHANGED
@@ -59,7 +59,11 @@ Read the [workflow guide](./docs/workflows.md) for context selection, copying, s
59
59
  ## ⚙️ Settings
60
60
 
61
61
  By default, `/btw` uses the current session model.
62
- To use an independent model for side questions, create:
62
+ Open `/btw` → **Settings** → **Model** to choose an available model or **Same as main thread** to remove the override.
63
+ The searchable picker follows the current Pi model scope and saves immediately without changing the main session model.
64
+ A manually configured available model outside that scope remains active and visible until you explicitly choose another option.
65
+
66
+ You can also edit the user settings file directly:
63
67
 
64
68
  ```text
65
69
  $PI_CODING_AGENT_DIR/pi-btw.json
@@ -83,6 +87,7 @@ The configured model must exist in Pi's model registry and have usable credentia
83
87
  If it is missing or unauthenticated, pi-btw warns and falls back to the current session model.
84
88
  If neither model is available, `/btw` reports an error and stops.
85
89
  This selection affects only `/btw`; it does not change the main session model.
90
+ Model changes apply when the next new or resumed side thread starts.
86
91
 
87
92
  Pi calls its reasoning setting the **thinking level**.
88
93
  In Settings, choose **Same as main thread** to start each new side thread from the main thread's current thinking level.
package/dist/index.ts CHANGED
@@ -4,9 +4,9 @@
4
4
  // src/btw.ts
5
5
  import {
6
6
  clampThinkingLevel,
7
- getSupportedThinkingLevels
7
+ getSupportedThinkingLevels as getSupportedThinkingLevels2
8
8
  } from "@earendil-works/pi-ai";
9
- import { BorderedLoader } from "@earendil-works/pi-coding-agent";
9
+ import { BorderedLoader as BorderedLoader2 } from "@earendil-works/pi-coding-agent";
10
10
 
11
11
  // src/bring-to-main.ts
12
12
  import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
@@ -1302,6 +1302,12 @@ import {
1302
1302
  } from "@earendil-works/pi-coding-agent";
1303
1303
  import { Key as Key3, matchesKey as matchesKey3 } from "@earendil-works/pi-tui";
1304
1304
 
1305
+ // src/menu.ts
1306
+ import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
1307
+ import {
1308
+ BorderedLoader
1309
+ } from "@earendil-works/pi-coding-agent";
1310
+
1305
1311
  // src/settings.ts
1306
1312
  import { randomUUID } from "node:crypto";
1307
1313
  import { constants } from "node:fs";
@@ -1628,6 +1634,10 @@ function applyBtwSettingsPatch(current, patch) {
1628
1634
  if (Object.keys(keys).length) updated.keybindings = keys;
1629
1635
  else delete updated.keybindings;
1630
1636
  }
1637
+ if (Object.hasOwn(patch, "model")) {
1638
+ if (patch.model === void 0) delete updated.model;
1639
+ else updated.model = patch.model;
1640
+ }
1631
1641
  if (Object.hasOwn(patch, "thinkingLevel")) {
1632
1642
  if (patch.thinkingLevel === void 0) delete updated.thinkingLevel;
1633
1643
  else updated.thinkingLevel = patch.thinkingLevel;
@@ -1661,12 +1671,30 @@ function formatError2(error) {
1661
1671
  var SAME_AS_MAIN_THREAD = "Same as main thread";
1662
1672
  async function showBtwCommandMenu(ctx, options) {
1663
1673
  if (ctx.mode !== "tui") return "closed";
1664
- const { defineMenu, runMenu } = await import("@narumitw/pi-tui-kit");
1674
+ const { defineMenu, runMenu, sanitizeTerminalText } = await import("@narumitw/pi-tui-kit");
1665
1675
  if (ctx.signal?.aborted) return "closed";
1666
1676
  const settingsPath = options.settingsPath ?? btwSettingsPath();
1667
1677
  const readSettings = options.readSettings ?? readBtwSettings;
1668
1678
  const updateSettings = options.updateSettings ?? updateBtwSettings;
1669
- const levels = options.availableThinkingLevels.length > 0 ? [...options.availableThinkingLevels] : ["off"];
1679
+ const getAvailable = ctx.modelRegistry.getAvailable;
1680
+ const allAvailableModels = deduplicateModels(
1681
+ options.availableModels ?? (typeof getAvailable === "function" ? getAvailable.call(ctx.modelRegistry) : ctx.modelRegistry.getAll())
1682
+ );
1683
+ const currentModel = options.currentModel ?? ctx.model;
1684
+ const scopedModels = options.scopedModels ?? ctx.scopedModels ?? [];
1685
+ const selectableModels = availableModelsInScope(allAvailableModels, scopedModels);
1686
+ const modelItemIds = new Map(selectableModels.map((model, index) => [model, `btw-settings-model:${index}`]));
1687
+ const modelsByItemId = new Map(selectableModels.map((model) => [modelItemIds.get(model), model]));
1688
+ const safeModelMetadata = (value, fallback) => {
1689
+ const safe = sanitizeTerminalText(value).trim() || fallback;
1690
+ return [...safe].slice(0, 512).join("");
1691
+ };
1692
+ const displayModelReference = (model) => `${safeModelMetadata(model.id, "unknown model")} [${safeModelMetadata(model.provider, "unknown provider")}]`;
1693
+ const rawModelReference = (model) => {
1694
+ const reference = `${model.provider}/${model.id}`;
1695
+ const parsed = parseBtwModelReference(reference);
1696
+ return parsed?.provider === model.provider && parsed.modelId === model.id ? reference : void 0;
1697
+ };
1670
1698
  const displaySettingsPath = sanitizeSingleLine(settingsPath);
1671
1699
  const resumeThreads = options.resumeThreads ?? [];
1672
1700
  let startSelected = false;
@@ -1725,6 +1753,48 @@ async function showBtwCommandMenu(ctx, options) {
1725
1753
  return { kind: "rejected" };
1726
1754
  }
1727
1755
  };
1756
+ const saveModel = async (model, signal) => {
1757
+ const modelReference = model ? rawModelReference(model) : void 0;
1758
+ if (model && !modelReference) return { kind: "cancelled" };
1759
+ const result2 = await ctx.ui.custom(
1760
+ (tui, theme, _keybindings, done) => {
1761
+ const loader = new BorderedLoader(tui, theme, "Saving Pi BTW model...");
1762
+ const ownerController = new AbortController();
1763
+ const saveSignal = AbortSignal.any([signal, loader.signal, ownerController.signal]);
1764
+ let settled = false;
1765
+ const finish = (value) => {
1766
+ if (settled) return;
1767
+ settled = true;
1768
+ done(value);
1769
+ };
1770
+ const cancel = () => finish({ kind: "cancelled" });
1771
+ loader.onAbort = cancel;
1772
+ saveSignal.addEventListener("abort", cancel, { once: true });
1773
+ queueMicrotask(() => {
1774
+ void (async () => {
1775
+ await Promise.resolve();
1776
+ if (saveSignal.aborted) return;
1777
+ try {
1778
+ await updateSettings({ model: modelReference }, { settingsPath, signal: saveSignal });
1779
+ finish({ kind: "saved" });
1780
+ } catch (error) {
1781
+ finish(saveSignal.aborted ? { kind: "cancelled" } : { kind: "failed", error });
1782
+ }
1783
+ })();
1784
+ });
1785
+ return {
1786
+ render: (width) => loader.render(width),
1787
+ invalidate: () => loader.invalidate(),
1788
+ handleInput: (data) => loader.handleInput(data),
1789
+ dispose() {
1790
+ ownerController.abort(new DOMException("Pi BTW model save disposed", "AbortError"));
1791
+ loader.dispose();
1792
+ }
1793
+ };
1794
+ }
1795
+ );
1796
+ return result2 ?? { kind: "cancelled" };
1797
+ };
1728
1798
  const loadState = async () => {
1729
1799
  const loaded = await readSettings(settingsPath);
1730
1800
  if (loaded.kind === "invalid") {
@@ -1732,13 +1802,79 @@ async function showBtwCommandMenu(ctx, options) {
1732
1802
  }
1733
1803
  return { kind: "valid", settings: loaded.kind === "loaded" ? loaded.settings : {} };
1734
1804
  };
1735
- const currentMainThinkingLevel = clampToAvailableThinkingLevel(options.currentThinkingLevel, levels);
1736
- const displayThinkingLevel = (settings) => settings.thinkingLevel === void 0 ? SAME_AS_MAIN_THREAD : clampToAvailableThinkingLevel(settings.thinkingLevel, levels);
1737
- const displayThinkingSummary = (settings) => settings.thinkingLevel === void 0 ? `${SAME_AS_MAIN_THREAD} (currently ${currentMainThinkingLevel})` : displayThinkingLevel(settings);
1805
+ const configuredModel = (settings) => {
1806
+ if (!settings.model) return void 0;
1807
+ const reference = parseBtwModelReference(settings.model);
1808
+ return reference ? allAvailableModels.find((model) => model.provider === reference.provider && model.id === reference.modelId) : void 0;
1809
+ };
1810
+ const selectableConfiguredModel = (settings) => {
1811
+ const configured = configuredModel(settings);
1812
+ return configured ? selectableModels.find((model) => sameModel(model, configured)) : void 0;
1813
+ };
1814
+ const thinkingLevels = (settings) => {
1815
+ const overridden = options.availableThinkingLevels;
1816
+ const effectiveModel = configuredModel(settings) ?? currentModel;
1817
+ const available = overridden && overridden.length > 0 ? overridden : effectiveModel ? getSupportedThinkingLevels(effectiveModel) : BTW_THINKING_LEVELS;
1818
+ return available.length > 0 ? [...available] : ["off"];
1819
+ };
1820
+ const currentMainThinkingLevel = (settings) => clampToAvailableThinkingLevel(options.currentThinkingLevel, thinkingLevels(settings));
1821
+ const displayThinkingLevel = (settings) => settings.thinkingLevel === void 0 ? SAME_AS_MAIN_THREAD : clampToAvailableThinkingLevel(settings.thinkingLevel, thinkingLevels(settings));
1822
+ const displayThinkingSummary = (settings) => settings.thinkingLevel === void 0 ? `${SAME_AS_MAIN_THREAD} (currently ${currentMainThinkingLevel(settings)})` : displayThinkingLevel(settings);
1738
1823
  const displayRememberSummary = (settings) => {
1739
1824
  const value = effectiveRememberThinkingLevelChanges(settings) ? "On" : "Off";
1740
1825
  return settings.thinkingLevel === void 0 ? `${value} (fixed levels only)` : value;
1741
1826
  };
1827
+ const displayModelValue = (settings) => {
1828
+ if (!settings.model) {
1829
+ return currentModel ? `${SAME_AS_MAIN_THREAD} (${displayModelReference(currentModel)})` : SAME_AS_MAIN_THREAD;
1830
+ }
1831
+ const available = configuredModel(settings);
1832
+ if (!available) return `${SAME_AS_MAIN_THREAD} \xB7 ${safeModelMetadata(settings.model, "unknown model")} unavailable`;
1833
+ const reference = displayModelReference(available);
1834
+ return selectableConfiguredModel(settings) ? reference : `${reference} \xB7 outside current scope`;
1835
+ };
1836
+ const modelItems = (settings) => {
1837
+ const configured = configuredModel(settings);
1838
+ const selectable = selectableConfiguredModel(settings);
1839
+ const retained = settings.model && !selectable;
1840
+ return [
1841
+ {
1842
+ id: "same-as-main",
1843
+ label: SAME_AS_MAIN_THREAD,
1844
+ description: currentModel ? `Currently ${displayModelReference(currentModel)}` : "Use the main thread model when /btw starts."
1845
+ },
1846
+ ...selectableModels.map((model) => {
1847
+ const reference = displayModelReference(model);
1848
+ const name = safeModelMetadata(model.name ?? "", "");
1849
+ const validReference = rawModelReference(model);
1850
+ return {
1851
+ id: modelItemIds.get(model),
1852
+ label: reference,
1853
+ ...name ? { details: [`Model Name: ${name}`] } : {},
1854
+ searchText: [reference, name].filter(Boolean).join(" "),
1855
+ ...!validReference ? {
1856
+ disabled: true,
1857
+ disabledReason: "This model identity cannot be stored in pi-btw.json."
1858
+ } : {}
1859
+ };
1860
+ }),
1861
+ ...retained ? [
1862
+ {
1863
+ id: "configured-model",
1864
+ label: configured ? displayModelReference(configured) : safeModelMetadata(settings.model, "unknown model"),
1865
+ ...configured ? { description: "Configured outside the current model scope; retained until changed." } : {
1866
+ disabled: true,
1867
+ disabledReason: "Configured model is unavailable; /btw falls back to the main model."
1868
+ }
1869
+ }
1870
+ ] : []
1871
+ ];
1872
+ };
1873
+ const selectedModelItemId = (settings) => {
1874
+ const selected = selectableConfiguredModel(settings);
1875
+ return selected ? modelItemIds.get(selected) : settings.model ? "configured-model" : "same-as-main";
1876
+ };
1877
+ const currentModelItemId = (settings) => settings.model && configuredModel(settings) ? selectedModelItemId(settings) : "same-as-main";
1742
1878
  const menu = defineMenu({
1743
1879
  start: "main",
1744
1880
  screens: {
@@ -1746,6 +1882,7 @@ async function showBtwCommandMenu(ctx, options) {
1746
1882
  kind: "actions",
1747
1883
  title: "Pi BTW",
1748
1884
  lines: [
1885
+ `Model: ${displayModelValue(state.settings)}`,
1749
1886
  `Thinking: ${displayThinkingSummary(state.settings)} \xB7 Remember changes: ${displayRememberSummary(state.settings)}`,
1750
1887
  `Copy on select: ${effectiveFullscreenCopyOnSelect(state.settings) ? "On" : "Off"}`
1751
1888
  ],
@@ -1773,7 +1910,7 @@ async function showBtwCommandMenu(ctx, options) {
1773
1910
  {
1774
1911
  id: "settings",
1775
1912
  label: "Settings",
1776
- description: "Choose thinking, keybindings, and selection copying",
1913
+ description: "Choose model, thinking, keybindings, and selection copying",
1777
1914
  to: state.kind === "invalid" ? "invalid" : "settings"
1778
1915
  }
1779
1916
  ],
@@ -1797,12 +1934,19 @@ async function showBtwCommandMenu(ctx, options) {
1797
1934
  title: "Pi BTW Settings",
1798
1935
  lines: [`User settings \xB7 ${displaySettingsPath}`],
1799
1936
  items: [
1937
+ {
1938
+ id: "model",
1939
+ label: "Model",
1940
+ description: "Choose the model for future pi-btw side threads without changing the main session.",
1941
+ currentValue: displayModelValue(state.settings),
1942
+ action: "open-model"
1943
+ },
1800
1944
  {
1801
1945
  id: "thinkingLevel",
1802
1946
  label: "Thinking level",
1803
- description: `Set the starting level for future pi-btw side threads. Currently ${currentMainThinkingLevel}.`,
1947
+ description: `Set the starting level for future pi-btw side threads. Currently ${currentMainThinkingLevel(state.settings)}.`,
1804
1948
  currentValue: displayThinkingLevel(state.settings),
1805
- values: [SAME_AS_MAIN_THREAD, ...levels],
1949
+ values: [SAME_AS_MAIN_THREAD, ...thinkingLevels(state.settings)],
1806
1950
  action: "set-thinking"
1807
1951
  },
1808
1952
  {
@@ -1830,6 +1974,21 @@ async function showBtwCommandMenu(ctx, options) {
1830
1974
  }))
1831
1975
  ]
1832
1976
  }),
1977
+ model: ({ state }) => ({
1978
+ kind: "choice",
1979
+ title: "Pi BTW Model",
1980
+ lines: [
1981
+ "Same as main thread is the default and fallback when a configured model is unavailable.",
1982
+ ...state.settings.model && !selectableConfiguredModel(state.settings) ? [`Configured: ${displayModelValue(state.settings)}`] : []
1983
+ ],
1984
+ items: modelItems(state.settings),
1985
+ action: "set-model",
1986
+ currentItemId: currentModelItemId(state.settings),
1987
+ initialItemId: selectedModelItemId(state.settings),
1988
+ enableSearch: true,
1989
+ viewportSize: 10,
1990
+ hint: "back"
1991
+ }),
1833
1992
  shortcut: ({ state }) => ({
1834
1993
  kind: "actions",
1835
1994
  title: shortcutLabels[shortcut],
@@ -1866,6 +2025,26 @@ async function showBtwCommandMenu(ctx, options) {
1866
2025
  },
1867
2026
  "save-shortcut": ({ state, value, signal }) => saveShortcut(state, value?.trim() ?? "", signal),
1868
2027
  "reset-shortcut": ({ state, signal }) => saveShortcut(state, void 0, signal),
2028
+ "open-model": async () => ({ kind: "to", screen: "model" }),
2029
+ "set-model": async ({ state, itemId, signal }) => {
2030
+ if (itemId === "configured-model" && configuredModel(state.settings)) {
2031
+ return { kind: "back" };
2032
+ }
2033
+ const model = itemId ? modelsByItemId.get(itemId) : void 0;
2034
+ if (itemId !== "same-as-main" && (!model || !rawModelReference(model))) return { kind: "rejected" };
2035
+ const result2 = await saveModel(model, signal);
2036
+ if (result2.kind === "failed") {
2037
+ notifySaveFailure(ctx, result2.error);
2038
+ return { kind: "rejected" };
2039
+ }
2040
+ if (result2.kind === "cancelled" || signal.aborted) return { kind: "close" };
2041
+ notifySafely(
2042
+ ctx,
2043
+ model ? `Pi BTW model: ${displayModelReference(model)}.` : `Pi BTW model: ${SAME_AS_MAIN_THREAD}.`,
2044
+ "info"
2045
+ );
2046
+ return { kind: "back" };
2047
+ },
1869
2048
  start: async () => {
1870
2049
  startSelected = true;
1871
2050
  return { kind: "close" };
@@ -1881,8 +2060,9 @@ async function showBtwCommandMenu(ctx, options) {
1881
2060
  resumedThreadId = itemId;
1882
2061
  return { kind: "close" };
1883
2062
  },
1884
- "set-thinking": async ({ value, signal }) => {
2063
+ "set-thinking": async ({ state, value, signal }) => {
1885
2064
  if (!value) return { kind: "rejected" };
2065
+ const levels = thinkingLevels(state.settings);
1886
2066
  const patch = value === SAME_AS_MAIN_THREAD ? { thinkingLevel: void 0 } : levels.includes(value) ? { thinkingLevel: value } : void 0;
1887
2067
  if (!patch) return { kind: "rejected" };
1888
2068
  try {
@@ -1985,6 +2165,21 @@ async function runBtwMenuPreservingEditor(ctx, run, onKeybindings) {
1985
2165
  }
1986
2166
  return result;
1987
2167
  }
2168
+ function deduplicateModels(models) {
2169
+ return models.filter((model, index) => models.findIndex((candidate) => sameModel(candidate, model)) === index);
2170
+ }
2171
+ function availableModelsInScope(availableModels, scopedModels) {
2172
+ if (scopedModels.length === 0) return [...availableModels];
2173
+ return deduplicateModels(
2174
+ scopedModels.flatMap((entry) => {
2175
+ const available = availableModels.find((model) => sameModel(model, entry.model));
2176
+ return available ? [available] : [];
2177
+ })
2178
+ );
2179
+ }
2180
+ function sameModel(left, right) {
2181
+ return left.provider === right.provider && left.id === right.id;
2182
+ }
1988
2183
  function clampToAvailableThinkingLevel(requested, available) {
1989
2184
  if (available.includes(requested)) return requested;
1990
2185
  const requestedIndex = BTW_THINKING_LEVELS.indexOf(requested);
@@ -3100,17 +3295,8 @@ function btw(pi, dependencies = {}) {
3100
3295
  });
3101
3296
  }
3102
3297
  async function showCommandMenuForBtw(pi, ctx, resumeThreads) {
3103
- const currentModel = ctx.model;
3104
- const availableModels = ctx.modelRegistry.getAll();
3105
- const currentThinkingLevel = pi.getThinkingLevel();
3106
- const loaded = await readBtwSettings();
3107
- const settings = loaded.kind === "loaded" ? loaded.settings : {};
3108
- const configured = settings.model ? parseBtwModelReference(settings.model) : void 0;
3109
- const configuredModel = configured ? availableModels.find((model2) => model2.provider === configured.provider && model2.id === configured.modelId) : void 0;
3110
- const model = configuredModel ?? currentModel;
3111
3298
  return showBtwCommandMenu(ctx, {
3112
- currentThinkingLevel,
3113
- availableThinkingLevels: model ? getSupportedThinkingLevels(model) : BTW_THINKING_LEVELS,
3299
+ currentThinkingLevel: pi.getThinkingLevel(),
3114
3300
  resumeThreads
3115
3301
  });
3116
3302
  }
@@ -3124,7 +3310,7 @@ async function loadSettingsForCommand(ctx) {
3124
3310
  }
3125
3311
  async function resolveBtwModelWithLoader(settings, ctx) {
3126
3312
  return ctx.ui.custom((tui, theme, _keybindings, done) => {
3127
- const loader = new BorderedLoader(tui, theme, "Resolving /btw model credentials...");
3313
+ const loader = new BorderedLoader2(tui, theme, "Resolving /btw model credentials...");
3128
3314
  let settled = false;
3129
3315
  loader.onAbort = () => {
3130
3316
  if (settled) return;
@@ -3167,7 +3353,7 @@ async function runBtwThread({
3167
3353
  const persistThinkingLevel = dependencies.persistThinkingLevel ?? ((level) => updateBtwSettings({ thinkingLevel: level }, { settingsPath }));
3168
3354
  const now = dependencies.now ?? Date.now;
3169
3355
  const thread = state?.thread ?? createSideThread(buildConversationContext(ctx.sessionManager.getBranch()));
3170
- const thinkingLevels = getSupportedThinkingLevels(selected.model);
3356
+ const thinkingLevels = getSupportedThinkingLevels2(selected.model);
3171
3357
  const pendingWrites = /* @__PURE__ */ new Set();
3172
3358
  const steeringQuestions = [];
3173
3359
  let activeThinkingLevel = clampThinkingLevel(selected.model, state?.thinkingLevel ?? thinkingLevel);