@0xkahi/pi-qol 0.0.1 → 0.0.2
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 +42 -12
- package/assets/config.schema.json +61 -2
- package/package.json +1 -1
- package/src/config-loader.ts +4 -4
- package/src/constants.ts +17 -0
- package/src/extensions/auto-session-name/index.ts +1 -1
- package/src/extensions/model-select/constants.ts +8 -0
- package/src/extensions/model-select/index.ts +119 -0
- package/src/extensions/model-select/model-formatter.ts +60 -0
- package/src/extensions/model-select/model-lists.ts +63 -0
- package/src/extensions/model-select/model-select-dialog.ts +340 -0
- package/src/extensions/model-select/types.ts +31 -0
- package/src/index.ts +2 -0
- package/src/schemas/auto-session-name.config.schema.ts +2 -2
- package/src/schemas/config.schema.ts +8 -0
- package/src/schemas/model-select.config.schema.ts +18 -0
- package/src/schemas/shared-config.schema.ts +2 -2
- package/src/types.ts +1 -0
package/README.md
CHANGED
|
@@ -3,8 +3,19 @@
|
|
|
3
3
|
> Quality-of-life extensions for [pi](https://github.com/earendil-works/pi).
|
|
4
4
|
|
|
5
5
|
A small collection of opt-in extensions that smooth out everyday pi usage. Today
|
|
6
|
-
it ships **`auto_session_name`** — automatic, opencode-style session titling —
|
|
7
|
-
|
|
6
|
+
it ships **`auto_session_name`** — automatic, opencode-style session titling — and
|
|
7
|
+
**`model_select`** — an interactive model picker.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
Install the package from npm:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pi install npm:@0xkahi/pi-qol
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Then register it in your pi configuration so pi loads `./src/index.ts` as an
|
|
18
|
+
extension (see the `pi.extensions` field in `package.json`).
|
|
8
19
|
|
|
9
20
|
## Features
|
|
10
21
|
|
|
@@ -23,16 +34,16 @@ never blocks your turn.
|
|
|
23
34
|
- **Safe output** — strips `<think>` blocks, unwraps quotes, and truncates titles
|
|
24
35
|
to 100 characters.
|
|
25
36
|
|
|
26
|
-
|
|
37
|
+
### `model_select`
|
|
27
38
|
|
|
28
|
-
|
|
39
|
+
Adds `/select-model`, an interactive model picker with fuzzy search, optional favourites,
|
|
40
|
+
provider filtering, and inline or overlay layouts,
|
|
29
41
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
42
|
+
- **quick access** to listed favourite models
|
|
43
|
+
- **filtering** specified providers from model search
|
|
44
|
+
|
|
45
|
+
allow keybindings with [pi-vim-keys](https://github.com/0xKahi/pi-vim-keys) with eventId of `pi.vimKeys.event:pi-qol.model_select`
|
|
33
46
|
|
|
34
|
-
Then register it in your pi configuration so pi loads `./src/index.ts` as an
|
|
35
|
-
extension (see the `pi.extensions` field in `package.json`).
|
|
36
47
|
|
|
37
48
|
## Configuration
|
|
38
49
|
|
|
@@ -44,6 +55,7 @@ them (project overrides global):
|
|
|
44
55
|
| Global | `<agent-dir>/extensions/pi-qol/config.json` |
|
|
45
56
|
| Project | `<cwd>/.pi/extensions/pi-qol/config.json` (trusted projects only) |
|
|
46
57
|
|
|
58
|
+
|
|
47
59
|
> Project config is only loaded when the project is trusted.
|
|
48
60
|
|
|
49
61
|
### Example
|
|
@@ -54,10 +66,16 @@ them (project overrides global):
|
|
|
54
66
|
"auto_session_name": {
|
|
55
67
|
"enabled": true,
|
|
56
68
|
"model": {
|
|
57
|
-
"provider": "
|
|
58
|
-
"modelId": "
|
|
59
|
-
"reasoning": "
|
|
69
|
+
"provider": "opencode",
|
|
70
|
+
"modelId": "gpt-5-nano",
|
|
71
|
+
"reasoning": "low"
|
|
60
72
|
}
|
|
73
|
+
},
|
|
74
|
+
"model_select": {
|
|
75
|
+
"enabled": true,
|
|
76
|
+
"layout": "overlay",
|
|
77
|
+
"provider_filter": ["anthropic", "openai"],
|
|
78
|
+
"favourite": [{ "provider": "anthropic", "modelId": "claude-sonnet-4-5" }]
|
|
61
79
|
}
|
|
62
80
|
}
|
|
63
81
|
```
|
|
@@ -84,6 +102,18 @@ them (project overrides global):
|
|
|
84
102
|
When `model` is set but cannot be found or authenticated, `pi-qol` automatically
|
|
85
103
|
falls back to the active session model and surfaces a warning notification.
|
|
86
104
|
|
|
105
|
+
#### `model_select`
|
|
106
|
+
|
|
107
|
+
| Key | Type | Default | Description |
|
|
108
|
+
| ----------------- | --------- | -------- | --------------------------------------------------------- |
|
|
109
|
+
| `enabled` | `boolean` | `false` | Turn the model picker on. |
|
|
110
|
+
| `layout` | `enum` | `inline` | Picker layout: `inline` or `overlay`. |
|
|
111
|
+
| `provider_filter` | `string[]`| `[]` | Restrict searchable models to these providers. |
|
|
112
|
+
| `favourite` | `object[]`| `[]` | Favourite models shown in a separate tab (`provider`, `modelId`). |
|
|
113
|
+
|
|
114
|
+
Project config shallow-merges each top-level section over global config. If project
|
|
115
|
+
config sets `model_select.favourite`, it replaces the global favourites array.
|
|
116
|
+
|
|
87
117
|
## How it works
|
|
88
118
|
|
|
89
119
|
On `before_agent_start`, the `auto_session_name` extension checks a series of
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
3
|
"$id": "https://raw.githubusercontent.com/0xKahi/pi-qol/main/assets/config.schema.json",
|
|
4
|
-
"title": "Pi
|
|
4
|
+
"title": "Pi Quality of Life Extensions Configuration",
|
|
5
5
|
"description": "Configuration schema for pi-qol extension",
|
|
6
6
|
"type": "object",
|
|
7
7
|
"properties": {
|
|
@@ -51,10 +51,69 @@
|
|
|
51
51
|
"enabled"
|
|
52
52
|
],
|
|
53
53
|
"additionalProperties": false
|
|
54
|
+
},
|
|
55
|
+
"model_select": {
|
|
56
|
+
"default": {
|
|
57
|
+
"enabled": false,
|
|
58
|
+
"favourite": [],
|
|
59
|
+
"provider_filter": [],
|
|
60
|
+
"layout": "inline"
|
|
61
|
+
},
|
|
62
|
+
"type": "object",
|
|
63
|
+
"properties": {
|
|
64
|
+
"enabled": {
|
|
65
|
+
"default": false,
|
|
66
|
+
"type": "boolean"
|
|
67
|
+
},
|
|
68
|
+
"favourite": {
|
|
69
|
+
"default": [],
|
|
70
|
+
"type": "array",
|
|
71
|
+
"items": {
|
|
72
|
+
"type": "object",
|
|
73
|
+
"properties": {
|
|
74
|
+
"provider": {
|
|
75
|
+
"type": "string"
|
|
76
|
+
},
|
|
77
|
+
"modelId": {
|
|
78
|
+
"type": "string"
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
"required": [
|
|
82
|
+
"provider",
|
|
83
|
+
"modelId"
|
|
84
|
+
],
|
|
85
|
+
"additionalProperties": false
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
"provider_filter": {
|
|
89
|
+
"default": [],
|
|
90
|
+
"type": "array",
|
|
91
|
+
"items": {
|
|
92
|
+
"type": "string",
|
|
93
|
+
"minLength": 1
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
"layout": {
|
|
97
|
+
"default": "inline",
|
|
98
|
+
"type": "string",
|
|
99
|
+
"enum": [
|
|
100
|
+
"inline",
|
|
101
|
+
"overlay"
|
|
102
|
+
]
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
"required": [
|
|
106
|
+
"enabled",
|
|
107
|
+
"favourite",
|
|
108
|
+
"provider_filter",
|
|
109
|
+
"layout"
|
|
110
|
+
],
|
|
111
|
+
"additionalProperties": false
|
|
54
112
|
}
|
|
55
113
|
},
|
|
56
114
|
"required": [
|
|
57
|
-
"auto_session_name"
|
|
115
|
+
"auto_session_name",
|
|
116
|
+
"model_select"
|
|
58
117
|
],
|
|
59
118
|
"additionalProperties": false
|
|
60
119
|
}
|
package/package.json
CHANGED
package/src/config-loader.ts
CHANGED
|
@@ -11,10 +11,6 @@ export class ConfigLoader {
|
|
|
11
11
|
return ConfigSchema.parse({});
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
getConfig(): Config {
|
|
15
|
-
return this.config;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
14
|
isEnabled(key: keyof Config): boolean {
|
|
19
15
|
const section = this.config[key];
|
|
20
16
|
return typeof section === 'object' && section !== null && 'enabled' in section ? Boolean((section as { enabled?: boolean }).enabled) : false;
|
|
@@ -24,6 +20,10 @@ export class ConfigLoader {
|
|
|
24
20
|
return this.config.auto_session_name;
|
|
25
21
|
}
|
|
26
22
|
|
|
23
|
+
getModelSelect(): Config['model_select'] {
|
|
24
|
+
return this.config.model_select;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
27
|
initializeConfig(ctx: ExtensionContext): { success: boolean; error?: string } {
|
|
28
28
|
let merged: Config = this.defaultConfig;
|
|
29
29
|
|
package/src/constants.ts
CHANGED
|
@@ -1 +1,18 @@
|
|
|
1
|
+
import type { ObjectValues } from './types';
|
|
2
|
+
|
|
1
3
|
export const EXTENSION_ID = 'pi-qol';
|
|
4
|
+
|
|
5
|
+
const SUB_EXTENSION_IDS = {
|
|
6
|
+
auto_session_name: 'auto_session_name',
|
|
7
|
+
model_select: 'model_select',
|
|
8
|
+
} as const;
|
|
9
|
+
|
|
10
|
+
export type SubExtentionIds = ObjectValues<typeof SUB_EXTENSION_IDS>;
|
|
11
|
+
|
|
12
|
+
export const piVimKeyEventId = (type: SubExtentionIds, extra: string[] = []) => {
|
|
13
|
+
let id = `pi.vimKeys.event:${EXTENSION_ID}.${type}`;
|
|
14
|
+
extra.forEach(val => {
|
|
15
|
+
id = `${id}.${val}`;
|
|
16
|
+
});
|
|
17
|
+
return id;
|
|
18
|
+
};
|
|
@@ -66,7 +66,7 @@ export function registerAutoSessionName(pi: ExtensionAPI, deps: { config: Config
|
|
|
66
66
|
return;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
ctx.ui.notify(`(pi-qol)
|
|
69
|
+
ctx.ui.notify(`(pi-qol) session renamed : ${result.text} `, 'info');
|
|
70
70
|
})().catch(err => {
|
|
71
71
|
ctx.ui.notify(`(pi-qol) auto_session_name: ${JSON.stringify(err)}`, 'error');
|
|
72
72
|
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { piVimKeyEventId } from '../../constants';
|
|
2
|
+
|
|
3
|
+
export const COMMAND_NAME = 'select-model';
|
|
4
|
+
export const MAX_VISIBLE_MODELS = 10;
|
|
5
|
+
export const MAX_CONFIG_WARNING_LINES = 4;
|
|
6
|
+
|
|
7
|
+
// Cross-extension activation hook (for example, pi-vim-keys can emit this to open the picker).
|
|
8
|
+
export const PI_VIM_KEY_EVENT_ID = piVimKeyEventId('model_select');
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { Api, Model } from '@earendil-works/pi-ai';
|
|
2
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
import type { ConfigLoader } from '../../config-loader';
|
|
4
|
+
import { COMMAND_NAME, PI_VIM_KEY_EVENT_ID } from './constants';
|
|
5
|
+
import { ModelFormatter } from './model-formatter';
|
|
6
|
+
import { buildModelLists, findExactModel } from './model-lists';
|
|
7
|
+
import { ModelSelectDialog } from './model-select-dialog';
|
|
8
|
+
import type { DialogResult } from './types';
|
|
9
|
+
|
|
10
|
+
async function applySelectedModel(pi: ExtensionAPI, ctx: ExtensionContext, model: Model<Api>): Promise<void> {
|
|
11
|
+
const success = await pi.setModel(model);
|
|
12
|
+
if (success) {
|
|
13
|
+
ctx.ui.notify(`Model set to ${ModelFormatter.modelLabel(model)}`, 'info');
|
|
14
|
+
} else {
|
|
15
|
+
ctx.ui.notify(`No configured auth for ${ModelFormatter.modelLabel(model)}`, 'error');
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function showModelSelector(pi: ExtensionAPI, args: string, ctx: ExtensionContext, configLoader: ConfigLoader): Promise<void> {
|
|
20
|
+
// `waitForIdle` only exists on command contexts. When invoked from the event
|
|
21
|
+
// bus we get a plain ExtensionContext, so fall back to a best-effort guard.
|
|
22
|
+
if ('waitForIdle' in ctx && typeof ctx.waitForIdle === 'function') {
|
|
23
|
+
await (ctx as ExtensionCommandContext).waitForIdle();
|
|
24
|
+
}
|
|
25
|
+
ctx.modelRegistry.refresh();
|
|
26
|
+
|
|
27
|
+
const exactModel = findExactModel(ctx, args);
|
|
28
|
+
if (exactModel) {
|
|
29
|
+
await applySelectedModel(pi, ctx, exactModel);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!ctx.hasUI) {
|
|
34
|
+
ctx.ui.notify('The /select-model picker requires an interactive UI. Pass provider/modelId to select directly.', 'warning');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const config = configLoader.getModelSelect();
|
|
39
|
+
const modelLists = await buildModelLists(ctx, config);
|
|
40
|
+
const registryError = ctx.modelRegistry.getError();
|
|
41
|
+
|
|
42
|
+
const selected = await ctx.ui.custom<DialogResult>(
|
|
43
|
+
(tui, theme, keybindings, done) =>
|
|
44
|
+
new ModelSelectDialog(tui, theme, keybindings, {
|
|
45
|
+
currentModel: ctx.model,
|
|
46
|
+
favouriteItems: modelLists.favouriteItems,
|
|
47
|
+
favouriteWarnings: modelLists.favouriteWarnings,
|
|
48
|
+
hasFavouriteSection: config.favourite.length > 0,
|
|
49
|
+
searchItems: modelLists.searchItems,
|
|
50
|
+
providerFilter: config.provider_filter,
|
|
51
|
+
configWarnings: registryError ? [`models.json: ${registryError}`] : [],
|
|
52
|
+
initialSearch: args.trim(),
|
|
53
|
+
layout: config.layout,
|
|
54
|
+
onDone: done,
|
|
55
|
+
}),
|
|
56
|
+
config.layout === 'overlay'
|
|
57
|
+
? {
|
|
58
|
+
overlay: true,
|
|
59
|
+
overlayOptions: {
|
|
60
|
+
anchor: 'center',
|
|
61
|
+
width: '85%',
|
|
62
|
+
margin: 1,
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
: undefined,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
if (selected) {
|
|
69
|
+
await applySelectedModel(pi, ctx, selected);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function activateModelSelect(pi: ExtensionAPI, deps: { config: ConfigLoader; initialCtx?: ExtensionContext }) {
|
|
74
|
+
// Keep a reference to the latest context so the event-bus handler (which gets
|
|
75
|
+
// no context of its own) can open the modal too. Lazy activation may happen
|
|
76
|
+
// during session_start, so seed this with the context that triggered it.
|
|
77
|
+
let latestCtx: ExtensionContext | undefined = deps.initialCtx;
|
|
78
|
+
|
|
79
|
+
pi.on('session_start', (_event, ctx) => {
|
|
80
|
+
latestCtx = ctx;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
pi.registerCommand(COMMAND_NAME, {
|
|
84
|
+
description: 'Select/search models with favourites and provider filtering',
|
|
85
|
+
handler: async (args, ctx) => {
|
|
86
|
+
latestCtx = ctx;
|
|
87
|
+
if (!deps.config.isEnabled('model_select')) {
|
|
88
|
+
ctx.ui.notify('(pi-qol) model_select is disabled', 'warning');
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
await showModelSelector(pi, args, ctx, deps.config);
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Cross-extension activation: another extension can open the modal via
|
|
96
|
+
// pi.events.emit(PI_VIM_KEY_EVENT_ID)
|
|
97
|
+
pi.events.on(PI_VIM_KEY_EVENT_ID, () => {
|
|
98
|
+
const ctx = latestCtx;
|
|
99
|
+
if (!ctx || !deps.config.isEnabled('model_select')) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
void showModelSelector(pi, '', ctx, deps.config).catch(error => {
|
|
103
|
+
ctx.ui.notify(`Failed to open model selector: ${error instanceof Error ? error.message : String(error)}`, 'error');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function registerModelSelect(pi: ExtensionAPI, deps: { config: ConfigLoader }) {
|
|
109
|
+
let modelSelectRegistered = false;
|
|
110
|
+
|
|
111
|
+
pi.on('session_start', (_event, ctx) => {
|
|
112
|
+
if (!deps.config.isEnabled('model_select') || modelSelectRegistered) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
activateModelSelect(pi, { config: deps.config, initialCtx: ctx });
|
|
117
|
+
modelSelectRegistered = true;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type Api, type Model, modelsAreEqual } from '@earendil-works/pi-ai';
|
|
2
|
+
import type { ModelItem } from './types';
|
|
3
|
+
|
|
4
|
+
export class ModelFormatter {
|
|
5
|
+
static formatTokenCount(count: number): string {
|
|
6
|
+
if (count >= 1_000_000) {
|
|
7
|
+
const millions = count / 1_000_000;
|
|
8
|
+
return millions % 1 === 0 ? `${millions}M` : `${millions.toFixed(1)}M`;
|
|
9
|
+
}
|
|
10
|
+
if (count >= 1_000) {
|
|
11
|
+
const thousands = count / 1_000;
|
|
12
|
+
return thousands % 1 === 0 ? `${thousands}K` : `${thousands.toFixed(1)}K`;
|
|
13
|
+
}
|
|
14
|
+
return count.toString();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
static modelLabel(model: Model<Api>): string {
|
|
18
|
+
return `${model.provider}/${model.id}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
static describeModel(model: Model<Api>): string {
|
|
22
|
+
const capabilities: string[] = [];
|
|
23
|
+
if (model.reasoning) {
|
|
24
|
+
capabilities.push('thinking');
|
|
25
|
+
}
|
|
26
|
+
if (model.input.includes('image')) {
|
|
27
|
+
capabilities.push('images');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const capabilityText = capabilities.length > 0 ? ` • ${capabilities.join(', ')}` : '';
|
|
31
|
+
return `${model.name} • ctx ${ModelFormatter.formatTokenCount(model.contextWindow)} • max ${ModelFormatter.formatTokenCount(model.maxTokens)}${capabilityText}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
static toModelItem(model: Model<Api>): ModelItem {
|
|
35
|
+
return {
|
|
36
|
+
model,
|
|
37
|
+
description: ModelFormatter.describeModel(model),
|
|
38
|
+
searchText: `${model.provider} ${model.id} ${model.provider}/${model.id} ${model.name}`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
static sortModels(models: Model<Api>[], currentModel: Model<Api> | undefined): Model<Api>[] {
|
|
43
|
+
return [...models].sort((a, b) => {
|
|
44
|
+
const aIsCurrent = modelsAreEqual(a, currentModel);
|
|
45
|
+
const bIsCurrent = modelsAreEqual(b, currentModel);
|
|
46
|
+
if (aIsCurrent && !bIsCurrent) {
|
|
47
|
+
return -1;
|
|
48
|
+
}
|
|
49
|
+
if (!aIsCurrent && bIsCurrent) {
|
|
50
|
+
return 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const provider = a.provider.localeCompare(b.provider);
|
|
54
|
+
if (provider !== 0) {
|
|
55
|
+
return provider;
|
|
56
|
+
}
|
|
57
|
+
return a.id.localeCompare(b.id);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Api, Model } from '@earendil-works/pi-ai';
|
|
2
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
import type { Config } from '../../schemas/config.schema';
|
|
4
|
+
import { ModelFormatter } from './model-formatter';
|
|
5
|
+
import type { ModelItem, ModelLists } from './types';
|
|
6
|
+
|
|
7
|
+
export async function buildModelLists(ctx: ExtensionContext, config: Config['model_select']): Promise<ModelLists> {
|
|
8
|
+
ctx.modelRegistry.refresh();
|
|
9
|
+
|
|
10
|
+
const availableModels = ctx.modelRegistry.getAvailable();
|
|
11
|
+
const providerFilterSet = new Set(config.provider_filter);
|
|
12
|
+
const searchModels = config.provider_filter.length > 0 ? availableModels.filter(model => providerFilterSet.has(model.provider)) : availableModels;
|
|
13
|
+
const sortedSearchModels = ModelFormatter.sortModels(searchModels, ctx.model).map(model => ModelFormatter.toModelItem(model));
|
|
14
|
+
|
|
15
|
+
const favouriteItems: ModelItem[] = [];
|
|
16
|
+
const favouriteWarnings: string[] = [];
|
|
17
|
+
const seenFavouriteModels = new Set<string>();
|
|
18
|
+
|
|
19
|
+
for (const favourite of config.favourite) {
|
|
20
|
+
const model = ctx.modelRegistry.find(favourite.provider, favourite.modelId);
|
|
21
|
+
const label = `${favourite.provider}/${favourite.modelId}`;
|
|
22
|
+
|
|
23
|
+
if (!model) {
|
|
24
|
+
favouriteWarnings.push(`${label} was not found`);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!ctx.modelRegistry.hasConfiguredAuth(model)) {
|
|
29
|
+
favouriteWarnings.push(`${label} has no configured auth`);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const key = `${model.provider}\u0000${model.id}`;
|
|
34
|
+
if (seenFavouriteModels.has(key)) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
seenFavouriteModels.add(key);
|
|
38
|
+
favouriteItems.push(ModelFormatter.toModelItem(model));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { favouriteItems, favouriteWarnings, searchItems: sortedSearchModels };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function findExactModel(ctx: ExtensionContext, args: string): Model<Api> | undefined {
|
|
45
|
+
const trimmed = args.trim();
|
|
46
|
+
if (!trimmed) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const slashIndex = trimmed.indexOf('/');
|
|
51
|
+
if (slashIndex > 0 && slashIndex < trimmed.length - 1) {
|
|
52
|
+
const provider = trimmed.slice(0, slashIndex).trim();
|
|
53
|
+
const modelId = trimmed.slice(slashIndex + 1).trim();
|
|
54
|
+
return ctx.modelRegistry.find(provider, modelId);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const [provider, modelId] = trimmed.split(/\s+/, 2);
|
|
58
|
+
if (provider && modelId) {
|
|
59
|
+
return ctx.modelRegistry.find(provider, modelId);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { modelsAreEqual } from '@earendil-works/pi-ai';
|
|
2
|
+
import type { KeybindingsManager, Theme } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
import { type Component, type Focusable, fuzzyFilter, Input, type TUI, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
|
|
4
|
+
import { MAX_CONFIG_WARNING_LINES, MAX_VISIBLE_MODELS } from './constants';
|
|
5
|
+
import { ModelFormatter } from './model-formatter';
|
|
6
|
+
import type { DialogOptions, ModelItem, SelectionSection } from './types';
|
|
7
|
+
|
|
8
|
+
function isPrintableInput(data: string): boolean {
|
|
9
|
+
if (data.length === 0) {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
return [...data].every(char => {
|
|
13
|
+
const code = char.charCodeAt(0);
|
|
14
|
+
return code >= 32 && code !== 0x7f && (code < 0x80 || code > 0x9f);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class ModelSelectDialog implements Component, Focusable {
|
|
19
|
+
private readonly searchInput = new Input();
|
|
20
|
+
private activeSection: SelectionSection;
|
|
21
|
+
private selectedFavouriteIndex = 0;
|
|
22
|
+
private selectedSearchIndex = 0;
|
|
23
|
+
private filteredSearchItems: ModelItem[];
|
|
24
|
+
private _focused = false;
|
|
25
|
+
|
|
26
|
+
constructor(
|
|
27
|
+
private readonly tui: TUI,
|
|
28
|
+
private readonly theme: Theme,
|
|
29
|
+
private readonly keybindings: KeybindingsManager,
|
|
30
|
+
private readonly options: DialogOptions,
|
|
31
|
+
) {
|
|
32
|
+
this.activeSection = options.initialSearch || !options.hasFavouriteSection ? 'search' : 'favourites';
|
|
33
|
+
const currentFavouriteIndex = options.currentModel
|
|
34
|
+
? options.favouriteItems.findIndex(item => modelsAreEqual(item.model, options.currentModel))
|
|
35
|
+
: -1;
|
|
36
|
+
this.selectedFavouriteIndex = currentFavouriteIndex >= 0 ? currentFavouriteIndex : 0;
|
|
37
|
+
this.searchInput.setValue(options.initialSearch);
|
|
38
|
+
this.searchInput.onSubmit = () => this.selectCurrentSearchItem();
|
|
39
|
+
this.searchInput.onEscape = () => this.options.onDone(null);
|
|
40
|
+
this.filteredSearchItems = this.filterSearchItems(options.initialSearch);
|
|
41
|
+
this.syncFocus();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get focused(): boolean {
|
|
45
|
+
return this._focused;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
set focused(value: boolean) {
|
|
49
|
+
this._focused = value;
|
|
50
|
+
this.syncFocus();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
invalidate(): void {
|
|
54
|
+
this.searchInput.invalidate();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
handleInput(data: string): void {
|
|
58
|
+
if (this.keybindings.matches(data, 'tui.input.tab')) {
|
|
59
|
+
this.switchSection();
|
|
60
|
+
this.tui.requestRender();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (this.keybindings.matches(data, 'tui.select.cancel')) {
|
|
65
|
+
this.options.onDone(null);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (this.keybindings.matches(data, 'tui.select.up')) {
|
|
70
|
+
this.moveSelection(-1);
|
|
71
|
+
this.tui.requestRender();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (this.keybindings.matches(data, 'tui.select.down')) {
|
|
76
|
+
this.moveSelection(1);
|
|
77
|
+
this.tui.requestRender();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (this.keybindings.matches(data, 'tui.select.pageUp')) {
|
|
82
|
+
this.moveSelection(-MAX_VISIBLE_MODELS);
|
|
83
|
+
this.tui.requestRender();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (this.keybindings.matches(data, 'tui.select.pageDown')) {
|
|
88
|
+
this.moveSelection(MAX_VISIBLE_MODELS);
|
|
89
|
+
this.tui.requestRender();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (this.keybindings.matches(data, 'tui.select.confirm')) {
|
|
94
|
+
this.selectCurrentItem();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (this.activeSection !== 'search' && isPrintableInput(data)) {
|
|
99
|
+
this.activeSection = 'search';
|
|
100
|
+
this.syncFocus();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (this.activeSection === 'search') {
|
|
104
|
+
this.searchInput.handleInput(data);
|
|
105
|
+
this.filteredSearchItems = this.filterSearchItems(this.searchInput.getValue());
|
|
106
|
+
this.selectedSearchIndex = Math.min(this.selectedSearchIndex, Math.max(0, this.filteredSearchItems.length - 1));
|
|
107
|
+
this.tui.requestRender();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
render(width: number): string[] {
|
|
112
|
+
const safeWidth = Math.max(3, width);
|
|
113
|
+
|
|
114
|
+
if (this.options.layout === 'inline') {
|
|
115
|
+
const inner = safeWidth;
|
|
116
|
+
const lines = this.buildContentLines(inner);
|
|
117
|
+
const rule = this.theme.fg('border', '─'.repeat(inner));
|
|
118
|
+
const body = lines.map(line => this.padLine(line, inner));
|
|
119
|
+
return [rule, ...body, rule];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const inner = safeWidth - 2;
|
|
123
|
+
const lines = this.buildContentLines(inner);
|
|
124
|
+
|
|
125
|
+
const borderColor = (str: string) => this.theme.fg('border', str);
|
|
126
|
+
const horizontal = '─'.repeat(inner);
|
|
127
|
+
const top = borderColor(`╭${horizontal}╮`);
|
|
128
|
+
const bottom = borderColor(`╰${horizontal}╯`);
|
|
129
|
+
const side = borderColor('│');
|
|
130
|
+
|
|
131
|
+
const wrapped = lines.map(line => `${side}${this.padLine(line, inner)}${side}`);
|
|
132
|
+
return [top, ...wrapped, bottom];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private buildContentLines(inner: number): string[] {
|
|
136
|
+
const lines: string[] = [];
|
|
137
|
+
|
|
138
|
+
lines.push(this.line(this.renderTitle(), inner));
|
|
139
|
+
lines.push(this.line(this.renderTabs(), inner));
|
|
140
|
+
|
|
141
|
+
for (const warning of this.options.configWarnings.slice(0, MAX_CONFIG_WARNING_LINES)) {
|
|
142
|
+
lines.push(this.line(this.theme.fg('warning', `⚠ ${warning}`), inner));
|
|
143
|
+
}
|
|
144
|
+
if (this.options.configWarnings.length > MAX_CONFIG_WARNING_LINES) {
|
|
145
|
+
lines.push(
|
|
146
|
+
this.line(this.theme.fg('warning', `⚠ ${this.options.configWarnings.length - MAX_CONFIG_WARNING_LINES} more config warning(s)`), inner),
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
lines.push('');
|
|
151
|
+
|
|
152
|
+
if (this.activeSection === 'favourites') {
|
|
153
|
+
lines.push(...this.renderFavourites(inner));
|
|
154
|
+
} else {
|
|
155
|
+
lines.push(...this.renderSearch(inner));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
lines.push('');
|
|
159
|
+
lines.push(this.line(this.renderHelp(), inner));
|
|
160
|
+
|
|
161
|
+
return lines;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private padLine(text: string, innerWidth: number): string {
|
|
165
|
+
const truncated = truncateToWidth(text.replace(/[\r\n]+/g, ' '), innerWidth, '');
|
|
166
|
+
const padding = Math.max(0, innerWidth - visibleWidth(truncated));
|
|
167
|
+
return truncated + ' '.repeat(padding);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private switchSection(): void {
|
|
171
|
+
if (!this.options.hasFavouriteSection) {
|
|
172
|
+
this.activeSection = 'search';
|
|
173
|
+
this.syncFocus();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
this.activeSection = this.activeSection === 'favourites' ? 'search' : 'favourites';
|
|
178
|
+
this.syncFocus();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private syncFocus(): void {
|
|
182
|
+
this.searchInput.focused = this._focused && this.activeSection === 'search';
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private moveSelection(delta: number): void {
|
|
186
|
+
if (this.activeSection === 'favourites') {
|
|
187
|
+
this.selectedFavouriteIndex = this.wrapIndex(this.selectedFavouriteIndex + delta, this.options.favouriteItems.length);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
this.selectedSearchIndex = this.wrapIndex(this.selectedSearchIndex + delta, this.filteredSearchItems.length);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private wrapIndex(index: number, length: number): number {
|
|
195
|
+
if (length <= 0) {
|
|
196
|
+
return 0;
|
|
197
|
+
}
|
|
198
|
+
return ((index % length) + length) % length;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private selectCurrentItem(): void {
|
|
202
|
+
if (this.activeSection === 'favourites') {
|
|
203
|
+
const item = this.options.favouriteItems[this.selectedFavouriteIndex];
|
|
204
|
+
if (item) {
|
|
205
|
+
this.options.onDone(item.model);
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
this.selectCurrentSearchItem();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private selectCurrentSearchItem(): void {
|
|
214
|
+
const item = this.filteredSearchItems[this.selectedSearchIndex];
|
|
215
|
+
if (item) {
|
|
216
|
+
this.options.onDone(item.model);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private filterSearchItems(query: string): ModelItem[] {
|
|
221
|
+
const trimmed = query.trim();
|
|
222
|
+
if (!trimmed) {
|
|
223
|
+
return this.options.searchItems;
|
|
224
|
+
}
|
|
225
|
+
return fuzzyFilter(this.options.searchItems, trimmed, item => item.searchText);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private renderTitle(): string {
|
|
229
|
+
const current = this.options.currentModel ? ModelFormatter.modelLabel(this.options.currentModel) : 'none';
|
|
230
|
+
return `${this.theme.fg('customMessageLabel', this.theme.bold('Select Model'))} ${this.theme.fg('muted', `current: ${current}`)}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private renderTabs(): string {
|
|
234
|
+
const tabs: string[] = [];
|
|
235
|
+
if (this.options.hasFavouriteSection) {
|
|
236
|
+
tabs.push(this.renderTab('favourites', `Favourites ${this.options.favouriteItems.length}`));
|
|
237
|
+
}
|
|
238
|
+
tabs.push(this.renderTab('search', `Search ${this.options.searchItems.length}`));
|
|
239
|
+
return tabs.join(this.theme.fg('muted', ' '));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private renderTab(section: SelectionSection, label: string): string {
|
|
243
|
+
const text = `[${label}]`;
|
|
244
|
+
if (this.activeSection === section) {
|
|
245
|
+
return this.theme.fg('accent', this.theme.bold(text));
|
|
246
|
+
}
|
|
247
|
+
return this.theme.fg('muted', text);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private renderFavourites(width: number): string[] {
|
|
251
|
+
const lines: string[] = [];
|
|
252
|
+
|
|
253
|
+
if (this.options.favouriteItems.length === 0) {
|
|
254
|
+
lines.push(this.line(this.theme.fg('muted', ' No configured favourites are available.'), width));
|
|
255
|
+
} else {
|
|
256
|
+
lines.push(...this.renderModelList(this.options.favouriteItems, this.selectedFavouriteIndex, width));
|
|
257
|
+
const selected = this.options.favouriteItems[this.selectedFavouriteIndex];
|
|
258
|
+
if (selected) {
|
|
259
|
+
lines.push('');
|
|
260
|
+
lines.push(this.line(this.theme.fg('muted', ` ${selected.description}`), width));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
for (const warning of this.options.favouriteWarnings.slice(0, MAX_CONFIG_WARNING_LINES)) {
|
|
265
|
+
lines.push(this.line(this.theme.fg('warning', ` ⚠ ${warning}`), width));
|
|
266
|
+
}
|
|
267
|
+
if (this.options.favouriteWarnings.length > MAX_CONFIG_WARNING_LINES) {
|
|
268
|
+
lines.push(
|
|
269
|
+
this.line(
|
|
270
|
+
this.theme.fg('warning', ` ⚠ ${this.options.favouriteWarnings.length - MAX_CONFIG_WARNING_LINES} more favourite warning(s)`),
|
|
271
|
+
width,
|
|
272
|
+
),
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return lines;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private renderSearch(width: number): string[] {
|
|
280
|
+
const lines: string[] = [];
|
|
281
|
+
const providerText =
|
|
282
|
+
this.options.providerFilter.length === 0 ? 'all authorized providers' : `providers: ${this.options.providerFilter.join(', ')}`;
|
|
283
|
+
|
|
284
|
+
lines.push(this.line(this.theme.fg('muted', `Provider filter: ${providerText}`), width));
|
|
285
|
+
lines.push(this.line(this.theme.fg('muted', 'Search query:'), width));
|
|
286
|
+
lines.push(...this.searchInput.render(width));
|
|
287
|
+
lines.push('');
|
|
288
|
+
|
|
289
|
+
if (this.filteredSearchItems.length === 0) {
|
|
290
|
+
lines.push(this.line(this.theme.fg('muted', ' No matching models'), width));
|
|
291
|
+
return lines;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
lines.push(...this.renderModelList(this.filteredSearchItems, this.selectedSearchIndex, width));
|
|
295
|
+
const selected = this.filteredSearchItems[this.selectedSearchIndex];
|
|
296
|
+
if (selected) {
|
|
297
|
+
lines.push('');
|
|
298
|
+
lines.push(this.line(this.theme.fg('muted', ` ${selected.description}`), width));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return lines;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private renderModelList(items: ModelItem[], selectedIndex: number, width: number): string[] {
|
|
305
|
+
const lines: string[] = [];
|
|
306
|
+
const startIndex = Math.max(0, Math.min(selectedIndex - Math.floor(MAX_VISIBLE_MODELS / 2), items.length - MAX_VISIBLE_MODELS));
|
|
307
|
+
const endIndex = Math.min(startIndex + MAX_VISIBLE_MODELS, items.length);
|
|
308
|
+
|
|
309
|
+
for (let index = startIndex; index < endIndex; index++) {
|
|
310
|
+
const item = items[index];
|
|
311
|
+
if (!item) {
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
lines.push(this.renderModelItem(item, index === selectedIndex, width));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (startIndex > 0 || endIndex < items.length) {
|
|
318
|
+
lines.push(this.line(this.theme.fg('dim', ` (${selectedIndex + 1}/${items.length})`), width));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return lines;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private renderModelItem(item: ModelItem, isSelected: boolean, width: number): string {
|
|
325
|
+
const currentMark = modelsAreEqual(item.model, this.options.currentModel) ? this.theme.fg('success', ' ✓') : '';
|
|
326
|
+
const providerBadge = this.theme.fg('muted', `[${item.model.provider}]`);
|
|
327
|
+
const prefix = isSelected ? this.theme.fg('accent', '→ ') : ' ';
|
|
328
|
+
const modelText = isSelected ? this.theme.fg('accent', item.model.id) : item.model.id;
|
|
329
|
+
return this.line(`${prefix}${modelText} ${providerBadge}${currentMark}`, width);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private renderHelp(): string {
|
|
333
|
+
const tabHint = this.options.hasFavouriteSection ? 'tab switch sections • ' : '';
|
|
334
|
+
return this.theme.fg('dim', `${tabHint}↑↓ navigate • enter select • esc cancel`);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private line(text: string, width: number): string {
|
|
338
|
+
return truncateToWidth(text.replace(/[\r\n]+/g, ' '), Math.max(1, width), '');
|
|
339
|
+
}
|
|
340
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Api, Model } from '@earendil-works/pi-ai';
|
|
2
|
+
import type { ModelSelectLayout } from '../../schemas/model-select.config.schema';
|
|
3
|
+
|
|
4
|
+
export type ModelItem = {
|
|
5
|
+
model: Model<Api>;
|
|
6
|
+
description: string;
|
|
7
|
+
searchText: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type ModelLists = {
|
|
11
|
+
favouriteItems: ModelItem[];
|
|
12
|
+
favouriteWarnings: string[];
|
|
13
|
+
searchItems: ModelItem[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type SelectionSection = 'favourites' | 'search';
|
|
17
|
+
|
|
18
|
+
export type DialogResult = Model<Api> | null;
|
|
19
|
+
|
|
20
|
+
export type DialogOptions = {
|
|
21
|
+
currentModel: Model<Api> | undefined;
|
|
22
|
+
favouriteItems: ModelItem[];
|
|
23
|
+
favouriteWarnings: string[];
|
|
24
|
+
hasFavouriteSection: boolean;
|
|
25
|
+
searchItems: ModelItem[];
|
|
26
|
+
providerFilter: string[];
|
|
27
|
+
configWarnings: string[];
|
|
28
|
+
initialSearch: string;
|
|
29
|
+
layout: ModelSelectLayout;
|
|
30
|
+
onDone: (result: DialogResult) => void;
|
|
31
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
import { ConfigLoader } from './config-loader';
|
|
3
3
|
import { registerAutoSessionName } from './extensions/auto-session-name';
|
|
4
|
+
import { registerModelSelect } from './extensions/model-select';
|
|
4
5
|
|
|
5
6
|
export default function (pi: ExtensionAPI) {
|
|
6
7
|
const config = new ConfigLoader();
|
|
@@ -11,4 +12,5 @@ export default function (pi: ExtensionAPI) {
|
|
|
11
12
|
});
|
|
12
13
|
|
|
13
14
|
registerAutoSessionName(pi, { config });
|
|
15
|
+
registerModelSelect(pi, { config });
|
|
14
16
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import z from 'zod';
|
|
2
|
-
import {
|
|
2
|
+
import { ModelConfigSchema } from './shared-config.schema';
|
|
3
3
|
|
|
4
4
|
export const AutoSessionNameConfigSchema = z.object({
|
|
5
5
|
enabled: z.boolean().default(false),
|
|
6
|
-
model:
|
|
6
|
+
model: ModelConfigSchema.optional(),
|
|
7
7
|
});
|
|
8
8
|
export type AutoSessionNameConfig = z.infer<typeof AutoSessionNameConfigSchema>;
|
|
9
9
|
|
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
import z from 'zod';
|
|
2
2
|
import { AutoSessionNameConfigSchema, PartialAutoSessionNameConfigSchema } from './auto-session-name.config.schema';
|
|
3
|
+
import { ModelSelectConfigSchema, PartialModelSelectConfigSchema } from './model-select.config.schema';
|
|
3
4
|
|
|
4
5
|
export const ConfigSchema = z.object({
|
|
5
6
|
$schema: z.string().optional(),
|
|
6
7
|
auto_session_name: AutoSessionNameConfigSchema.default({
|
|
7
8
|
enabled: false,
|
|
8
9
|
}),
|
|
10
|
+
model_select: ModelSelectConfigSchema.default({
|
|
11
|
+
enabled: false,
|
|
12
|
+
favourite: [],
|
|
13
|
+
provider_filter: [],
|
|
14
|
+
layout: 'inline',
|
|
15
|
+
}),
|
|
9
16
|
});
|
|
10
17
|
export type Config = z.infer<typeof ConfigSchema>;
|
|
11
18
|
|
|
12
19
|
export const PartialConfigSchema = z.object({
|
|
13
20
|
$schema: z.string().optional(),
|
|
14
21
|
auto_session_name: PartialAutoSessionNameConfigSchema.optional(),
|
|
22
|
+
model_select: PartialModelSelectConfigSchema.optional(),
|
|
15
23
|
});
|
|
16
24
|
export type PartialConfig = z.infer<typeof PartialConfigSchema>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import z from 'zod';
|
|
2
|
+
import { ModelConfigSchema } from './shared-config.schema';
|
|
3
|
+
|
|
4
|
+
const FavouriteModelSchema = ModelConfigSchema.omit({ reasoning: true });
|
|
5
|
+
|
|
6
|
+
const ModelSelectLayoutSchema = z.enum(['inline', 'overlay']);
|
|
7
|
+
export type ModelSelectLayout = z.infer<typeof ModelSelectLayoutSchema>;
|
|
8
|
+
|
|
9
|
+
export const ModelSelectConfigSchema = z.object({
|
|
10
|
+
enabled: z.boolean().default(false),
|
|
11
|
+
favourite: z.array(FavouriteModelSchema).default([]),
|
|
12
|
+
provider_filter: z.array(z.string().min(1)).default([]),
|
|
13
|
+
layout: ModelSelectLayoutSchema.default('inline'),
|
|
14
|
+
});
|
|
15
|
+
export type ModelSelectConfig = z.infer<typeof ModelSelectConfigSchema>;
|
|
16
|
+
|
|
17
|
+
export const PartialModelSelectConfigSchema = ModelSelectConfigSchema.partial();
|
|
18
|
+
export type PartialModelSelectConfig = z.infer<typeof PartialModelSelectConfigSchema>;
|
|
@@ -3,9 +3,9 @@ import z from 'zod';
|
|
|
3
3
|
export const ReasoningLevelSchema = z.enum(['off', 'minimal', 'low', 'medium', 'high', 'xhigh']);
|
|
4
4
|
export type ReasoningLevel = z.infer<typeof ReasoningLevelSchema>;
|
|
5
5
|
|
|
6
|
-
export const
|
|
6
|
+
export const ModelConfigSchema = z.object({
|
|
7
7
|
provider: z.string(),
|
|
8
8
|
modelId: z.string(),
|
|
9
9
|
reasoning: ReasoningLevelSchema,
|
|
10
10
|
});
|
|
11
|
-
export type ModelConfig = z.infer<typeof
|
|
11
|
+
export type ModelConfig = z.infer<typeof ModelConfigSchema>;
|
package/src/types.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type ObjectValues<T> = T[keyof T];
|