pi-picks 0.0.0-stage → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/LICENSE +22 -0
- package/README.md +133 -2
- package/SKILL.md +26 -0
- package/index.ts +914 -0
- package/package.json +55 -4
- package/scripts/install.js +77 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.3.0 — 2026-10-17
|
|
4
|
+
|
|
5
|
+
- Rename the package to `pi-picks` (npm and GitHub). Settings move to the `"pi-picks"` block in settings.json; an existing `"pi-model-picker"` block is still read and is migrated on the next write
|
|
6
|
+
- Publish to npm from GitHub Actions with trusted publishing on every version bump pushed to main
|
|
7
|
+
- Change the default picker shortcut from Ctrl+Shift+M to Ctrl+\ (unbound in pi; configurable via pi-picks.shortcut)
|
|
8
|
+
|
|
9
|
+
- Merge the tabbed and fuzzy screens into one: tabs are facets (Favorites, All, then providers A→Z) over a single global fuzzy query. Tab switches tabs without touching the query (skipping no-match provider tabs while a query is active); selection follows the model across tab switches. Active searches add a subtle violet background to matching tabs and a subtle neutral background to matching rows, with a distinct search-match background for the top-ranked row. Favorites and All stay pinned when provider tabs overflow, with `<<` / `>>` overflow markers. The All tab sorts same-model duplicates adjacently, the details line shows provider/id on cross-provider tabs, and favorites carry a gold ● marker outside the Favorites tab (the active model's ● turns gold when it is also a favorite)
|
|
10
|
+
- Add a Favorites tab (first tab, and the opening screen when any favorite is available) with provider-labelled rows; the list is the extension's own, stored under `pi-picks.favorites` in settings.json, independent of pi's native scoped models
|
|
11
|
+
- Add Ctrl+F to toggle the selected model in Favorites from any tab (it stays in its provider tab; Ctrl+S was dropped because multiplexers intercept it), and Ctrl+X to remove favorites contained in the active tab (provider-scoped on provider tabs; full clear on Favorites)
|
|
12
|
+
|
|
13
|
+
- Report an incomplete installation and fail the command if an extension file cannot be copied
|
|
14
|
+
- Keep rendered lines within the terminal width during very narrow resizes
|
|
15
|
+
- Use Pi's agent-directory resolver so tilde paths and empty overrides follow Pi's settings behavior
|
|
16
|
+
- Preserve custom colon-qualified model names unless the prefix identifies the provider or model ID namespace
|
|
17
|
+
- Give the selected provider tab a themed background highlight in scoped and fuzzy search
|
|
18
|
+
- Align bracketed providers by the longest fuzzy match name and show the selected model's full ID beside the counter
|
|
19
|
+
- Remove maker prefixes from model display names while keeping version and variant information
|
|
20
|
+
- Add Ctrl+F global fuzzy search with ranked results, provider labels, and matching-tab highlights
|
|
21
|
+
- Omit redundant trailing provider annotations such as `(Antigravity)` from model labels
|
|
22
|
+
- Always show the selected position and filtered total as `(5/43)`, or `(0/0)` for no results
|
|
23
|
+
- Add Shift+Up/Down to move ten models at a time within the filtered list
|
|
24
|
+
- Save each selected model as the default for new sessions
|
|
25
|
+
- Show compact action and navigation hints on one footer row
|
|
26
|
+
- Remove creator grouping and its shortcut; keep provider tabs only
|
|
27
|
+
- Restore the Select Model header and Tab/Shift+Tab category navigation
|
|
28
|
+
- Restore upstream tab creation and naming: one tab per provider, with no provider-specific exceptions
|
|
29
|
+
|
|
30
|
+
## 1.2.0 — 2026-09-17
|
|
31
|
+
|
|
32
|
+
- Show token costs and aligned model metadata in picker rows
|
|
33
|
+
- Switch between provider and creator grouping with `Tab`
|
|
34
|
+
- Remember the last grouping and tab while pi is running
|
|
35
|
+
- Keep a fixed 10-row picker height across tabs and searches
|
|
36
|
+
|
|
37
|
+
## 1.1.0 — 2026-07-02
|
|
38
|
+
|
|
39
|
+
- Keyboard shortcut is now configurable via `~/.pi/agent/settings.json` (`pi-model-picker.shortcut`)
|
|
40
|
+
- Accepts a string, an array of strings (binds multiple keys), or `false` to disable
|
|
41
|
+
- Defaults to `Ctrl+Shift+M` when unset
|
|
42
|
+
|
|
43
|
+
## 1.0.0 — 2026-02-25
|
|
44
|
+
|
|
45
|
+
Initial release.
|
|
46
|
+
|
|
47
|
+
- Categorized model picker grouped by provider
|
|
48
|
+
- Tab / ← → to switch categories
|
|
49
|
+
- Per-category search field (preserves query when switching categories)
|
|
50
|
+
- ↑ / ↓ navigation with wraparound
|
|
51
|
+
- Active model highlighted with ● marker
|
|
52
|
+
- Model metadata: context window size, `thinking` and `vision` tags
|
|
53
|
+
- `/models` command and `Ctrl+Shift+M` shortcut
|
|
54
|
+
- Uses same data source as built-in `/model` (`modelRegistry.refresh()` + `getAvailable()`)
|
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ilham Ramadhan
|
|
4
|
+
Copyright (c) 2026 JangManJ
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,134 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-picks
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/pi-picks)
|
|
4
|
+
|
|
5
|
+
A keyboard-driven model picker extension for the [pi coding agent](https://github.com/badlogic/pi-mono). It started as a fork of [rilham97/pi-model-picker](https://github.com/rilham97/pi-model-picker) and was renamed once it diverged: favorites, one fuzzy query across tabs, token costs, and aligned metadata.
|
|
6
|
+
|
|
7
|
+
One screen, with tabs as filters over a single list: **Favorites**, **All**, then one tab per provider (A→Z). Typing fuzzy-searches within the current tab. Press `Tab` or `Shift+Tab` to switch tabs without losing your query; the All tab sits beside Favorites when you need to widen. The picker opens on Favorites when any favorite is available, so the usual flow is just open, type a character or two, `Enter`.
|
|
8
|
+
|
|
9
|
+
The first tab, **Favorites**, is the picker's own list, independent of pi's native scoped models, and the screen the picker opens on. Press `Ctrl+F` on any tab to toggle the selected model in Favorites (it stays in its provider tab too), and `Ctrl+X` to remove the favorites contained in the active tab: every favorite on Favorites or All, or only that provider's favorites on a provider tab. Favorites are marked with a gold `●` outside the Favorites tab. They are stored as `provider/id` strings in `~/.pi/agent/settings.json` under `pi-picks.favorites` (hand-editable), in the order added; a favorite whose model is temporarily unavailable is kept and reappears when the model does.
|
|
10
|
+
|
|
11
|
+
## Preview
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
───────────────────────────────────────────────────────────────────
|
|
15
|
+
Select Model
|
|
16
|
+
───────────────────────────────────────────────────────────────────
|
|
17
|
+
Favorites │ All │ Anthropic │ Antigravity │ Fireworks │ Xai
|
|
18
|
+
───────────────────────────────────────────────────────────────────
|
|
19
|
+
Search: > claude_
|
|
20
|
+
|
|
21
|
+
▶ Claude Sonnet ● [Anthropic] $3/$15 200k thinking vision
|
|
22
|
+
|
|
23
|
+
(1/1) anthropic/claude-sonnet
|
|
24
|
+
───────────────────────────────────────────────────────────────────
|
|
25
|
+
↑↓ Enter select model · ←→ Tab select menu · Ctrl+F toggle favorite · Ctrl+X clear favorites · Esc cancel
|
|
26
|
+
───────────────────────────────────────────────────────────────────
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- **Active model** highlighted in green with a green `●`; if it is also a favorite, the `●` is gold (on every tab, including Favorites)
|
|
30
|
+
- **Context window** shown as `200k`, `1M`, etc.
|
|
31
|
+
- **Capability tags**: `thinking` (extended reasoning), `vision` (image input)
|
|
32
|
+
- **Search** fuzzy-matches model name, id, and provider within the current tab
|
|
33
|
+
- **Ranked search highlights** — matching tabs and rows get a subtle neutral background, while the best-ranked result gets a distinct search-match background separate from the selected tab highlight
|
|
34
|
+
- **One query everywhere** — switching tabs keeps your query and, when it survives, your selection
|
|
35
|
+
- **Duplicates side by side** — on the All tab the same model from several providers sits adjacently, so cost and context differences are easy to compare
|
|
36
|
+
- **Wraparound navigation** — `↑` on the first item jumps to the last, and vice versa
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
### Via pi package manager (recommended)
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pi install npm:pi-picks
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Restart pi after installation. `pi update --extensions` pulls new releases.
|
|
47
|
+
|
|
48
|
+
### From git
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pi install git:github.com/JangMan-J/pi-picks
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Migrating from pi-model-picker
|
|
55
|
+
|
|
56
|
+
Remove the old package first so `/models` is not registered twice (`pi remove npm:pi-model-picker` or `pi remove git:github.com/JangMan-J/pi-model-picker`, and delete `~/.pi/agent/extensions/model-picker` if you installed it by hand). Settings under the old `"pi-model-picker"` key in `settings.json` are still read, and move to `"pi-picks"` the next time the picker saves a setting.
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
| Trigger | Description |
|
|
61
|
+
|---------|-------------|
|
|
62
|
+
| `/models` | Open the categorized picker |
|
|
63
|
+
| Ctrl+\ | Keyboard shortcut (default, configurable) |
|
|
64
|
+
|
|
65
|
+
> **Note:** `/model` is a built-in pi command and cannot be overridden. Use `/models` (with an `s`) for this picker. The built-in `/model` (flat search) continues to work as normal.
|
|
66
|
+
|
|
67
|
+
### Configuring the shortcut
|
|
68
|
+
|
|
69
|
+
By default the picker binds `Ctrl+\`. Override it in `~/.pi/agent/settings.json`:
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"pi-picks": {
|
|
74
|
+
"shortcut": "alt+m",
|
|
75
|
+
"rememberLastTab": true
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`shortcut` accepts:
|
|
81
|
+
|
|
82
|
+
- a single key string, e.g. `"alt+m"`
|
|
83
|
+
- an array of key strings to bind multiple keys, e.g. ["alt+m", "ctrl+\\"]
|
|
84
|
+
- `false` to disable the shortcut entirely (the `/models` command still works)
|
|
85
|
+
|
|
86
|
+
See pi's [keybindings docs](https://github.com/badlogic/pi-mono) for the key identifier format (`ctrl`, `shift`, `alt` modifiers combined with letters, digits, or symbols).
|
|
87
|
+
|
|
88
|
+
Restart pi (or run `/reload`) after changing `settings.json`.
|
|
89
|
+
|
|
90
|
+
### Last tab
|
|
91
|
+
|
|
92
|
+
The picker opens on **Favorites** when any favorite is available. Otherwise, with `rememberLastTab` (default `true`) it reopens the tab it last closed on; this state stays in memory while pi is running — restarting pi or using `/reload` clears it, and no state file is used. Search text is not saved.
|
|
93
|
+
|
|
94
|
+
With `rememberLastTab: false` and no available favorites, the picker starts on the current model's provider tab.
|
|
95
|
+
If the saved tab is unavailable, the picker uses the current model's tab.
|
|
96
|
+
|
|
97
|
+
### Searching beyond the current tab
|
|
98
|
+
|
|
99
|
+
Every tab uses the same fuzzy search; tabs only narrow which models it runs over. Move to the All tab when the current tab has no match (the empty state tells you when matches exist elsewhere). With a query active, matching tabs get green text on a subtle violet background, matching rows get a subtle neutral background, and the best-ranked row (first after fuzzy sorting) gets a distinct search-match background; the selected tab keeps its separate selected background. `Tab` skips provider tabs that have no matches; Favorites and All are always stops. Favorites and All stay visible when the provider tabs overflow the bar; the `<<` / `>>` markers show what is hidden on each side.
|
|
100
|
+
|
|
101
|
+
## Controls
|
|
102
|
+
|
|
103
|
+
| Key | Action |
|
|
104
|
+
|-----|--------|
|
|
105
|
+
| `↑` / `↓` | Navigate models (wraps around) |
|
|
106
|
+
| `Shift+↑` / `Shift+↓` | Move up / down 10 models, stopping at the first or last result |
|
|
107
|
+
| `Tab` / `Shift+Tab` | Switch tab (Favorites, All, then providers); with a query active, provider tabs with no matches are skipped |
|
|
108
|
+
| `Ctrl+F` | Toggle the selected model in Favorites, on any tab |
|
|
109
|
+
| `Ctrl+X` | Remove favorites contained in the active tab (every favorite on Favorites; only the relevant provider's favorites on a provider tab) |
|
|
110
|
+
| `←` / `→` | Switch tab when the search field is empty; otherwise move the text cursor |
|
|
111
|
+
| Type | Fuzzy-filter the current tab |
|
|
112
|
+
| `Enter` | Select highlighted model and save it as the default for new sessions |
|
|
113
|
+
| `Esc` / `Ctrl+C` | Cancel |
|
|
114
|
+
|
|
115
|
+
Each successful selection saves the provider and model in Pi's global settings.
|
|
116
|
+
It preserves other settings, including the default thinking level.
|
|
117
|
+
|
|
118
|
+
## How it works
|
|
119
|
+
|
|
120
|
+
The picker calls `modelRegistry.refresh()` then `modelRegistry.getAvailable()` — the same data source as pi's built-in `/model` command. Only models with auth configured (API key or OAuth) are shown. Models are grouped by their `provider` field into provider tabs in A→Z order, sorted alphabetically within each tab with the active model first. The All tab instead sorts by model name, then provider, so duplicates offered by several providers sit together.
|
|
121
|
+
|
|
122
|
+
## Uninstall
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
pi remove npm:pi-picks
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Releasing
|
|
129
|
+
|
|
130
|
+
Every push to `main` runs `npm run check`. When `package.json` has a version that is not on npm yet, CI publishes it with npm trusted publishing (OIDC, with provenance), then tags `v<version>` and creates a GitHub release from that version's `CHANGELOG.md` section. To release: bump `version`, rename the `## Unreleased` heading to `## <version> — <date>`, and push.
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MIT — see [LICENSE](LICENSE).
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pi-picks
|
|
3
|
+
description: Categorized model selector for pi. Use /models or Ctrl+\ to open a single-screen TUI picker with tabs as filters — Favorites first (the picker's own stored list and the opening screen), then All, then one tab per provider. Type to fuzzy-search within the current tab, Tab or ← → to switch tabs keeping the query, ↑↓ to navigate, Ctrl+F to toggle the selected model in Favorites, Ctrl+X to remove favorites contained in the active tab, Enter to select and save the startup default.
|
|
4
|
+
license: MIT
|
|
5
|
+
compatibility: Requires pi coding agent with auth configured for at least one model provider.
|
|
6
|
+
metadata:
|
|
7
|
+
author: JangMan-J
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# pi-picks
|
|
11
|
+
|
|
12
|
+
Open the categorized model picker with `/models` or `Ctrl+\`.
|
|
13
|
+
|
|
14
|
+
## Controls
|
|
15
|
+
|
|
16
|
+
| Key | Action |
|
|
17
|
+
|-----|--------|
|
|
18
|
+
| `↑` / `↓` | Navigate models (wraps around) |
|
|
19
|
+
| `Shift+↑` / `Shift+↓` | Move ten results, stopping at either end |
|
|
20
|
+
| `Ctrl+F` | Toggle the selected model in Favorites, on any tab (keeps it in its provider tab) |
|
|
21
|
+
| `Ctrl+X` | Remove favorites contained in the active tab |
|
|
22
|
+
| `Tab` / `Shift+Tab` | Switch tab; with a query active, provider tabs with no matches are skipped |
|
|
23
|
+
| `←` / `→` | Switch tab when the search field is empty; otherwise move the text cursor |
|
|
24
|
+
| Type | Fuzzy-filter the current tab |
|
|
25
|
+
| `Enter` | Select highlighted model and save it as the startup default |
|
|
26
|
+
| `Esc` | Cancel |
|
package/index.ts
ADDED
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-picks — model picker extension
|
|
3
|
+
*
|
|
4
|
+
* Categorized, keyboard-driven model selector with one global fuzzy query.
|
|
5
|
+
*
|
|
6
|
+
* Tabs are facets over a single list: Favorites, All, then one per
|
|
7
|
+
* provider (A→Z). Typing fuzzy-searches within the current tab; Tab
|
|
8
|
+
* switches tab without touching the query. The picker opens on Favorites
|
|
9
|
+
* when any favorite is
|
|
10
|
+
* available. Favorites is this extension's own list, stored as
|
|
11
|
+
* `provider/id` strings under `pi-picks.favorites` in
|
|
12
|
+
* settings.json — independent of pi's native scoped models
|
|
13
|
+
* (`enabledModels`). Ctrl+F toggles the selected model in Favorites
|
|
14
|
+
* from any tab (it stays in its provider tab); Ctrl+X removes the
|
|
15
|
+
* favorites contained in the active tab (every favorite on Favorites).
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* /models — open the categorized picker
|
|
19
|
+
* Ctrl+\ — keyboard shortcut (default; configurable, see below)
|
|
20
|
+
*
|
|
21
|
+
* Note: /model is a built-in pi command and cannot be overridden.
|
|
22
|
+
*
|
|
23
|
+
* Configuring the shortcut:
|
|
24
|
+
* Add to ~/.pi/agent/settings.json:
|
|
25
|
+
* { "pi-picks": { "shortcut": "alt+m" } }
|
|
26
|
+
* Accepts a string, an array of strings (binds multiple keys), or `false`
|
|
27
|
+
* to disable the shortcut entirely (the /models command still works).
|
|
28
|
+
* A legacy "pi-model-picker" block is still read and is migrated on the
|
|
29
|
+
* next settings write.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
33
|
+
import { join } from "node:path";
|
|
34
|
+
import { getAgentDir, SettingsManager } from "@mariozechner/pi-coding-agent";
|
|
35
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
36
|
+
import { Input, Key, fuzzyFilter, matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
37
|
+
import type { KeyId } from "@mariozechner/pi-tui";
|
|
38
|
+
import type { Api, Model } from "@mariozechner/pi-ai";
|
|
39
|
+
|
|
40
|
+
// ─── settings ───────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
const DEFAULT_SHORTCUT = "ctrl+\\";
|
|
43
|
+
|
|
44
|
+
const SETTINGS_KEY = "pi-picks";
|
|
45
|
+
/** Settings block name used before the rename; read as a fallback, removed on write. */
|
|
46
|
+
const LEGACY_SETTINGS_KEY = "pi-model-picker";
|
|
47
|
+
|
|
48
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
49
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The extension's settings, with the current block overriding the legacy one. */
|
|
53
|
+
function settingsBlock(raw: unknown): Record<string, unknown> {
|
|
54
|
+
if (!isRecord(raw)) return {};
|
|
55
|
+
const legacy = raw[LEGACY_SETTINGS_KEY];
|
|
56
|
+
const current = raw[SETTINGS_KEY];
|
|
57
|
+
return { ...(isRecord(legacy) ? legacy : {}), ...(isRecord(current) ? current : {}) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readSetting(setting: string): unknown {
|
|
61
|
+
try {
|
|
62
|
+
return settingsBlock(JSON.parse(readFileSync(join(getAgentDir(), "settings.json"), "utf-8")))[setting];
|
|
63
|
+
} catch {
|
|
64
|
+
return undefined; // Missing or malformed settings use defaults.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Stored favorites as `provider/id` refs, in the order they were added. */
|
|
69
|
+
function readFavoriteRefs(): string[] {
|
|
70
|
+
const favorites = readSetting("favorites");
|
|
71
|
+
return Array.isArray(favorites)
|
|
72
|
+
? favorites.filter((f): f is string => typeof f === "string" && f.length > 0)
|
|
73
|
+
: [];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Write one pi-picks setting, preserving everything else in settings.json. */
|
|
77
|
+
function writeSetting(setting: string, value: unknown): void {
|
|
78
|
+
const path = join(getAgentDir(), "settings.json");
|
|
79
|
+
let raw: Record<string, unknown> = {};
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
82
|
+
if (isRecord(parsed)) raw = parsed;
|
|
83
|
+
} catch {
|
|
84
|
+
// Missing or malformed settings start from an empty object.
|
|
85
|
+
}
|
|
86
|
+
raw[SETTINGS_KEY] = { ...settingsBlock(raw), [setting]: value };
|
|
87
|
+
delete raw[LEGACY_SETTINGS_KEY];
|
|
88
|
+
writeFileSync(path, JSON.stringify(raw, null, 2) + "\n", "utf-8");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Accept a key, multiple keys, or false/[] to disable. */
|
|
92
|
+
function resolveShortcuts(setting = "shortcut", fallback = DEFAULT_SHORTCUT): string[] {
|
|
93
|
+
const shortcut = readSetting(setting);
|
|
94
|
+
if (shortcut === false) return [];
|
|
95
|
+
if (typeof shortcut === "string") return shortcut ? [shortcut] : [];
|
|
96
|
+
if (Array.isArray(shortcut)) return shortcut.filter((s): s is string => typeof s === "string" && s.length > 0);
|
|
97
|
+
return [fallback];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── helpers ────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/** Friendly display name for a provider id — derived from the id itself, no hardcoding */
|
|
103
|
+
function providerLabel(id: string): string {
|
|
104
|
+
return id
|
|
105
|
+
.split("-")
|
|
106
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
107
|
+
.join(" ");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Omit identified maker prefixes and redundant provider suffixes, not custom name qualifiers. */
|
|
111
|
+
function modelLabel(model: Model<Api>): string {
|
|
112
|
+
let name = model.name;
|
|
113
|
+
const prefix = name.match(/^([^():]+):\s+(?=\S)/);
|
|
114
|
+
if (prefix) {
|
|
115
|
+
const maker = prefix[1]!.toLowerCase().replace(/[\s._-]/g, "");
|
|
116
|
+
const namespace = model.id.includes("/") ? model.id.split("/")[0]! : "";
|
|
117
|
+
if (maker && [model.provider, namespace].some((id) => id.toLowerCase().replace(/[\s._-]/g, "") === maker)) {
|
|
118
|
+
name = name.slice(prefix[0].length);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const suffix = name.match(/\s+\(([^()]*)\)\s*$/);
|
|
122
|
+
const provider = suffix?.[1].trim().toLowerCase();
|
|
123
|
+
return suffix && (provider === model.provider.toLowerCase() || provider === providerLabel(model.provider).toLowerCase())
|
|
124
|
+
? name.slice(0, suffix.index).trimEnd() || name : name;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Format context window as human-readable */
|
|
128
|
+
function fmtCtx(tokens: number): string {
|
|
129
|
+
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(0)}M`;
|
|
130
|
+
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(0)}k`;
|
|
131
|
+
return String(tokens);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Format per-million-token cost as "$in/$out"; omit invalid or all-zero rates. */
|
|
135
|
+
function fmtCost(cost: { input: number; output: number }): string {
|
|
136
|
+
const rates = [cost?.input, cost?.output];
|
|
137
|
+
if (!rates.every((n) => Number.isFinite(n) && n >= 0) || !rates.some((n) => n > 0)) return "";
|
|
138
|
+
const fmt = (n: number) =>
|
|
139
|
+
n === 0 ? "0"
|
|
140
|
+
: n < 0.01 ? String(Number(n.toPrecision(2))) // keep sub-cent rates non-zero
|
|
141
|
+
: n < 1 ? n.toFixed(2)
|
|
142
|
+
: n.toFixed(2).replace(/\.?0+$/, "");
|
|
143
|
+
return rates.map((n) => `$${fmt(n)}`).join("/");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── component ──────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
/** Tab keys for the cross-provider tabs. Sentinels so they cannot collide with a provider id. */
|
|
149
|
+
const FAVORITES_TAB = "\0favorites";
|
|
150
|
+
const ALL_TAB = "\0all";
|
|
151
|
+
|
|
152
|
+
const modelRef = (m: Model<Api>): string => `${m.provider}\0${m.id}`;
|
|
153
|
+
|
|
154
|
+
interface ModelPickerOptions {
|
|
155
|
+
allModels: Model<Api>[];
|
|
156
|
+
/** Stored favorites resolved against the catalogue, in stored order. */
|
|
157
|
+
favorites: Model<Api>[];
|
|
158
|
+
currentModel: Model<Api> | undefined;
|
|
159
|
+
lastTab?: string;
|
|
160
|
+
onSelect: (model: Model<Api>) => void;
|
|
161
|
+
onCancel: () => void;
|
|
162
|
+
onAddToFavorite: (model: Model<Api>) => void;
|
|
163
|
+
onRemoveFavorite: (model: Model<Api>) => void;
|
|
164
|
+
onRemoveFavorites: (models: Model<Api>[]) => void;
|
|
165
|
+
onClearFavorites: () => void;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
class ModelPickerComponent {
|
|
169
|
+
// Focusable — needed so the Input inside gets IME cursor positioning
|
|
170
|
+
focused = false;
|
|
171
|
+
|
|
172
|
+
private categories: string[];
|
|
173
|
+
private catIndex: number;
|
|
174
|
+
private rowIndex = 0;
|
|
175
|
+
private matchingProviders = new Set<string>();
|
|
176
|
+
|
|
177
|
+
// Favorite models in stored order; mutated locally by Ctrl+F/Ctrl+X
|
|
178
|
+
private favorites: Model<Api>[];
|
|
179
|
+
|
|
180
|
+
// Fuzzy matches across every provider for the current query (drives tab
|
|
181
|
+
// highlighting, match-only Tab stops, and widen hints)
|
|
182
|
+
private globalMatches: Model<Api>[] = [];
|
|
183
|
+
|
|
184
|
+
// First provider shown in the windowed part of the tab bar (index into
|
|
185
|
+
// categories); adjusted minimally so the active provider stays visible
|
|
186
|
+
private providerLo = 0;
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
// per-category source models (sorted, never mutated)
|
|
190
|
+
private byCategory: Map<string, Model<Api>[]>;
|
|
191
|
+
|
|
192
|
+
// every model, sorted by display label then provider so the same model
|
|
193
|
+
// offered by several providers sits adjacently on the All tab
|
|
194
|
+
private allSorted: Model<Api>[] = [];
|
|
195
|
+
|
|
196
|
+
// the search Input widget
|
|
197
|
+
private searchInput: Input;
|
|
198
|
+
|
|
199
|
+
// filtered models for the current view (recomputed on query/category change)
|
|
200
|
+
private filteredRows: Model<Api>[] = [];
|
|
201
|
+
|
|
202
|
+
constructor(private opts: ModelPickerOptions) {
|
|
203
|
+
this.byCategory = this.buildCategories();
|
|
204
|
+
this.categories = Array.from(this.byCategory.keys());
|
|
205
|
+
this.favorites = [...opts.favorites];
|
|
206
|
+
|
|
207
|
+
// Home is Favorites when any favorite is available; otherwise restore
|
|
208
|
+
// the saved tab if available, otherwise follow the current model.
|
|
209
|
+
const cur = opts.currentModel;
|
|
210
|
+
const startTab = this.favorites.length > 0 ? FAVORITES_TAB
|
|
211
|
+
: opts.lastTab && this.tabs.includes(opts.lastTab) ? opts.lastTab
|
|
212
|
+
: cur?.provider ?? ALL_TAB;
|
|
213
|
+
this.catIndex = Math.max(0, this.tabs.indexOf(startTab));
|
|
214
|
+
|
|
215
|
+
// Build the search Input
|
|
216
|
+
this.searchInput = new Input();
|
|
217
|
+
this.searchInput.focused = true;
|
|
218
|
+
this.searchInput.onEscape = () => opts.onCancel();
|
|
219
|
+
this.searchInput.onSubmit = () => {
|
|
220
|
+
const selected = this.filteredRows[this.rowIndex];
|
|
221
|
+
if (selected) opts.onSelect(selected);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// Initialise filtered rows and pre-select current model
|
|
225
|
+
this.applyFilter();
|
|
226
|
+
if (cur) {
|
|
227
|
+
const idx = this.filteredRows.findIndex(
|
|
228
|
+
(m) => m.id === cur.id && m.provider === cur.provider,
|
|
229
|
+
);
|
|
230
|
+
this.rowIndex = Math.max(0, idx);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** All tabs: Favorites, All, then one per provider (A→Z). */
|
|
235
|
+
private get tabs(): string[] {
|
|
236
|
+
return [FAVORITES_TAB, ALL_TAB, ...this.categories];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** True for tabs whose rows span providers (they show the provider column). */
|
|
240
|
+
private get crossProvider(): boolean {
|
|
241
|
+
const tab = this.currentTab();
|
|
242
|
+
return tab === FAVORITES_TAB || tab === ALL_TAB;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private currentTab(): string {
|
|
246
|
+
return this.tabs[this.catIndex] ?? FAVORITES_TAB;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private onFavoritesTab(): boolean {
|
|
250
|
+
return this.currentTab() === FAVORITES_TAB;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private isFavorite(model: Model<Api>): boolean {
|
|
254
|
+
return this.favorites.some((m) => modelRef(m) === modelRef(model));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
getLastTab() {
|
|
258
|
+
return this.currentTab();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
updateModels(allModels: Model<Api>[], currentModel?: Model<Api>): void {
|
|
262
|
+
const prevTab = this.currentTab();
|
|
263
|
+
const prevSelected = this.filteredRows[this.rowIndex];
|
|
264
|
+
this.opts.allModels = allModels;
|
|
265
|
+
if (currentModel !== undefined) this.opts.currentModel = currentModel;
|
|
266
|
+
this.byCategory = this.buildCategories();
|
|
267
|
+
this.categories = Array.from(this.byCategory.keys());
|
|
268
|
+
// Remap favorites onto refreshed objects, dropping unavailable ones.
|
|
269
|
+
const refreshed = new Map(allModels.map((m) => [modelRef(m), m]));
|
|
270
|
+
this.favorites = this.favorites.flatMap((m) => {
|
|
271
|
+
const next = refreshed.get(modelRef(m));
|
|
272
|
+
return next ? [next] : [];
|
|
273
|
+
});
|
|
274
|
+
const nextTabIdx = this.tabs.indexOf(prevTab);
|
|
275
|
+
this.catIndex = nextTabIdx >= 0 ? nextTabIdx : Math.min(this.catIndex, Math.max(0, this.tabs.length - 1));
|
|
276
|
+
this.applyFilter();
|
|
277
|
+
if (prevSelected) {
|
|
278
|
+
const idx = this.filteredRows.findIndex(
|
|
279
|
+
(m) => m.id === prevSelected.id && m.provider === prevSelected.provider,
|
|
280
|
+
);
|
|
281
|
+
if (idx >= 0) this.rowIndex = idx;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── public Focusable propagation ─────────────────────────────────────
|
|
286
|
+
set focusedState(v: boolean) {
|
|
287
|
+
this.focused = v;
|
|
288
|
+
this.searchInput.focused = v;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── category building ────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
private buildCategories(): Map<string, Model<Api>[]> {
|
|
294
|
+
const map = new Map<string, Model<Api>[]>();
|
|
295
|
+
for (const m of this.opts.allModels) {
|
|
296
|
+
if (!map.has(m.provider)) map.set(m.provider, []);
|
|
297
|
+
map.get(m.provider)!.push(m);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const cur = this.opts.currentModel;
|
|
301
|
+
|
|
302
|
+
// Sort models within each category: active first, then alphabetical
|
|
303
|
+
for (const [, arr] of map) {
|
|
304
|
+
arr.sort((a, b) => {
|
|
305
|
+
const aCur = cur && a.id === cur.id && a.provider === cur.provider ? -1 : 0;
|
|
306
|
+
const bCur = cur && b.id === cur.id && b.provider === cur.provider ? -1 : 0;
|
|
307
|
+
if (aCur !== bCur) return aCur - bCur;
|
|
308
|
+
return a.name.localeCompare(b.name);
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Provider tabs keep a stable A→Z order; the picker opens on the
|
|
313
|
+
// relevant tab directly, so promoting the current provider would
|
|
314
|
+
// only rearrange the bar whenever the model changes.
|
|
315
|
+
const sorted = new Map(
|
|
316
|
+
[...map.entries()].sort(([aKey], [bKey]) => aKey.localeCompare(bKey)),
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
// All-tab order: display label, then provider — duplicate models
|
|
320
|
+
// offered by several providers end up adjacent for comparison.
|
|
321
|
+
this.allSorted = [...map.values()].flat().sort((a, b) =>
|
|
322
|
+
modelLabel(a).localeCompare(modelLabel(b))
|
|
323
|
+
|| a.provider.localeCompare(b.provider)
|
|
324
|
+
|| a.id.localeCompare(b.id));
|
|
325
|
+
return sorted;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── filtering ────────────────────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
private tabLabel(key: string): string {
|
|
331
|
+
return key === FAVORITES_TAB ? "Favorites" : key === ALL_TAB ? "All" : providerLabel(key);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Source rows for a tab before the query is applied. */
|
|
335
|
+
private tabSource(tabKey: string): Model<Api>[] {
|
|
336
|
+
return tabKey === FAVORITES_TAB ? this.favorites
|
|
337
|
+
: tabKey === ALL_TAB ? this.allSorted
|
|
338
|
+
: this.byCategory.get(tabKey) ?? [];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
private applyFilter(): void {
|
|
342
|
+
const query = this.searchInput.getValue().trim();
|
|
343
|
+
const searchText = (m: Model<Api>) => `${m.name} ${m.id} ${m.provider}`;
|
|
344
|
+
// fuzzyFilter returns the source untouched for an empty query, so
|
|
345
|
+
// browsing keeps each tab's source order (favorites: stored order).
|
|
346
|
+
this.globalMatches = fuzzyFilter(this.allSorted, query, searchText);
|
|
347
|
+
this.filteredRows = fuzzyFilter(this.tabSource(this.currentTab()), query, searchText);
|
|
348
|
+
this.matchingProviders = new Set(this.globalMatches.map((m) => m.provider));
|
|
349
|
+
// Clamp row selection
|
|
350
|
+
this.rowIndex = Math.max(0, Math.min(this.rowIndex, this.filteredRows.length - 1));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Move to another tab, keeping the query and, when it survives the
|
|
355
|
+
* switch, the selected model. With a query active, provider tabs with
|
|
356
|
+
* no matches are skipped; Favorites and All are always stops.
|
|
357
|
+
*/
|
|
358
|
+
private switchCategory(delta: number): void {
|
|
359
|
+
const tabs = this.tabs;
|
|
360
|
+
const query = this.searchInput.getValue().trim();
|
|
361
|
+
const visitable = tabs.filter((tab) =>
|
|
362
|
+
!query || tab === FAVORITES_TAB || tab === ALL_TAB || this.matchingProviders.has(tab));
|
|
363
|
+
if (!visitable.length) return;
|
|
364
|
+
const prevSelected = this.filteredRows[this.rowIndex];
|
|
365
|
+
// Walk the full tab order from the current position so a current tab
|
|
366
|
+
// that lost its matches still advances to the next visitable one.
|
|
367
|
+
let idx = this.catIndex;
|
|
368
|
+
for (let step = 0; step < tabs.length; step++) {
|
|
369
|
+
idx = (idx + delta + tabs.length) % tabs.length;
|
|
370
|
+
if (visitable.includes(tabs[idx]!)) break;
|
|
371
|
+
}
|
|
372
|
+
this.catIndex = idx;
|
|
373
|
+
this.applyFilter();
|
|
374
|
+
const kept = prevSelected
|
|
375
|
+
? this.filteredRows.findIndex((m) => modelRef(m) === modelRef(prevSelected))
|
|
376
|
+
: -1;
|
|
377
|
+
this.rowIndex = kept >= 0 ? kept : 0;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ── favorites mutations ──────────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Ctrl+F: toggle the selected model in Favorites. Adding keeps it in its
|
|
384
|
+
* provider tab; removing on the Favorites tab drops the row and keeps
|
|
385
|
+
* the cursor at the same position.
|
|
386
|
+
*/
|
|
387
|
+
private toggleSelectedFavorite(): void {
|
|
388
|
+
const selected = this.filteredRows[this.rowIndex];
|
|
389
|
+
if (!selected) return;
|
|
390
|
+
// No status line: the row's colour and dot are the feedback, so the
|
|
391
|
+
// picker keeps a fixed height.
|
|
392
|
+
if (this.isFavorite(selected)) {
|
|
393
|
+
this.favorites = this.favorites.filter((m) => modelRef(m) !== modelRef(selected));
|
|
394
|
+
this.opts.onRemoveFavorite(selected);
|
|
395
|
+
} else {
|
|
396
|
+
this.favorites.push(selected);
|
|
397
|
+
this.opts.onAddToFavorite(selected);
|
|
398
|
+
}
|
|
399
|
+
this.applyFilter();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Ctrl+X: remove favorites contained in the active tab. Scope is the
|
|
404
|
+
* tab's unfiltered source list, not just the rows matched by the query:
|
|
405
|
+
* provider tabs lose only that provider's favorites and All loses every
|
|
406
|
+
* resolved favorite. The Favorites tab retains its full clear, including
|
|
407
|
+
* stored refs whose models are temporarily unavailable.
|
|
408
|
+
*/
|
|
409
|
+
private clearFavoritesInTab(): void {
|
|
410
|
+
if (!this.favorites.length) return;
|
|
411
|
+
if (this.onFavoritesTab()) {
|
|
412
|
+
this.favorites = [];
|
|
413
|
+
this.opts.onClearFavorites();
|
|
414
|
+
this.rowIndex = 0;
|
|
415
|
+
this.applyFilter();
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const contained = new Set(this.tabSource(this.currentTab()).map(modelRef));
|
|
419
|
+
const removed = this.favorites.filter((m) => contained.has(modelRef(m)));
|
|
420
|
+
if (!removed.length) return;
|
|
421
|
+
const removedRefs = new Set(removed.map(modelRef));
|
|
422
|
+
this.favorites = this.favorites.filter((m) => !removedRefs.has(modelRef(m)));
|
|
423
|
+
this.opts.onRemoveFavorites(removed);
|
|
424
|
+
this.applyFilter();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ── input handling ───────────────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
handleInput(data: string): void {
|
|
430
|
+
// Favorite toggle works on every tab; the model stays in its provider tab.
|
|
431
|
+
if (matchesKey(data, Key.ctrl("f"))) {
|
|
432
|
+
this.toggleSelectedFavorite();
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (matchesKey(data, Key.ctrl("x"))) {
|
|
436
|
+
this.clearFavoritesInTab();
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
if (matchesKey(data, Key.tab)) {
|
|
440
|
+
this.switchCategory(1);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (matchesKey(data, Key.shift("tab"))) {
|
|
444
|
+
this.switchCategory(-1);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
// Shift+↑ / ↓ — skip ten rows without wrapping
|
|
448
|
+
if (matchesKey(data, Key.shift("up"))) {
|
|
449
|
+
this.rowIndex = Math.max(0, this.rowIndex - 10);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (matchesKey(data, Key.shift("down"))) {
|
|
453
|
+
this.rowIndex = Math.max(0, Math.min(this.filteredRows.length - 1, this.rowIndex + 10));
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
// ↑ / ↓ — navigate the list with wraparound
|
|
457
|
+
if (matchesKey(data, Key.up)) {
|
|
458
|
+
if (!this.filteredRows.length) return;
|
|
459
|
+
this.rowIndex =
|
|
460
|
+
this.rowIndex === 0
|
|
461
|
+
? this.filteredRows.length - 1
|
|
462
|
+
: this.rowIndex - 1;
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (matchesKey(data, Key.down)) {
|
|
466
|
+
if (!this.filteredRows.length) return;
|
|
467
|
+
this.rowIndex =
|
|
468
|
+
this.rowIndex === this.filteredRows.length - 1
|
|
469
|
+
? 0
|
|
470
|
+
: this.rowIndex + 1;
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ← with an empty field — switch tab left
|
|
475
|
+
if (matchesKey(data, Key.left) && this.searchInput.getValue() === "") {
|
|
476
|
+
this.switchCategory(-1);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
// → with an empty field — switch tab right
|
|
480
|
+
if (matchesKey(data, Key.right) && this.searchInput.getValue() === "") {
|
|
481
|
+
this.switchCategory(1);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Everything else (including ← / → when field has text) → Input
|
|
486
|
+
const before = this.searchInput.getValue();
|
|
487
|
+
this.searchInput.handleInput(data);
|
|
488
|
+
const after = this.searchInput.getValue();
|
|
489
|
+
|
|
490
|
+
if (before !== after) {
|
|
491
|
+
this.rowIndex = 0;
|
|
492
|
+
this.applyFilter();
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ── rendering ────────────────────────────────────────────────────────
|
|
497
|
+
|
|
498
|
+
render(width: number, theme: any): string[] {
|
|
499
|
+
const lines: string[] = [
|
|
500
|
+
theme.fg("accent", "─".repeat(width)),
|
|
501
|
+
truncateToWidth(theme.fg("accent", theme.bold(" Select Model")), width),
|
|
502
|
+
theme.fg("border", "─".repeat(width)),
|
|
503
|
+
];
|
|
504
|
+
|
|
505
|
+
// ── tab bar ──────────────────────────────────────────────────────
|
|
506
|
+
lines.push(this.renderTabs(width, theme));
|
|
507
|
+
|
|
508
|
+
// ── search field ─────────────────────────────────────────────────
|
|
509
|
+
lines.push(theme.fg("border", "─".repeat(width)));
|
|
510
|
+
const promptText = " Search: ";
|
|
511
|
+
const prompt = theme.fg("muted", promptText);
|
|
512
|
+
const promptW = visibleWidth(promptText);
|
|
513
|
+
const inputLines = this.searchInput.render(Math.max(0, width - promptW));
|
|
514
|
+
lines.push(prompt + (inputLines[0] ?? ""));
|
|
515
|
+
|
|
516
|
+
lines.push("");
|
|
517
|
+
|
|
518
|
+
// ── model list ───────────────────────────────────────────────────
|
|
519
|
+
const MAX_VISIBLE = 10;
|
|
520
|
+
const half = Math.floor(MAX_VISIBLE / 2);
|
|
521
|
+
const rows = this.filteredRows;
|
|
522
|
+
const hasQuery = this.searchInput.getValue().trim() !== "";
|
|
523
|
+
const start = Math.max(0, Math.min(this.rowIndex - half, rows.length - MAX_VISIBLE));
|
|
524
|
+
const visible = rows.slice(start, start + MAX_VISIBLE);
|
|
525
|
+
|
|
526
|
+
if (rows.length === 0) {
|
|
527
|
+
const query = this.searchInput.getValue().trim();
|
|
528
|
+
const tab = this.currentTab();
|
|
529
|
+
let msg: string;
|
|
530
|
+
if (query && tab !== ALL_TAB && this.globalMatches.length > 0) {
|
|
531
|
+
// Matches exist outside this tab — point at the way to widen.
|
|
532
|
+
const scope = tab === FAVORITES_TAB ? "favorites" : `${providerLabel(tab)} models`;
|
|
533
|
+
msg = ` No ${scope} match "${query}" — Tab to search all ${this.allSorted.length} models`;
|
|
534
|
+
} else if (query) {
|
|
535
|
+
msg = ` No models match "${query}"`;
|
|
536
|
+
} else if (tab === FAVORITES_TAB) {
|
|
537
|
+
msg = " No favorites yet — Tab to browse; Ctrl+F favorites the selected model";
|
|
538
|
+
} else if (tab === ALL_TAB) {
|
|
539
|
+
msg = " No models available";
|
|
540
|
+
} else {
|
|
541
|
+
msg = " No models in this category";
|
|
542
|
+
}
|
|
543
|
+
lines.push(theme.fg("muted", truncateToWidth(msg, width)));
|
|
544
|
+
} else {
|
|
545
|
+
// Cross-provider tabs (Favorites, All) show the provider column.
|
|
546
|
+
const showProvider = this.crossProvider;
|
|
547
|
+
// Measure over every row so columns do not move when the list scrolls.
|
|
548
|
+
const measured = showProvider ? rows : visible;
|
|
549
|
+
const colW = {
|
|
550
|
+
name: showProvider ? Math.max(...rows.map((m) => visibleWidth(modelLabel(m)))) : 0,
|
|
551
|
+
provider: showProvider ? Math.max(...rows.map((m) => visibleWidth(`[${providerLabel(m.provider)}]`))) : 0,
|
|
552
|
+
cost: Math.max(0, ...measured.map((m) => visibleWidth(fmtCost(m.cost)))),
|
|
553
|
+
ctx: Math.max(0, ...measured.map((m) => visibleWidth(m.contextWindow ? fmtCtx(m.contextWindow) : ""))),
|
|
554
|
+
thinking: measured.some((m) => m.reasoning) ? "thinking".length : 0,
|
|
555
|
+
vision: measured.some((m) => m.input.includes("image")) ? "vision".length : 0,
|
|
556
|
+
};
|
|
557
|
+
// Keep the model and provider identifiable before showing optional metadata.
|
|
558
|
+
for (const column of ["cost", "vision", "thinking", "ctx"] as const) {
|
|
559
|
+
const infoWidth = Math.max(0, [colW.cost, colW.ctx, colW.thinking, colW.vision].filter(Boolean).reduce((sum, n) => sum + n + 2, -2));
|
|
560
|
+
const labelWidth = showProvider ? colW.name + colW.provider + 8 : 16;
|
|
561
|
+
if (infoWidth + labelWidth <= width) break;
|
|
562
|
+
colW[column] = 0;
|
|
563
|
+
}
|
|
564
|
+
for (let i = 0; i < visible.length; i++) {
|
|
565
|
+
const model = visible[i]!;
|
|
566
|
+
const absIdx = start + i;
|
|
567
|
+
const isSelected = absIdx === this.rowIndex;
|
|
568
|
+
const isCurrent =
|
|
569
|
+
this.opts.currentModel?.id === model.id &&
|
|
570
|
+
this.opts.currentModel?.provider === model.provider;
|
|
571
|
+
// fuzzyFilter sorts by score, so visible order is rank order:
|
|
572
|
+
// row 0 is the best match for the active query.
|
|
573
|
+
const fuzzyHighlight = hasQuery ? (absIdx === 0 ? "top" : "match") : undefined;
|
|
574
|
+
lines.push(this.renderRow(model, isSelected, isCurrent, width, theme, colW, fuzzyHighlight));
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
// The empty-state message occupies one row; keep the count slot too.
|
|
578
|
+
for (let i = Math.max(1, visible.length); i < MAX_VISIBLE; i++) lines.push("");
|
|
579
|
+
const position = rows.length ? this.rowIndex + 1 : 0;
|
|
580
|
+
const selected = rows[this.rowIndex];
|
|
581
|
+
// On cross-provider tabs the bare id is ambiguous when several
|
|
582
|
+
// providers offer the same model, so show the full ref.
|
|
583
|
+
const ref = selected ? (this.crossProvider ? `${selected.provider}/${selected.id}` : selected.id) : "";
|
|
584
|
+
const details = ` (${position}/${rows.length})${ref ? ` ${ref}` : ""}`;
|
|
585
|
+
// One line, always: truncate rather than wrap so the height never changes.
|
|
586
|
+
lines.push(theme.fg("dim", truncateToWidth(details, width)));
|
|
587
|
+
|
|
588
|
+
// ── help bar ─────────────────────────────────────────────────────
|
|
589
|
+
lines.push(theme.fg("border", "─".repeat(width)));
|
|
590
|
+
const help = [
|
|
591
|
+
"↑↓ Enter select model",
|
|
592
|
+
"←→ Tab select menu",
|
|
593
|
+
"Ctrl+F toggle favorite",
|
|
594
|
+
"Ctrl+X clear favorites",
|
|
595
|
+
"Esc cancel",
|
|
596
|
+
].join(" · ");
|
|
597
|
+
lines.push(theme.fg("dim", truncateToWidth(" " + help, width)));
|
|
598
|
+
lines.push(theme.fg("accent", "─".repeat(width)));
|
|
599
|
+
|
|
600
|
+
// Tiny terminal resizes must not emit over-wide prompts, markers, or wide glyphs.
|
|
601
|
+
return lines.map((line) => truncateToWidth(line, width));
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
private renderTabs(width: number, theme: any): string {
|
|
605
|
+
const activeKey = this.currentTab();
|
|
606
|
+
const hasQuery = this.searchInput.getValue().trim() !== "";
|
|
607
|
+
const anyFavoriteMatch = this.globalMatches.some((m) => this.isFavorite(m));
|
|
608
|
+
const SEP = theme.fg("dim", "│");
|
|
609
|
+
const SEP_W = 1;
|
|
610
|
+
const labelOf = (key: string) => ` ${this.tabLabel(key)} `;
|
|
611
|
+
const segment = (key: string): string => {
|
|
612
|
+
const label = labelOf(key);
|
|
613
|
+
const selected = key === activeKey;
|
|
614
|
+
const matches = key === FAVORITES_TAB ? anyFavoriteMatch
|
|
615
|
+
: key === ALL_TAB ? this.globalMatches.length > 0
|
|
616
|
+
: this.matchingProviders.has(key);
|
|
617
|
+
const color = selected ? "accent" : hasQuery
|
|
618
|
+
? matches ? "success" : "dim"
|
|
619
|
+
: "muted";
|
|
620
|
+
const styled = theme.fg(color, selected ? theme.bold(label) : label);
|
|
621
|
+
if (selected) return theme.bg("selectedBg", styled);
|
|
622
|
+
// During search, tabs with matches get a subtle violet panel behind
|
|
623
|
+
// their green text, rather than a green-tinted neutral panel that
|
|
624
|
+
// blends with the label. selectedBg stays reserved for selection.
|
|
625
|
+
return hasQuery && matches ? theme.bg("customMessageBg", styled) : styled;
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
// Favorites and All are pinned: always visible. Only the provider
|
|
629
|
+
// segment windows, scrolling just enough to keep the active tab in
|
|
630
|
+
// view, with << / >> marking what is hidden on each side.
|
|
631
|
+
const pinned = [FAVORITES_TAB, ALL_TAB];
|
|
632
|
+
const pinnedPart = pinned.map(segment).join(SEP);
|
|
633
|
+
const pinnedW = pinned.reduce((sum, key) => sum + visibleWidth(labelOf(key)), 0) + SEP_W;
|
|
634
|
+
const providers = this.categories;
|
|
635
|
+
if (!providers.length) return truncateToWidth(" " + pinnedPart, width);
|
|
636
|
+
|
|
637
|
+
const segW = providers.map((key) => visibleWidth(labelOf(key)));
|
|
638
|
+
const rangeW = (lo: number, hi: number) =>
|
|
639
|
+
segW.slice(lo, hi + 1).reduce((sum, w) => sum + w, 0) + Math.max(0, hi - lo) * SEP_W;
|
|
640
|
+
const activeIdx = providers.indexOf(activeKey);
|
|
641
|
+
let lo = 0;
|
|
642
|
+
let hi = -1;
|
|
643
|
+
// Two passes: arrow widths depend on the hidden counts the window
|
|
644
|
+
// produces, so recompute the budget once with real counts.
|
|
645
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
646
|
+
const leftHidden = lo;
|
|
647
|
+
const rightHidden = providers.length - 1 - hi;
|
|
648
|
+
const reserve = (pass === 0 ? 3 : leftHidden > 0 ? visibleWidth("<< ") : 0)
|
|
649
|
+
+ (pass === 0 ? 3 : rightHidden > 0 ? visibleWidth(" >>") : 0);
|
|
650
|
+
const avail = Math.max(0, width - 2 - pinnedW - SEP_W - reserve);
|
|
651
|
+
lo = Math.min(Math.max(0, this.providerLo), providers.length - 1);
|
|
652
|
+
if (activeIdx >= 0) {
|
|
653
|
+
if (activeIdx < lo) lo = activeIdx;
|
|
654
|
+
while (lo < activeIdx && rangeW(lo, activeIdx) > avail) lo++;
|
|
655
|
+
}
|
|
656
|
+
hi = lo - 1;
|
|
657
|
+
while (hi + 1 < providers.length && rangeW(lo, hi + 1) <= avail) hi++;
|
|
658
|
+
if (hi < lo) hi = lo; // a single over-wide label still shows, truncated at the end
|
|
659
|
+
while (lo > 0 && rangeW(lo - 1, hi) <= avail) lo--;
|
|
660
|
+
}
|
|
661
|
+
this.providerLo = lo;
|
|
662
|
+
|
|
663
|
+
const leftHidden = lo;
|
|
664
|
+
const rightHidden = providers.length - 1 - hi;
|
|
665
|
+
const leftPart = leftHidden > 0 ? theme.fg("dim", "<< ") : "";
|
|
666
|
+
const rightPart = rightHidden > 0 ? theme.fg("dim", " >>") : "";
|
|
667
|
+
const providerPart = providers.slice(lo, hi + 1).map(segment).join(SEP);
|
|
668
|
+
return truncateToWidth(" " + pinnedPart + SEP + leftPart + providerPart + rightPart, width);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
private renderRow(
|
|
672
|
+
model: Model<Api>,
|
|
673
|
+
isSelected: boolean,
|
|
674
|
+
isCurrent: boolean,
|
|
675
|
+
width: number,
|
|
676
|
+
theme: any,
|
|
677
|
+
colW: { name: number; provider: number; cost: number; ctx: number; thinking: number; vision: number },
|
|
678
|
+
fuzzyHighlight?: "match" | "top",
|
|
679
|
+
): string {
|
|
680
|
+
const prefix = isSelected ? "▶ " : " ";
|
|
681
|
+
const ctxStr = model.contextWindow ? fmtCtx(model.contextWindow) : "";
|
|
682
|
+
const costStr = fmtCost(model.cost);
|
|
683
|
+
// Pad blank cells too, so missing values cannot shift the other columns.
|
|
684
|
+
const right = [
|
|
685
|
+
colW.cost ? costStr.padStart(colW.cost) : "",
|
|
686
|
+
colW.ctx ? ctxStr.padStart(colW.ctx) : "",
|
|
687
|
+
colW.thinking ? (model.reasoning ? "thinking" : "").padEnd(colW.thinking) : "",
|
|
688
|
+
colW.vision ? (model.input.includes("image") ? "vision" : "").padEnd(colW.vision) : "",
|
|
689
|
+
].filter(Boolean).join(" ");
|
|
690
|
+
|
|
691
|
+
const showProvider = this.crossProvider;
|
|
692
|
+
const isFav = this.isFavorite(model);
|
|
693
|
+
// One status dot per row, coloured by meaning:
|
|
694
|
+
// current model → green (success)
|
|
695
|
+
// favorite → gold (warning); hidden on the Favorites
|
|
696
|
+
// tab, where every row is a favorite
|
|
697
|
+
// current model + favorite → gold, shown on every tab
|
|
698
|
+
const dotColor = isCurrent ? (isFav ? "warning" : "success")
|
|
699
|
+
: isFav && !this.onFavoritesTab() ? "warning"
|
|
700
|
+
: undefined;
|
|
701
|
+
const dot = dotColor ? " ●" : "";
|
|
702
|
+
const nameAvail = Math.max(0, width - visibleWidth(prefix) - visibleWidth(right) - (showProvider ? 2 : visibleWidth(dot)) - 2);
|
|
703
|
+
const providerWidth = showProvider ? Math.min(colW.provider, Math.max(0, nameAvail - 12)) : 0;
|
|
704
|
+
const nameWidth = showProvider ? Math.min(colW.name, Math.max(0, nameAvail - providerWidth - 2)) : nameAvail;
|
|
705
|
+
const name = truncateToWidth(modelLabel(model), nameWidth);
|
|
706
|
+
const provider = showProvider && providerWidth >= 2 ? `[${truncateToWidth(providerLabel(model.provider), providerWidth - 2)}]` : "";
|
|
707
|
+
const tail = showProvider
|
|
708
|
+
? " ".repeat(Math.max(0, nameWidth + 2 - visibleWidth(name + dot))) + " " + provider
|
|
709
|
+
: "";
|
|
710
|
+
const gap = " ".repeat(
|
|
711
|
+
Math.max(0, width - visibleWidth(prefix + name + dot + tail) - visibleWidth(right)),
|
|
712
|
+
);
|
|
713
|
+
|
|
714
|
+
// Row colour: selected → accent, current → green, otherwise default.
|
|
715
|
+
// Favorite state is carried by the dot alone. The dot keeps its own
|
|
716
|
+
// colour on every row, including the selected one, so it is coloured
|
|
717
|
+
// separately from the text around it.
|
|
718
|
+
const rowColor = isSelected ? "accent" : isCurrent ? "success" : "text";
|
|
719
|
+
const rightColor = isSelected ? "accent" : isCurrent ? "muted" : "dim";
|
|
720
|
+
const left = theme.fg(rowColor, prefix + name)
|
|
721
|
+
+ (dotColor ? theme.fg(dotColor, theme.bold(dot)) : "")
|
|
722
|
+
+ (tail ? theme.fg(rowColor, tail) : "");
|
|
723
|
+
const line = left + gap + theme.fg(rightColor, isSelected ? theme.bold(right) : right);
|
|
724
|
+
// Fuzzy matches sit on a subtle neutral panel. The best-ranked row uses
|
|
725
|
+
// the theme's dedicated search-match background instead. Both deliberately
|
|
726
|
+
// differ from selectedBg, which remains the selected tab's highlight.
|
|
727
|
+
const fuzzyBg = fuzzyHighlight === "top" ? "searchMatchBg"
|
|
728
|
+
: fuzzyHighlight === "match" ? "toolPendingBg"
|
|
729
|
+
: undefined;
|
|
730
|
+
return fuzzyBg ? theme.bg(fuzzyBg, line) : line;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
invalidate(): void {
|
|
734
|
+
this.searchInput.invalidate();
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// ─── extension ──────────────────────────────────────────────────────────────
|
|
739
|
+
|
|
740
|
+
export default function modelPickerExtension(pi: ExtensionAPI) {
|
|
741
|
+
const rememberLastTab = readSetting("rememberLastTab") !== false;
|
|
742
|
+
let lastTab: ModelPickerOptions["lastTab"];
|
|
743
|
+
async function openPicker(ctx: ExtensionContext) {
|
|
744
|
+
// Same logic as /model: render cached snapshot immediately, refresh in the background
|
|
745
|
+
const refreshPromise = Promise.resolve(ctx.modelRegistry.refresh());
|
|
746
|
+
let allModels = ctx.modelRegistry.getAvailable();
|
|
747
|
+
if (allModels.length === 0) {
|
|
748
|
+
await refreshPromise.catch(() => {});
|
|
749
|
+
allModels = ctx.modelRegistry.getAvailable();
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (allModels.length === 0) {
|
|
753
|
+
ctx.ui.notify("No models available", "warning");
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Favorites are this extension's own list in settings.json, unrelated
|
|
758
|
+
// to pi's native scoped models. Refs for models missing from the
|
|
759
|
+
// catalogue are kept, so a favorite survives its provider being
|
|
760
|
+
// temporarily unavailable.
|
|
761
|
+
let favoriteRefs = readFavoriteRefs();
|
|
762
|
+
const persistFavorites = () => {
|
|
763
|
+
try {
|
|
764
|
+
writeSetting("favorites", favoriteRefs);
|
|
765
|
+
} catch (error) {
|
|
766
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
767
|
+
ctx.ui.notify(`Could not save favorites: ${message}`, "error");
|
|
768
|
+
}
|
|
769
|
+
};
|
|
770
|
+
const resolveFavorites = (models: Model<Api>[]): Model<Api>[] =>
|
|
771
|
+
favoriteRefs.flatMap((ref) => {
|
|
772
|
+
const match = models.find(
|
|
773
|
+
(m) => `${m.provider}/${m.id}`.toLowerCase() === ref.toLowerCase(),
|
|
774
|
+
);
|
|
775
|
+
return match ? [match] : [];
|
|
776
|
+
});
|
|
777
|
+
const addToFavorite = (model: Model<Api>) => {
|
|
778
|
+
const ref = `${model.provider}/${model.id}`;
|
|
779
|
+
if (favoriteRefs.some((r) => r.toLowerCase() === ref.toLowerCase())) return;
|
|
780
|
+
favoriteRefs = [...favoriteRefs, ref];
|
|
781
|
+
persistFavorites();
|
|
782
|
+
};
|
|
783
|
+
const removeFavorite = (model: Model<Api>) => {
|
|
784
|
+
const ref = `${model.provider}/${model.id}`.toLowerCase();
|
|
785
|
+
favoriteRefs = favoriteRefs.filter((r) => r.toLowerCase() !== ref);
|
|
786
|
+
persistFavorites();
|
|
787
|
+
};
|
|
788
|
+
const removeFavorites = (models: Model<Api>[]) => {
|
|
789
|
+
const refs = new Set(models.map((m) => `${m.provider}/${m.id}`.toLowerCase()));
|
|
790
|
+
favoriteRefs = favoriteRefs.filter((r) => !refs.has(r.toLowerCase()));
|
|
791
|
+
persistFavorites();
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
const selected = await ctx.ui.custom<Model<Api> | null>((tui, theme, _kb, done) => {
|
|
795
|
+
let closed = false;
|
|
796
|
+
const close = (model: Model<Api> | null) => {
|
|
797
|
+
closed = true;
|
|
798
|
+
if (rememberLastTab) lastTab = model?.provider ?? picker.getLastTab();
|
|
799
|
+
done(model);
|
|
800
|
+
};
|
|
801
|
+
const picker = new ModelPickerComponent({
|
|
802
|
+
allModels,
|
|
803
|
+
favorites: resolveFavorites(allModels),
|
|
804
|
+
currentModel: ctx.model ?? undefined,
|
|
805
|
+
lastTab,
|
|
806
|
+
onSelect: close,
|
|
807
|
+
onCancel: () => close(null),
|
|
808
|
+
onAddToFavorite: addToFavorite,
|
|
809
|
+
onRemoveFavorite: removeFavorite,
|
|
810
|
+
onRemoveFavorites: removeFavorites,
|
|
811
|
+
onClearFavorites: () => {
|
|
812
|
+
favoriteRefs = [];
|
|
813
|
+
persistFavorites();
|
|
814
|
+
},
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
// Give the picker focus so the embedded Input gets IME cursor
|
|
818
|
+
picker.focusedState = true;
|
|
819
|
+
|
|
820
|
+
void refreshPromise
|
|
821
|
+
.then(() => {
|
|
822
|
+
if (closed) return;
|
|
823
|
+
const refreshed = ctx.modelRegistry.getAvailable();
|
|
824
|
+
if (refreshed.length > 0) {
|
|
825
|
+
picker.updateModels(refreshed, ctx.model ?? undefined);
|
|
826
|
+
tui.requestRender();
|
|
827
|
+
}
|
|
828
|
+
})
|
|
829
|
+
.catch(() => {});
|
|
830
|
+
|
|
831
|
+
return {
|
|
832
|
+
// Implement Focusable so pi propagates focus to the Input's cursor
|
|
833
|
+
get focused() {
|
|
834
|
+
return picker.focused;
|
|
835
|
+
},
|
|
836
|
+
set focused(v: boolean) {
|
|
837
|
+
picker.focusedState = v;
|
|
838
|
+
},
|
|
839
|
+
|
|
840
|
+
render(width: number): string[] {
|
|
841
|
+
return picker.render(width, theme);
|
|
842
|
+
},
|
|
843
|
+
invalidate() {
|
|
844
|
+
picker.invalidate();
|
|
845
|
+
},
|
|
846
|
+
handleInput(data: string) {
|
|
847
|
+
picker.handleInput(data);
|
|
848
|
+
tui.requestRender();
|
|
849
|
+
},
|
|
850
|
+
};
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
if (!selected) return;
|
|
854
|
+
|
|
855
|
+
const success = await pi.setModel(selected);
|
|
856
|
+
if (!success) {
|
|
857
|
+
ctx.ui.notify(`No API key for ${selected.provider}/${selected.id}`, "error");
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
try {
|
|
861
|
+
const settings = SettingsManager.create(ctx.cwd);
|
|
862
|
+
settings.setDefaultModelAndProvider(selected.provider, selected.id);
|
|
863
|
+
// Like /model: startup ignores a default outside a non-empty enabledModels scope, so add it.
|
|
864
|
+
const ref = `${selected.provider}/${selected.id}`;
|
|
865
|
+
const enabled = settings.getEnabledModels();
|
|
866
|
+
const inScope = (ctx.scopedModels ?? []).some(({ model }) => model.provider === selected.provider && model.id === selected.id);
|
|
867
|
+
if (enabled?.length && !inScope && !enabled.some((pattern) => pattern.toLowerCase() === ref.toLowerCase())) {
|
|
868
|
+
settings.setEnabledModels([...enabled, ref]);
|
|
869
|
+
}
|
|
870
|
+
await settings.flush();
|
|
871
|
+
const error = settings.drainErrors().find((entry) => entry.scope === "global");
|
|
872
|
+
if (error) throw error.error;
|
|
873
|
+
ctx.ui.notify(`Model: ${selected.name} (saved as startup default)`, "info");
|
|
874
|
+
} catch (error) {
|
|
875
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
876
|
+
ctx.ui.notify(`Model changed, but could not save startup default: ${message}`, "error");
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// /model is a reserved built-in — use /models instead
|
|
881
|
+
pi.registerCommand("models", {
|
|
882
|
+
description: "Select model by provider category with search (Tab/← → switch, ↑↓ navigate)",
|
|
883
|
+
handler: async (_args, ctx) => {
|
|
884
|
+
await openPicker(ctx);
|
|
885
|
+
},
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
// Keyboard shortcut(s) — configurable via settings.json ("pi-picks": { "shortcut": ... })
|
|
889
|
+
const shortcuts = resolveShortcuts();
|
|
890
|
+
const seen = new Set<string>();
|
|
891
|
+
for (const shortcut of shortcuts) {
|
|
892
|
+
const normalized = shortcut.toLowerCase();
|
|
893
|
+
if (seen.has(normalized)) continue;
|
|
894
|
+
seen.add(normalized);
|
|
895
|
+
// User-configured shortcuts come from settings.json as plain strings; pi validates
|
|
896
|
+
// the actual key format at registration time, so an invalid value is a no-op rather
|
|
897
|
+
// than a startup crash.
|
|
898
|
+
pi.registerShortcut(shortcut as KeyId, {
|
|
899
|
+
description: "Open categorized model picker",
|
|
900
|
+
handler: async (ctx) => {
|
|
901
|
+
await openPicker(ctx);
|
|
902
|
+
},
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-picks",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.3.0",
|
|
4
|
+
"description": "Keyboard-driven model picker for the pi coding agent: favorites, provider tabs, and one fuzzy query",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"pi-picks": "./scripts/install.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.ts",
|
|
11
|
+
"scripts/install.js",
|
|
12
|
+
"SKILL.md",
|
|
13
|
+
"CHANGELOG.md",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"pi": {
|
|
18
|
+
"extensions": [
|
|
19
|
+
"./index.ts"
|
|
20
|
+
]
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
24
|
+
"@mariozechner/pi-tui": "*",
|
|
25
|
+
"@mariozechner/pi-ai": "*"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"check": "node scripts/check.mjs"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"pi-package",
|
|
32
|
+
"pi",
|
|
33
|
+
"pi-coding-agent",
|
|
34
|
+
"extension",
|
|
35
|
+
"model",
|
|
36
|
+
"selector",
|
|
37
|
+
"fuzzy",
|
|
38
|
+
"favorites",
|
|
39
|
+
"tui"
|
|
40
|
+
],
|
|
41
|
+
"author": "JangManJ",
|
|
42
|
+
"contributors": [
|
|
43
|
+
"Ilham Ramadhan"
|
|
44
|
+
],
|
|
45
|
+
"license": "MIT",
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "git+https://github.com/JangMan-J/pi-picks.git"
|
|
49
|
+
},
|
|
50
|
+
"bugs": {
|
|
51
|
+
"url": "https://github.com/JangMan-J/pi-picks/issues"
|
|
52
|
+
},
|
|
53
|
+
"homepage": "https://github.com/JangMan-J/pi-picks#readme",
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { existsSync, mkdirSync, cpSync, statSync, readFileSync } from "node:fs";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const packageRoot = join(__dirname, "..");
|
|
10
|
+
|
|
11
|
+
function getAgentDir() {
|
|
12
|
+
const envDir = process.env.PI_CODING_AGENT_DIR;
|
|
13
|
+
if (envDir) {
|
|
14
|
+
if (envDir === "~") return homedir();
|
|
15
|
+
if (envDir.startsWith("~/")) return homedir() + envDir.slice(1);
|
|
16
|
+
return envDir;
|
|
17
|
+
}
|
|
18
|
+
return join(homedir(), ".pi", "agent");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const agentDir = getAgentDir();
|
|
22
|
+
const EXTENSION_DIR = join(agentDir, "extensions", "pi-picks");
|
|
23
|
+
|
|
24
|
+
function log(msg) {
|
|
25
|
+
console.log(`[pi-picks] ${msg}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function main() {
|
|
29
|
+
const pkg = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf-8"));
|
|
30
|
+
log(`Installing version ${pkg.version}...`);
|
|
31
|
+
|
|
32
|
+
mkdirSync(EXTENSION_DIR, { recursive: true });
|
|
33
|
+
|
|
34
|
+
const files = ["package.json", ...(pkg.files || [])];
|
|
35
|
+
let failed = false;
|
|
36
|
+
|
|
37
|
+
for (const rawEntry of files) {
|
|
38
|
+
const entry = rawEntry.replace(/\/+$/, "");
|
|
39
|
+
const src = join(packageRoot, entry);
|
|
40
|
+
const dest = join(EXTENSION_DIR, entry);
|
|
41
|
+
|
|
42
|
+
if (!existsSync(src)) continue;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const stat = statSync(src);
|
|
46
|
+
if (stat.isDirectory()) {
|
|
47
|
+
mkdirSync(dest, { recursive: true });
|
|
48
|
+
cpSync(src, dest, { recursive: true });
|
|
49
|
+
log(`Copied ${entry}/`);
|
|
50
|
+
} else {
|
|
51
|
+
cpSync(src, dest);
|
|
52
|
+
log(`Copied ${entry}`);
|
|
53
|
+
}
|
|
54
|
+
} catch (error) {
|
|
55
|
+
failed = true;
|
|
56
|
+
log(`Warning: Could not copy ${entry}: ${error.message}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (failed) {
|
|
61
|
+
process.exitCode = 1;
|
|
62
|
+
log("Installation incomplete. See warnings above.");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
log("");
|
|
67
|
+
log("Installation complete!");
|
|
68
|
+
log("");
|
|
69
|
+
log("Restart pi to load the extension.");
|
|
70
|
+
log("");
|
|
71
|
+
log("Usage:");
|
|
72
|
+
log(" /models — open the categorized model picker");
|
|
73
|
+
log(" Ctrl+\\ — keyboard shortcut (default)");
|
|
74
|
+
log("");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
main();
|