@xynogen/pix-models 0.1.17 → 0.1.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-models",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "Pi extension — enhanced /models picker with BenchLM ranks",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -7,8 +7,56 @@ import {
7
7
  type ModelSearchLookup,
8
8
  normalizeModelText,
9
9
  sortModels,
10
+ stepEffectiveThinkingLevel,
11
+ stepThinkingLevel,
12
+ THINKING_LEVELS,
10
13
  } from "./models.ts";
11
14
 
15
+ describe("stepThinkingLevel", () => {
16
+ it("steps up one notch", () => {
17
+ expect(stepThinkingLevel("low", 1)).toBe("medium");
18
+ expect(stepThinkingLevel("off", 1)).toBe("minimal");
19
+ });
20
+ it("steps down one notch", () => {
21
+ expect(stepThinkingLevel("medium", -1)).toBe("low");
22
+ expect(stepThinkingLevel("xhigh", -1)).toBe("high");
23
+ });
24
+ it("clamps at the low end (no wrap)", () => {
25
+ expect(stepThinkingLevel("off", -1)).toBe("off");
26
+ });
27
+ it("clamps at the high end (no wrap)", () => {
28
+ expect(stepThinkingLevel("xhigh", 1)).toBe("xhigh");
29
+ });
30
+ it("falls back to a medium-anchored step for unknown input", () => {
31
+ expect(stepThinkingLevel("bogus", 1)).toBe("high");
32
+ expect(stepThinkingLevel("", -1)).toBe("low");
33
+ });
34
+ it("exposes the six canonical levels ascending", () => {
35
+ expect(THINKING_LEVELS).toEqual(["off", "minimal", "low", "medium", "high", "xhigh"]);
36
+ });
37
+ it("can walk the full ladder up and back down", () => {
38
+ let lvl: string = "off";
39
+ for (let i = 0; i < 10; i++) lvl = stepThinkingLevel(lvl, 1);
40
+ expect(lvl).toBe("xhigh");
41
+ for (let i = 0; i < 10; i++) lvl = stepThinkingLevel(lvl, -1);
42
+ expect(lvl).toBe("off");
43
+ });
44
+ });
45
+
46
+ describe("stepEffectiveThinkingLevel", () => {
47
+ it("skips levels that clamp back to the current level", () => {
48
+ const clamp = (requested: string) => {
49
+ if (requested === "minimal" || requested === "low") return "off";
50
+ return requested;
51
+ };
52
+ expect(stepEffectiveThinkingLevel("off", 1, clamp)).toBe("medium");
53
+ });
54
+
55
+ it("stays at an endpoint when every farther request clamps back", () => {
56
+ expect(stepEffectiveThinkingLevel("high", 1, () => "high")).toBe("high");
57
+ });
58
+ });
59
+
12
60
  describe("fmtCtx", () => {
13
61
  it("formats 0 as 0", () => expect(fmtCtx(0)).toBe("0"));
14
62
  it("formats small numbers as-is", () => expect(fmtCtx(512)).toBe("512"));
package/src/models.ts CHANGED
@@ -12,6 +12,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
12
12
  import {
13
13
  fuzzyFilter,
14
14
  Input,
15
+ Key,
15
16
  matchesKey,
16
17
  type SelectItem,
17
18
  SelectList,
@@ -92,6 +93,49 @@ export function normalizeModelText(s: string): string {
92
93
  return s.toLowerCase().replace(/[^a-z0-9]/g, "");
93
94
  }
94
95
 
96
+ // ─── Thinking level control ──────────────────────────────────────────────────
97
+
98
+ /**
99
+ * Canonical thinking levels, ascending. Shift+←/→ in the picker steps through this
100
+ * list; pi.setThinkingLevel() clamps to what the active model actually supports,
101
+ * so visiting an unsupported rung is harmless (it lands on the nearest allowed).
102
+ */
103
+ export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
104
+
105
+ export type ThinkingLevelName = (typeof THINKING_LEVELS)[number];
106
+
107
+ /**
108
+ * Step the thinking level one notch. `dir` is -1 (←) or +1 (→). Clamps at the
109
+ * ends (no wraparound) so ← at "off" stays "off" and → at "xhigh" stays "xhigh".
110
+ * Unknown input falls back to "medium" as a neutral midpoint.
111
+ */
112
+ export function stepThinkingLevel(current: string, dir: -1 | 1): ThinkingLevelName {
113
+ const idx = THINKING_LEVELS.indexOf(current as ThinkingLevelName);
114
+ const base = idx === -1 ? THINKING_LEVELS.indexOf("medium") : idx;
115
+ const next = Math.min(THINKING_LEVELS.length - 1, Math.max(0, base + dir));
116
+ return THINKING_LEVELS[next] as ThinkingLevelName;
117
+ }
118
+
119
+ /**
120
+ * Move one effective notch in a direction after the host clamps unsupported
121
+ * levels. Keeps trying farther rungs until the model's resolved level changes.
122
+ */
123
+ export function stepEffectiveThinkingLevel(
124
+ current: string,
125
+ dir: -1 | 1,
126
+ apply: (level: ThinkingLevelName) => string,
127
+ ): ThinkingLevelName {
128
+ let candidate = stepThinkingLevel(current, dir);
129
+ while (candidate !== current) {
130
+ const effective = apply(candidate) as ThinkingLevelName;
131
+ if (effective !== current) return effective;
132
+ const farther = stepThinkingLevel(candidate, dir);
133
+ if (farther === candidate) break;
134
+ candidate = farther;
135
+ }
136
+ return current as ThinkingLevelName;
137
+ }
138
+
95
139
  export type ModelSearchLookup = {
96
140
  /** benchlm local rank per item value (ranked models only). */
97
141
  rankByValue: Map<string, number>;
@@ -227,7 +271,7 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
227
271
  // items built inside the custom() factory so we have theme access for colors
228
272
 
229
273
  const result = await ctx.ui.custom<string | null>(
230
- (_tui, theme, _kb, done) => {
274
+ (tui, theme, _kb, done) => {
231
275
  const accent = "accent";
232
276
 
233
277
  // Find max rank width across all benchmarked rows for # padding
@@ -365,6 +409,27 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
365
409
  internal.invalidate();
366
410
  };
367
411
 
412
+ // Live thinking-level readout. Shift+←/→ mutates the session immediately via
413
+ // pi.setThinkingLevel(); we mirror pi.getThinkingLevel() so the header
414
+ // reflects the clamped result (model may not support every rung).
415
+ //
416
+ // `local` shadows the level so the header updates even on builds/contexts
417
+ // where pi.getThinkingLevel() lags or is unavailable inside the overlay.
418
+ // We seed it from the getter, then advance it in lock-step with each
419
+ // setThinkingLevel() call and reconcile back to the getter when present.
420
+ let localLevel = pi.getThinkingLevel?.() ?? "";
421
+ const thinkLine = () => {
422
+ const live = pi.getThinkingLevel?.();
423
+ const resolved = live ?? localLevel;
424
+ const label = resolved || "—";
425
+ const coloredLabel = resolved
426
+ ? theme.getThinkingBorderColor(resolved)(label)
427
+ : theme.fg("dim", label);
428
+ return (
429
+ theme.fg("muted", "Thinking: ") + coloredLabel + theme.fg("dim", " (shift+←/→ adjust)")
430
+ );
431
+ };
432
+
368
433
  return {
369
434
  render(w: number) {
370
435
  const mw = modalWidth(w);
@@ -372,10 +437,14 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
372
437
  const lines: string[] = [
373
438
  theme.fg(accent, theme.bold(`${icon("picker.model")} Select model`)),
374
439
  theme.fg("dim", "context · pricing · coding rank & score from modelgrep.com"),
440
+ thinkLine(),
375
441
  theme.fg("muted", "Search:"),
376
442
  ...search.render(inner),
377
443
  ...list.render(inner),
378
- theme.fg("dim", "fuzzy search · ↑↓ navigate · enter select · esc cancel"),
444
+ theme.fg(
445
+ "dim",
446
+ "fuzzy search · ↑↓ navigate · shift+←/→ thinking · enter select · esc cancel",
447
+ ),
379
448
  ];
380
449
  return frameLines({
381
450
  width: mw,
@@ -393,7 +462,23 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
393
462
  // SelectList uses. Arrows arrive as named keys ("up"/"down"),
394
463
  // not raw escape sequences, so string-equality checks fail.
395
464
  const isNav = matchesKey(data, "up") || matchesKey(data, "down");
396
- if (isNav || matchesKey(data, "enter")) {
465
+ // Shift+←/→ tunes the ACTIVE session model's thinking level without
466
+ // stealing plain ←/→ cursor movement from search. setThinkingLevel clamps
467
+ // to model capability, so unsupported rungs land on the nearest allowed.
468
+ let dir: -1 | 1 | 0 = 0;
469
+ if (matchesKey(data, Key.shift(Key.left))) dir = -1;
470
+ else if (matchesKey(data, Key.shift(Key.right))) dir = 1;
471
+ if (dir !== 0) {
472
+ const cur = pi.getThinkingLevel?.() || localLevel || "medium";
473
+ localLevel = stepEffectiveThinkingLevel(cur, dir, (candidate) => {
474
+ pi.setThinkingLevel(candidate);
475
+ return pi.getThinkingLevel();
476
+ });
477
+ // setThinkingLevel doesn't repaint this overlay, so force a render
478
+ // now — otherwise the header shows a stale level until the next key.
479
+ tui.requestRender();
480
+ return;
481
+ } else if (isNav || matchesKey(data, "enter")) {
397
482
  list.handleInput?.(data);
398
483
  } else if (matchesKey(data, "escape")) {
399
484
  done(null);
@@ -2,19 +2,7 @@ import { describe, expect, it } from "bun:test";
2
2
  import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
-
6
- // Pure replacement tested in isolation (the exported fn resolves the host
7
- // package, which isn't present in the test sandbox).
8
- const MODEL_COMMAND_LINE = '{ name: "model", description: "Select model (opens selector UI)" },';
9
-
10
- function escapeRegExp(text: string): string {
11
- return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
12
- }
13
-
14
- function patchSource(source: string): string {
15
- if (!source.includes(MODEL_COMMAND_LINE)) return source;
16
- return source.replace(new RegExp(`[ \\t]*${escapeRegExp(MODEL_COMMAND_LINE)}\\n?`), "");
17
- }
5
+ import { stripBuiltinModelCommand } from "./patch-builtin.ts";
18
6
 
19
7
  const UNPATCHED = `export const BUILTIN_SLASH_COMMANDS = [
20
8
  { name: "settings", description: "Open settings menu" },
@@ -23,40 +11,80 @@ const UNPATCHED = `export const BUILTIN_SLASH_COMMANDS = [
23
11
  ];
24
12
  `;
25
13
 
14
+ const CURRENT_PI = `export const BUILTIN_SLASH_COMMANDS = [
15
+ { name: "settings", description: "Open settings menu" },
16
+ { name: "model", description: "Select model (opens selector UI)", argumentHint: "<provider/model>" },
17
+ { name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" },
18
+ ];
19
+ `;
20
+
26
21
  describe("patch-builtin /model removal", () => {
27
22
  it("removes the built-in /model line and keeps neighbors", () => {
28
- const out = patchSource(UNPATCHED);
23
+ const out = stripBuiltinModelCommand(UNPATCHED);
29
24
  expect(out).not.toContain('name: "model"');
30
25
  expect(out).toContain('name: "settings"');
31
26
  expect(out).toContain('name: "login"');
32
27
  });
33
28
 
34
29
  it("is idempotent — second pass is a no-op", () => {
35
- const once = patchSource(UNPATCHED);
36
- const twice = patchSource(once);
30
+ const once = stripBuiltinModelCommand(UNPATCHED);
31
+ const twice = stripBuiltinModelCommand(once);
37
32
  expect(twice).toBe(once);
38
33
  });
39
34
 
40
35
  it("leaves an already-clean file untouched", () => {
41
36
  const clean = `export const X = [\n { name: "login" },\n];\n`;
42
- expect(patchSource(clean)).toBe(clean);
37
+ expect(stripBuiltinModelCommand(clean)).toBe(clean);
43
38
  });
44
39
 
45
40
  it("does not strip the plural /models entry", () => {
46
- const withPlural = `[
41
+ const withPlural = `export const BUILTIN_SLASH_COMMANDS = [
47
42
  { name: "models", description: "Enhanced picker" },
48
43
  { name: "model", description: "Select model (opens selector UI)" },
49
- ]`;
50
- const out = patchSource(withPlural);
44
+ ];`;
45
+ const out = stripBuiltinModelCommand(withPlural);
51
46
  expect(out).toContain('name: "models"');
52
47
  expect(out).not.toContain('{ name: "model", description');
53
48
  });
54
49
 
50
+ it("removes Pi's current /model form with an argument hint", () => {
51
+ const out = stripBuiltinModelCommand(CURRENT_PI);
52
+ expect(out).not.toContain('name: "model"');
53
+ expect(out).toContain('name: "settings"');
54
+ expect(out).toContain('name: "scoped-models"');
55
+ });
56
+
57
+ it("does not alter /model text outside the built-in command array", () => {
58
+ const source = `const source = '{ name: "model" }';
59
+ export const BUILTIN_SLASH_COMMANDS = [
60
+ { name: "settings", description: "Open settings menu" },
61
+ ];
62
+ `;
63
+ expect(stripBuiltinModelCommand(source)).toBe(source);
64
+ });
65
+
66
+ it("removes a multi-line /model command without touching adjacent entries", () => {
67
+ const multiline = `export const BUILTIN_SLASH_COMMANDS = [
68
+ { name: "settings", description: "Open settings menu" },
69
+ {
70
+ name: "model",
71
+ description: "Select model (opens selector UI)",
72
+ argumentHint: "<provider/model>",
73
+ },
74
+ { name: "login", description: "Configure provider authentication" },
75
+ ];
76
+ `;
77
+ const out = stripBuiltinModelCommand(multiline);
78
+ expect(out).not.toContain('name: "model"');
79
+ expect(out).toContain('name: "settings"');
80
+ expect(out).toContain('name: "login"');
81
+ });
82
+
55
83
  it("round-trips through disk", () => {
56
84
  const dir = mkdtempSync(join(tmpdir(), "pix-patch-"));
57
85
  const file = join(dir, "slash-commands.js");
58
- writeFileSync(file, UNPATCHED, "utf8");
59
- writeFileSync(file, patchSource(readFileSync(file, "utf8")), "utf8");
86
+ writeFileSync(file, CURRENT_PI, "utf8");
87
+ writeFileSync(file, stripBuiltinModelCommand(readFileSync(file, "utf8")), "utf8");
60
88
  expect(readFileSync(file, "utf8")).not.toContain('name: "model"');
61
89
  });
62
90
  });
@@ -19,7 +19,12 @@ import { createRequire } from "node:module";
19
19
  import { homedir } from "node:os";
20
20
  import { dirname, join, resolve } from "node:path";
21
21
 
22
- const MODEL_COMMAND_LINE = '{ name: "model", description: "Select model (opens selector UI)" },';
22
+ // Pi has added fields to this object over time (for example, `argumentHint` in
23
+ // v0.80). Match the command entry by its stable `name`, rather than an exact
24
+ // serialized line, while limiting the match to a single non-nested object.
25
+ const BUILTIN_COMMANDS_ARRAY = /export\s+const\s+BUILTIN_SLASH_COMMANDS[^=]*=\s*\[/;
26
+ const BUILTIN_MODEL_COMMAND =
27
+ /^[ \t]*\{(?=[^{}]*\bname\s*:\s*["']model["'])[^{}]*\},?[ \t]*(?:\r?\n|$)/gm;
23
28
 
24
29
  /** Candidate slash-commands.js paths, most-specific first. */
25
30
  function candidatePaths(): string[] {
@@ -89,10 +94,8 @@ export function patchOutBuiltinModelCommand(): void {
89
94
  return;
90
95
  }
91
96
 
92
- if (!source.includes(MODEL_COMMAND_LINE)) return; // already patched
93
-
94
- const patched = source.replace(new RegExp(`[ \\t]*${escapeRegExp(MODEL_COMMAND_LINE)}\\n?`), "");
95
- if (patched === source) return;
97
+ const patched = stripBuiltinModelCommand(source);
98
+ if (patched === source) return; // already patched, or host format is unknown
96
99
 
97
100
  try {
98
101
  writeFileSync(file, patched, "utf8");
@@ -101,8 +104,25 @@ export function patchOutBuiltinModelCommand(): void {
101
104
  }
102
105
  }
103
106
 
104
- function escapeRegExp(text: string): string {
105
- return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
107
+ /**
108
+ * Remove Pi's built-in `/model` entry from compiled slash-command source.
109
+ *
110
+ * The command objects are static, flat literals. Matching the entry's `name`
111
+ * tolerates added properties and line wrapping without touching `/models`.
112
+ */
113
+ export function stripBuiltinModelCommand(source: string): string {
114
+ const array = BUILTIN_COMMANDS_ARRAY.exec(source);
115
+ if (!array || array.index === undefined) return source;
116
+
117
+ const open = array.index + array[0].lastIndexOf("[");
118
+ const close = source.indexOf("];", open);
119
+ if (close < 0) return source;
120
+
121
+ const entries = source.slice(open + 1, close);
122
+ const patchedEntries = entries.replace(BUILTIN_MODEL_COMMAND, "");
123
+ if (patchedEntries === entries) return source;
124
+
125
+ return `${source.slice(0, open + 1)}${patchedEntries}${source.slice(close)}`;
106
126
  }
107
127
 
108
128
  // Export for tests