@butcool/pi-model-picker 0.0.0-stage → 0.1.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/CHANGELOG.md +11 -0
- package/LICENSE +21 -0
- package/README.md +129 -2
- package/package.json +53 -4
- package/src/index.ts +424 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-11-03
|
|
4
|
+
|
|
5
|
+
- Three-pane model picker: providers, models, and a model inspector.
|
|
6
|
+
- `/model-ui` command and `Ctrl+Shift+M` shortcut.
|
|
7
|
+
- Inspector shows context window, max output, capabilities, protocol, and per-1M-token pricing.
|
|
8
|
+
- Save the highlighted model as the global or project default with `Ctrl+S` and `Ctrl+Shift+S`.
|
|
9
|
+
- Show configured per-model thinking levels after the model name, color-coded by depth.
|
|
10
|
+
- Draw the configured thinking level as a slider over the model's supported levels, with clamped values reported.
|
|
11
|
+
- Read project settings only for trusted projects.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 butfool
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,130 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Pi Model Picker
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A three-pane model picker for the [Pi coding agent](https://pi.dev). Pick a provider on the left, a model in the middle, and inspect the highlighted model on the right. Models whose default thinking level is configured show it next to the name, and the inspector draws that level on the model's scale.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
╭────────────────────────────────────────────────────────────────────────────────────╮
|
|
7
|
+
│ MODEL STUDIO Choose a provider, inspect a model, apply with Enter │
|
|
8
|
+
│ ● Selected: iwhalex / i-gpt-5.6-terra │
|
|
9
|
+
├──────────────────┬──────────────────────────────┬──────────────────────────────────┤
|
|
10
|
+
│ PROVIDERS 5 │ MODELS 7 │ INSPECTOR │
|
|
11
|
+
├──────────────────┼──────────────────────────────┼──────────────────────────────────┤
|
|
12
|
+
│ iwhale │ DeepSeek Flash [max] ◆ │ GPT-5.6-Terra │
|
|
13
|
+
│› iwhalex ● │ GPT-5.6-Sol │ i-gpt-5.6-terra │
|
|
14
|
+
│ minimax-cn │ GPT-5.6-Terra [medium] ● │ ● CURRENT SESSION MODEL │
|
|
15
|
+
│ owner │ GPT-6-Astra │ │
|
|
16
|
+
│ ownerx │ GPT-6-Luna [medium] │ CAPACITY │
|
|
17
|
+
│ │ GPT-6-Sol │ Context 1.1M tokens │
|
|
18
|
+
│ │ GPT-6.1-Sol │ Max output 128K tokens │
|
|
19
|
+
│ │ │ │
|
|
20
|
+
│ │ │ CAPABILITIES │
|
|
21
|
+
│ │ │ Mode Reasoning · Vision│
|
|
22
|
+
│ │ │ Protocol openai-responses │
|
|
23
|
+
│ │ │ │
|
|
24
|
+
│ │ │ THINKING medium │
|
|
25
|
+
│ │ │ ━━━━━━━━━━━━━●──────────────────│
|
|
26
|
+
│ │ │ low medium high xhigh max │
|
|
27
|
+
│ │ │ │
|
|
28
|
+
│ │ │ PRICING / 1M TOKENS │
|
|
29
|
+
│ │ │ Input $3/M │
|
|
30
|
+
├──────────────────┴──────────────────────────────┴──────────────────────────────────┤
|
|
31
|
+
│ ↑↓ move ←→ / Tab switch pane Enter apply Esc cancel │
|
|
32
|
+
╰────────────────────────────────────────────────────────────────────────────────────╯
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pi install npm:@butcool/pi-model-picker
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Try it without touching your settings:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pi -e npm:@butcool/pi-model-picker
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Install into a project instead of your personal configuration:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pi install npm:@butcool/pi-model-picker -l
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
Open the picker with `/model-ui` or `Ctrl+Shift+M`.
|
|
56
|
+
|
|
57
|
+
| Key | Action |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `↑` `↓` | Move within the focused pane |
|
|
60
|
+
| `←` `→` or `Tab` | Switch between the provider and model panes |
|
|
61
|
+
| `Enter` | Apply the highlighted model to the current session |
|
|
62
|
+
| `Esc` or `Ctrl+C` | Close the picker |
|
|
63
|
+
| `Ctrl+S` | Save the highlighted model as the global default |
|
|
64
|
+
| `Ctrl+Shift+S` | Save the highlighted model as the project default |
|
|
65
|
+
|
|
66
|
+
Applying a model runs the same path as `/model`, so the configured per-model thinking level takes effect immediately.
|
|
67
|
+
|
|
68
|
+
Markers in the model list:
|
|
69
|
+
|
|
70
|
+
| Marker | Meaning |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `●` | Model used by the current session |
|
|
73
|
+
| `◆` | Global default model |
|
|
74
|
+
| `▲` | Project default model |
|
|
75
|
+
|
|
76
|
+
## Per-model thinking levels
|
|
77
|
+
|
|
78
|
+
Pi supports `modelThinkingLevels` in `settings.json`:
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"modelThinkingLevels": {
|
|
83
|
+
"anthropic/claude-sonnet-4-6": "high",
|
|
84
|
+
"openai/gpt-5.6-terra": "medium"
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The picker reads the global settings file plus the project settings file of a trusted project, with project entries overriding global ones.
|
|
90
|
+
|
|
91
|
+
- A model with an entry shows the level after its name, for example `GPT-5.6-Terra [medium]`. Levels are color-coded and bold: dim for `off`/`minimal`, gray for `low`, teal for `medium`, green for `high`, orange for `xhigh`/`max`.
|
|
92
|
+
- The inspector renders the level as a slider over the levels the model actually supports, with the active stop marked and labeled.
|
|
93
|
+
- Models without an entry show no level and no thinking section.
|
|
94
|
+
- When a configured level is outside the model's supported range, the picker shows the level the session will actually use and notes the configured value, for example `THINKING high (configured medium)`.
|
|
95
|
+
|
|
96
|
+
The model list rows and the level tags are colored by state: the current session model row is green, rows carrying a default marker are orange, and the level tag keeps its own color inside them.
|
|
97
|
+
|
|
98
|
+
## Requirements
|
|
99
|
+
|
|
100
|
+
- Pi 1.5.0 or newer
|
|
101
|
+
- Node.js 22 or newer
|
|
102
|
+
|
|
103
|
+
## Development
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
npm install
|
|
107
|
+
npm run typecheck # TypeScript strict check
|
|
108
|
+
npm test # drives the real TUI through a pty (requires uv)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`npm test` starts Pi inside a temporary agent directory, declares this package as a local package, opens `/model-ui`, and asserts what the renderer writes for configured, missing, and clamped thinking levels. It needs `pi` on `PATH` and a Unix pty (macOS or Linux).
|
|
112
|
+
|
|
113
|
+
## Releasing
|
|
114
|
+
|
|
115
|
+
Publishing runs from GitHub Actions on a version tag. Create an npm automation token, store it as the `NPM_TOKEN` repository secret, then push a tag that matches `package.json`:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
npm version patch
|
|
119
|
+
git push --follow-tags
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`.github/workflows/publish.yml` installs from the lockfile, runs the typecheck, and publishes with provenance. Manual publishing works the same way:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
npm publish --access public
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@butcool/pi-model-picker",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Three-pane model picker for Pi: provider list, model list, and an inspector with per-model thinking levels",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "butfool",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/butfool/pi-model-picker.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/butfool/pi-model-picker#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/butfool/pi-model-picker/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi-package",
|
|
18
|
+
"pi-extension",
|
|
19
|
+
"pi",
|
|
20
|
+
"pi-coding-agent",
|
|
21
|
+
"model-picker",
|
|
22
|
+
"model-selector",
|
|
23
|
+
"thinking-level"
|
|
24
|
+
],
|
|
25
|
+
"files": [
|
|
26
|
+
"src",
|
|
27
|
+
"README.md",
|
|
28
|
+
"CHANGELOG.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"pi": {
|
|
32
|
+
"extensions": [
|
|
33
|
+
"./src/index.ts"
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@earendil-works/pi-ai": "*",
|
|
38
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
39
|
+
"@earendil-works/pi-tui": "*"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@earendil-works/pi-ai": "^1.5.0",
|
|
43
|
+
"@earendil-works/pi-coding-agent": "^1.5.0",
|
|
44
|
+
"@earendil-works/pi-tui": "^1.5.0",
|
|
45
|
+
"@types/node": "^26.6.6",
|
|
46
|
+
"typescript": "^7.0.2"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"typecheck": "tsc --noEmit",
|
|
50
|
+
"test": "uv run --with pexpect python tests/picker_test.py"
|
|
51
|
+
},
|
|
52
|
+
"engines": {
|
|
53
|
+
"node": ">=22"
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
// Pi model picker: three panes for providers, models, and model details.
|
|
2
|
+
// Open it with /model-ui or Ctrl+Shift+M.
|
|
3
|
+
// Navigate with ↑/↓ and ←/→ (or Tab), apply with Enter, cancel with Esc.
|
|
4
|
+
// Ctrl+S saves the highlighted model as the global default, Ctrl+Shift+S as the project default.
|
|
5
|
+
// The inspector shows the configured per-model thinking level when settings define one.
|
|
6
|
+
// Models with a configured level print it after the name; the inspector places it on the model's scale.
|
|
7
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { clampThinkingLevel, getSupportedThinkingLevels, type Api, type Model, type ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
10
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
|
|
15
|
+
type PickerModel = Model<Api>;
|
|
16
|
+
|
|
17
|
+
type SaveScope = "global" | "project";
|
|
18
|
+
|
|
19
|
+
type PickerResult = { model: PickerModel; persist?: SaveScope };
|
|
20
|
+
|
|
21
|
+
type SavedDefaults = {
|
|
22
|
+
global: string | undefined;
|
|
23
|
+
project: string | undefined;
|
|
24
|
+
thinkingLevels: Record<string, ModelThinkingLevel>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const modelRef = (model: PickerModel): string => `${model.provider}/${model.id}`;
|
|
28
|
+
|
|
29
|
+
/** Marker column width: session model, global default, and project default, separated by spaces. */
|
|
30
|
+
const MARKER_WIDTH = 5;
|
|
31
|
+
|
|
32
|
+
/** Lines the picker draws around the table body: borders, title, status lines, header, and footer. */
|
|
33
|
+
const CHROME_ROWS = 12;
|
|
34
|
+
|
|
35
|
+
type PickerTheme = {
|
|
36
|
+
fg(color: "accent" | "muted" | "dim" | "text" | "success" | "warning" | "border", text: string): string;
|
|
37
|
+
bg(color: "selectedBg", text: string): string;
|
|
38
|
+
bold(text: string): string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const formatTokens = (tokens: number): string => {
|
|
42
|
+
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(tokens % 1_000_000 === 0 ? 0 : 1)}M`;
|
|
43
|
+
if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}K`;
|
|
44
|
+
return String(tokens);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const formatRate = (rate: number): string => `$${rate.toLocaleString("en-US", { maximumFractionDigits: 3 })}/M`;
|
|
48
|
+
|
|
49
|
+
class ModelPicker {
|
|
50
|
+
private providerIndex: number;
|
|
51
|
+
private modelIndex = 0;
|
|
52
|
+
private column: "provider" | "model" = "provider";
|
|
53
|
+
private readonly requiredRows: number;
|
|
54
|
+
|
|
55
|
+
constructor(
|
|
56
|
+
private readonly byProvider: Map<string, PickerModel[]>,
|
|
57
|
+
private readonly providers: string[],
|
|
58
|
+
private readonly current: PickerModel | undefined,
|
|
59
|
+
private readonly defaults: SavedDefaults,
|
|
60
|
+
private readonly terminalRows: () => number,
|
|
61
|
+
private readonly theme: PickerTheme,
|
|
62
|
+
private readonly onSelect: (model: PickerModel) => void,
|
|
63
|
+
private readonly onSave: (model: PickerModel, scope: SaveScope) => void,
|
|
64
|
+
private readonly onCancel: () => void,
|
|
65
|
+
) {
|
|
66
|
+
this.providerIndex = Math.max(0, providers.indexOf(current?.provider ?? ""));
|
|
67
|
+
const currentIndex = this.models.findIndex((model) => model.id === current?.id);
|
|
68
|
+
this.modelIndex = Math.max(0, currentIndex);
|
|
69
|
+
this.requiredRows = this.measureRows();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Tallest content the table body must hold, so the box keeps one height while navigating. */
|
|
73
|
+
private measureRows(): number {
|
|
74
|
+
const modelCounts = this.providers.map((provider) => (this.byProvider.get(provider) ?? []).length);
|
|
75
|
+
const inspectorCounts = [...this.byProvider.values()].flat().map((model) => this.details(model, 40).length);
|
|
76
|
+
return Math.max(12, this.providers.length, ...modelCounts, ...inspectorCounts);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Table body height: the tallest content, clamped to the terminal so a resize still fits. */
|
|
80
|
+
private get bodyRows(): number {
|
|
81
|
+
return Math.min(this.requiredRows, Math.max(12, this.terminalRows() - CHROME_ROWS));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private get provider(): string {
|
|
85
|
+
return this.providers[this.providerIndex] ?? "";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private get models(): PickerModel[] {
|
|
89
|
+
return this.byProvider.get(this.provider) ?? [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private get selected(): PickerModel | undefined {
|
|
93
|
+
return this.models[this.modelIndex];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private markers(model: PickerModel): string {
|
|
97
|
+
const ref = modelRef(model);
|
|
98
|
+
return [
|
|
99
|
+
this.current?.provider === model.provider && this.current.id === model.id ? "●" : "",
|
|
100
|
+
this.defaults.global === ref ? "◆" : "",
|
|
101
|
+
this.defaults.project === ref ? "▲" : "",
|
|
102
|
+
].filter(Boolean).join(" ").padEnd(MARKER_WIDTH);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
handleInput(data: string): void {
|
|
106
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
107
|
+
this.onCancel();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (matchesKey(data, Key.left)) {
|
|
112
|
+
this.column = "provider";
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (matchesKey(data, Key.right)) {
|
|
116
|
+
this.column = "model";
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (matchesKey(data, Key.tab)) {
|
|
120
|
+
this.column = this.column === "provider" ? "model" : "provider";
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (matchesKey(data, Key.up)) {
|
|
124
|
+
if (this.column === "provider") {
|
|
125
|
+
this.providerIndex = (this.providerIndex - 1 + this.providers.length) % this.providers.length;
|
|
126
|
+
this.modelIndex = 0;
|
|
127
|
+
} else if (this.models.length > 0) {
|
|
128
|
+
this.modelIndex = (this.modelIndex - 1 + this.models.length) % this.models.length;
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (matchesKey(data, Key.down)) {
|
|
133
|
+
if (this.column === "provider") {
|
|
134
|
+
this.providerIndex = (this.providerIndex + 1) % this.providers.length;
|
|
135
|
+
this.modelIndex = 0;
|
|
136
|
+
} else if (this.models.length > 0) {
|
|
137
|
+
this.modelIndex = (this.modelIndex + 1) % this.models.length;
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (matchesKey(data, Key.ctrlShift("s"))) {
|
|
142
|
+
if (this.selected) this.onSave(this.selected, "project");
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (matchesKey(data, Key.ctrl("s"))) {
|
|
146
|
+
if (this.selected) this.onSave(this.selected, "global");
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (matchesKey(data, Key.enter) && this.selected) this.onSelect(this.selected);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private fit(text: string, width: number): string {
|
|
153
|
+
const truncated = truncateToWidth(text, width);
|
|
154
|
+
return truncated + " ".repeat(Math.max(0, width - visibleWidth(truncated)));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private item(label: string, selected: boolean, markers: string, width: number, level?: ModelThinkingLevel): string {
|
|
158
|
+
const prefix = selected ? "›" : " ";
|
|
159
|
+
const rowColor = selected ? "accent" : markers.includes("●") ? "success" : markers.trim().length > 0 ? "warning" : undefined;
|
|
160
|
+
const paint = (segment: string): string => {
|
|
161
|
+
if (rowColor === undefined) return segment;
|
|
162
|
+
const colored = this.theme.fg(rowColor, segment);
|
|
163
|
+
return selected ? this.theme.bold(colored) : colored;
|
|
164
|
+
};
|
|
165
|
+
const tag = level === undefined ? " " : ` ${this.theme.bold(this.theme.fg(this.levelColor(level), `[${level}]`))} `;
|
|
166
|
+
const line = `${paint(`${prefix} ${label}`)}${tag}${paint(markers)}`;
|
|
167
|
+
return selected ? this.theme.bg("selectedBg", this.fit(line, width)) : this.fit(line, width);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private levelColor(level: ModelThinkingLevel): "dim" | "muted" | "accent" | "success" | "warning" {
|
|
171
|
+
if (level === "low") return "muted";
|
|
172
|
+
if (level === "medium") return "accent";
|
|
173
|
+
if (level === "high") return "success";
|
|
174
|
+
if (level === "xhigh" || level === "max") return "warning";
|
|
175
|
+
return "dim";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Level the model will start at, or undefined when settings define no entry for it. */
|
|
179
|
+
private levelTag(model: PickerModel): ModelThinkingLevel | undefined {
|
|
180
|
+
const configured = this.defaults.thinkingLevels[modelRef(model)];
|
|
181
|
+
return configured === undefined ? undefined : clampThinkingLevel(model, configured);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Slider for the configured level: one stop per supported level, filled up to the active one. */
|
|
185
|
+
private thinkingSection(model: PickerModel, configured: ModelThinkingLevel, effective: ModelThinkingLevel, width: number): string[] {
|
|
186
|
+
const levels = getSupportedThinkingLevels(model);
|
|
187
|
+
const track = Math.max(8, width - 3);
|
|
188
|
+
const position = this.stopPosition(levels.length, levels.indexOf(effective), track);
|
|
189
|
+
const bar = [
|
|
190
|
+
this.theme.fg("accent", "━".repeat(position)),
|
|
191
|
+
this.theme.fg("success", this.theme.bold("●")),
|
|
192
|
+
this.theme.fg("dim", "─".repeat(Math.max(0, track - position - 1))),
|
|
193
|
+
].join("");
|
|
194
|
+
const header = [
|
|
195
|
+
this.theme.fg("muted", "THINKING"),
|
|
196
|
+
this.theme.fg(this.levelColor(effective), this.theme.bold(effective)),
|
|
197
|
+
...(configured === effective ? [] : [this.theme.fg("dim", `(configured ${configured})`)]),
|
|
198
|
+
].join(" ");
|
|
199
|
+
return [
|
|
200
|
+
this.fit(` ${header}`, width),
|
|
201
|
+
this.fit(` ${bar}`, width),
|
|
202
|
+
this.theme.fg("dim", this.fit(` ${this.levelTicks(levels, track)}`, width)),
|
|
203
|
+
"",
|
|
204
|
+
];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private stopPosition(count: number, index: number, track: number): number {
|
|
208
|
+
if (count <= 1) return 0;
|
|
209
|
+
return Math.round((Math.max(0, index) * (track - 1)) / (count - 1));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Level names centered under their stops, skipped when the space is already taken. */
|
|
213
|
+
private levelTicks(levels: ModelThinkingLevel[], track: number): string {
|
|
214
|
+
const cells = Array.from({ length: track }, () => " ");
|
|
215
|
+
let lastEnd = -2;
|
|
216
|
+
levels.forEach((level, index) => {
|
|
217
|
+
if (level.length > track) return;
|
|
218
|
+
const start = Math.max(0, Math.min(track - level.length, this.stopPosition(levels.length, index, track) - Math.floor(level.length / 2)));
|
|
219
|
+
if (start <= lastEnd + 1) return;
|
|
220
|
+
for (let offset = 0; offset < level.length; offset++) cells[start + offset] = level.charAt(offset);
|
|
221
|
+
lastEnd = start + level.length - 1;
|
|
222
|
+
});
|
|
223
|
+
return cells.join("");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private detail(label: string, value: string, width: number): string {
|
|
227
|
+
const key = this.theme.fg("muted", label.padEnd(15));
|
|
228
|
+
return this.fit(` ${key}${value}`, width);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private details(model: PickerModel | undefined, width: number): string[] {
|
|
232
|
+
if (!model) return [this.theme.fg("dim", this.fit(" No available models", width))];
|
|
233
|
+
|
|
234
|
+
const current = this.current?.provider === model.provider && this.current.id === model.id;
|
|
235
|
+
const ref = modelRef(model);
|
|
236
|
+
const configured = this.defaults.thinkingLevels[ref];
|
|
237
|
+
const effective = this.levelTag(model);
|
|
238
|
+
const rates = model.cost;
|
|
239
|
+
const capability = [model.reasoning ? "Reasoning" : "Standard", model.input.includes("image") ? "Vision" : "Text only"].join(" · ");
|
|
240
|
+
const lines = [
|
|
241
|
+
this.theme.fg("accent", this.fit(` ${model.name}`, width)),
|
|
242
|
+
this.theme.fg("dim", this.fit(` ${model.id}`, width)),
|
|
243
|
+
"",
|
|
244
|
+
this.theme.fg("muted", this.fit(" CAPACITY", width)),
|
|
245
|
+
this.detail("Context", `${formatTokens(model.contextWindow)} tokens`, width),
|
|
246
|
+
this.detail("Max output", `${formatTokens(model.maxTokens)} tokens`, width),
|
|
247
|
+
"",
|
|
248
|
+
this.theme.fg("muted", this.fit(" CAPABILITIES", width)),
|
|
249
|
+
this.detail("Mode", capability, width),
|
|
250
|
+
this.detail("Protocol", model.api, width),
|
|
251
|
+
"",
|
|
252
|
+
...(configured === undefined || effective === undefined ? [] : this.thinkingSection(model, configured, effective, width)),
|
|
253
|
+
this.theme.fg("muted", this.fit(" PRICING / 1M TOKENS", width)),
|
|
254
|
+
this.detail("Input", formatRate(rates.input), width),
|
|
255
|
+
this.detail("Output", formatRate(rates.output), width),
|
|
256
|
+
this.detail("Cache read", formatRate(rates.cacheRead), width),
|
|
257
|
+
this.detail("Cache write", formatRate(rates.cacheWrite), width),
|
|
258
|
+
];
|
|
259
|
+
const badges: string[] = [];
|
|
260
|
+
if (current) badges.push(this.theme.fg("success", this.fit(" ● CURRENT SESSION MODEL", width)));
|
|
261
|
+
if (this.defaults.global === ref) badges.push(this.theme.fg("warning", this.fit(" ◆ GLOBAL DEFAULT", width)));
|
|
262
|
+
if (this.defaults.project === ref) badges.push(this.theme.fg("warning", this.fit(" ▲ PROJECT DEFAULT", width)));
|
|
263
|
+
if (badges.length > 0) lines.splice(2, 0, ...badges);
|
|
264
|
+
if (rates.tiers?.length) lines.push(this.theme.fg("dim", this.fit(` ${rates.tiers.length} long-context price tier(s)`, width)));
|
|
265
|
+
return lines.map((line) => line === "" ? " ".repeat(width) : line);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
render(width: number): string[] {
|
|
269
|
+
const safeWidth = Math.max(40, width);
|
|
270
|
+
const innerWidth = safeWidth - 2;
|
|
271
|
+
const providerWidth = Math.max(16, Math.floor(innerWidth * 0.21));
|
|
272
|
+
const modelWidth = Math.max(25, Math.floor(innerWidth * 0.35));
|
|
273
|
+
const detailWidth = Math.max(16, innerWidth - providerWidth - modelWidth - 2);
|
|
274
|
+
const divider = this.theme.fg("border", "│");
|
|
275
|
+
const header = (label: string, column: "provider" | "model", columnWidth: number, count?: number) => {
|
|
276
|
+
const focused = this.column === column;
|
|
277
|
+
const title = `${label}${count === undefined ? "" : ` ${count}`}`;
|
|
278
|
+
const content = focused ? this.theme.fg("accent", this.theme.bold(title)) : this.theme.fg("muted", title);
|
|
279
|
+
return this.fit(` ${content}`, columnWidth);
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const providerLines = this.providers.map((provider, index) =>
|
|
283
|
+
this.item(provider, this.column === "provider" && index === this.providerIndex, provider === this.current?.provider ? "●" : "", providerWidth),
|
|
284
|
+
);
|
|
285
|
+
const modelLines = this.models.map((model, index) =>
|
|
286
|
+
this.item(model.name, this.column === "model" && index === this.modelIndex, this.markers(model), modelWidth, this.levelTag(model)),
|
|
287
|
+
);
|
|
288
|
+
const rows = this.bodyRows;
|
|
289
|
+
const detailLines = this.details(this.selected, detailWidth).slice(0, rows);
|
|
290
|
+
const selectedLabel = this.selected ? `${this.selected.provider} / ${this.selected.id}` : "No selection";
|
|
291
|
+
|
|
292
|
+
const statusLine = (glyph: string, color: "success" | "warning", label: string, value: string) =>
|
|
293
|
+
`│${this.fit(` ${this.theme.fg(color, glyph)} ${this.theme.fg("muted", `${label}:`)} ${value}`, innerWidth)}│`;
|
|
294
|
+
const lines = [
|
|
295
|
+
this.theme.fg("border", `╭${"─".repeat(innerWidth)}╮`),
|
|
296
|
+
`│${this.fit(` ${this.theme.fg("accent", this.theme.bold("MODEL STUDIO"))} ${this.theme.fg("dim", "Choose a provider, inspect a model, apply with Enter")}`, innerWidth)}│`,
|
|
297
|
+
statusLine("●", "success", "Selected", selectedLabel),
|
|
298
|
+
statusLine("◆", "warning", "Global default", this.defaults.global ?? "none"),
|
|
299
|
+
statusLine("▲", "warning", "Project default", this.defaults.project ?? "none"),
|
|
300
|
+
this.theme.fg("border", `├${"─".repeat(providerWidth)}┬${"─".repeat(modelWidth)}┬${"─".repeat(detailWidth)}┤`),
|
|
301
|
+
`│${header("PROVIDERS", "provider", providerWidth, this.providers.length)}${divider}${header("MODELS", "model", modelWidth, this.models.length)}${divider}${this.fit(` ${this.theme.fg("muted", "INSPECTOR")}`, detailWidth)}│`,
|
|
302
|
+
this.theme.fg("border", `├${"─".repeat(providerWidth)}┼${"─".repeat(modelWidth)}┼${"─".repeat(detailWidth)}┤`),
|
|
303
|
+
];
|
|
304
|
+
|
|
305
|
+
for (let row = 0; row < rows; row++) {
|
|
306
|
+
lines.push(`│${providerLines[row] ?? " ".repeat(providerWidth)}${divider}${modelLines[row] ?? " ".repeat(modelWidth)}${divider}${detailLines[row] ?? " ".repeat(detailWidth)}│`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
lines.push(this.theme.fg("border", `├${"─".repeat(innerWidth)}┤`));
|
|
310
|
+
lines.push(`│${this.fit(` ${this.theme.fg("accent", "↑↓")} move ${this.theme.fg("accent", "←→ / Tab")} switch pane ${this.theme.fg("success", "Enter")} apply ${this.theme.fg("muted", "Esc")} cancel`, innerWidth)}│`);
|
|
311
|
+
lines.push(`│${this.fit(` ${this.theme.fg("success", "Ctrl+S")} save global default ${this.theme.fg("warning", "Ctrl+Shift+S")} save project default`, innerWidth)}│`);
|
|
312
|
+
lines.push(this.theme.fg("border", `╰${"─".repeat(innerWidth)}╯`));
|
|
313
|
+
return lines.map((line) => truncateToWidth(line, width));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
invalidate(): void {}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const settingsPath = (scope: SaveScope, cwd: string): string =>
|
|
320
|
+
scope === "global" ? join(getAgentDir(), "settings.json") : join(cwd, CONFIG_DIR_NAME, "settings.json");
|
|
321
|
+
|
|
322
|
+
const readSettingsFile = async (path: string): Promise<Record<string, unknown>> =>
|
|
323
|
+
existsSync(path) ? JSON.parse((await readFile(path, "utf-8")).replace(/^\uFEFF/, "")) : {};
|
|
324
|
+
|
|
325
|
+
const savedModelRef = (settings: Record<string, unknown>): string | undefined =>
|
|
326
|
+
typeof settings.defaultProvider === "string" && typeof settings.defaultModel === "string"
|
|
327
|
+
? `${settings.defaultProvider}/${settings.defaultModel}`
|
|
328
|
+
: undefined;
|
|
329
|
+
|
|
330
|
+
/** Write `defaultProvider` and `defaultModel` into the global or project settings file. */
|
|
331
|
+
export const saveDefaultModel = async (cwd: string, model: PickerModel, scope: SaveScope): Promise<string> => {
|
|
332
|
+
const path = settingsPath(scope, cwd);
|
|
333
|
+
const settings = await readSettingsFile(path);
|
|
334
|
+
settings.defaultProvider = model.provider;
|
|
335
|
+
settings.defaultModel = model.id;
|
|
336
|
+
await mkdir(dirname(path), { recursive: true });
|
|
337
|
+
await writeFile(path, `${JSON.stringify(settings, null, 2)}\n`, "utf-8");
|
|
338
|
+
return path;
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const savedThinkingLevels = (settings: Record<string, unknown>): Record<string, ModelThinkingLevel> => {
|
|
342
|
+
const levels = settings.modelThinkingLevels;
|
|
343
|
+
return typeof levels === "object" && levels !== null ? (levels as Record<string, ModelThinkingLevel>) : {};
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
/** Read the configured global and project default models and per-model thinking levels for display. */
|
|
347
|
+
export const loadSavedDefaults = async (cwd: string, projectTrusted: boolean): Promise<SavedDefaults> => {
|
|
348
|
+
const global = await readSettingsFile(settingsPath("global", cwd));
|
|
349
|
+
const project = projectTrusted ? await readSettingsFile(settingsPath("project", cwd)) : {};
|
|
350
|
+
return {
|
|
351
|
+
global: savedModelRef(global),
|
|
352
|
+
project: savedModelRef(project),
|
|
353
|
+
thinkingLevels: { ...savedThinkingLevels(global), ...savedThinkingLevels(project) },
|
|
354
|
+
};
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
export default function (pi: ExtensionAPI) {
|
|
358
|
+
const openPicker = async (ctx: ExtensionContext) => {
|
|
359
|
+
const models = ctx.modelRegistry.getAvailable()
|
|
360
|
+
.slice()
|
|
361
|
+
.sort((a, b) => a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id));
|
|
362
|
+
const byProvider = new Map<string, PickerModel[]>();
|
|
363
|
+
for (const model of models) {
|
|
364
|
+
const group = byProvider.get(model.provider) ?? [];
|
|
365
|
+
group.push(model);
|
|
366
|
+
byProvider.set(model.provider, group);
|
|
367
|
+
}
|
|
368
|
+
const providers = [...byProvider.keys()];
|
|
369
|
+
if (providers.length === 0) {
|
|
370
|
+
ctx.ui.notify("No authenticated models are available.", "warning");
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const defaults = await loadSavedDefaults(ctx.cwd, ctx.isProjectTrusted());
|
|
375
|
+
const result = await ctx.ui.custom<PickerResult | null>((tui, theme, _keybindings, done) => {
|
|
376
|
+
const picker = new ModelPicker(
|
|
377
|
+
byProvider,
|
|
378
|
+
providers,
|
|
379
|
+
ctx.model,
|
|
380
|
+
defaults,
|
|
381
|
+
() => tui.terminal.rows,
|
|
382
|
+
theme as PickerTheme,
|
|
383
|
+
(model) => done({ model }),
|
|
384
|
+
(model, scope) => done({ model, persist: scope }),
|
|
385
|
+
() => done(null),
|
|
386
|
+
);
|
|
387
|
+
return {
|
|
388
|
+
render: (width) => picker.render(width),
|
|
389
|
+
invalidate: () => picker.invalidate(),
|
|
390
|
+
handleInput: (data) => {
|
|
391
|
+
picker.handleInput(data);
|
|
392
|
+
tui.requestRender();
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
if (!result) return;
|
|
398
|
+
const { model, persist } = result;
|
|
399
|
+
if (!(await pi.setModel(model))) {
|
|
400
|
+
ctx.ui.notify(`Could not select ${model.provider}/${model.id}: authentication is unavailable.`, "warning");
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (!persist) {
|
|
404
|
+
ctx.ui.notify(`Model set to ${model.provider}/${model.id}`, "info");
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (persist === "project" && !ctx.isProjectTrusted()) {
|
|
408
|
+
ctx.ui.notify("Model set, but this project is not trusted, so no project default was saved.", "warning");
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
await saveDefaultModel(ctx.cwd, model, persist);
|
|
412
|
+
ctx.ui.notify(persist === "global" ? `Default model: ${modelRef(model)}` : `Project default model: ${modelRef(model)}`, "info");
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
pi.registerCommand("model-ui", {
|
|
416
|
+
description: "Select a model with provider, model, and metadata panes",
|
|
417
|
+
handler: async (_args, ctx) => openPicker(ctx),
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
pi.registerShortcut("ctrl+shift+m", {
|
|
421
|
+
description: "Open the model picker UI",
|
|
422
|
+
handler: async (ctx) => openPicker(ctx),
|
|
423
|
+
});
|
|
424
|
+
}
|