@nicknisi/pi-model-switch 0.2.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 CHANGED
@@ -34,6 +34,18 @@ These extension-owned action IDs are ignored by Pi's built-in keybinding manager
34
34
 
35
35
  ## Configure models
36
36
 
37
+ ### Add models from Pi
38
+
39
+ Run `/model-switch add` to search Pi's available model catalog, pick a model, then choose an existing section to save it in. Run `/model-switch add-current` to save the model you're already using without opening the catalog picker. Both subcommands have argument completion.
40
+
41
+ If the config file is missing, either command asks you to name the first section and creates the file. With an existing config, the section picker includes empty sections too. Add more sections by editing the config below.
42
+
43
+ Adding a model does not switch the active model. Duplicate references in the chosen section are skipped, but the same model can belong to multiple sections. Escape cancels without writing. Saves preserve the legacy flat format, other sections, and extra config fields. Invalid configs produce a warning and are not overwritten.
44
+
45
+ The catalog picker requires Pi's terminal UI. `add-current` also works with RPC clients that support Pi's selection and input dialogs. Neither command registers new providers or model definitions. The model must already be known to Pi.
46
+
47
+ ### Edit the config file
48
+
37
49
  Copy the example config:
38
50
 
39
51
  ```bash
@@ -125,7 +137,7 @@ The config may be missing, empty, malformed, or contain only missing/unauthentic
125
137
  - The picker uses a custom search + list component with `ctx.ui.custom()`; native `/model` and Ctrl+L remain available for the full catalog.
126
138
  - Terminal support for multi-modifier keys varies; configure simpler non-conflicting keys or use a terminal with the Kitty keyboard protocol when modified keys are not distinguishable.
127
139
  - Availability is checked on each interaction, which may resolve provider credentials before switching.
128
- - Duplicate references within a section are preserved as written; avoid them unless repeated cycle positions are intentional.
140
+ - Duplicate references within a section are preserved as written; avoid them unless repeated cycle positions are intentional. The add commands never insert another copy of an existing reference.
129
141
 
130
142
  ## Development
131
143
 
package/config.ts CHANGED
@@ -1,5 +1,16 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { randomUUID } from 'node:crypto';
2
+ import {
3
+ existsSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ realpathSync,
8
+ renameSync,
9
+ rmSync,
10
+ statSync,
11
+ writeFileSync,
12
+ } from 'node:fs';
13
+ import { dirname, join } from 'node:path';
3
14
  import { getAgentDir } from '@earendil-works/pi-coding-agent';
4
15
 
5
16
  export interface ModelSwitchSection {
@@ -91,14 +102,21 @@ export function loadModelSwitchConfig(path = modelCycleConfigPath()): ConfigLoad
91
102
  return { ok: false, error: `Invalid model-switch config at ${path}: ${message}` };
92
103
  }
93
104
 
94
- if (!value || typeof value !== 'object') {
105
+ return parseModelSwitchConfig(value, path);
106
+ }
107
+
108
+ function parseModelSwitchConfig(value: unknown, path: string): ConfigLoadResult {
109
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
95
110
  return { ok: false, error: `Invalid model-switch config at ${path}: expected an object` };
96
111
  }
97
112
 
98
113
  const obj = value as Record<string, unknown>;
99
114
 
100
115
  // Prefer "sections" if present; fall back to legacy "models" as a single section.
101
- if ('sections' in obj && obj.sections && typeof obj.sections === 'object') {
116
+ if ('sections' in obj) {
117
+ if (!obj.sections || typeof obj.sections !== 'object' || Array.isArray(obj.sections)) {
118
+ return { ok: false, error: `Invalid model-switch config at ${path}: expected "sections" to be an object` };
119
+ }
102
120
  const sectionsRaw = obj.sections as Record<string, unknown>;
103
121
  const sections: ModelSwitchSection[] = [];
104
122
 
@@ -129,3 +147,44 @@ export function loadModelSwitchConfig(path = modelCycleConfigPath()): ConfigLoad
129
147
  error: `Invalid model-switch config at ${path}: expected { "sections": { ... } } or { "models": [...] }`,
130
148
  };
131
149
  }
150
+
151
+ export function addModelToSection(
152
+ reference: string,
153
+ sectionName: string,
154
+ path = modelCycleConfigPath(),
155
+ ): { ok: true; added: boolean } | { ok: false; error: string } {
156
+ try {
157
+ // Read again after the dialogs so edits made while they were open are retained.
158
+ const existing = lstatSync(path, { throwIfNoEntry: false });
159
+ const target = existing ? realpathSync(path) : path;
160
+ const value = existing ? JSON.parse(readFileSync(target, 'utf8')) : { sections: { [sectionName]: [] } };
161
+ const loaded = parseModelSwitchConfig(value, path);
162
+ if (!loaded.ok) return loaded;
163
+
164
+ const section = loaded.config.sections.find((item) => item.name === sectionName);
165
+ if (!section) {
166
+ return { ok: false, error: `Section "${sectionName}" no longer exists in ${path}` };
167
+ }
168
+ if (section.models.includes(reference)) return { ok: true, added: false };
169
+
170
+ const models: string[] = 'sections' in value ? value.sections[sectionName] : value.models;
171
+ models.push(reference);
172
+
173
+ // Replace the file atomically, following config symlinks rather than replacing them.
174
+ mkdirSync(dirname(target), { recursive: true });
175
+ const temporaryPath = `${target}.${randomUUID()}.tmp`;
176
+ try {
177
+ writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, {
178
+ flag: 'wx',
179
+ mode: existing ? statSync(target).mode & 0o777 : 0o600,
180
+ });
181
+ renameSync(temporaryPath, target);
182
+ } finally {
183
+ rmSync(temporaryPath, { force: true });
184
+ }
185
+ return { ok: true, added: true };
186
+ } catch (error) {
187
+ const message = error instanceof Error ? error.message : String(error);
188
+ return { ok: false, error: `Could not update model-switch config at ${path}: ${message}` };
189
+ }
190
+ }
package/dist/config.d.ts CHANGED
@@ -22,3 +22,10 @@ export declare function modelCycleConfigPath(): string;
22
22
  export declare function modelCycleKeybindingsPath(): string;
23
23
  export declare function loadModelSwitchKeybindings(path?: string): ModelSwitchKeybindings;
24
24
  export declare function loadModelSwitchConfig(path?: string): ConfigLoadResult;
25
+ export declare function addModelToSection(reference: string, sectionName: string, path?: string): {
26
+ ok: true;
27
+ added: boolean;
28
+ } | {
29
+ ok: false;
30
+ error: string;
31
+ };
package/dist/config.js CHANGED
@@ -1,5 +1,6 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { randomUUID } from 'node:crypto';
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
3
4
  import { getAgentDir } from '@earendil-works/pi-coding-agent';
4
5
  export const DEFAULT_MODEL_CYCLE_KEYBINDINGS = {
5
6
  forward: 'ctrl+shift+m',
@@ -62,12 +63,18 @@ export function loadModelSwitchConfig(path = modelCycleConfigPath()) {
62
63
  const message = error instanceof Error ? error.message : String(error);
63
64
  return { ok: false, error: `Invalid model-switch config at ${path}: ${message}` };
64
65
  }
65
- if (!value || typeof value !== 'object') {
66
+ return parseModelSwitchConfig(value, path);
67
+ }
68
+ function parseModelSwitchConfig(value, path) {
69
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
66
70
  return { ok: false, error: `Invalid model-switch config at ${path}: expected an object` };
67
71
  }
68
72
  const obj = value;
69
73
  // Prefer "sections" if present; fall back to legacy "models" as a single section.
70
- if ('sections' in obj && obj.sections && typeof obj.sections === 'object') {
74
+ if ('sections' in obj) {
75
+ if (!obj.sections || typeof obj.sections !== 'object' || Array.isArray(obj.sections)) {
76
+ return { ok: false, error: `Invalid model-switch config at ${path}: expected "sections" to be an object` };
77
+ }
71
78
  const sectionsRaw = obj.sections;
72
79
  const sections = [];
73
80
  for (const [name, modelsRaw] of Object.entries(sectionsRaw)) {
@@ -95,3 +102,40 @@ export function loadModelSwitchConfig(path = modelCycleConfigPath()) {
95
102
  error: `Invalid model-switch config at ${path}: expected { "sections": { ... } } or { "models": [...] }`,
96
103
  };
97
104
  }
105
+ export function addModelToSection(reference, sectionName, path = modelCycleConfigPath()) {
106
+ try {
107
+ // Read again after the dialogs so edits made while they were open are retained.
108
+ const existing = lstatSync(path, { throwIfNoEntry: false });
109
+ const target = existing ? realpathSync(path) : path;
110
+ const value = existing ? JSON.parse(readFileSync(target, 'utf8')) : { sections: { [sectionName]: [] } };
111
+ const loaded = parseModelSwitchConfig(value, path);
112
+ if (!loaded.ok)
113
+ return loaded;
114
+ const section = loaded.config.sections.find((item) => item.name === sectionName);
115
+ if (!section) {
116
+ return { ok: false, error: `Section "${sectionName}" no longer exists in ${path}` };
117
+ }
118
+ if (section.models.includes(reference))
119
+ return { ok: true, added: false };
120
+ const models = 'sections' in value ? value.sections[sectionName] : value.models;
121
+ models.push(reference);
122
+ // Replace the file atomically, following config symlinks rather than replacing them.
123
+ mkdirSync(dirname(target), { recursive: true });
124
+ const temporaryPath = `${target}.${randomUUID()}.tmp`;
125
+ try {
126
+ writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, {
127
+ flag: 'wx',
128
+ mode: existing ? statSync(target).mode & 0o777 : 0o600,
129
+ });
130
+ renameSync(temporaryPath, target);
131
+ }
132
+ finally {
133
+ rmSync(temporaryPath, { force: true });
134
+ }
135
+ return { ok: true, added: true };
136
+ }
137
+ catch (error) {
138
+ const message = error instanceof Error ? error.message : String(error);
139
+ return { ok: false, error: `Could not update model-switch config at ${path}: ${message}` };
140
+ }
141
+ }
@@ -1,8 +1,10 @@
1
- import { afterEach, describe, expect, it } from 'vitest';
2
- import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import * as fs from 'node:fs';
3
+ import { lstatSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
3
4
  import { tmpdir } from 'node:os';
4
5
  import { join } from 'node:path';
5
- import { DEFAULT_MODEL_CYCLE_KEYBINDINGS, loadModelSwitchConfig, loadModelSwitchKeybindings } from './config.js';
6
+ import { addModelToSection, DEFAULT_MODEL_CYCLE_KEYBINDINGS, loadModelSwitchConfig, loadModelSwitchKeybindings, } from './config.js';
7
+ vi.mock('node:fs', async (importOriginal) => ({ ...(await importOriginal()) }));
6
8
  const tempDirs = [];
7
9
  function tempConfig(content) {
8
10
  const dir = mkdtempSync(join(tmpdir(), 'pi-model-switch-'));
@@ -13,6 +15,7 @@ function tempConfig(content) {
13
15
  return path;
14
16
  }
15
17
  afterEach(() => {
18
+ vi.restoreAllMocks();
16
19
  for (const dir of tempDirs.splice(0)) {
17
20
  rmSync(dir, { recursive: true, force: true });
18
21
  }
@@ -99,6 +102,8 @@ describe('loadModelSwitchConfig', () => {
99
102
  ['missing sections and models', '{}'],
100
103
  ['non-object sections', JSON.stringify({ sections: 'nope' })],
101
104
  ['empty sections object', JSON.stringify({ sections: {} })],
105
+ ['array sections', JSON.stringify({ sections: [['provider/model']] })],
106
+ ['invalid sections with legacy models', JSON.stringify({ sections: null, models: ['provider/model'] })],
102
107
  ['non-array section models', JSON.stringify({ sections: { work: 'nope' } })],
103
108
  ['non-string model in section', JSON.stringify({ sections: { work: [42] } })],
104
109
  ['empty model in section', JSON.stringify({ sections: { work: [' '] } })],
@@ -111,3 +116,96 @@ describe('loadModelSwitchConfig', () => {
111
116
  expect(result.error).toContain(path);
112
117
  });
113
118
  });
119
+ describe('addModelToSection', () => {
120
+ it('appends only to the selected section and preserves other config fields', () => {
121
+ const config = {
122
+ sections: { work: ['provider/old'], personal: ['provider/new'] },
123
+ models: ['legacy/ignored'],
124
+ note: 'keep me',
125
+ };
126
+ const path = tempConfig(JSON.stringify(config));
127
+ expect(addModelToSection('provider/new', 'work', path)).toEqual({ ok: true, added: true });
128
+ expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual({
129
+ ...config,
130
+ sections: { work: ['provider/old', 'provider/new'], personal: ['provider/new'] },
131
+ });
132
+ });
133
+ it('does not rewrite a duplicate, including a whitespace-padded reference', () => {
134
+ const content = '{ "sections": { "work": [" provider/model "] } }';
135
+ const path = tempConfig(content);
136
+ expect(addModelToSection('provider/model', 'work', path)).toEqual({ ok: true, added: false });
137
+ expect(readFileSync(path, 'utf8')).toBe(content);
138
+ });
139
+ it('keeps the legacy flat format', () => {
140
+ const path = tempConfig(JSON.stringify({ models: ['provider/old'], note: 'keep' }));
141
+ expect(addModelToSection('provider/new', 'models', path)).toEqual({ ok: true, added: true });
142
+ expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual({
143
+ models: ['provider/old', 'provider/new'],
144
+ note: 'keep',
145
+ });
146
+ });
147
+ it('creates missing config directories and preserves slashes in model IDs', () => {
148
+ const path = join(tempConfig(), 'configs', 'model-switch.json');
149
+ expect(addModelToSection('fireworks/accounts/fireworks/models/kimi-k3', 'personal', path)).toEqual({
150
+ ok: true,
151
+ added: true,
152
+ });
153
+ expect(loadModelSwitchConfig(path)).toEqual({
154
+ ok: true,
155
+ config: { sections: [{ name: 'personal', models: ['fireworks/accounts/fireworks/models/kimi-k3'] }] },
156
+ });
157
+ });
158
+ it('supports section names that match object prototype properties', () => {
159
+ const path = tempConfig();
160
+ expect(addModelToSection('provider/model', '__proto__', path)).toEqual({ ok: true, added: true });
161
+ expect(loadModelSwitchConfig(path)).toEqual({
162
+ ok: true,
163
+ config: { sections: [{ name: '__proto__', models: ['provider/model'] }] },
164
+ });
165
+ });
166
+ it.each(['{ nope', '{"sections":null,"models":[]}', '{"sections":[[]]}', '{"sections":{"work":[42]}}'])('does not overwrite malformed config: %s', (content) => {
167
+ const path = tempConfig(content);
168
+ const result = addModelToSection('provider/new', 'work', path);
169
+ expect(result.ok).toBe(false);
170
+ if (!result.ok)
171
+ expect(result.error).toContain(path);
172
+ expect(readFileSync(path, 'utf8')).toBe(content);
173
+ });
174
+ it('does not recreate a section removed while the picker was open', () => {
175
+ const content = '{"sections":{"personal":[]}}';
176
+ const path = tempConfig(content);
177
+ expect(addModelToSection('provider/new', 'work', path)).toEqual({
178
+ ok: false,
179
+ error: `Section "work" no longer exists in ${path}`,
180
+ });
181
+ expect(readFileSync(path, 'utf8')).toBe(content);
182
+ });
183
+ it('updates a symlink target without replacing the link', () => {
184
+ const path = tempConfig('{"sections":{"work":[]}}');
185
+ const link = `${path}.link`;
186
+ symlinkSync(path, link);
187
+ expect(addModelToSection('provider/new', 'work', link)).toEqual({ ok: true, added: true });
188
+ expect(lstatSync(link).isSymbolicLink()).toBe(true);
189
+ expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual({ sections: { work: ['provider/new'] } });
190
+ });
191
+ it('does not replace a dangling config symlink', () => {
192
+ const path = tempConfig();
193
+ const link = `${path}.link`;
194
+ symlinkSync(path, link);
195
+ expect(addModelToSection('provider/new', 'work', link).ok).toBe(false);
196
+ expect(lstatSync(link).isSymbolicLink()).toBe(true);
197
+ });
198
+ it('preserves the original config and cleans up when replacing the file fails', () => {
199
+ const content = '{"sections":{"work":[]}}';
200
+ const path = tempConfig(content);
201
+ vi.spyOn(fs, 'renameSync').mockImplementation(() => {
202
+ throw new Error('disk error');
203
+ });
204
+ expect(addModelToSection('provider/new', 'work', path)).toEqual({
205
+ ok: false,
206
+ error: `Could not update model-switch config at ${path}: disk error`,
207
+ });
208
+ expect(readFileSync(path, 'utf8')).toBe(content);
209
+ expect(readdirSync(join(path, '..'))).toEqual(['model-switch.json']);
210
+ });
211
+ });
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { loadModelSwitchConfig, loadModelSwitchKeybindings, modelCycleConfigPath } from './config.js';
1
+ import { addModelToSection, loadModelSwitchConfig, loadModelSwitchKeybindings, modelCycleConfigPath, } from './config.js';
2
2
  import { findActiveSection, resolveAvailableModels, selectCycleTarget } from './cycle.js';
3
3
  import { SectionPicker } from './section-picker.js';
4
4
  async function resolveSectionModels(references, ctx) {
@@ -73,6 +73,54 @@ async function showModelPicker(pi, ctx) {
73
73
  if (target)
74
74
  await switchModel(pi, ctx, target);
75
75
  }
76
+ async function addConfiguredModel(ctx, useCurrent) {
77
+ if (!ctx.hasUI)
78
+ return;
79
+ if (!useCurrent && ctx.mode !== 'tui') {
80
+ ctx.ui.notify('/model-switch add requires the terminal UI', 'warning');
81
+ return;
82
+ }
83
+ const loaded = loadModelSwitchConfig();
84
+ if (!loaded.ok) {
85
+ ctx.ui.notify(loaded.error, 'warning');
86
+ return;
87
+ }
88
+ let target = ctx.model;
89
+ if (!useCurrent) {
90
+ const available = ctx.modelRegistry.getAvailable();
91
+ if (available.length === 0) {
92
+ ctx.ui.notify('No available models to add. Configure a provider with /login first.', 'warning');
93
+ return;
94
+ }
95
+ const selected = await ctx.ui.custom((_tui, theme, _keybindings, done) => {
96
+ return new SectionPicker([{ name: 'Add a model', items: buildSectionItems(available, ctx.model) }], theme, done);
97
+ });
98
+ if (!selected)
99
+ return;
100
+ target = available.find((model) => `${model.provider}/${model.id}` === selected);
101
+ }
102
+ if (!target) {
103
+ ctx.ui.notify('No model selected to add', 'warning');
104
+ return;
105
+ }
106
+ const reference = `${target.provider}/${target.id}`;
107
+ const sections = loaded.config.sections.map((section) => section.name);
108
+ const sectionName = sections.length > 0
109
+ ? await ctx.ui.select(`Add ${reference} to section`, sections)
110
+ : (await ctx.ui.input('Name your first model-switch section', 'models'))?.trim();
111
+ if (sectionName === undefined)
112
+ return;
113
+ if (sections.length === 0 && !sectionName) {
114
+ ctx.ui.notify('Section name must not be empty', 'warning');
115
+ return;
116
+ }
117
+ const result = addModelToSection(reference, sectionName);
118
+ if (!result.ok) {
119
+ ctx.ui.notify(result.error, 'warning');
120
+ return;
121
+ }
122
+ ctx.ui.notify(result.added ? `Added ${reference} to "${sectionName}"` : `${reference} is already in "${sectionName}"`, 'info');
123
+ }
76
124
  export default function modelCycle(pi) {
77
125
  const keybindings = loadModelSwitchKeybindings();
78
126
  pi.registerShortcut(keybindings.forward, {
@@ -88,7 +136,25 @@ export default function modelCycle(pi) {
88
136
  handler: async (ctx) => showModelPicker(pi, ctx),
89
137
  });
90
138
  pi.registerCommand('model-switch', {
91
- description: 'Select from configured models',
92
- handler: async (_args, ctx) => showModelPicker(pi, ctx),
139
+ description: 'Select configured models, add a model, or add-current',
140
+ getArgumentCompletions: (prefix) => {
141
+ const items = [
142
+ { value: 'add', label: 'add', description: 'Pick an available model to save' },
143
+ { value: 'add-current', label: 'add-current', description: 'Save the current model' },
144
+ ].filter((item) => item.value.startsWith(prefix));
145
+ return items.length > 0 ? items : null;
146
+ },
147
+ handler: async (args, ctx) => {
148
+ switch (args.trim()) {
149
+ case '':
150
+ return showModelPicker(pi, ctx);
151
+ case 'add':
152
+ return addConfiguredModel(ctx, false);
153
+ case 'add-current':
154
+ return addConfiguredModel(ctx, true);
155
+ default:
156
+ ctx.ui.notify('Usage: /model-switch [add | add-current]', 'warning');
157
+ }
158
+ },
93
159
  });
94
160
  }
@@ -1,7 +1,9 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
+ import { loadModelSwitchConfig, modelCycleConfigPath } from './config.js';
6
+ import { SectionPicker } from './section-picker.js';
5
7
  import modelCycle from './index.js';
6
8
  const tempDirs = [];
7
9
  const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
@@ -34,6 +36,8 @@ function harness(options = {}) {
34
36
  const setModel = vi.fn(async () => options.switchResult ?? true);
35
37
  const notify = vi.fn();
36
38
  const custom = vi.fn(async () => options.customResult ?? null);
39
+ const select = vi.fn(async () => options.selectResult);
40
+ const input = vi.fn(async () => options.inputResult);
37
41
  const models = options.models ?? [];
38
42
  const byReference = new Map(models.map((item) => [`${item.provider}/${item.id}`, item]));
39
43
  const unauthenticated = new Set(options.unauthenticated ?? []);
@@ -49,7 +53,9 @@ function harness(options = {}) {
49
53
  const ctx = {
50
54
  model: options.current,
51
55
  hasUI: options.hasUI ?? true,
56
+ mode: options.mode ?? 'tui',
52
57
  modelRegistry: {
58
+ getAvailable: () => models.filter((item) => !unauthenticated.has(`${item.provider}/${item.id}`)),
53
59
  find(provider, modelId) {
54
60
  return byReference.get(`${provider}/${modelId}`);
55
61
  },
@@ -60,10 +66,10 @@ function harness(options = {}) {
60
66
  : { ok: true, apiKey: 'test' };
61
67
  },
62
68
  },
63
- ui: { notify, custom },
69
+ ui: { notify, custom, select, input },
64
70
  };
65
71
  modelCycle(pi);
66
- return { shortcuts, commands, setModel, notify, custom, ctx };
72
+ return { shortcuts, commands, setModel, notify, custom, select, input, ctx };
67
73
  }
68
74
  beforeEach(() => {
69
75
  delete process.env.PI_CODING_AGENT_DIR;
@@ -196,4 +202,158 @@ describe('model-switch extension', () => {
196
202
  await commands.get('model-switch')('', ctx);
197
203
  expect(notify).toHaveBeenCalledWith('Could not switch to provider/target', 'warning');
198
204
  });
205
+ it('picks from available models and appends to the chosen section without switching', async () => {
206
+ const target = model('provider', 'new');
207
+ writeConfig({ work: ['provider/old'], personal: [] });
208
+ const { commands, ctx, custom, select, notify, setModel } = harness({
209
+ models: [target, model('locked', 'hidden')],
210
+ unauthenticated: ['locked/hidden'],
211
+ customResult: 'provider/new',
212
+ selectResult: 'personal',
213
+ });
214
+ await commands.get('model-switch')('add', ctx);
215
+ expect(select).toHaveBeenCalledWith('Add provider/new to section', ['work', 'personal']);
216
+ expect(loadModelSwitchConfig()).toEqual({
217
+ ok: true,
218
+ config: {
219
+ sections: [
220
+ { name: 'work', models: ['provider/old'] },
221
+ { name: 'personal', models: ['provider/new'] },
222
+ ],
223
+ },
224
+ });
225
+ expect(setModel).not.toHaveBeenCalled();
226
+ expect(notify).toHaveBeenCalledWith('Added provider/new to "personal"', 'info');
227
+ // Exercise the actual picker factory, not just the mocked dialog result.
228
+ const factory = custom.mock.calls[0][0];
229
+ const done = vi.fn();
230
+ const picker = (await factory({}, { fg: (_color, text) => text, bold: (text) => text }, {}, done));
231
+ expect(picker.render(160).join('\n')).not.toContain('locked/hidden');
232
+ for (const key of 'new')
233
+ picker.handleInput(key);
234
+ picker.handleInput('\r');
235
+ expect(done).toHaveBeenCalledWith('provider/new');
236
+ });
237
+ it('saves the current model and skips a duplicate on the next invocation', async () => {
238
+ writeConfig({ work: [] });
239
+ const { commands, ctx, custom, notify, setModel } = harness({
240
+ current: model('provider', 'current'),
241
+ selectResult: 'work',
242
+ });
243
+ await commands.get('model-switch')('add-current', ctx);
244
+ await commands.get('model-switch')('add-current', ctx);
245
+ expect(custom).not.toHaveBeenCalled();
246
+ expect(setModel).not.toHaveBeenCalled();
247
+ expect(loadModelSwitchConfig()).toEqual({
248
+ ok: true,
249
+ config: { sections: [{ name: 'work', models: ['provider/current'] }] },
250
+ });
251
+ expect(notify).toHaveBeenLastCalledWith('provider/current is already in "work"', 'info');
252
+ });
253
+ it('creates a first section when no config exists', async () => {
254
+ const { commands, ctx, input } = harness({ current: model('provider', 'current'), inputResult: ' personal ' });
255
+ await commands.get('model-switch')('add-current', ctx);
256
+ expect(input).toHaveBeenCalled();
257
+ expect(loadModelSwitchConfig()).toEqual({
258
+ ok: true,
259
+ config: { sections: [{ name: 'personal', models: ['provider/current'] }] },
260
+ });
261
+ });
262
+ it.each(['add', 'add-current'])('does not write when the section selection is cancelled for %s', async (command) => {
263
+ writeConfig({ work: [] });
264
+ const content = readFileSync(modelCycleConfigPath(), 'utf8');
265
+ const target = model('provider', 'new');
266
+ const { commands, ctx, notify } = harness({ current: target, models: [target], customResult: 'provider/new' });
267
+ await commands.get('model-switch')(command, ctx);
268
+ expect(readFileSync(modelCycleConfigPath(), 'utf8')).toBe(content);
269
+ expect(notify).not.toHaveBeenCalled();
270
+ });
271
+ it('does not ask for a section or write when the catalog picker is cancelled', async () => {
272
+ const { commands, ctx, select, input } = harness({ models: [model('provider', 'new')] });
273
+ await commands.get('model-switch')('add', ctx);
274
+ expect(select).not.toHaveBeenCalled();
275
+ expect(input).not.toHaveBeenCalled();
276
+ expect(existsSync(modelCycleConfigPath())).toBe(false);
277
+ });
278
+ it.each([undefined, '', ' '])('does not create a config without a section name: %s', async (inputResult) => {
279
+ const { commands, ctx } = harness({ current: model('provider', 'new'), inputResult });
280
+ await commands.get('model-switch')('add-current', ctx);
281
+ expect(existsSync(modelCycleConfigPath())).toBe(false);
282
+ });
283
+ it.each(['add', 'add-current'])('does not prompt or write without UI for %s', async (command) => {
284
+ const target = model('provider', 'new');
285
+ const { commands, ctx, custom, select, input } = harness({
286
+ hasUI: false,
287
+ current: target,
288
+ models: [target],
289
+ customResult: 'provider/new',
290
+ inputResult: 'work',
291
+ });
292
+ await commands.get('model-switch')(command, ctx);
293
+ expect(custom).not.toHaveBeenCalled();
294
+ expect(select).not.toHaveBeenCalled();
295
+ expect(input).not.toHaveBeenCalled();
296
+ expect(existsSync(modelCycleConfigPath())).toBe(false);
297
+ });
298
+ it('warns when no current model or available catalog model exists', async () => {
299
+ const { commands, ctx, notify, custom, select } = harness();
300
+ await commands.get('model-switch')('add-current', ctx);
301
+ expect(notify).toHaveBeenLastCalledWith('No model selected to add', 'warning');
302
+ await commands.get('model-switch')('add', ctx);
303
+ expect(notify).toHaveBeenLastCalledWith(expect.stringContaining('No available models to add'), 'warning');
304
+ expect(custom).not.toHaveBeenCalled();
305
+ expect(select).not.toHaveBeenCalled();
306
+ expect(existsSync(modelCycleConfigPath())).toBe(false);
307
+ });
308
+ it('refuses custom catalog UI in RPC mode', async () => {
309
+ const { commands, ctx, notify, custom } = harness({ mode: 'rpc' });
310
+ await commands.get('model-switch')('add', ctx);
311
+ expect(custom).not.toHaveBeenCalled();
312
+ expect(notify).toHaveBeenCalledWith('/model-switch add requires the terminal UI', 'warning');
313
+ });
314
+ it.each(['add', 'add-current'])('refuses malformed config before prompting for %s', async (command) => {
315
+ writeConfig({ work: [] });
316
+ writeFileSync(modelCycleConfigPath(), '{ nope');
317
+ const { commands, ctx, notify, custom, select } = harness({ current: model('provider', 'new') });
318
+ await commands.get('model-switch')(command, ctx);
319
+ expect(custom).not.toHaveBeenCalled();
320
+ expect(select).not.toHaveBeenCalled();
321
+ expect(notify).toHaveBeenCalledWith(expect.stringContaining(modelCycleConfigPath()), 'warning');
322
+ expect(readFileSync(modelCycleConfigPath(), 'utf8')).toBe('{ nope');
323
+ });
324
+ it('retains edits made while the section prompt was open', async () => {
325
+ writeConfig({ work: [] });
326
+ const { commands, ctx, select } = harness({ current: model('provider', 'new') });
327
+ select.mockImplementation(async () => {
328
+ writeFileSync(modelCycleConfigPath(), JSON.stringify({ sections: { work: ['provider/edited'], personal: [] } }));
329
+ return 'work';
330
+ });
331
+ await commands.get('model-switch')('add-current', ctx);
332
+ expect(loadModelSwitchConfig()).toEqual({
333
+ ok: true,
334
+ config: {
335
+ sections: [
336
+ { name: 'work', models: ['provider/edited', 'provider/new'] },
337
+ { name: 'personal', models: [] },
338
+ ],
339
+ },
340
+ });
341
+ });
342
+ it('reports save errors instead of claiming the model was added', async () => {
343
+ writeConfig({ work: [] });
344
+ const { commands, ctx, select, notify } = harness({ current: model('provider', 'new') });
345
+ select.mockImplementation(async () => {
346
+ writeFileSync(modelCycleConfigPath(), '{ broken during selection');
347
+ return 'work';
348
+ });
349
+ await commands.get('model-switch')('add-current', ctx);
350
+ expect(notify).toHaveBeenCalledTimes(1);
351
+ expect(notify).toHaveBeenCalledWith(expect.stringContaining('Could not update model-switch config'), 'warning');
352
+ });
353
+ it('shows usage for unknown arguments without opening a picker', async () => {
354
+ const { commands, ctx, notify, custom } = harness();
355
+ await commands.get('model-switch')('typo', ctx);
356
+ expect(custom).not.toHaveBeenCalled();
357
+ expect(notify).toHaveBeenCalledWith('Usage: /model-switch [add | add-current]', 'warning');
358
+ });
199
359
  });
@@ -16,7 +16,10 @@ export declare class SectionPicker implements Component {
16
16
  private readonly selectTheme;
17
17
  constructor(sections: PickerSection[], theme: Theme, done: (value: string | null) => void);
18
18
  private renderTabBar;
19
+ private wireSelectList;
19
20
  private switchSection;
21
+ private filteredItems;
22
+ private rebuildList;
20
23
  render(width: number): string[];
21
24
  invalidate(): void;
22
25
  handleInput(data: string): void;
@@ -21,8 +21,7 @@ export class SectionPicker {
21
21
  noMatch: (text) => theme.fg('warning', text),
22
22
  };
23
23
  this.selectList = new SelectList(sections[0]?.items ?? [], 10, this.selectTheme);
24
- this.selectList.onSelect = (item) => this.done(item.value);
25
- this.selectList.onCancel = () => this.done(null);
24
+ this.wireSelectList();
26
25
  this.container.addChild(this.tabBar);
27
26
  this.container.addChild(this.searchInput);
28
27
  this.container.addChild(this.selectList);
@@ -37,12 +36,42 @@ export class SectionPicker {
37
36
  });
38
37
  this.tabBar.setText(parts.join(''));
39
38
  }
39
+ wireSelectList() {
40
+ this.selectList.onSelect = (item) => this.done(item.value);
41
+ this.selectList.onCancel = () => this.done(null);
42
+ }
40
43
  switchSection(direction) {
41
44
  this.activeSectionIndex = (this.activeSectionIndex + direction + this.sections.length) % this.sections.length;
42
45
  this.searchInput.setValue('');
43
- this.selectList = new SelectList(this.sections[this.activeSectionIndex]?.items ?? [], 10, this.selectTheme);
44
- this.selectList.onSelect = (item) => this.done(item.value);
45
- this.selectList.onCancel = () => this.done(null);
46
+ this.rebuildList();
47
+ }
48
+ filteredItems() {
49
+ const query = this.searchInput.getValue().trim().toLowerCase();
50
+ if (query.length === 0)
51
+ return this.sections[this.activeSectionIndex]?.items ?? [];
52
+ // SelectList.setFilter only matches value.startsWith(query), which can never
53
+ // match a model name inside "provider/modelId" — filter here instead, with a
54
+ // substring match across every section.
55
+ const seen = new Set();
56
+ const matches = [];
57
+ for (const section of this.sections) {
58
+ for (const item of section.items) {
59
+ if (seen.has(item.value) || !item.value.toLowerCase().includes(query))
60
+ continue;
61
+ seen.add(item.value);
62
+ matches.push({ ...item, description: section.name });
63
+ }
64
+ }
65
+ return matches;
66
+ }
67
+ rebuildList() {
68
+ // Size the primary column to the content so section descriptions never
69
+ // truncate long provider/modelId labels (SelectList caps it at 32 otherwise).
70
+ this.selectList = new SelectList(this.filteredItems(), 10, this.selectTheme, {
71
+ minPrimaryColumnWidth: 1,
72
+ maxPrimaryColumnWidth: Number.MAX_SAFE_INTEGER,
73
+ });
74
+ this.wireSelectList();
46
75
  this.container.clear();
47
76
  this.container.addChild(this.tabBar);
48
77
  this.container.addChild(this.searchInput);
@@ -69,7 +98,7 @@ export class SectionPicker {
69
98
  }
70
99
  else {
71
100
  this.searchInput.handleInput(data);
72
- this.selectList.setFilter(this.searchInput.getValue());
101
+ this.rebuildList();
73
102
  }
74
103
  }
75
104
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { SectionPicker } from './section-picker.js';
3
+ const theme = {
4
+ fg: (_color, text) => text,
5
+ bold: (text) => text,
6
+ };
7
+ function sections() {
8
+ return [
9
+ {
10
+ name: 'work',
11
+ items: [
12
+ { value: 'cloudflare-ai-gateway/gpt-5.6-sol', label: ' cloudflare-ai-gateway/gpt-5.6-sol' },
13
+ { value: 'cloudflare-ai-gateway/claude-opus-5', label: ' cloudflare-ai-gateway/claude-opus-5' },
14
+ {
15
+ value: 'fireworks/accounts/fireworks/models/kimi-k3',
16
+ label: ' fireworks/accounts/fireworks/models/kimi-k3',
17
+ },
18
+ ],
19
+ },
20
+ {
21
+ name: 'personal',
22
+ items: [
23
+ {
24
+ value: 'fireworks/accounts/fireworks/models/kimi-k3',
25
+ label: ' fireworks/accounts/fireworks/models/kimi-k3',
26
+ },
27
+ ],
28
+ },
29
+ ];
30
+ }
31
+ function render(picker) {
32
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI escape codes
33
+ return picker
34
+ .render(240)
35
+ .join('\n')
36
+ .replace(/\x1b\[[0-9;]*m/g, '');
37
+ }
38
+ function type(picker, text) {
39
+ for (const char of text)
40
+ picker.handleInput(char);
41
+ }
42
+ describe('SectionPicker filtering', () => {
43
+ it('matches a model name substring inside provider/modelId', () => {
44
+ const picker = new SectionPicker(sections(), theme, vi.fn());
45
+ type(picker, 'claude');
46
+ const output = render(picker);
47
+ expect(output).toContain('cloudflare-ai-gateway/claude-opus-5');
48
+ expect(output).not.toContain('gpt-5.6-sol');
49
+ expect(output).not.toContain('kimi-k3');
50
+ });
51
+ it('searches across all sections and dedupes models present in multiple sections', () => {
52
+ const picker = new SectionPicker(sections(), theme, vi.fn());
53
+ type(picker, 'kimi');
54
+ const output = render(picker);
55
+ const occurrences = output.split('kimi-k3').length - 1;
56
+ expect(occurrences).toBe(1);
57
+ });
58
+ it('shows only the active section when the query is empty', () => {
59
+ const picker = new SectionPicker(sections(), theme, vi.fn());
60
+ type(picker, 'claude');
61
+ type(picker, '\x7f'.repeat(7)); // backspace away the query
62
+ const output = render(picker);
63
+ expect(output).toContain('cloudflare-ai-gateway/claude-opus-5');
64
+ expect(output).toContain('cloudflare-ai-gateway/gpt-5.6-sol');
65
+ });
66
+ it('confirms the highlighted filtered match on enter', () => {
67
+ const done = vi.fn();
68
+ const picker = new SectionPicker(sections(), theme, done);
69
+ type(picker, 'kimi');
70
+ picker.handleInput('\r');
71
+ expect(done).toHaveBeenCalledWith('fireworks/accounts/fireworks/models/kimi-k3');
72
+ });
73
+ });
package/index.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  import type { Api, Model } from '@earendil-works/pi-ai';
2
2
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
3
3
  import type { SelectItem } from '@earendil-works/pi-tui';
4
- import { loadModelSwitchConfig, loadModelSwitchKeybindings, modelCycleConfigPath } from './config.js';
4
+ import {
5
+ addModelToSection,
6
+ loadModelSwitchConfig,
7
+ loadModelSwitchKeybindings,
8
+ modelCycleConfigPath,
9
+ } from './config.js';
5
10
  import { findActiveSection, resolveAvailableModels, selectCycleTarget, type CycleDirection } from './cycle.js';
6
11
  import { SectionPicker, type PickerSection } from './section-picker.js';
7
12
 
@@ -88,6 +93,60 @@ async function showModelPicker(pi: ExtensionAPI, ctx: ExtensionContext): Promise
88
93
  if (target) await switchModel(pi, ctx, target);
89
94
  }
90
95
 
96
+ async function addConfiguredModel(ctx: ExtensionContext, useCurrent: boolean): Promise<void> {
97
+ if (!ctx.hasUI) return;
98
+ if (!useCurrent && ctx.mode !== 'tui') {
99
+ ctx.ui.notify('/model-switch add requires the terminal UI', 'warning');
100
+ return;
101
+ }
102
+
103
+ const loaded = loadModelSwitchConfig();
104
+ if (!loaded.ok) {
105
+ ctx.ui.notify(loaded.error, 'warning');
106
+ return;
107
+ }
108
+
109
+ let target = ctx.model;
110
+ if (!useCurrent) {
111
+ const available = ctx.modelRegistry.getAvailable();
112
+ if (available.length === 0) {
113
+ ctx.ui.notify('No available models to add. Configure a provider with /login first.', 'warning');
114
+ return;
115
+ }
116
+ const selected = await ctx.ui.custom<string | null>((_tui, theme, _keybindings, done) => {
117
+ return new SectionPicker([{ name: 'Add a model', items: buildSectionItems(available, ctx.model) }], theme, done);
118
+ });
119
+ if (!selected) return;
120
+ target = available.find((model) => `${model.provider}/${model.id}` === selected);
121
+ }
122
+ if (!target) {
123
+ ctx.ui.notify('No model selected to add', 'warning');
124
+ return;
125
+ }
126
+
127
+ const reference = `${target.provider}/${target.id}`;
128
+ const sections = loaded.config.sections.map((section) => section.name);
129
+ const sectionName =
130
+ sections.length > 0
131
+ ? await ctx.ui.select(`Add ${reference} to section`, sections)
132
+ : (await ctx.ui.input('Name your first model-switch section', 'models'))?.trim();
133
+ if (sectionName === undefined) return;
134
+ if (sections.length === 0 && !sectionName) {
135
+ ctx.ui.notify('Section name must not be empty', 'warning');
136
+ return;
137
+ }
138
+
139
+ const result = addModelToSection(reference, sectionName);
140
+ if (!result.ok) {
141
+ ctx.ui.notify(result.error, 'warning');
142
+ return;
143
+ }
144
+ ctx.ui.notify(
145
+ result.added ? `Added ${reference} to "${sectionName}"` : `${reference} is already in "${sectionName}"`,
146
+ 'info',
147
+ );
148
+ }
149
+
91
150
  export default function modelCycle(pi: ExtensionAPI) {
92
151
  const keybindings = loadModelSwitchKeybindings();
93
152
  type ShortcutKey = Parameters<ExtensionAPI['registerShortcut']>[0];
@@ -108,7 +167,25 @@ export default function modelCycle(pi: ExtensionAPI) {
108
167
  });
109
168
 
110
169
  pi.registerCommand('model-switch', {
111
- description: 'Select from configured models',
112
- handler: async (_args, ctx) => showModelPicker(pi, ctx),
170
+ description: 'Select configured models, add a model, or add-current',
171
+ getArgumentCompletions: (prefix) => {
172
+ const items = [
173
+ { value: 'add', label: 'add', description: 'Pick an available model to save' },
174
+ { value: 'add-current', label: 'add-current', description: 'Save the current model' },
175
+ ].filter((item) => item.value.startsWith(prefix));
176
+ return items.length > 0 ? items : null;
177
+ },
178
+ handler: async (args, ctx) => {
179
+ switch (args.trim()) {
180
+ case '':
181
+ return showModelPicker(pi, ctx);
182
+ case 'add':
183
+ return addConfiguredModel(ctx, false);
184
+ case 'add-current':
185
+ return addConfiguredModel(ctx, true);
186
+ default:
187
+ ctx.ui.notify('Usage: /model-switch [add | add-current]', 'warning');
188
+ }
189
+ },
113
190
  });
114
191
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nicknisi/pi-model-switch",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Cycle or fuzzy-pick from a machine-local, sectioned list of preferred Pi models",
5
5
  "keywords": [
6
6
  "pi",
package/section-picker.ts CHANGED
@@ -38,8 +38,7 @@ export class SectionPicker implements Component {
38
38
  };
39
39
 
40
40
  this.selectList = new SelectList(sections[0]?.items ?? [], 10, this.selectTheme);
41
- this.selectList.onSelect = (item) => this.done(item.value);
42
- this.selectList.onCancel = () => this.done(null);
41
+ this.wireSelectList();
43
42
 
44
43
  this.container.addChild(this.tabBar);
45
44
  this.container.addChild(this.searchInput);
@@ -57,12 +56,44 @@ export class SectionPicker implements Component {
57
56
  this.tabBar.setText(parts.join(''));
58
57
  }
59
58
 
59
+ private wireSelectList(): void {
60
+ this.selectList.onSelect = (item) => this.done(item.value);
61
+ this.selectList.onCancel = () => this.done(null);
62
+ }
63
+
60
64
  private switchSection(direction: 1 | -1): void {
61
65
  this.activeSectionIndex = (this.activeSectionIndex + direction + this.sections.length) % this.sections.length;
62
66
  this.searchInput.setValue('');
63
- this.selectList = new SelectList(this.sections[this.activeSectionIndex]?.items ?? [], 10, this.selectTheme);
64
- this.selectList.onSelect = (item) => this.done(item.value);
65
- this.selectList.onCancel = () => this.done(null);
67
+ this.rebuildList();
68
+ }
69
+
70
+ private filteredItems(): SelectItem[] {
71
+ const query = this.searchInput.getValue().trim().toLowerCase();
72
+ if (query.length === 0) return this.sections[this.activeSectionIndex]?.items ?? [];
73
+
74
+ // SelectList.setFilter only matches value.startsWith(query), which can never
75
+ // match a model name inside "provider/modelId" — filter here instead, with a
76
+ // substring match across every section.
77
+ const seen = new Set<string>();
78
+ const matches: SelectItem[] = [];
79
+ for (const section of this.sections) {
80
+ for (const item of section.items) {
81
+ if (seen.has(item.value) || !item.value.toLowerCase().includes(query)) continue;
82
+ seen.add(item.value);
83
+ matches.push({ ...item, description: section.name });
84
+ }
85
+ }
86
+ return matches;
87
+ }
88
+
89
+ private rebuildList(): void {
90
+ // Size the primary column to the content so section descriptions never
91
+ // truncate long provider/modelId labels (SelectList caps it at 32 otherwise).
92
+ this.selectList = new SelectList(this.filteredItems(), 10, this.selectTheme, {
93
+ minPrimaryColumnWidth: 1,
94
+ maxPrimaryColumnWidth: Number.MAX_SAFE_INTEGER,
95
+ });
96
+ this.wireSelectList();
66
97
  this.container.clear();
67
98
  this.container.addChild(this.tabBar);
68
99
  this.container.addChild(this.searchInput);
@@ -94,7 +125,7 @@ export class SectionPicker implements Component {
94
125
  this.selectList.handleInput(data);
95
126
  } else {
96
127
  this.searchInput.handleInput(data);
97
- this.selectList.setFilter(this.searchInput.getValue());
128
+ this.rebuildList();
98
129
  }
99
130
  }
100
131
  }