@nicknisi/pi-model-switch 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config.d.ts +24 -0
- package/dist/config.js +97 -0
- package/dist/config.test.d.ts +1 -0
- package/dist/config.test.js +113 -0
- package/dist/cycle.d.ts +22 -0
- package/dist/cycle.js +48 -0
- package/dist/cycle.test.d.ts +1 -0
- package/dist/cycle.test.js +96 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +94 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +199 -0
- package/dist/section-picker.d.ts +26 -0
- package/dist/section-picker.js +104 -0
- package/dist/section-picker.test.d.ts +1 -0
- package/dist/section-picker.test.js +73 -0
- package/package.json +1 -1
- package/section-picker.ts +37 -6
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface ModelSwitchSection {
|
|
2
|
+
name: string;
|
|
3
|
+
models: string[];
|
|
4
|
+
}
|
|
5
|
+
export interface ModelSwitchConfig {
|
|
6
|
+
sections: ModelSwitchSection[];
|
|
7
|
+
}
|
|
8
|
+
export interface ModelSwitchKeybindings {
|
|
9
|
+
forward: string;
|
|
10
|
+
backward: string;
|
|
11
|
+
select: string;
|
|
12
|
+
}
|
|
13
|
+
export declare const DEFAULT_MODEL_CYCLE_KEYBINDINGS: ModelSwitchKeybindings;
|
|
14
|
+
export type ConfigLoadResult = {
|
|
15
|
+
ok: true;
|
|
16
|
+
config: ModelSwitchConfig;
|
|
17
|
+
} | {
|
|
18
|
+
ok: false;
|
|
19
|
+
error: string;
|
|
20
|
+
};
|
|
21
|
+
export declare function modelCycleConfigPath(): string;
|
|
22
|
+
export declare function modelCycleKeybindingsPath(): string;
|
|
23
|
+
export declare function loadModelSwitchKeybindings(path?: string): ModelSwitchKeybindings;
|
|
24
|
+
export declare function loadModelSwitchConfig(path?: string): ConfigLoadResult;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { getAgentDir } from '@earendil-works/pi-coding-agent';
|
|
4
|
+
export const DEFAULT_MODEL_CYCLE_KEYBINDINGS = {
|
|
5
|
+
forward: 'ctrl+shift+m',
|
|
6
|
+
backward: 'ctrl+shift+alt+m',
|
|
7
|
+
select: 'ctrl+shift+l',
|
|
8
|
+
};
|
|
9
|
+
const FORWARD_KEYBINDING = 'model-switch.cycleForward';
|
|
10
|
+
const BACKWARD_KEYBINDING = 'model-switch.cycleBackward';
|
|
11
|
+
const SELECT_KEYBINDING = 'model-switch.select';
|
|
12
|
+
export function modelCycleConfigPath() {
|
|
13
|
+
return join(getAgentDir(), 'configs', 'model-switch.json');
|
|
14
|
+
}
|
|
15
|
+
export function modelCycleKeybindingsPath() {
|
|
16
|
+
return join(getAgentDir(), 'keybindings.json');
|
|
17
|
+
}
|
|
18
|
+
export function loadModelSwitchKeybindings(path = modelCycleKeybindingsPath()) {
|
|
19
|
+
if (!existsSync(path))
|
|
20
|
+
return { ...DEFAULT_MODEL_CYCLE_KEYBINDINGS };
|
|
21
|
+
let value;
|
|
22
|
+
try {
|
|
23
|
+
value = JSON.parse(readFileSync(path, 'utf8'));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return { ...DEFAULT_MODEL_CYCLE_KEYBINDINGS };
|
|
27
|
+
}
|
|
28
|
+
if (!value || typeof value !== 'object')
|
|
29
|
+
return { ...DEFAULT_MODEL_CYCLE_KEYBINDINGS };
|
|
30
|
+
const bindings = value;
|
|
31
|
+
const forward = bindings[FORWARD_KEYBINDING];
|
|
32
|
+
const backward = bindings[BACKWARD_KEYBINDING];
|
|
33
|
+
const select = bindings[SELECT_KEYBINDING];
|
|
34
|
+
return {
|
|
35
|
+
forward: typeof forward === 'string' && forward.trim().length > 0
|
|
36
|
+
? forward.trim()
|
|
37
|
+
: DEFAULT_MODEL_CYCLE_KEYBINDINGS.forward,
|
|
38
|
+
backward: typeof backward === 'string' && backward.trim().length > 0
|
|
39
|
+
? backward.trim()
|
|
40
|
+
: DEFAULT_MODEL_CYCLE_KEYBINDINGS.backward,
|
|
41
|
+
select: typeof select === 'string' && select.trim().length > 0 ? select.trim() : DEFAULT_MODEL_CYCLE_KEYBINDINGS.select,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function validateModelStrings(raw, path, context) {
|
|
45
|
+
if (!Array.isArray(raw)) {
|
|
46
|
+
return { error: `Invalid model-switch config at ${path}: expected "${context}" to be a string[]` };
|
|
47
|
+
}
|
|
48
|
+
if (raw.some((model) => typeof model !== 'string' || model.trim().length === 0)) {
|
|
49
|
+
return { error: `Invalid model-switch config at ${path}: every model in "${context}" must be a non-empty string` };
|
|
50
|
+
}
|
|
51
|
+
return raw.map((model) => model.trim());
|
|
52
|
+
}
|
|
53
|
+
export function loadModelSwitchConfig(path = modelCycleConfigPath()) {
|
|
54
|
+
if (!existsSync(path)) {
|
|
55
|
+
return { ok: true, config: { sections: [] } };
|
|
56
|
+
}
|
|
57
|
+
let value;
|
|
58
|
+
try {
|
|
59
|
+
value = JSON.parse(readFileSync(path, 'utf8'));
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
63
|
+
return { ok: false, error: `Invalid model-switch config at ${path}: ${message}` };
|
|
64
|
+
}
|
|
65
|
+
if (!value || typeof value !== 'object') {
|
|
66
|
+
return { ok: false, error: `Invalid model-switch config at ${path}: expected an object` };
|
|
67
|
+
}
|
|
68
|
+
const obj = value;
|
|
69
|
+
// Prefer "sections" if present; fall back to legacy "models" as a single section.
|
|
70
|
+
if ('sections' in obj && obj.sections && typeof obj.sections === 'object') {
|
|
71
|
+
const sectionsRaw = obj.sections;
|
|
72
|
+
const sections = [];
|
|
73
|
+
for (const [name, modelsRaw] of Object.entries(sectionsRaw)) {
|
|
74
|
+
const result = validateModelStrings(modelsRaw, path, `sections.${name}`);
|
|
75
|
+
if (!Array.isArray(result))
|
|
76
|
+
return { ok: false, error: result.error };
|
|
77
|
+
sections.push({ name, models: result });
|
|
78
|
+
}
|
|
79
|
+
if (sections.length === 0) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
error: `Invalid model-switch config at ${path}: "sections" must define at least one section`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return { ok: true, config: { sections } };
|
|
86
|
+
}
|
|
87
|
+
if ('models' in obj) {
|
|
88
|
+
const result = validateModelStrings(obj.models, path, 'models');
|
|
89
|
+
if (!Array.isArray(result))
|
|
90
|
+
return { ok: false, error: result.error };
|
|
91
|
+
return { ok: true, config: { sections: [{ name: 'models', models: result }] } };
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
error: `Invalid model-switch config at ${path}: expected { "sections": { ... } } or { "models": [...] }`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { DEFAULT_MODEL_CYCLE_KEYBINDINGS, loadModelSwitchConfig, loadModelSwitchKeybindings } from './config.js';
|
|
6
|
+
const tempDirs = [];
|
|
7
|
+
function tempConfig(content) {
|
|
8
|
+
const dir = mkdtempSync(join(tmpdir(), 'pi-model-switch-'));
|
|
9
|
+
tempDirs.push(dir);
|
|
10
|
+
const path = join(dir, 'model-switch.json');
|
|
11
|
+
if (content !== undefined)
|
|
12
|
+
writeFileSync(path, content);
|
|
13
|
+
return path;
|
|
14
|
+
}
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
for (const dir of tempDirs.splice(0)) {
|
|
17
|
+
rmSync(dir, { recursive: true, force: true });
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
describe('loadModelSwitchKeybindings', () => {
|
|
21
|
+
it('uses defaults when keybindings.json is missing or malformed', () => {
|
|
22
|
+
expect(loadModelSwitchKeybindings(tempConfig())).toEqual(DEFAULT_MODEL_CYCLE_KEYBINDINGS);
|
|
23
|
+
expect(loadModelSwitchKeybindings(tempConfig('{ nope'))).toEqual(DEFAULT_MODEL_CYCLE_KEYBINDINGS);
|
|
24
|
+
});
|
|
25
|
+
it('loads extension-owned bindings from keybindings.json', () => {
|
|
26
|
+
const result = loadModelSwitchKeybindings(tempConfig(JSON.stringify({
|
|
27
|
+
'model-switch.cycleForward': 'ctrl+alt+n',
|
|
28
|
+
'model-switch.cycleBackward': 'ctrl+alt+b',
|
|
29
|
+
'model-switch.select': 'ctrl+alt+l',
|
|
30
|
+
'app.model.select': 'ctrl+l',
|
|
31
|
+
})));
|
|
32
|
+
expect(result).toEqual({
|
|
33
|
+
forward: 'ctrl+alt+n',
|
|
34
|
+
backward: 'ctrl+alt+b',
|
|
35
|
+
select: 'ctrl+alt+l',
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
it('falls back per binding when extension-owned values are absent or invalid', () => {
|
|
39
|
+
const result = loadModelSwitchKeybindings(tempConfig(JSON.stringify({
|
|
40
|
+
'model-switch.cycleForward': [],
|
|
41
|
+
'model-switch.cycleBackward': ' ctrl+alt+b ',
|
|
42
|
+
})));
|
|
43
|
+
expect(result).toEqual({
|
|
44
|
+
forward: DEFAULT_MODEL_CYCLE_KEYBINDINGS.forward,
|
|
45
|
+
backward: 'ctrl+alt+b',
|
|
46
|
+
select: DEFAULT_MODEL_CYCLE_KEYBINDINGS.select,
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
describe('loadModelSwitchConfig', () => {
|
|
51
|
+
it('returns empty sections when the config is missing', () => {
|
|
52
|
+
const result = loadModelSwitchConfig(tempConfig());
|
|
53
|
+
expect(result).toEqual({ ok: true, config: { sections: [] } });
|
|
54
|
+
});
|
|
55
|
+
it('loads named sections in order', () => {
|
|
56
|
+
const result = loadModelSwitchConfig(tempConfig(JSON.stringify({
|
|
57
|
+
sections: {
|
|
58
|
+
work: ['cloudflare-ai-gateway/grok-4.5', ' fireworks/.../kimi-k3 '],
|
|
59
|
+
personal: ['fireworks/.../kimi-k3'],
|
|
60
|
+
},
|
|
61
|
+
})));
|
|
62
|
+
expect(result).toEqual({
|
|
63
|
+
ok: true,
|
|
64
|
+
config: {
|
|
65
|
+
sections: [
|
|
66
|
+
{ name: 'work', models: ['cloudflare-ai-gateway/grok-4.5', 'fireworks/.../kimi-k3'] },
|
|
67
|
+
{ name: 'personal', models: ['fireworks/.../kimi-k3'] },
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
it('falls back to legacy flat models as a single section', () => {
|
|
73
|
+
const result = loadModelSwitchConfig(tempConfig(JSON.stringify({ models: ['cloudflare-ai-gateway/grok-4.5'] })));
|
|
74
|
+
expect(result).toEqual({
|
|
75
|
+
ok: true,
|
|
76
|
+
config: { sections: [{ name: 'models', models: ['cloudflare-ai-gateway/grok-4.5'] }] },
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
it('prefers sections when both sections and models are present', () => {
|
|
80
|
+
const result = loadModelSwitchConfig(tempConfig(JSON.stringify({
|
|
81
|
+
sections: { work: ['provider/model-a'] },
|
|
82
|
+
models: ['provider/model-b'],
|
|
83
|
+
})));
|
|
84
|
+
expect(result.ok).toBe(true);
|
|
85
|
+
if (result.ok)
|
|
86
|
+
expect(result.config.sections).toEqual([{ name: 'work', models: ['provider/model-a'] }]);
|
|
87
|
+
});
|
|
88
|
+
it('reports malformed JSON with the config path', () => {
|
|
89
|
+
const path = tempConfig('{ nope');
|
|
90
|
+
const result = loadModelSwitchConfig(path);
|
|
91
|
+
expect(result.ok).toBe(false);
|
|
92
|
+
if (!result.ok) {
|
|
93
|
+
expect(result.error).toContain(path);
|
|
94
|
+
expect(result.error).toContain('Invalid model-switch config');
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
it.each([
|
|
98
|
+
['non-object input', '[]'],
|
|
99
|
+
['missing sections and models', '{}'],
|
|
100
|
+
['non-object sections', JSON.stringify({ sections: 'nope' })],
|
|
101
|
+
['empty sections object', JSON.stringify({ sections: {} })],
|
|
102
|
+
['non-array section models', JSON.stringify({ sections: { work: 'nope' } })],
|
|
103
|
+
['non-string model in section', JSON.stringify({ sections: { work: [42] } })],
|
|
104
|
+
['empty model in section', JSON.stringify({ sections: { work: [' '] } })],
|
|
105
|
+
['non-array legacy models', JSON.stringify({ models: 'grok-4.5' })],
|
|
106
|
+
])('rejects %s', (_label, content) => {
|
|
107
|
+
const path = tempConfig(content);
|
|
108
|
+
const result = loadModelSwitchConfig(path);
|
|
109
|
+
expect(result.ok).toBe(false);
|
|
110
|
+
if (!result.ok)
|
|
111
|
+
expect(result.error).toContain(path);
|
|
112
|
+
});
|
|
113
|
+
});
|
package/dist/cycle.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Api, Model } from '@earendil-works/pi-ai';
|
|
2
|
+
import type { ModelSwitchSection } from './config.js';
|
|
3
|
+
export type CycleDirection = 'forward' | 'backward';
|
|
4
|
+
export interface ModelReference {
|
|
5
|
+
provider: string;
|
|
6
|
+
modelId: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ModelRegistryLike {
|
|
9
|
+
find(provider: string, modelId: string): Model<Api> | undefined;
|
|
10
|
+
getApiKeyAndHeaders(model: Model<Api>): Promise<{
|
|
11
|
+
ok: true;
|
|
12
|
+
apiKey?: string;
|
|
13
|
+
headers?: Record<string, string | null>;
|
|
14
|
+
} | {
|
|
15
|
+
ok: false;
|
|
16
|
+
error: string;
|
|
17
|
+
}>;
|
|
18
|
+
}
|
|
19
|
+
export declare function parseModelReference(value: string): ModelReference | undefined;
|
|
20
|
+
export declare function resolveAvailableModels(references: readonly string[], registry: ModelRegistryLike): Promise<Model<Api>[]>;
|
|
21
|
+
export declare function findActiveSection(sections: readonly ModelSwitchSection[], current: Model<Api> | undefined): ModelSwitchSection | undefined;
|
|
22
|
+
export declare function selectCycleTarget(current: Model<Api> | undefined, available: readonly Model<Api>[], direction: CycleDirection): Model<Api> | undefined;
|
package/dist/cycle.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export function parseModelReference(value) {
|
|
2
|
+
const separator = value.indexOf('/');
|
|
3
|
+
if (separator <= 0 || separator === value.length - 1)
|
|
4
|
+
return undefined;
|
|
5
|
+
return {
|
|
6
|
+
provider: value.slice(0, separator),
|
|
7
|
+
modelId: value.slice(separator + 1),
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export async function resolveAvailableModels(references, registry) {
|
|
11
|
+
const available = [];
|
|
12
|
+
for (const value of references) {
|
|
13
|
+
const reference = parseModelReference(value);
|
|
14
|
+
if (!reference)
|
|
15
|
+
continue;
|
|
16
|
+
const model = registry.find(reference.provider, reference.modelId);
|
|
17
|
+
if (!model)
|
|
18
|
+
continue;
|
|
19
|
+
const auth = await registry.getApiKeyAndHeaders(model);
|
|
20
|
+
if (auth.ok)
|
|
21
|
+
available.push(model);
|
|
22
|
+
}
|
|
23
|
+
return available;
|
|
24
|
+
}
|
|
25
|
+
export function findActiveSection(sections, current) {
|
|
26
|
+
if (sections.length === 0)
|
|
27
|
+
return undefined;
|
|
28
|
+
if (!current)
|
|
29
|
+
return sections[0];
|
|
30
|
+
const found = sections.find((section) => section.models.some((value) => {
|
|
31
|
+
const ref = parseModelReference(value);
|
|
32
|
+
return ref?.provider === current.provider && ref?.modelId === current.id;
|
|
33
|
+
}));
|
|
34
|
+
return found ?? sections[0];
|
|
35
|
+
}
|
|
36
|
+
export function selectCycleTarget(current, available, direction) {
|
|
37
|
+
if (available.length === 0)
|
|
38
|
+
return undefined;
|
|
39
|
+
const currentIndex = current
|
|
40
|
+
? available.findIndex((model) => model.provider === current.provider && model.id === current.id)
|
|
41
|
+
: -1;
|
|
42
|
+
if (currentIndex === -1) {
|
|
43
|
+
return direction === 'forward' ? available[0] : available[available.length - 1];
|
|
44
|
+
}
|
|
45
|
+
const offset = direction === 'forward' ? 1 : -1;
|
|
46
|
+
const nextIndex = (currentIndex + offset + available.length) % available.length;
|
|
47
|
+
return available[nextIndex];
|
|
48
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { findActiveSection, parseModelReference, resolveAvailableModels, selectCycleTarget, } from './cycle.js';
|
|
3
|
+
function model(provider, id) {
|
|
4
|
+
return { provider, id };
|
|
5
|
+
}
|
|
6
|
+
function registry(models, unauthenticated = []) {
|
|
7
|
+
const byReference = new Map(models.map((item) => [`${item.provider}/${item.id}`, item]));
|
|
8
|
+
const unavailable = new Set(unauthenticated);
|
|
9
|
+
return {
|
|
10
|
+
find(provider, modelId) {
|
|
11
|
+
return byReference.get(`${provider}/${modelId}`);
|
|
12
|
+
},
|
|
13
|
+
async getApiKeyAndHeaders(item) {
|
|
14
|
+
const reference = `${item.provider}/${item.id}`;
|
|
15
|
+
return unavailable.has(reference)
|
|
16
|
+
? { ok: false, error: 'not authenticated' }
|
|
17
|
+
: { ok: true, apiKey: 'test' };
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
describe('parseModelReference', () => {
|
|
22
|
+
it('keeps slashes inside the model id', () => {
|
|
23
|
+
expect(parseModelReference('fireworks/accounts/fireworks/models/kimi-k3')).toEqual({
|
|
24
|
+
provider: 'fireworks',
|
|
25
|
+
modelId: 'accounts/fireworks/models/kimi-k3',
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
it.each(['missing-provider', '/missing-provider', 'missing-model/'])('rejects %s', (value) => {
|
|
29
|
+
expect(parseModelReference(value)).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
describe('resolveAvailableModels', () => {
|
|
33
|
+
it('preserves configured order while skipping invalid, missing, and unauthenticated models', async () => {
|
|
34
|
+
const grok = model('cloudflare-ai-gateway', 'grok-4.5');
|
|
35
|
+
const kimi = model('fireworks', 'accounts/fireworks/models/kimi-k3');
|
|
36
|
+
const deepseek = model('fireworks', 'accounts/fireworks/models/deepseek-v4-pro');
|
|
37
|
+
const result = await resolveAvailableModels([
|
|
38
|
+
'fireworks/accounts/fireworks/models/kimi-k3',
|
|
39
|
+
'invalid',
|
|
40
|
+
'missing/nope',
|
|
41
|
+
'cloudflare-ai-gateway/grok-4.5',
|
|
42
|
+
'fireworks/accounts/fireworks/models/deepseek-v4-pro',
|
|
43
|
+
], registry([grok, kimi, deepseek], ['fireworks/accounts/fireworks/models/deepseek-v4-pro']));
|
|
44
|
+
expect(result).toEqual([kimi, grok]);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
describe('findActiveSection', () => {
|
|
48
|
+
const workSection = {
|
|
49
|
+
name: 'work',
|
|
50
|
+
models: ['provider/work-a', 'provider/work-b'],
|
|
51
|
+
};
|
|
52
|
+
const personalSection = {
|
|
53
|
+
name: 'personal',
|
|
54
|
+
models: ['provider/personal-a'],
|
|
55
|
+
};
|
|
56
|
+
const sections = [workSection, personalSection];
|
|
57
|
+
it('returns undefined for empty sections', () => {
|
|
58
|
+
expect(findActiveSection([], model('provider', 'x'))).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
it('returns the first section when there is no current model', () => {
|
|
61
|
+
expect(findActiveSection(sections, undefined)).toBe(workSection);
|
|
62
|
+
});
|
|
63
|
+
it('returns the section containing the current model', () => {
|
|
64
|
+
expect(findActiveSection(sections, model('provider', 'work-b'))).toBe(workSection);
|
|
65
|
+
expect(findActiveSection(sections, model('provider', 'personal-a'))).toBe(personalSection);
|
|
66
|
+
});
|
|
67
|
+
it('falls back to the first section when the current model is not in any section', () => {
|
|
68
|
+
expect(findActiveSection(sections, model('other', 'outside'))).toBe(workSection);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
describe('selectCycleTarget', () => {
|
|
72
|
+
const first = model('provider', 'first');
|
|
73
|
+
const second = model('provider', 'second');
|
|
74
|
+
const third = model('provider', 'third');
|
|
75
|
+
const available = [first, second, third];
|
|
76
|
+
it('returns undefined for an empty list', () => {
|
|
77
|
+
expect(selectCycleTarget(first, [], 'forward')).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
it('returns the only model in either direction', () => {
|
|
80
|
+
expect(selectCycleTarget(first, [first], 'forward')).toBe(first);
|
|
81
|
+
expect(selectCycleTarget(first, [first], 'backward')).toBe(first);
|
|
82
|
+
});
|
|
83
|
+
it('enters at the directional boundary when current is outside the list', () => {
|
|
84
|
+
const outside = model('other', 'outside');
|
|
85
|
+
expect(selectCycleTarget(outside, available, 'forward')).toBe(first);
|
|
86
|
+
expect(selectCycleTarget(outside, available, 'backward')).toBe(third);
|
|
87
|
+
expect(selectCycleTarget(undefined, available, 'forward')).toBe(first);
|
|
88
|
+
expect(selectCycleTarget(undefined, available, 'backward')).toBe(third);
|
|
89
|
+
});
|
|
90
|
+
it('moves in both directions and wraps at each boundary', () => {
|
|
91
|
+
expect(selectCycleTarget(first, available, 'forward')).toBe(second);
|
|
92
|
+
expect(selectCycleTarget(second, available, 'backward')).toBe(first);
|
|
93
|
+
expect(selectCycleTarget(third, available, 'forward')).toBe(first);
|
|
94
|
+
expect(selectCycleTarget(first, available, 'backward')).toBe(third);
|
|
95
|
+
});
|
|
96
|
+
});
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { loadModelSwitchConfig, loadModelSwitchKeybindings, modelCycleConfigPath } from './config.js';
|
|
2
|
+
import { findActiveSection, resolveAvailableModels, selectCycleTarget } from './cycle.js';
|
|
3
|
+
import { SectionPicker } from './section-picker.js';
|
|
4
|
+
async function resolveSectionModels(references, ctx) {
|
|
5
|
+
return resolveAvailableModels(references, ctx.modelRegistry);
|
|
6
|
+
}
|
|
7
|
+
async function switchModel(pi, ctx, target) {
|
|
8
|
+
if (!(await pi.setModel(target))) {
|
|
9
|
+
ctx.ui.notify(`Could not switch to ${target.provider}/${target.id}`, 'warning');
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
async function cycleConfiguredModel(pi, ctx, direction) {
|
|
13
|
+
const loaded = loadModelSwitchConfig();
|
|
14
|
+
if (!loaded.ok) {
|
|
15
|
+
ctx.ui.notify(loaded.error, 'warning');
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const activeSection = findActiveSection(loaded.config.sections, ctx.model);
|
|
19
|
+
if (!activeSection) {
|
|
20
|
+
ctx.ui.notify(`No configured models are available in ${modelCycleConfigPath()}`, 'warning');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const available = await resolveSectionModels(activeSection.models, ctx);
|
|
24
|
+
if (available.length === 0) {
|
|
25
|
+
ctx.ui.notify(`No usable models in section "${activeSection.name}" (${modelCycleConfigPath()})`, 'warning');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const target = selectCycleTarget(ctx.model, available, direction);
|
|
29
|
+
if (target)
|
|
30
|
+
await switchModel(pi, ctx, target);
|
|
31
|
+
}
|
|
32
|
+
function buildSectionItems(models, current) {
|
|
33
|
+
return models.map((model) => {
|
|
34
|
+
const isCurrent = model.provider === current?.provider && model.id === current.id;
|
|
35
|
+
return {
|
|
36
|
+
value: `${model.provider}/${model.id}`,
|
|
37
|
+
label: `${isCurrent ? '●' : ' '} ${model.provider}/${model.id}`,
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
async function showModelPicker(pi, ctx) {
|
|
42
|
+
if (!ctx.hasUI)
|
|
43
|
+
return;
|
|
44
|
+
const loaded = loadModelSwitchConfig();
|
|
45
|
+
if (!loaded.ok) {
|
|
46
|
+
ctx.ui.notify(loaded.error, 'warning');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const pickerSections = [];
|
|
50
|
+
const modelByReference = new Map();
|
|
51
|
+
for (const section of loaded.config.sections) {
|
|
52
|
+
const available = await resolveSectionModels(section.models, ctx);
|
|
53
|
+
if (available.length > 0) {
|
|
54
|
+
pickerSections.push({
|
|
55
|
+
name: section.name,
|
|
56
|
+
items: buildSectionItems(available, ctx.model),
|
|
57
|
+
});
|
|
58
|
+
for (const model of available) {
|
|
59
|
+
modelByReference.set(`${model.provider}/${model.id}`, model);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (pickerSections.length === 0) {
|
|
64
|
+
ctx.ui.notify(`No configured models are available in ${modelCycleConfigPath()}`, 'warning');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const selected = await ctx.ui.custom((tui, theme, _keybindings, done) => {
|
|
68
|
+
return new SectionPicker(pickerSections, theme, done);
|
|
69
|
+
});
|
|
70
|
+
if (!selected)
|
|
71
|
+
return;
|
|
72
|
+
const target = modelByReference.get(selected);
|
|
73
|
+
if (target)
|
|
74
|
+
await switchModel(pi, ctx, target);
|
|
75
|
+
}
|
|
76
|
+
export default function modelCycle(pi) {
|
|
77
|
+
const keybindings = loadModelSwitchKeybindings();
|
|
78
|
+
pi.registerShortcut(keybindings.forward, {
|
|
79
|
+
description: 'Cycle configured models forward',
|
|
80
|
+
handler: async (ctx) => cycleConfiguredModel(pi, ctx, 'forward'),
|
|
81
|
+
});
|
|
82
|
+
pi.registerShortcut(keybindings.backward, {
|
|
83
|
+
description: 'Cycle configured models backward',
|
|
84
|
+
handler: async (ctx) => cycleConfiguredModel(pi, ctx, 'backward'),
|
|
85
|
+
});
|
|
86
|
+
pi.registerShortcut(keybindings.select, {
|
|
87
|
+
description: 'Select a configured model',
|
|
88
|
+
handler: async (ctx) => showModelPicker(pi, ctx),
|
|
89
|
+
});
|
|
90
|
+
pi.registerCommand('model-switch', {
|
|
91
|
+
description: 'Select from configured models',
|
|
92
|
+
handler: async (_args, ctx) => showModelPicker(pi, ctx),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import modelCycle from './index.js';
|
|
6
|
+
const tempDirs = [];
|
|
7
|
+
const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
8
|
+
function model(provider, id) {
|
|
9
|
+
return { provider, id };
|
|
10
|
+
}
|
|
11
|
+
function tempAgentDir() {
|
|
12
|
+
const dir = mkdtempSync(join(tmpdir(), 'pi-model-switch-handler-'));
|
|
13
|
+
tempDirs.push(dir);
|
|
14
|
+
process.env.PI_CODING_AGENT_DIR = dir;
|
|
15
|
+
return dir;
|
|
16
|
+
}
|
|
17
|
+
function writeConfig(sections) {
|
|
18
|
+
const dir = tempAgentDir();
|
|
19
|
+
const configDir = join(dir, 'configs');
|
|
20
|
+
mkdirSync(configDir);
|
|
21
|
+
writeFileSync(join(configDir, 'model-switch.json'), JSON.stringify({ sections }));
|
|
22
|
+
return dir;
|
|
23
|
+
}
|
|
24
|
+
function writeKeybindings(value) {
|
|
25
|
+
const dir = tempAgentDir();
|
|
26
|
+
writeFileSync(join(dir, 'keybindings.json'), JSON.stringify(value));
|
|
27
|
+
return dir;
|
|
28
|
+
}
|
|
29
|
+
function harness(options = {}) {
|
|
30
|
+
if (!process.env.PI_CODING_AGENT_DIR)
|
|
31
|
+
tempAgentDir();
|
|
32
|
+
const shortcuts = new Map();
|
|
33
|
+
const commands = new Map();
|
|
34
|
+
const setModel = vi.fn(async () => options.switchResult ?? true);
|
|
35
|
+
const notify = vi.fn();
|
|
36
|
+
const custom = vi.fn(async () => options.customResult ?? null);
|
|
37
|
+
const models = options.models ?? [];
|
|
38
|
+
const byReference = new Map(models.map((item) => [`${item.provider}/${item.id}`, item]));
|
|
39
|
+
const unauthenticated = new Set(options.unauthenticated ?? []);
|
|
40
|
+
const pi = {
|
|
41
|
+
registerShortcut(key, shortcut) {
|
|
42
|
+
shortcuts.set(key, shortcut.handler);
|
|
43
|
+
},
|
|
44
|
+
registerCommand(name, command) {
|
|
45
|
+
commands.set(name, command.handler);
|
|
46
|
+
},
|
|
47
|
+
setModel,
|
|
48
|
+
};
|
|
49
|
+
const ctx = {
|
|
50
|
+
model: options.current,
|
|
51
|
+
hasUI: options.hasUI ?? true,
|
|
52
|
+
modelRegistry: {
|
|
53
|
+
find(provider, modelId) {
|
|
54
|
+
return byReference.get(`${provider}/${modelId}`);
|
|
55
|
+
},
|
|
56
|
+
async getApiKeyAndHeaders(item) {
|
|
57
|
+
const reference = `${item.provider}/${item.id}`;
|
|
58
|
+
return unauthenticated.has(reference)
|
|
59
|
+
? { ok: false, error: 'not authenticated' }
|
|
60
|
+
: { ok: true, apiKey: 'test' };
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
ui: { notify, custom },
|
|
64
|
+
};
|
|
65
|
+
modelCycle(pi);
|
|
66
|
+
return { shortcuts, commands, setModel, notify, custom, ctx };
|
|
67
|
+
}
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
delete process.env.PI_CODING_AGENT_DIR;
|
|
70
|
+
});
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
if (originalAgentDir === undefined)
|
|
73
|
+
delete process.env.PI_CODING_AGENT_DIR;
|
|
74
|
+
else
|
|
75
|
+
process.env.PI_CODING_AGENT_DIR = originalAgentDir;
|
|
76
|
+
for (const dir of tempDirs.splice(0)) {
|
|
77
|
+
rmSync(dir, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
describe('model-switch extension', () => {
|
|
81
|
+
it('registers default cycle and picker shortcuts plus the picker command', () => {
|
|
82
|
+
const { shortcuts, commands } = harness();
|
|
83
|
+
expect([...shortcuts.keys()]).toEqual(['ctrl+shift+m', 'ctrl+shift+alt+m', 'ctrl+shift+l']);
|
|
84
|
+
expect([...commands.keys()]).toEqual(['model-switch']);
|
|
85
|
+
});
|
|
86
|
+
it('registers extension-owned shortcuts from keybindings.json', () => {
|
|
87
|
+
writeKeybindings({
|
|
88
|
+
'model-switch.cycleForward': 'ctrl+alt+n',
|
|
89
|
+
'model-switch.cycleBackward': 'ctrl+alt+b',
|
|
90
|
+
'model-switch.select': 'ctrl+alt+l',
|
|
91
|
+
});
|
|
92
|
+
const { shortcuts } = harness();
|
|
93
|
+
expect([...shortcuts.keys()]).toEqual(['ctrl+alt+n', 'ctrl+alt+b', 'ctrl+alt+l']);
|
|
94
|
+
});
|
|
95
|
+
it('cycles within the section containing the current model', async () => {
|
|
96
|
+
const current = model('provider', 'work-a');
|
|
97
|
+
const next = model('provider', 'work-b');
|
|
98
|
+
const personalModel = model('provider', 'personal-a');
|
|
99
|
+
writeConfig({
|
|
100
|
+
work: ['provider/work-a', 'provider/work-b'],
|
|
101
|
+
personal: ['provider/personal-a'],
|
|
102
|
+
});
|
|
103
|
+
const { shortcuts, setModel, ctx } = harness({ current, models: [current, next, personalModel] });
|
|
104
|
+
await shortcuts.get('ctrl+shift+m')(ctx);
|
|
105
|
+
expect(setModel).toHaveBeenCalledWith(next);
|
|
106
|
+
expect(setModel).not.toHaveBeenCalledWith(personalModel);
|
|
107
|
+
});
|
|
108
|
+
it('enters the first section at the boundary when current is outside all sections', async () => {
|
|
109
|
+
const outside = model('other', 'outside');
|
|
110
|
+
const first = model('provider', 'work-a');
|
|
111
|
+
const last = model('provider', 'work-b');
|
|
112
|
+
writeConfig({ work: ['provider/work-a', 'provider/work-b'] });
|
|
113
|
+
const { shortcuts, setModel, ctx } = harness({ current: outside, models: [first, last] });
|
|
114
|
+
await shortcuts.get('ctrl+shift+m')(ctx);
|
|
115
|
+
expect(setModel).toHaveBeenLastCalledWith(first);
|
|
116
|
+
await shortcuts.get('ctrl+shift+alt+m')(ctx);
|
|
117
|
+
expect(setModel).toHaveBeenLastCalledWith(last);
|
|
118
|
+
});
|
|
119
|
+
it('skips unavailable models before switching', async () => {
|
|
120
|
+
const first = model('provider', 'first');
|
|
121
|
+
const second = model('provider', 'second');
|
|
122
|
+
writeConfig({ work: ['provider/first', 'provider/second'] });
|
|
123
|
+
const { shortcuts, setModel, ctx } = harness({
|
|
124
|
+
models: [first, second],
|
|
125
|
+
unauthenticated: ['provider/first'],
|
|
126
|
+
});
|
|
127
|
+
await shortcuts.get('ctrl+shift+m')(ctx);
|
|
128
|
+
expect(setModel).toHaveBeenCalledWith(second);
|
|
129
|
+
});
|
|
130
|
+
it('warns when cycling through a section with no usable models', async () => {
|
|
131
|
+
writeConfig({ work: ['missing/model'] });
|
|
132
|
+
const { shortcuts, setModel, notify, ctx } = harness();
|
|
133
|
+
await shortcuts.get('ctrl+shift+m')(ctx);
|
|
134
|
+
expect(setModel).not.toHaveBeenCalled();
|
|
135
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining('No usable models in section "work"'), 'warning');
|
|
136
|
+
});
|
|
137
|
+
it('warns for invalid config or empty sections', async () => {
|
|
138
|
+
const dir = tempAgentDir();
|
|
139
|
+
const configDir = join(dir, 'configs');
|
|
140
|
+
mkdirSync(configDir);
|
|
141
|
+
writeFileSync(join(configDir, 'model-switch.json'), '{ nope');
|
|
142
|
+
const invalid = harness();
|
|
143
|
+
await invalid.shortcuts.get('ctrl+shift+m')(invalid.ctx);
|
|
144
|
+
expect(invalid.setModel).not.toHaveBeenCalled();
|
|
145
|
+
expect(invalid.notify).toHaveBeenCalledWith(expect.stringContaining(join(dir, 'configs', 'model-switch.json')), 'warning');
|
|
146
|
+
writeConfig({});
|
|
147
|
+
const empty = harness();
|
|
148
|
+
await empty.shortcuts.get('ctrl+shift+m')(empty.ctx);
|
|
149
|
+
expect(empty.setModel).not.toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
it('opens the fuzzy picker via command and switches the selected model', async () => {
|
|
152
|
+
const target = model('provider', 'target');
|
|
153
|
+
writeConfig({ work: ['provider/target'] });
|
|
154
|
+
const { commands, setModel, custom, ctx } = harness({
|
|
155
|
+
models: [target],
|
|
156
|
+
customResult: 'provider/target',
|
|
157
|
+
});
|
|
158
|
+
await commands.get('model-switch')('', ctx);
|
|
159
|
+
expect(custom).toHaveBeenCalledTimes(1);
|
|
160
|
+
expect(setModel).toHaveBeenCalledWith(target);
|
|
161
|
+
});
|
|
162
|
+
it('opens the fuzzy picker via the configured select shortcut', async () => {
|
|
163
|
+
const target = model('provider', 'target');
|
|
164
|
+
writeConfig({ work: ['provider/target'] });
|
|
165
|
+
const { shortcuts, custom, ctx } = harness({ models: [target], customResult: 'provider/target' });
|
|
166
|
+
await shortcuts.get('ctrl+shift+l')(ctx);
|
|
167
|
+
expect(custom).toHaveBeenCalledTimes(1);
|
|
168
|
+
});
|
|
169
|
+
it('does not switch when the picker is cancelled or UI is unavailable', async () => {
|
|
170
|
+
const target = model('provider', 'target');
|
|
171
|
+
writeConfig({ work: ['provider/target'] });
|
|
172
|
+
const cancelled = harness({ models: [target], customResult: null });
|
|
173
|
+
await cancelled.commands.get('model-switch')('', cancelled.ctx);
|
|
174
|
+
expect(cancelled.setModel).not.toHaveBeenCalled();
|
|
175
|
+
const noUi = harness({ models: [target], hasUI: false, customResult: 'provider/target' });
|
|
176
|
+
await noUi.commands.get('model-switch')('', noUi.ctx);
|
|
177
|
+
expect(noUi.custom).not.toHaveBeenCalled();
|
|
178
|
+
expect(noUi.setModel).not.toHaveBeenCalled();
|
|
179
|
+
});
|
|
180
|
+
it('warns when the picker has no usable models across all sections', async () => {
|
|
181
|
+
writeConfig({ work: ['missing/model'], personal: ['also/missing'] });
|
|
182
|
+
const { commands, setModel, notify, custom, ctx } = harness();
|
|
183
|
+
await commands.get('model-switch')('', ctx);
|
|
184
|
+
expect(custom).not.toHaveBeenCalled();
|
|
185
|
+
expect(setModel).not.toHaveBeenCalled();
|
|
186
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining('No configured models are available'), 'warning');
|
|
187
|
+
});
|
|
188
|
+
it('warns when Pi rejects a picker selection', async () => {
|
|
189
|
+
const target = model('provider', 'target');
|
|
190
|
+
writeConfig({ work: ['provider/target'] });
|
|
191
|
+
const { commands, notify, ctx } = harness({
|
|
192
|
+
models: [target],
|
|
193
|
+
customResult: 'provider/target',
|
|
194
|
+
switchResult: false,
|
|
195
|
+
});
|
|
196
|
+
await commands.get('model-switch')('', ctx);
|
|
197
|
+
expect(notify).toHaveBeenCalledWith('Could not switch to provider/target', 'warning');
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type Component, type SelectItem } from '@earendil-works/pi-tui';
|
|
2
|
+
import type { Theme } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
export interface PickerSection {
|
|
4
|
+
name: string;
|
|
5
|
+
items: SelectItem[];
|
|
6
|
+
}
|
|
7
|
+
export declare class SectionPicker implements Component {
|
|
8
|
+
private readonly sections;
|
|
9
|
+
private readonly theme;
|
|
10
|
+
private readonly done;
|
|
11
|
+
private container;
|
|
12
|
+
private tabBar;
|
|
13
|
+
private searchInput;
|
|
14
|
+
private selectList;
|
|
15
|
+
private activeSectionIndex;
|
|
16
|
+
private readonly selectTheme;
|
|
17
|
+
constructor(sections: PickerSection[], theme: Theme, done: (value: string | null) => void);
|
|
18
|
+
private renderTabBar;
|
|
19
|
+
private wireSelectList;
|
|
20
|
+
private switchSection;
|
|
21
|
+
private filteredItems;
|
|
22
|
+
private rebuildList;
|
|
23
|
+
render(width: number): string[];
|
|
24
|
+
invalidate(): void;
|
|
25
|
+
handleInput(data: string): void;
|
|
26
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Container, Input, SelectList, Text, getKeybindings, matchesKey, } from '@earendil-works/pi-tui';
|
|
2
|
+
export class SectionPicker {
|
|
3
|
+
sections;
|
|
4
|
+
theme;
|
|
5
|
+
done;
|
|
6
|
+
container = new Container();
|
|
7
|
+
tabBar = new Text('', 0, 0);
|
|
8
|
+
searchInput = new Input();
|
|
9
|
+
selectList;
|
|
10
|
+
activeSectionIndex = 0;
|
|
11
|
+
selectTheme;
|
|
12
|
+
constructor(sections, theme, done) {
|
|
13
|
+
this.sections = sections;
|
|
14
|
+
this.theme = theme;
|
|
15
|
+
this.done = done;
|
|
16
|
+
this.selectTheme = {
|
|
17
|
+
selectedPrefix: (text) => theme.fg('accent', text),
|
|
18
|
+
selectedText: (text) => theme.fg('accent', text),
|
|
19
|
+
description: (text) => theme.fg('muted', text),
|
|
20
|
+
scrollInfo: (text) => theme.fg('dim', text),
|
|
21
|
+
noMatch: (text) => theme.fg('warning', text),
|
|
22
|
+
};
|
|
23
|
+
this.selectList = new SelectList(sections[0]?.items ?? [], 10, this.selectTheme);
|
|
24
|
+
this.wireSelectList();
|
|
25
|
+
this.container.addChild(this.tabBar);
|
|
26
|
+
this.container.addChild(this.searchInput);
|
|
27
|
+
this.container.addChild(this.selectList);
|
|
28
|
+
this.renderTabBar();
|
|
29
|
+
}
|
|
30
|
+
renderTabBar() {
|
|
31
|
+
const parts = this.sections.map((section, index) => {
|
|
32
|
+
const label = section.name;
|
|
33
|
+
return index === this.activeSectionIndex
|
|
34
|
+
? this.theme.fg('accent', this.theme.bold(`[${label}]`))
|
|
35
|
+
: this.theme.fg('dim', ` ${label} `);
|
|
36
|
+
});
|
|
37
|
+
this.tabBar.setText(parts.join(''));
|
|
38
|
+
}
|
|
39
|
+
wireSelectList() {
|
|
40
|
+
this.selectList.onSelect = (item) => this.done(item.value);
|
|
41
|
+
this.selectList.onCancel = () => this.done(null);
|
|
42
|
+
}
|
|
43
|
+
switchSection(direction) {
|
|
44
|
+
this.activeSectionIndex = (this.activeSectionIndex + direction + this.sections.length) % this.sections.length;
|
|
45
|
+
this.searchInput.setValue('');
|
|
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();
|
|
75
|
+
this.container.clear();
|
|
76
|
+
this.container.addChild(this.tabBar);
|
|
77
|
+
this.container.addChild(this.searchInput);
|
|
78
|
+
this.container.addChild(this.selectList);
|
|
79
|
+
this.renderTabBar();
|
|
80
|
+
}
|
|
81
|
+
render(width) {
|
|
82
|
+
return this.container.render(width);
|
|
83
|
+
}
|
|
84
|
+
invalidate() {
|
|
85
|
+
this.container.invalidate();
|
|
86
|
+
}
|
|
87
|
+
handleInput(data) {
|
|
88
|
+
if (matchesKey(data, 'tab')) {
|
|
89
|
+
this.switchSection(1);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const kb = getKeybindings();
|
|
93
|
+
if (kb.matches(data, 'tui.select.up') ||
|
|
94
|
+
kb.matches(data, 'tui.select.down') ||
|
|
95
|
+
kb.matches(data, 'tui.select.confirm') ||
|
|
96
|
+
kb.matches(data, 'tui.select.cancel')) {
|
|
97
|
+
this.selectList.handleInput(data);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
this.searchInput.handleInput(data);
|
|
101
|
+
this.rebuildList();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
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/package.json
CHANGED
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.
|
|
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.
|
|
64
|
-
|
|
65
|
-
|
|
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.
|
|
128
|
+
this.rebuildList();
|
|
98
129
|
}
|
|
99
130
|
}
|
|
100
131
|
}
|