@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 +1 -1
- package/src/models.test.ts +48 -0
- package/src/models.ts +88 -3
- package/src/patch-builtin.test.ts +50 -22
- package/src/patch-builtin.ts +27 -7
package/package.json
CHANGED
package/src/models.test.ts
CHANGED
|
@@ -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
|
-
(
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
36
|
-
const twice =
|
|
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(
|
|
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 =
|
|
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,
|
|
59
|
-
writeFileSync(file,
|
|
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
|
});
|
package/src/patch-builtin.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|