@xynogen/pix-data 0.3.0 → 0.3.2
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 +2 -1
- package/package.json +1 -1
- package/src/data.test.ts +11 -26
- package/src/data.ts +17 -55
- package/src/index.ts +3 -1
- package/src/pix-command.ts +298 -0
- package/src/pix-config.ts +69 -11
package/README.md
CHANGED
|
@@ -154,8 +154,9 @@ pix-data hosts the **single shared config file** consumed by every `pix-*` packa
|
|
|
154
154
|
|
|
155
155
|
// Rendering options (pix-pretty)
|
|
156
156
|
"pretty": {
|
|
157
|
-
"
|
|
157
|
+
"syntaxTheme": "monokai", // syntax-highlight theme (overrides PRETTY_THEME)
|
|
158
158
|
"icons": "nerd", // icon mode: nerd | unicode | ascii (overrides PRETTY_ICONS)
|
|
159
|
+
"lsStyle": "grid", // ls output layout: "grid" (horizontal) | "tree" (vertical)
|
|
159
160
|
"maxPreviewLines": 50, // overrides PRETTY_MAX_PREVIEW_LINES
|
|
160
161
|
"diffColors": true // colored diff output
|
|
161
162
|
},
|
package/package.json
CHANGED
package/src/data.test.ts
CHANGED
|
@@ -100,33 +100,25 @@ describe("lookupInIndex", () => {
|
|
|
100
100
|
]);
|
|
101
101
|
|
|
102
102
|
it("finds exact match", () => {
|
|
103
|
-
expect(lookupInIndex("claude-sonnet-4-5", index)?.name).toBe(
|
|
104
|
-
"Claude Sonnet 4.5",
|
|
105
|
-
);
|
|
103
|
+
expect(lookupInIndex("claude-sonnet-4-5", index)?.name).toBe("Claude Sonnet 4.5");
|
|
106
104
|
});
|
|
107
105
|
|
|
108
106
|
it("strips provider prefix (provider/model)", () => {
|
|
109
|
-
expect(lookupInIndex("anthropic/claude-opus-4", index)?.name).toBe(
|
|
110
|
-
"Claude Opus 4",
|
|
111
|
-
);
|
|
107
|
+
expect(lookupInIndex("anthropic/claude-opus-4", index)?.name).toBe("Claude Opus 4");
|
|
112
108
|
});
|
|
113
109
|
|
|
114
110
|
it("strips deep prefix (cc/model)", () => {
|
|
115
|
-
expect(lookupInIndex("cc/claude-opus-4", index)?.name).toBe(
|
|
116
|
-
"Claude Opus 4",
|
|
117
|
-
);
|
|
111
|
+
expect(lookupInIndex("cc/claude-opus-4", index)?.name).toBe("Claude Opus 4");
|
|
118
112
|
});
|
|
119
113
|
|
|
120
114
|
it("strips date suffix", () => {
|
|
121
|
-
expect(lookupInIndex("claude-sonnet-4-5-20250514", index)?.name).toBe(
|
|
122
|
-
"Claude Sonnet 4.5",
|
|
123
|
-
);
|
|
115
|
+
expect(lookupInIndex("claude-sonnet-4-5-20250514", index)?.name).toBe("Claude Sonnet 4.5");
|
|
124
116
|
});
|
|
125
117
|
|
|
126
118
|
it("strips provider prefix + date suffix", () => {
|
|
127
|
-
expect(
|
|
128
|
-
|
|
129
|
-
)
|
|
119
|
+
expect(lookupInIndex("anthropic/claude-sonnet-4-5-20250514", index)?.name).toBe(
|
|
120
|
+
"Claude Sonnet 4.5",
|
|
121
|
+
);
|
|
130
122
|
});
|
|
131
123
|
|
|
132
124
|
it("returns undefined for unknown model", () => {
|
|
@@ -183,10 +175,7 @@ describe("modelgrep adapters", () => {
|
|
|
183
175
|
});
|
|
184
176
|
|
|
185
177
|
it("lookupModelsDev finds hy3 via prefix + suffix strip", () => {
|
|
186
|
-
expect(
|
|
187
|
-
lookupModelsDev("openrouter", "tencent/hy3-preview:nitro")?.limit
|
|
188
|
-
?.context,
|
|
189
|
-
).toBe(256000);
|
|
178
|
+
expect(lookupModelsDev("openrouter", "tencent/hy3-preview:nitro")?.limit?.context).toBe(256000);
|
|
190
179
|
});
|
|
191
180
|
|
|
192
181
|
it("lookupModelsDev returns undefined for unknown model", () => {
|
|
@@ -251,8 +240,7 @@ describe("benchlm fallback", () => {
|
|
|
251
240
|
|
|
252
241
|
beforeEach(() => {
|
|
253
242
|
(modelgrep as unknown as { _mem: ModelGrepModel[] })._mem = catalog;
|
|
254
|
-
(benchlm as unknown as { _mem: typeof benchlmEntries })._mem =
|
|
255
|
-
benchlmEntries;
|
|
243
|
+
(benchlm as unknown as { _mem: typeof benchlmEntries })._mem = benchlmEntries;
|
|
256
244
|
});
|
|
257
245
|
afterEach(() => {
|
|
258
246
|
(modelgrep as unknown as { _mem: ModelGrepModel[] | null })._mem = null;
|
|
@@ -293,14 +281,11 @@ describe("modelgrep AA primary wins over benchlm", () => {
|
|
|
293
281
|
bench: { intelligence: 60 }, // AA index: 60/65 → 92
|
|
294
282
|
}),
|
|
295
283
|
];
|
|
296
|
-
const benchlmEntries = [
|
|
297
|
-
{ rank: 1, model: "Claude Opus 4.8", overallScore: 50 },
|
|
298
|
-
];
|
|
284
|
+
const benchlmEntries = [{ rank: 1, model: "Claude Opus 4.8", overallScore: 50 }];
|
|
299
285
|
|
|
300
286
|
beforeEach(() => {
|
|
301
287
|
(modelgrep as unknown as { _mem: ModelGrepModel[] })._mem = catalog;
|
|
302
|
-
(benchlm as unknown as { _mem: typeof benchlmEntries })._mem =
|
|
303
|
-
benchlmEntries;
|
|
288
|
+
(benchlm as unknown as { _mem: typeof benchlmEntries })._mem = benchlmEntries;
|
|
304
289
|
});
|
|
305
290
|
afterEach(() => {
|
|
306
291
|
(modelgrep as unknown as { _mem: ModelGrepModel[] | null })._mem = null;
|
package/src/data.ts
CHANGED
|
@@ -40,10 +40,7 @@ export interface ModelsDevModel {
|
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
export type ModelsDevApi = Record<
|
|
44
|
-
string,
|
|
45
|
-
{ models?: Record<string, ModelsDevModel> }
|
|
46
|
-
>;
|
|
43
|
+
export type ModelsDevApi = Record<string, { models?: Record<string, ModelsDevModel> }>;
|
|
47
44
|
|
|
48
45
|
export interface BenchmarkEntry {
|
|
49
46
|
rank: number;
|
|
@@ -158,13 +155,8 @@ export class DataSource<T> {
|
|
|
158
155
|
return val;
|
|
159
156
|
}
|
|
160
157
|
try {
|
|
161
|
-
const url =
|
|
162
|
-
|
|
163
|
-
const raw = await this.opts.fetchRaw(
|
|
164
|
-
url,
|
|
165
|
-
this.opts.headers(),
|
|
166
|
-
this.opts.timeoutMs,
|
|
167
|
-
);
|
|
158
|
+
const url = typeof this.opts.url === "function" ? this.opts.url() : this.opts.url;
|
|
159
|
+
const raw = await this.opts.fetchRaw(url, this.opts.headers(), this.opts.timeoutMs);
|
|
168
160
|
const val = this.opts.parse(raw);
|
|
169
161
|
this._mem = val;
|
|
170
162
|
void this._writeCache(raw);
|
|
@@ -172,9 +164,7 @@ export class DataSource<T> {
|
|
|
172
164
|
} catch (error) {
|
|
173
165
|
const msg = error instanceof Error ? error.message : String(error);
|
|
174
166
|
if (cached !== undefined) {
|
|
175
|
-
console.warn(
|
|
176
|
-
`${this.opts.label} fetch failed, using stale cache: ${msg}`,
|
|
177
|
-
);
|
|
167
|
+
console.warn(`${this.opts.label} fetch failed, using stale cache: ${msg}`);
|
|
178
168
|
const val = this.opts.parseCache(cached.data);
|
|
179
169
|
this._mem = val;
|
|
180
170
|
return val;
|
|
@@ -184,9 +174,7 @@ export class DataSource<T> {
|
|
|
184
174
|
}
|
|
185
175
|
}
|
|
186
176
|
|
|
187
|
-
private async _readCache(): Promise<
|
|
188
|
-
{ ts: number; data: unknown } | undefined
|
|
189
|
-
> {
|
|
177
|
+
private async _readCache(): Promise<{ ts: number; data: unknown } | undefined> {
|
|
190
178
|
try {
|
|
191
179
|
const raw = await readFile(this.opts.cachePath, "utf8");
|
|
192
180
|
const parsed = JSON.parse(raw) as { ts: number; data: unknown };
|
|
@@ -200,10 +188,7 @@ export class DataSource<T> {
|
|
|
200
188
|
private async _writeCache(data: unknown): Promise<void> {
|
|
201
189
|
try {
|
|
202
190
|
await mkdir(dirname(this.opts.cachePath), { recursive: true });
|
|
203
|
-
await writeFile(
|
|
204
|
-
this.opts.cachePath,
|
|
205
|
-
JSON.stringify({ ts: Date.now(), data }),
|
|
206
|
-
);
|
|
191
|
+
await writeFile(this.opts.cachePath, JSON.stringify({ ts: Date.now(), data }));
|
|
207
192
|
} catch {
|
|
208
193
|
// Write failure is non-fatal — stale cache used on next run
|
|
209
194
|
}
|
|
@@ -217,9 +202,7 @@ function fetchWithTimeout(
|
|
|
217
202
|
): Promise<Response> {
|
|
218
203
|
const controller = new AbortController();
|
|
219
204
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
220
|
-
return fetch(url, { signal: controller.signal, headers }).finally(() =>
|
|
221
|
-
clearTimeout(timer),
|
|
222
|
-
);
|
|
205
|
+
return fetch(url, { signal: controller.signal, headers }).finally(() => clearTimeout(timer));
|
|
223
206
|
}
|
|
224
207
|
|
|
225
208
|
/** Single-request raw fetch — the default DataSource fetch strategy. */
|
|
@@ -270,10 +253,7 @@ async function fetchModelGrepAll(
|
|
|
270
253
|
|
|
271
254
|
// ── Cache dir ─────────────────────────────────────────────────────────────────
|
|
272
255
|
|
|
273
|
-
export const CACHE_DIR = join(
|
|
274
|
-
process.env.XDG_CACHE_HOME || join(homedir(), ".cache"),
|
|
275
|
-
"pi",
|
|
276
|
-
);
|
|
256
|
+
export const CACHE_DIR = join(process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), "pi");
|
|
277
257
|
|
|
278
258
|
// ── Data sources ──────────────────────────────────────────────────────────────
|
|
279
259
|
|
|
@@ -375,9 +355,7 @@ function toModelsDevModel(g: ModelGrepModel): ModelsDevModel {
|
|
|
375
355
|
};
|
|
376
356
|
}
|
|
377
357
|
|
|
378
|
-
export function buildModelsDevIndex(
|
|
379
|
-
source: ModelGrepModel[],
|
|
380
|
-
): Map<string, ModelsDevModel> {
|
|
358
|
+
export function buildModelsDevIndex(source: ModelGrepModel[]): Map<string, ModelsDevModel> {
|
|
381
359
|
const index = new Map<string, ModelsDevModel>();
|
|
382
360
|
for (const g of source) {
|
|
383
361
|
const m = toModelsDevModel(g);
|
|
@@ -388,18 +366,13 @@ export function buildModelsDevIndex(
|
|
|
388
366
|
return index;
|
|
389
367
|
}
|
|
390
368
|
|
|
391
|
-
export function lookupModelsDev(
|
|
392
|
-
_provider: string,
|
|
393
|
-
id: string,
|
|
394
|
-
): ModelsDevModel | undefined {
|
|
369
|
+
export function lookupModelsDev(_provider: string, id: string): ModelsDevModel | undefined {
|
|
395
370
|
// Provider prefix differs between Pi routing (cc/ds/openrouter) and modelgrep
|
|
396
371
|
// (anthropic/tencent), so join on the model slug only via the normalized index.
|
|
397
372
|
return findInIndex(id, buildModelsDevIndex(modelgrep.getCached()));
|
|
398
373
|
}
|
|
399
374
|
|
|
400
|
-
export async function fetchModelsDevIndex(): Promise<
|
|
401
|
-
Map<string, ModelsDevModel>
|
|
402
|
-
> {
|
|
375
|
+
export async function fetchModelsDevIndex(): Promise<Map<string, ModelsDevModel>> {
|
|
403
376
|
return buildModelsDevIndex(await modelgrep.get());
|
|
404
377
|
}
|
|
405
378
|
|
|
@@ -438,9 +411,7 @@ const clamp01to100 = (x: number) => Math.max(0, Math.min(100, x));
|
|
|
438
411
|
// agentic-heavy (.60) since tool-call matters most, coding (.30), reasoning a
|
|
439
412
|
// .10 tiebreaker. Sub-weights likewise fit — tau2 dominates the agentic group.
|
|
440
413
|
function heuristicScore(
|
|
441
|
-
aa: NonNullable<
|
|
442
|
-
NonNullable<ModelGrepModel["benchmarks"]>["artificial_analysis"]
|
|
443
|
-
>,
|
|
414
|
+
aa: NonNullable<NonNullable<ModelGrepModel["benchmarks"]>["artificial_analysis"]>,
|
|
444
415
|
): number | null {
|
|
445
416
|
const coding = blend([
|
|
446
417
|
[0.6, frac(aa.coding)],
|
|
@@ -464,17 +435,13 @@ function heuristicScore(
|
|
|
464
435
|
// Model score 0–100. Prefer AA's Intelligence Index (authoritative 9-eval
|
|
465
436
|
// composite); when absent, map our heuristic onto the index scale via the
|
|
466
437
|
// fitted line. Null only when nothing is benchmarked.
|
|
467
|
-
function codingScore(
|
|
468
|
-
bench: NonNullable<ModelGrepModel["benchmarks"]>,
|
|
469
|
-
): number | null {
|
|
438
|
+
function codingScore(bench: NonNullable<ModelGrepModel["benchmarks"]>): number | null {
|
|
470
439
|
const aa = bench.artificial_analysis ?? {};
|
|
471
440
|
if (aa.intelligence != null) {
|
|
472
441
|
return Math.round((aa.intelligence / INTELLIGENCE_MAX) * 100);
|
|
473
442
|
}
|
|
474
443
|
const h = heuristicScore(aa);
|
|
475
|
-
return h == null
|
|
476
|
-
? null
|
|
477
|
-
: Math.round(clamp01to100(FALLBACK_SLOPE * h + FALLBACK_INTERCEPT));
|
|
444
|
+
return h == null ? null : Math.round(clamp01to100(FALLBACK_SLOPE * h + FALLBACK_INTERCEPT));
|
|
478
445
|
}
|
|
479
446
|
|
|
480
447
|
function buildBenchIndex(): Map<string, BenchmarkEntry> {
|
|
@@ -508,8 +475,7 @@ function buildBenchIndex(): Map<string, BenchmarkEntry> {
|
|
|
508
475
|
inputPrice: g.pricing?.input ?? null,
|
|
509
476
|
outputPrice: g.pricing?.output ?? null,
|
|
510
477
|
};
|
|
511
|
-
for (const k of [slug, normalize(slug)])
|
|
512
|
-
if (!index.has(k)) index.set(k, entry);
|
|
478
|
+
for (const k of [slug, normalize(slug)]) if (!index.has(k)) index.set(k, entry);
|
|
513
479
|
});
|
|
514
480
|
return index;
|
|
515
481
|
}
|
|
@@ -544,8 +510,7 @@ function lookupBenchlmScore(
|
|
|
544
510
|
if (direct) candidates.push(...direct);
|
|
545
511
|
for (const [key, entries] of benchlmByNorm) {
|
|
546
512
|
if (key === norm) continue;
|
|
547
|
-
if (key.startsWith(norm) || norm.startsWith(key))
|
|
548
|
-
candidates.push(...entries);
|
|
513
|
+
if (key.startsWith(norm) || norm.startsWith(key)) candidates.push(...entries);
|
|
549
514
|
}
|
|
550
515
|
if (candidates.length === 0) return null;
|
|
551
516
|
|
|
@@ -555,10 +520,7 @@ function lookupBenchlmScore(
|
|
|
555
520
|
const sa = a.overallScore ?? -Infinity;
|
|
556
521
|
const sb = b.overallScore ?? -Infinity;
|
|
557
522
|
if (sa !== sb) return sb - sa;
|
|
558
|
-
return (
|
|
559
|
-
normalizeBenchlmName(a.model).length -
|
|
560
|
-
normalizeBenchlmName(b.model).length
|
|
561
|
-
);
|
|
523
|
+
return normalizeBenchlmName(a.model).length - normalizeBenchlmName(b.model).length;
|
|
562
524
|
});
|
|
563
525
|
const best = sorted[0];
|
|
564
526
|
if (!best) return null;
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import { benchlm, modelgrep } from "./data.ts";
|
|
13
|
+
import { registerPixCommand } from "./pix-command.ts";
|
|
13
14
|
|
|
14
15
|
export type {
|
|
15
16
|
BenchmarkEntry,
|
|
@@ -33,7 +34,8 @@ export {
|
|
|
33
34
|
modelgrep,
|
|
34
35
|
} from "./data.ts";
|
|
35
36
|
|
|
36
|
-
export default function (
|
|
37
|
+
export default function (pi: ExtensionAPI): void {
|
|
37
38
|
void modelgrep.get();
|
|
38
39
|
void benchlm.get();
|
|
40
|
+
registerPixCommand(pi);
|
|
39
41
|
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pix-command.ts — the `/pix` command: unified settings overlay for pix.json.
|
|
3
|
+
*
|
|
4
|
+
* Opens an interactive overlay that surfaces every section of
|
|
5
|
+
* `~/.pi/agent/pix.json` as a browsable, editable settings panel. Each setting
|
|
6
|
+
* is a row with ←→ to cycle its value. Sections are separated by headers.
|
|
7
|
+
*
|
|
8
|
+
* Headless hosts get a notify summary instead.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { type PixConfig, pixConfig, savePixConfig } from "./pix-config.js";
|
|
13
|
+
|
|
14
|
+
// ── Setting descriptors ──────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
interface SettingRow {
|
|
17
|
+
/** Section header — only the first row per section renders a header. */
|
|
18
|
+
section: string;
|
|
19
|
+
/** Display label. */
|
|
20
|
+
label: string;
|
|
21
|
+
/** The pix.json path: top-level key. */
|
|
22
|
+
configSection: keyof PixConfig;
|
|
23
|
+
/** The field name within the section. */
|
|
24
|
+
configKey: string;
|
|
25
|
+
/** Allowed values to cycle through. */
|
|
26
|
+
values: readonly string[];
|
|
27
|
+
/** Read the current value from config. */
|
|
28
|
+
read: (cfg: PixConfig) => string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const SETTINGS: SettingRow[] = [
|
|
32
|
+
// ── Pretty ──────────────────────────────────────────────────────────────
|
|
33
|
+
{
|
|
34
|
+
section: "Pretty",
|
|
35
|
+
label: "icons",
|
|
36
|
+
configSection: "pretty",
|
|
37
|
+
configKey: "icons",
|
|
38
|
+
values: ["nerd", "unicode", "ascii"],
|
|
39
|
+
read: (c) => c.pretty.icons,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
section: "Pretty",
|
|
43
|
+
label: "ls style",
|
|
44
|
+
configSection: "pretty",
|
|
45
|
+
configKey: "lsStyle",
|
|
46
|
+
values: ["grid", "tree"],
|
|
47
|
+
read: (c) => c.pretty.lsStyle,
|
|
48
|
+
},
|
|
49
|
+
// ── Collapse ─────────────────────────────────────────────────────────────
|
|
50
|
+
{
|
|
51
|
+
section: "Collapse",
|
|
52
|
+
label: "enabled",
|
|
53
|
+
configSection: "collapse",
|
|
54
|
+
configKey: "enabled",
|
|
55
|
+
values: ["true", "false"],
|
|
56
|
+
read: (c) => String(c.collapse.enabled),
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
section: "Collapse",
|
|
60
|
+
label: "delay (sec)",
|
|
61
|
+
configSection: "collapse",
|
|
62
|
+
configKey: "delaySec",
|
|
63
|
+
values: ["5", "10", "15", "20", "30", "60"],
|
|
64
|
+
read: (c) => String(c.collapse.delaySec),
|
|
65
|
+
},
|
|
66
|
+
// ── Optimizer ─────────────────────────────────────────────────────────────
|
|
67
|
+
{
|
|
68
|
+
section: "Optimizer",
|
|
69
|
+
label: "caveman",
|
|
70
|
+
configSection: "optimizer",
|
|
71
|
+
configKey: "caveman",
|
|
72
|
+
values: ["off", "lite", "full", "ultra", "micro"],
|
|
73
|
+
read: (c) => c.optimizer.caveman,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
section: "Optimizer",
|
|
77
|
+
label: "rtk",
|
|
78
|
+
configSection: "optimizer",
|
|
79
|
+
configKey: "rtk",
|
|
80
|
+
values: ["off", "on"],
|
|
81
|
+
read: (c) => c.optimizer.rtk,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
section: "Optimizer",
|
|
85
|
+
label: "toon",
|
|
86
|
+
configSection: "optimizer",
|
|
87
|
+
configKey: "toon",
|
|
88
|
+
values: ["off", "on"],
|
|
89
|
+
read: (c) => c.optimizer.toon,
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
section: "Optimizer",
|
|
93
|
+
label: "ponytail",
|
|
94
|
+
configSection: "optimizer",
|
|
95
|
+
configKey: "ponytail",
|
|
96
|
+
values: ["off", "lite", "full", "ultra"],
|
|
97
|
+
read: (c) => c.optimizer.ponytail,
|
|
98
|
+
},
|
|
99
|
+
// ── Gate ──────────────────────────────────────────────────────────────────
|
|
100
|
+
{
|
|
101
|
+
section: "Gate",
|
|
102
|
+
label: "disable defaults",
|
|
103
|
+
configSection: "gate",
|
|
104
|
+
configKey: "disableDefaults",
|
|
105
|
+
values: ["false", "true"],
|
|
106
|
+
read: (c) => String(c.gate.disableDefaults),
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
/** Coerce string values back to proper JSON types for saving. */
|
|
113
|
+
function coerce(value: string): string | number | boolean {
|
|
114
|
+
if (value === "true") return true as unknown as string;
|
|
115
|
+
if (value === "false") return false as unknown as string;
|
|
116
|
+
const n = Number(value);
|
|
117
|
+
if (Number.isFinite(n) && String(n) === value) return n as unknown as string;
|
|
118
|
+
return value;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Build a plain text summary for headless hosts. */
|
|
122
|
+
function buildSummary(): string {
|
|
123
|
+
const cfg = pixConfig();
|
|
124
|
+
const lines = ["pix settings (~/.pi/agent/pix.json)", ""];
|
|
125
|
+
let lastSection = "";
|
|
126
|
+
for (const row of SETTINGS) {
|
|
127
|
+
if (row.section !== lastSection) {
|
|
128
|
+
if (lastSection) lines.push("");
|
|
129
|
+
lines.push(`[${row.section}]`);
|
|
130
|
+
lastSection = row.section;
|
|
131
|
+
}
|
|
132
|
+
lines.push(` ${row.label}: ${row.read(cfg)}`);
|
|
133
|
+
}
|
|
134
|
+
return lines.join("\n");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Command registration ─────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
export function registerPixCommand(pi: ExtensionAPI): void {
|
|
140
|
+
pi.registerCommand("pix", {
|
|
141
|
+
description: "pix: open settings (edit ~/.pi/agent/pix.json)",
|
|
142
|
+
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
143
|
+
const ui = ctx.ui as unknown as {
|
|
144
|
+
theme: {
|
|
145
|
+
fg(c: string, t: string): string;
|
|
146
|
+
bg(c: string, t: string): string;
|
|
147
|
+
bold(t: string): string;
|
|
148
|
+
};
|
|
149
|
+
custom?: <T>(
|
|
150
|
+
f: unknown,
|
|
151
|
+
opts?: {
|
|
152
|
+
overlay?: boolean;
|
|
153
|
+
overlayOptions?: {
|
|
154
|
+
anchor?: string;
|
|
155
|
+
width?: number;
|
|
156
|
+
maxHeight?: string;
|
|
157
|
+
};
|
|
158
|
+
},
|
|
159
|
+
) => Promise<T>;
|
|
160
|
+
notify(m: string, t?: "info" | "warning" | "error"): void;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// Headless fallback.
|
|
164
|
+
if (typeof ui.custom !== "function") {
|
|
165
|
+
ui.notify(buildSummary(), "info");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const boxW = 52;
|
|
170
|
+
|
|
171
|
+
await ui.custom<null>(
|
|
172
|
+
(
|
|
173
|
+
tui: { requestRender(): void },
|
|
174
|
+
theme: typeof ui.theme,
|
|
175
|
+
_kb: unknown,
|
|
176
|
+
done: (v: null) => void,
|
|
177
|
+
) => {
|
|
178
|
+
let selected = 0;
|
|
179
|
+
let cfg = pixConfig();
|
|
180
|
+
|
|
181
|
+
/** Cycle the selected setting's value. */
|
|
182
|
+
const cycle = (direction: -1 | 1) => {
|
|
183
|
+
const row = SETTINGS[selected];
|
|
184
|
+
if (!row) return;
|
|
185
|
+
const vals = row.values;
|
|
186
|
+
const cur = vals.indexOf(row.read(cfg));
|
|
187
|
+
const next = (cur + direction + vals.length) % vals.length;
|
|
188
|
+
const val = vals[next];
|
|
189
|
+
if (val === undefined) return;
|
|
190
|
+
|
|
191
|
+
// Persist to pix.json.
|
|
192
|
+
cfg = savePixConfig({
|
|
193
|
+
[row.configSection]: { [row.configKey]: coerce(val) },
|
|
194
|
+
});
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const move = (direction: -1 | 1) => {
|
|
198
|
+
selected = (selected + direction + SETTINGS.length) % SETTINGS.length;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
render: () => {
|
|
203
|
+
const labelW = Math.max(...SETTINGS.map((r) => r.label.length));
|
|
204
|
+
const lines: string[] = [theme.fg("accent", theme.bold(" pix settings")), ""];
|
|
205
|
+
|
|
206
|
+
let lastSection = "";
|
|
207
|
+
for (let i = 0; i < SETTINGS.length; i++) {
|
|
208
|
+
const row = SETTINGS[i]!;
|
|
209
|
+
// Section header.
|
|
210
|
+
if (row.section !== lastSection) {
|
|
211
|
+
if (lastSection) lines.push("");
|
|
212
|
+
lines.push(theme.fg("dim", ` ${row.section}`));
|
|
213
|
+
lastSection = row.section;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const sel = i === selected;
|
|
217
|
+
const cursor = sel ? theme.fg("accent", "→") : " ";
|
|
218
|
+
const label = theme.fg(sel ? "accent" : "text", row.label.padEnd(labelW));
|
|
219
|
+
const val = row.read(cfg);
|
|
220
|
+
const isDefault = val === row.values[0];
|
|
221
|
+
const value = theme.fg(isDefault ? "dim" : "success", val);
|
|
222
|
+
lines.push(`${cursor} ${label} ${value}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
lines.push("");
|
|
226
|
+
lines.push(theme.fg("dim", "←→ change · ↑↓ move · esc close"));
|
|
227
|
+
|
|
228
|
+
return frameLines({
|
|
229
|
+
width: boxW,
|
|
230
|
+
lines,
|
|
231
|
+
color: (s: string) => theme.fg("accent", s),
|
|
232
|
+
bg: (s: string) => theme.bg("customMessageBg", s),
|
|
233
|
+
});
|
|
234
|
+
},
|
|
235
|
+
invalidate: () => {},
|
|
236
|
+
handleInput: (data: string) => {
|
|
237
|
+
if (data === "k" || data === "\u001b[A") move(-1);
|
|
238
|
+
else if (data === "j" || data === "\u001b[B") move(1);
|
|
239
|
+
else if (data === "h" || data === "\u001b[D") cycle(-1);
|
|
240
|
+
else if (data === "l" || data === "\u001b[C" || data === " " || data === "\r")
|
|
241
|
+
cycle(1);
|
|
242
|
+
else if (data === "\u001b" || data === "q") {
|
|
243
|
+
done(null);
|
|
244
|
+
return;
|
|
245
|
+
} else return;
|
|
246
|
+
tui.requestRender();
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
overlay: true,
|
|
252
|
+
overlayOptions: { anchor: "center", width: boxW, maxHeight: "80%" },
|
|
253
|
+
},
|
|
254
|
+
);
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── Inline frameLines (avoid cross-package dep on pix-pretty) ────────────────
|
|
260
|
+
|
|
261
|
+
interface FrameOptions {
|
|
262
|
+
width: number;
|
|
263
|
+
lines: string[];
|
|
264
|
+
color: (s: string) => string;
|
|
265
|
+
bg?: (s: string) => string;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function visibleWidth(s: string): number {
|
|
269
|
+
// Strip ANSI escape sequences for width calculation.
|
|
270
|
+
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function frameLines(opts: FrameOptions): string[] {
|
|
274
|
+
const { width, lines, color } = opts;
|
|
275
|
+
const bg = opts.bg ?? ((s: string) => s);
|
|
276
|
+
const inner = Math.max(1, width - 4); // 2 border + 2 padding
|
|
277
|
+
const dashes = "─".repeat(width - 2);
|
|
278
|
+
|
|
279
|
+
const SENTINEL = "\x00";
|
|
280
|
+
const bgOpen = bg(SENTINEL).split(SENTINEL)[0] ?? "";
|
|
281
|
+
const reassert = (s: string): string =>
|
|
282
|
+
bgOpen
|
|
283
|
+
? s.replace(/\x1b\[([0-9;]*)m/g, (seq, p: string) =>
|
|
284
|
+
p === "0" || p.split(";").includes("49") ? `${seq}${bgOpen}` : seq,
|
|
285
|
+
)
|
|
286
|
+
: s;
|
|
287
|
+
|
|
288
|
+
const row = (content: string): string => {
|
|
289
|
+
const pad = inner - visibleWidth(content);
|
|
290
|
+
const padded = pad > 0 ? content + " ".repeat(pad) : content.slice(0, inner);
|
|
291
|
+
return bg(`${color("│")} ${reassert(padded)} ${color("│")}`);
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const out: string[] = [bg(color(`╭${dashes}╮`))];
|
|
295
|
+
for (const line of lines) out.push(row(line));
|
|
296
|
+
out.push(bg(color(`╰${dashes}╯`)));
|
|
297
|
+
return out;
|
|
298
|
+
}
|
package/src/pix-config.ts
CHANGED
|
@@ -41,9 +41,13 @@ export interface DiffColors {
|
|
|
41
41
|
fgDel: string;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** How `ls` output is rendered: `"grid"` (horizontal columns) or `"tree"` (vertical tree view). */
|
|
45
|
+
export type LsStyle = "grid" | "tree";
|
|
46
|
+
|
|
44
47
|
export interface PrettyConfig {
|
|
45
|
-
theme: string;
|
|
46
48
|
icons: string;
|
|
49
|
+
/** `"grid"` = horizontal columns (default), `"tree"` = vertical tree view. */
|
|
50
|
+
lsStyle: LsStyle;
|
|
47
51
|
maxPreviewLines: number;
|
|
48
52
|
maxRenderLines: number;
|
|
49
53
|
maxHighlightChars: number;
|
|
@@ -100,8 +104,8 @@ const DEFAULT_COLLAPSE: CollapseConfig = {
|
|
|
100
104
|
};
|
|
101
105
|
|
|
102
106
|
const DEFAULT_PRETTY: PrettyConfig = {
|
|
103
|
-
theme: "github-dark",
|
|
104
107
|
icons: "nerd",
|
|
108
|
+
lsStyle: "grid",
|
|
105
109
|
maxPreviewLines: 80,
|
|
106
110
|
maxRenderLines: 150,
|
|
107
111
|
maxHighlightChars: 80_000,
|
|
@@ -208,10 +212,7 @@ function mergeDiff(raw: unknown): DiffColors {
|
|
|
208
212
|
if (!isObj(raw)) return { ...DEFAULT_DIFF };
|
|
209
213
|
return {
|
|
210
214
|
splitMinWidth: num(raw.splitMinWidth, DEFAULT_DIFF.splitMinWidth),
|
|
211
|
-
splitMinCodeWidth: num(
|
|
212
|
-
raw.splitMinCodeWidth,
|
|
213
|
-
DEFAULT_DIFF.splitMinCodeWidth,
|
|
214
|
-
),
|
|
215
|
+
splitMinCodeWidth: num(raw.splitMinCodeWidth, DEFAULT_DIFF.splitMinCodeWidth),
|
|
215
216
|
bgAdd: str(raw.bgAdd, DEFAULT_DIFF.bgAdd),
|
|
216
217
|
bgDel: str(raw.bgDel, DEFAULT_DIFF.bgDel),
|
|
217
218
|
bgAddHighlight: str(raw.bgAddHighlight, DEFAULT_DIFF.bgAddHighlight),
|
|
@@ -223,17 +224,19 @@ function mergeDiff(raw: unknown): DiffColors {
|
|
|
223
224
|
};
|
|
224
225
|
}
|
|
225
226
|
|
|
227
|
+
function lsStyle(v: unknown): LsStyle {
|
|
228
|
+
if (v === "grid" || v === "tree") return v;
|
|
229
|
+
return DEFAULT_PRETTY.lsStyle;
|
|
230
|
+
}
|
|
231
|
+
|
|
226
232
|
function mergePretty(raw: unknown): PrettyConfig {
|
|
227
233
|
if (!isObj(raw)) return { ...DEFAULT_PRETTY };
|
|
228
234
|
return {
|
|
229
|
-
theme: str(raw.theme, DEFAULT_PRETTY.theme),
|
|
230
235
|
icons: str(raw.icons, DEFAULT_PRETTY.icons),
|
|
236
|
+
lsStyle: lsStyle(raw.lsStyle),
|
|
231
237
|
maxPreviewLines: num(raw.maxPreviewLines, DEFAULT_PRETTY.maxPreviewLines),
|
|
232
238
|
maxRenderLines: num(raw.maxRenderLines, DEFAULT_PRETTY.maxRenderLines),
|
|
233
|
-
maxHighlightChars: num(
|
|
234
|
-
raw.maxHighlightChars,
|
|
235
|
-
DEFAULT_PRETTY.maxHighlightChars,
|
|
236
|
-
),
|
|
239
|
+
maxHighlightChars: num(raw.maxHighlightChars, DEFAULT_PRETTY.maxHighlightChars),
|
|
237
240
|
cacheLimit: num(raw.cacheLimit, DEFAULT_PRETTY.cacheLimit),
|
|
238
241
|
diff: mergeDiff(raw.diff),
|
|
239
242
|
};
|
|
@@ -275,6 +278,20 @@ function buildConfig(raw: Record<string, unknown>): PixConfig {
|
|
|
275
278
|
};
|
|
276
279
|
}
|
|
277
280
|
|
|
281
|
+
// ── Change listeners ─────────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
type ConfigListener = (cfg: PixConfig) => void;
|
|
284
|
+
const configListeners = new Set<ConfigListener>();
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Subscribe to config changes (fired by `savePixConfig`). Returns unsubscribe.
|
|
288
|
+
* Listeners receive the freshly-reloaded PixConfig.
|
|
289
|
+
*/
|
|
290
|
+
export function onPixConfigChange(cb: ConfigListener): () => void {
|
|
291
|
+
configListeners.add(cb);
|
|
292
|
+
return () => configListeners.delete(cb);
|
|
293
|
+
}
|
|
294
|
+
|
|
278
295
|
// ── Public API ───────────────────────────────────────────────────────────────
|
|
279
296
|
|
|
280
297
|
/** Get the resolved pix config. Loads from disk on first call, cached after. */
|
|
@@ -303,3 +320,44 @@ export function shouldCollapse(toolName: string): boolean {
|
|
|
303
320
|
export function collapseDelayMs(): number {
|
|
304
321
|
return pixConfig().collapse.delaySec * 1000;
|
|
305
322
|
}
|
|
323
|
+
|
|
324
|
+
/** Get the ls rendering style: `"grid"` (horizontal) or `"tree"` (vertical). */
|
|
325
|
+
export function getLsStyle(): LsStyle {
|
|
326
|
+
return pixConfig().pretty.lsStyle;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Merge a partial update into pix.json and reload the in-process cache.
|
|
331
|
+
* Only the keys present in `patch` are overwritten; the rest of the file is
|
|
332
|
+
* preserved. Nested objects (e.g. `pretty`, `collapse`) are shallow-merged
|
|
333
|
+
* one level deep so callers can update a single field without wiping siblings.
|
|
334
|
+
*/
|
|
335
|
+
export function savePixConfig(patch: Record<string, unknown>): PixConfig {
|
|
336
|
+
const p = configPath();
|
|
337
|
+
if (!p) return pixConfig();
|
|
338
|
+
try {
|
|
339
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
340
|
+
let existing: Record<string, unknown> = {};
|
|
341
|
+
if (existsSync(p)) {
|
|
342
|
+
try {
|
|
343
|
+
existing = JSON.parse(readFileSync(p, "utf-8")) as Record<string, unknown>;
|
|
344
|
+
} catch {
|
|
345
|
+
existing = {};
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
// Shallow-merge each top-level section so partial updates don't wipe siblings.
|
|
349
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
350
|
+
if (isObj(value) && isObj(existing[key])) {
|
|
351
|
+
existing[key] = { ...(existing[key] as Record<string, unknown>), ...value };
|
|
352
|
+
} else {
|
|
353
|
+
existing[key] = value;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
writeFileSync(p, `${JSON.stringify(existing, null, 2)}\n`);
|
|
357
|
+
} catch (err) {
|
|
358
|
+
console.warn("pix-config: save failed:", err);
|
|
359
|
+
}
|
|
360
|
+
const cfg = reloadPixConfig();
|
|
361
|
+
for (const cb of configListeners) cb(cfg);
|
|
362
|
+
return cfg;
|
|
363
|
+
}
|