@d3ara1n/pi-command-palette 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-command-palette
2
2
 
3
- Global command palette for [Pi Coding Agent](https://pi.dev) — press **Ctrl+Shift+P** to search and run commands from anywhere.
3
+ Global command palette for [Pi Coding Agent](https://pi.dev) — press **Ctrl+Alt+P** to search and run commands from anywhere.
4
4
 
5
5
  ## Why?
6
6
 
@@ -18,7 +18,7 @@ pi install npm:@d3ara1n/pi-command-palette
18
18
 
19
19
  Or add to `~/.pi/agent/settings.json`:
20
20
 
21
- ```jsonc
21
+ ```json
22
22
  {
23
23
  "extensions": [
24
24
  "/absolute/path/to/pi-extensions/packages/pi-command-palette"
@@ -30,7 +30,7 @@ Or add to `~/.pi/agent/settings.json`:
30
30
 
31
31
  | Shortcut | Action |
32
32
  |----------|--------|
33
- | `Ctrl+Shift+P` _(default, configurable)_ | Open command palette |
33
+ | `Ctrl+Alt+P` _(default, configurable)_ | Open command palette |
34
34
 
35
35
  The palette lists:
36
36
 
@@ -46,30 +46,32 @@ When a command replaces your current editor text, the original content is saved
46
46
 
47
47
  The "Model: Switch Model" action opens a secondary overlay listing all models with configured API keys. Select one to switch instantly — no need to go through `/model` or `Ctrl+P`.
48
48
 
49
+ **Scoped models float to the top**, marked with a ★ (favorite) prefix. "Scoped" here means the same set pi uses for its built-in selector's scoped tab and `Ctrl+P` cycling — the `enabledModels` patterns in your `settings.json` (project `.pi/settings.json` overrides global `~/.pi/agent/settings.json`). Everything else follows alphabetically. If no scope is configured, the list is a plain alphabetical roster — nothing breaks.
50
+
49
51
  ## Configuration
50
52
 
51
- The default shortcut is `Ctrl+Shift+P`. If it conflicts with your terminal, override it via either of the following (evaluated in order, first match wins).
53
+ The default shortcut is `Ctrl+Alt+P`. This avoids pi's built-in `Ctrl+P` / `Ctrl+Shift+P` model cycling shortcuts while keeping the palette mnemonic. If it conflicts with your terminal, override it via either of the following (evaluated in order, first match wins).
52
54
 
53
55
  ### 1. Environment variable
54
56
 
55
57
  Useful for terminals that intercept `Ctrl+Shift+<key>` before it reaches the session (e.g. Termius on Windows/WSL2):
56
58
 
57
59
  ```bash
58
- export PI_COMMAND_PALETTE_KEY=ctrl+alt+k
60
+ export PI_COMMAND_PALETTE_KEY=ctrl+shift+p
59
61
  ```
60
62
 
61
63
  Add it to your shell profile to persist (`~/.zshrc` on macOS, `~/.bashrc` on bash).
62
64
 
63
65
  ### 2. settings.json
64
66
 
65
- Set `commandPalette.shortcut` in `~/.pi/agent/settings.json` (global) or `.pi/settings.json` in your project (project overrides global):
67
+ Set `commandPalette.shortcut` in `~/.pi/agent/settings.json` (global) or `.pi/settings.json` in your project. A present project `commandPalette` block replaces the global block:
66
68
 
67
- ```jsonc
69
+ ```json
68
70
  {
69
71
  "commandPalette": {
70
- "shortcut": "ctrl+alt+k"
72
+ "shortcut": "ctrl+shift+p"
71
73
  }
72
74
  }
73
75
  ```
74
76
 
75
- Any valid pi keybinding string works (e.g. `ctrl+shift+k`, `ctrl+alt+p`, `ctrl+k`). Restart pi (or run `/reload`) after changing the shortcut.
77
+ Any valid pi keybinding string works (e.g. `ctrl+shift+p`, `ctrl+shift+k`, `ctrl+alt+k`, `ctrl+k`). Restart pi (or run `/reload`) after changing the shortcut.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-command-palette",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "description": "Global command palette for pi — press Ctrl+Shift+P to search and run commands from anywhere",
6
6
  "main": "src/index.ts",
package/src/config.ts CHANGED
@@ -4,9 +4,9 @@
4
4
  * Priority (highest wins):
5
5
  * 1. `PI_COMMAND_PALETTE_KEY` env var — works even when the terminal intercepts
6
6
  * `Ctrl+Shift+P` before it reaches the session (e.g. Termius on Windows/WSL2).
7
- * 2. `settings.json` `commandPalette.shortcut` (project `.pi/settings.json`
8
- * overrides global `~/.pi/agent/settings.json`)
9
- * 3. default `"ctrl+shift+p"`
7
+ * 2. `settings.json` `commandPalette.shortcut` (a present project
8
+ * `commandPalette` block replaces the global block)
9
+ * 3. default `"ctrl+alt+p"`
10
10
  *
11
11
  * Why not a CLI flag? Flags are applied to the extension runtime AFTER extensions
12
12
  * load, so `pi.getFlag()` only returns the registered default at `registerShortcut()`
@@ -19,7 +19,7 @@ import * as os from "node:os";
19
19
  import * as path from "node:path";
20
20
  import type { KeyId } from "@earendil-works/pi-tui";
21
21
 
22
- export const DEFAULT_SHORTCUT = "ctrl+shift+p";
22
+ export const DEFAULT_SHORTCUT = "ctrl+alt+p";
23
23
 
24
24
  function getAgentDir(): string {
25
25
  const envDir = process.env.PI_AGENT_DIR;
@@ -54,12 +54,13 @@ export function resolveShortcutKey(cwd: string = process.cwd()): KeyId {
54
54
  const envKey = normalizeKey(process.env.PI_COMMAND_PALETTE_KEY);
55
55
  if (envKey) return envKey as KeyId;
56
56
 
57
- // 2. settings.json — global ~/.pi/agent/settings.json; project overrides global.
57
+ // 2. settings.json — a present project block replaces the global block.
58
58
  const globalSettings = readSettings(path.join(getAgentDir(), "settings.json"));
59
59
  const projectSettings = readSettings(path.join(cwd, ".pi", "settings.json"));
60
- const globalCfg = (globalSettings.commandPalette ?? {}) as { shortcut?: unknown };
61
- const projectCfg = (projectSettings.commandPalette ?? {}) as { shortcut?: unknown };
62
- const settingsKey = normalizeKey(projectCfg.shortcut ?? globalCfg.shortcut);
60
+ const globalRaw = globalSettings.commandPalette;
61
+ const projectRaw = projectSettings.commandPalette;
62
+ const config = (projectRaw ?? globalRaw ?? {}) as { shortcut?: unknown };
63
+ const settingsKey = normalizeKey(config.shortcut);
63
64
  if (settingsKey) return settingsKey as KeyId;
64
65
 
65
66
  // 3. default
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Regression tests for model reference parsing.
3
+ * Run: node --test packages/pi-command-palette/src/index.test.ts
4
+ */
5
+
6
+ import assert from "node:assert/strict";
7
+ import { test } from "node:test";
8
+ import { parseModelRef } from "./index.ts";
9
+
10
+ test("parseModelRef splits provider and model at the first slash", () => {
11
+ assert.deepEqual(parseModelRef("anthropic/claude-sonnet"), {
12
+ provider: "anthropic",
13
+ modelId: "claude-sonnet",
14
+ });
15
+ assert.deepEqual(parseModelRef("openrouter/vendor/model/with/slashes"), {
16
+ provider: "openrouter",
17
+ modelId: "vendor/model/with/slashes",
18
+ });
19
+ });
20
+
21
+ test("parseModelRef preserves empty provider or model segments", () => {
22
+ assert.equal(parseModelRef("model-without-provider"), undefined);
23
+ assert.deepEqual(parseModelRef("/model"), { provider: "", modelId: "model" });
24
+ assert.deepEqual(parseModelRef("provider/"), { provider: "provider", modelId: "" });
25
+ });
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * pi-command-palette — Global command palette for pi.
3
3
  *
4
- * Press Ctrl+Shift+P to open a searchable command palette overlay,
4
+ * Press Ctrl+Alt+P to open a searchable command palette overlay,
5
5
  * regardless of whether the editor has content.
6
6
  *
7
7
  * Features:
@@ -13,7 +13,12 @@
13
13
  */
14
14
 
15
15
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
16
- import { DynamicBorder } from "@earendil-works/pi-coding-agent";
16
+ import {
17
+ DynamicBorder,
18
+ getAgentDir,
19
+ resolveModelScopeWithDiagnostics,
20
+ SettingsManager,
21
+ } from "@earendil-works/pi-coding-agent";
17
22
  import {
18
23
  Container,
19
24
  type SelectItem,
@@ -150,6 +155,48 @@ function buildPaletteItems(pi: ExtensionAPI, ctx: ExtensionContext): PaletteItem
150
155
 
151
156
  // ── Model selector ─────────────────────────────────────────────────
152
157
 
158
+ const STAR = "★ ";
159
+
160
+ /**
161
+ * @internal — exported for testing; parses the selector's `provider/model-id` values.
162
+ */
163
+ export function parseModelRef(modelRef: string): { provider: string; modelId: string } | undefined {
164
+ const slash = modelRef.indexOf("/");
165
+ if (slash === -1) return undefined;
166
+ return {
167
+ provider: modelRef.slice(0, slash),
168
+ modelId: modelRef.slice(slash + 1),
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Resolve the set of "scoped" model full-ids (`provider/id`) — the same models
174
+ * pi surfaces in its built-in selector's "scoped" tab and Ctrl+P cycling.
175
+ *
176
+ * Fully official-API driven, no manual settings parsing:
177
+ * - `SettingsManager.getEnabledModels()` reads the `enabledModels` scope
178
+ * patterns (global + project merge handled by pi).
179
+ * - `resolveModelScopeWithDiagnostics()` expands those patterns (globs, aliases,
180
+ * thinking-level suffixes) into concrete models — identical to pi's scope tab.
181
+ *
182
+ * Any failure degrades to an empty set: the selector still works, just without
183
+ * the scoped grouping.
184
+ */
185
+ async function resolveScopedModelIds(
186
+ modelRegistry: ExtensionContext["modelRegistry"],
187
+ cwd: string,
188
+ ): Promise<Set<string>> {
189
+ try {
190
+ const settings = SettingsManager.create(cwd, getAgentDir());
191
+ const patterns = settings.getEnabledModels();
192
+ if (!patterns || patterns.length === 0) return new Set();
193
+ const { scopedModels } = await resolveModelScopeWithDiagnostics(patterns, modelRegistry);
194
+ return new Set(scopedModels.map((s) => `${s.model.provider}/${s.model.id}`));
195
+ } catch {
196
+ return new Set();
197
+ }
198
+ }
199
+
153
200
  async function showModelSelector(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
154
201
  let models: Awaited<ReturnType<typeof ctx.modelRegistry.getAvailable>>;
155
202
  try {
@@ -164,10 +211,24 @@ async function showModelSelector(pi: ExtensionAPI, ctx: ExtensionContext): Promi
164
211
  return;
165
212
  }
166
213
 
167
- const items: SelectItem[] = models.map((m) => ({
168
- value: `${m.provider}/${m.id}`,
169
- label: m.name,
170
- description: m.provider,
214
+ const scopedIds = await resolveScopedModelIds(ctx.modelRegistry, ctx.cwd);
215
+
216
+ // Scoped models float to the top with a ★ prefix (favorites); the rest follow
217
+ // alphabetically. Within the scoped group we also sort alphabetically so the
218
+ // ordering is stable and predictable regardless of registry order.
219
+ const decorated = models.map((m) => {
220
+ const value = `${m.provider}/${m.id}`;
221
+ return { model: m, value, scoped: scopedIds.has(value) };
222
+ });
223
+ decorated.sort((a, b) => {
224
+ if (a.scoped !== b.scoped) return a.scoped ? -1 : 1;
225
+ return a.model.name.localeCompare(b.model.name);
226
+ });
227
+
228
+ const items: SelectItem[] = decorated.map((d) => ({
229
+ value: d.value,
230
+ label: d.scoped ? `${STAR}${d.model.name}` : d.model.name,
231
+ description: d.model.provider,
171
232
  }));
172
233
 
173
234
  const result = await ctx.ui.custom<string | null>(
@@ -249,7 +310,9 @@ async function showModelSelector(pi: ExtensionAPI, ctx: ExtensionContext): Promi
249
310
 
250
311
  if (!result) return;
251
312
 
252
- const [provider, modelId] = result.split("/");
313
+ const parsed = parseModelRef(result);
314
+ if (!parsed) return;
315
+ const { provider, modelId } = parsed;
253
316
  const model = ctx.modelRegistry.find(provider, modelId);
254
317
  if (model) {
255
318
  const success = await pi.setModel(model);