@xynogen/pix-runtime 0.15.2 → 0.16.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/package.json +1 -1
- package/src/exec.ts +7 -3
- package/src/icon-catalog.ts +10 -6
- package/src/index.ts +1 -0
- package/src/once.ts +25 -1
- package/src/os.ts +29 -6
- package/src/pix-command.ts +231 -18
package/package.json
CHANGED
package/src/exec.ts
CHANGED
|
@@ -97,7 +97,7 @@ export interface ToolExecOptions {
|
|
|
97
97
|
onStatus?: (status: ToolStatus) => void;
|
|
98
98
|
/** Test seam for the host OS. */
|
|
99
99
|
host?: HostPlatform;
|
|
100
|
-
/** Cap on captured stdout/stderr bytes each (default 50 MB). */
|
|
100
|
+
/** Cap on captured stdout/stderr bytes each (default 50 MB). Past it the child is stopped. */
|
|
101
101
|
maxBuffer?: number;
|
|
102
102
|
/** Args are already quoted for the target (cmd.exe `/c` lines); Node must not re-quote. */
|
|
103
103
|
verbatim?: boolean;
|
|
@@ -111,7 +111,7 @@ export interface ToolRunResult {
|
|
|
111
111
|
/** Raw stdout for binary output (clipboard images, archives). */
|
|
112
112
|
stdoutBytes: Buffer;
|
|
113
113
|
timedOut: boolean;
|
|
114
|
-
/** Output passed `maxBuffer
|
|
114
|
+
/** Output passed `maxBuffer`; the child was stopped and output cut. Never a complete result. */
|
|
115
115
|
truncated: boolean;
|
|
116
116
|
/** The binary that ran and where it was found. */
|
|
117
117
|
tool: ResolvedTool;
|
|
@@ -220,7 +220,11 @@ function collect(
|
|
|
220
220
|
let truncated = false;
|
|
221
221
|
const push = (list: Buffer[], chunk: Buffer, len: number): number => {
|
|
222
222
|
const room = cap - len;
|
|
223
|
-
|
|
223
|
+
// Like pi.exec: stop a runaway child instead of draining output nobody keeps.
|
|
224
|
+
if (chunk.length > room) {
|
|
225
|
+
truncated = true;
|
|
226
|
+
stop();
|
|
227
|
+
}
|
|
224
228
|
if (room <= 0) return len;
|
|
225
229
|
list.push(chunk.length > room ? chunk.subarray(0, room) : chunk);
|
|
226
230
|
return len + Math.min(chunk.length, room);
|
package/src/icon-catalog.ts
CHANGED
|
@@ -135,11 +135,15 @@ function envMode(): IconMode {
|
|
|
135
135
|
return "nerd";
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
|
|
138
|
+
const shared = globalThis as {
|
|
139
|
+
__pixIconState?: { mode: IconMode; listeners: Set<(mode: IconMode) => void> };
|
|
140
|
+
};
|
|
141
|
+
shared.__pixIconState ??= { mode: envMode(), listeners: new Set() };
|
|
142
|
+
const state = shared.__pixIconState;
|
|
139
143
|
|
|
140
144
|
/** Current global icon mode. */
|
|
141
145
|
export function getIconMode(): IconMode {
|
|
142
|
-
return
|
|
146
|
+
return state.mode;
|
|
143
147
|
}
|
|
144
148
|
|
|
145
149
|
/**
|
|
@@ -149,7 +153,7 @@ export function getIconMode(): IconMode {
|
|
|
149
153
|
* here; setIconMode fires every callback on an actual change.
|
|
150
154
|
*/
|
|
151
155
|
type ModeListener = (mode: IconMode) => void;
|
|
152
|
-
const listeners =
|
|
156
|
+
const listeners = state.listeners;
|
|
153
157
|
|
|
154
158
|
/** Subscribe to global icon-mode changes. Returns an unsubscribe fn. */
|
|
155
159
|
export function onIconModeChange(cb: ModeListener): () => void {
|
|
@@ -163,8 +167,8 @@ export function onIconModeChange(cb: ModeListener): () => void {
|
|
|
163
167
|
* subscribers only on an actual change (no-op re-sets are ignored).
|
|
164
168
|
*/
|
|
165
169
|
export function setIconMode(mode: IconMode): void {
|
|
166
|
-
if (!ICON_MODES.includes(mode) || mode ===
|
|
167
|
-
|
|
170
|
+
if (!ICON_MODES.includes(mode) || mode === state.mode) return;
|
|
171
|
+
state.mode = mode;
|
|
168
172
|
for (const cb of listeners) cb(mode);
|
|
169
173
|
}
|
|
170
174
|
|
|
@@ -174,7 +178,7 @@ export function setIconMode(mode: IconMode): void {
|
|
|
174
178
|
*/
|
|
175
179
|
export function icon(key: IconKey): string {
|
|
176
180
|
const entry = CATALOG[key];
|
|
177
|
-
return entry ? entry[
|
|
181
|
+
return entry ? entry[state.mode] : "";
|
|
178
182
|
}
|
|
179
183
|
|
|
180
184
|
/** Resolve a key for an explicit mode (used by /pix previews + tests). */
|
package/src/index.ts
CHANGED
package/src/once.ts
CHANGED
|
@@ -10,7 +10,25 @@
|
|
|
10
10
|
* skip, new instance => run. The registry lives on globalThis because jiti
|
|
11
11
|
* (`moduleCache: false`) re-evaluates this module on every load pass.
|
|
12
12
|
*/
|
|
13
|
-
|
|
13
|
+
type PackageHost = {
|
|
14
|
+
events?: {
|
|
15
|
+
emit(channel: string, data: unknown): void;
|
|
16
|
+
on(channel: string, handler: (data: unknown) => void): unknown;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const PACKAGE_QUERY = "pix:package-loaded";
|
|
21
|
+
|
|
22
|
+
/** Check after extension loading (for example, session_start), not factory order. */
|
|
23
|
+
export function isPackageLoaded(pi: PackageHost, key: string): boolean {
|
|
24
|
+
const loaded = (globalThis as { __pixOnce?: WeakMap<object, Set<string>> }).__pixOnce;
|
|
25
|
+
if (loaded?.get(pi)?.has(key)) return true;
|
|
26
|
+
const query = { key, loaded: false };
|
|
27
|
+
pi.events?.emit(PACKAGE_QUERY, query);
|
|
28
|
+
return query.loaded;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function once(pi: PackageHost, key: string, fn: () => void): void {
|
|
14
32
|
const g = globalThis as { __pixOnce?: WeakMap<object, Set<string>> };
|
|
15
33
|
if (!g.__pixOnce) g.__pixOnce = new WeakMap<object, Set<string>>();
|
|
16
34
|
const registry = g.__pixOnce;
|
|
@@ -18,6 +36,12 @@ export function once(pi: object, key: string, fn: () => void): void {
|
|
|
18
36
|
if (!loaded) {
|
|
19
37
|
loaded = new Set<string>();
|
|
20
38
|
registry.set(pi, loaded);
|
|
39
|
+
pi.events?.on(PACKAGE_QUERY, (data) => {
|
|
40
|
+
if (!data || typeof data !== "object" || !("key" in data) || !("loaded" in data)) return;
|
|
41
|
+
if (typeof data.key === "string" && registry.get(pi)?.has(data.key)) {
|
|
42
|
+
data.loaded = true;
|
|
43
|
+
}
|
|
44
|
+
});
|
|
21
45
|
}
|
|
22
46
|
if (loaded.has(key)) return;
|
|
23
47
|
loaded.add(key);
|
package/src/os.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { randomUUID } from "node:crypto";
|
|
10
10
|
import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { userInfo } from "node:os";
|
|
11
12
|
import { join } from "node:path";
|
|
12
13
|
import { resolveTool } from "./binaries/resolve.ts";
|
|
13
14
|
import { quoteForCmd, runTool, runToolSync } from "./exec.ts";
|
|
@@ -19,6 +20,19 @@ export interface OsOptions {
|
|
|
19
20
|
host?: HostPlatform;
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
/** Current OS account name; unavailable accounts do not prevent startup. */
|
|
24
|
+
export function username(): string | undefined {
|
|
25
|
+
try {
|
|
26
|
+
return (
|
|
27
|
+
userInfo()
|
|
28
|
+
.username.replace(/[\x00-\x1f\x7f-\x9f]/g, "")
|
|
29
|
+
.trim() || undefined
|
|
30
|
+
);
|
|
31
|
+
} catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
22
36
|
// ── Open a URL or path ──────────────────────────────────────────────────────
|
|
23
37
|
|
|
24
38
|
export interface OpenOptions extends OsOptions {
|
|
@@ -32,7 +46,7 @@ export function openCommand(
|
|
|
32
46
|
target: string,
|
|
33
47
|
host: HostPlatform,
|
|
34
48
|
app?: string,
|
|
35
|
-
): { name: string; args: string[]; verbatim?: boolean } {
|
|
49
|
+
): { name: string; args: string[]; verbatim?: boolean; cwd?: string } {
|
|
36
50
|
if (host.os === "darwin") return { name: "open", args: app ? ["-a", app, target] : [target] };
|
|
37
51
|
if (host.os === "win32") {
|
|
38
52
|
// `start` is a cmd.exe builtin; "" is its window-title argument.
|
|
@@ -40,8 +54,9 @@ export function openCommand(
|
|
|
40
54
|
return { name: "cmd", args: ["/d", "/s", "/c", `"${parts.join(" ")}"`], verbatim: true };
|
|
41
55
|
}
|
|
42
56
|
if (host.wsl && !app) {
|
|
43
|
-
// WSL: hand the target to Windows so the user's default browser opens.
|
|
44
|
-
|
|
57
|
+
// WSL: hand the target to Windows so the user's default browser opens. Windows programs
|
|
58
|
+
// cannot start from a long `\\wsl.localhost\...` cwd, so start from `/`.
|
|
59
|
+
return { name: "wslview", args: [target], cwd: "/" };
|
|
45
60
|
}
|
|
46
61
|
return { name: app ?? "xdg-open", args: [target] };
|
|
47
62
|
}
|
|
@@ -57,6 +72,7 @@ export async function openTarget(target: string, opts: OpenOptions = {}): Promis
|
|
|
57
72
|
if (cmd.name === "wslview" && !resolveTool("wslview", opts))
|
|
58
73
|
cmd = openCommand(target, { ...host, wsl: false });
|
|
59
74
|
const result = await runTool(cmd.name, cmd.args, {
|
|
75
|
+
cwd: cmd.cwd,
|
|
60
76
|
env: opts.env,
|
|
61
77
|
host,
|
|
62
78
|
timeoutMs: opts.timeoutMs ?? 15_000,
|
|
@@ -126,10 +142,16 @@ export function pickPreferredMime(types: readonly string[]): string | null {
|
|
|
126
142
|
}
|
|
127
143
|
|
|
128
144
|
/** Output bytes when the tool exists and exits 0; null otherwise (probing, never throws). */
|
|
129
|
-
function probe(
|
|
145
|
+
function probe(
|
|
146
|
+
name: string,
|
|
147
|
+
args: string[],
|
|
148
|
+
timeoutMs: number,
|
|
149
|
+
opts: OsOptions,
|
|
150
|
+
cwd?: string,
|
|
151
|
+
): Buffer | null {
|
|
130
152
|
if (!resolveTool(name, opts)) return null;
|
|
131
153
|
try {
|
|
132
|
-
const r = runToolSync(name, args, { env: opts.env, host: opts.host, timeoutMs });
|
|
154
|
+
const r = runToolSync(name, args, { env: opts.env, host: opts.host, timeoutMs, cwd });
|
|
133
155
|
return r.code === 0 ? r.stdoutBytes : null;
|
|
134
156
|
} catch {
|
|
135
157
|
return null;
|
|
@@ -189,7 +211,8 @@ function viaPowerShell(host: HostPlatform, opts: OsOptions): ClipboardImage | nu
|
|
|
189
211
|
"if ($img) { $img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png); Write-Output 'ok' } else { Write-Output 'empty' }",
|
|
190
212
|
].join("; ");
|
|
191
213
|
const args = ["-NoProfile", "-STA", "-Command", script];
|
|
192
|
-
|
|
214
|
+
// From WSL, Windows programs cannot start from a long `\\wsl.localhost\...` cwd.
|
|
215
|
+
const out = probe("powershell", args, POWERSHELL_TIMEOUT_MS, opts, host.wsl ? "/" : undefined);
|
|
193
216
|
if (out?.toString("utf-8").trim() !== "ok") return null;
|
|
194
217
|
const bytes = readFileSync(tmpFile);
|
|
195
218
|
return bytes.length ? { bytes, mimeType: "image/png" } : null;
|
package/src/pix-command.ts
CHANGED
|
@@ -18,7 +18,8 @@ import {
|
|
|
18
18
|
} from "@earendil-works/pi-tui";
|
|
19
19
|
import { createBinariesTab } from "./binaries-tab.ts";
|
|
20
20
|
import { icon } from "./icon-catalog.ts";
|
|
21
|
-
import
|
|
21
|
+
import { isPackageLoaded } from "./once.ts";
|
|
22
|
+
import { type PixRuntime, pixRuntime } from "./runtime.ts";
|
|
22
23
|
import { type DeepPartial, deepMerge, type SectionHandle } from "./schema.ts";
|
|
23
24
|
import { collapseSection } from "./sections/collapse.ts";
|
|
24
25
|
import { compactionSection } from "./sections/compaction.ts";
|
|
@@ -41,6 +42,21 @@ function row<T>(r: SettingRow<T>): SettingRow<unknown> {
|
|
|
41
42
|
return r as SettingRow<unknown>;
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
/** Pi-owned theme row: not stored in pix.json, applied through `ctx.ui.setTheme`. */
|
|
46
|
+
interface ThemeRow {
|
|
47
|
+
kind: "theme";
|
|
48
|
+
section: string;
|
|
49
|
+
label: string;
|
|
50
|
+
values: readonly string[];
|
|
51
|
+
current: () => string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type Row = SettingRow<unknown> | ThemeRow;
|
|
55
|
+
|
|
56
|
+
function isThemeRow(row: Row): row is ThemeRow {
|
|
57
|
+
return "kind" in row;
|
|
58
|
+
}
|
|
59
|
+
|
|
44
60
|
/** Render a token count as a compact label: 1_000_000 → "1M", 150_000 → "150k". */
|
|
45
61
|
function formatTokens(tokens: number): string {
|
|
46
62
|
return tokens % 1_000_000 === 0 ? `${tokens / 1_000_000}M` : `${tokens / 1000}k`;
|
|
@@ -213,11 +229,34 @@ function binariesEnv(runtime: PixRuntime): NodeJS.ProcessEnv {
|
|
|
213
229
|
}
|
|
214
230
|
|
|
215
231
|
const TABS = ["Settings", "Binaries", "Footer"] as const;
|
|
232
|
+
/** Most frequently changed tabs sit leftmost; unknown package tabs go before rare ones. */
|
|
233
|
+
const TAB_ORDER = ["Settings", "Tools", "MCP", "Optimizer", "Web", "Voice"];
|
|
234
|
+
const RARE_TABS = ["Footer", "Binaries"];
|
|
235
|
+
function tabRank(label: string): number {
|
|
236
|
+
const common = TAB_ORDER.indexOf(label);
|
|
237
|
+
if (common >= 0) return common;
|
|
238
|
+
const rare = RARE_TABS.indexOf(label);
|
|
239
|
+
return rare >= 0 ? 100 + rare : 50;
|
|
240
|
+
}
|
|
241
|
+
const SECTION_PACKAGES: Record<string, string> = {
|
|
242
|
+
Compaction: "pix-core",
|
|
243
|
+
Edit: "pix-diagnostics",
|
|
244
|
+
Gate: "pix-gate",
|
|
245
|
+
Footer: "pix-footer",
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
function visibleSettings(pi: ExtensionAPI): SettingRow<unknown>[] {
|
|
249
|
+
return SETTINGS.filter(
|
|
250
|
+
(row) =>
|
|
251
|
+
!SECTION_PACKAGES[row.section] ||
|
|
252
|
+
isPackageLoaded(pi, SECTION_PACKAGES[row.section] as string),
|
|
253
|
+
);
|
|
254
|
+
}
|
|
216
255
|
|
|
217
|
-
function buildSummary(runtime: PixRuntime): string {
|
|
256
|
+
function buildSummary(runtime: PixRuntime, settings: SettingRow<unknown>[]): string {
|
|
218
257
|
const lines = [`Pix Settings (${runtime.path})`, ""];
|
|
219
258
|
let lastSection = "";
|
|
220
|
-
for (const row of
|
|
259
|
+
for (const row of settings) {
|
|
221
260
|
if (row.section !== lastSection) {
|
|
222
261
|
if (lastSection) lines.push("");
|
|
223
262
|
lines.push(`[${row.section}]`);
|
|
@@ -236,12 +275,63 @@ function buildSummary(runtime: PixRuntime): string {
|
|
|
236
275
|
return lines.join("\n");
|
|
237
276
|
}
|
|
238
277
|
|
|
278
|
+
export type PackageSettings = {
|
|
279
|
+
id: string;
|
|
280
|
+
label: string;
|
|
281
|
+
open(ctx: ExtensionCommandContext): Promise<void>;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const SETTINGS_QUERY = "pix:settings";
|
|
285
|
+
const COMMAND_QUERY = "pix:settings-command";
|
|
286
|
+
|
|
287
|
+
/** Register only from an active package factory; Pi clears subscriptions on reload. */
|
|
288
|
+
export function registerPackageSettings(pi: ExtensionAPI, settings: PackageSettings): void {
|
|
289
|
+
pi.events?.on(SETTINGS_QUERY, (data: unknown) => {
|
|
290
|
+
if (data instanceof Map) data.set(settings.id, settings);
|
|
291
|
+
});
|
|
292
|
+
registerPixCommand(pi, pixRuntime());
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function packageSettings(pi: ExtensionAPI): PackageSettings[] {
|
|
296
|
+
const entries = new Map<string, PackageSettings>();
|
|
297
|
+
pi.events?.emit(SETTINGS_QUERY, entries);
|
|
298
|
+
return [...entries.values()].sort((a, b) => a.label.localeCompare(b.label));
|
|
299
|
+
}
|
|
300
|
+
|
|
239
301
|
export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void {
|
|
302
|
+
const claim = { registered: false };
|
|
303
|
+
pi.events?.emit(COMMAND_QUERY, claim);
|
|
304
|
+
if (claim.registered) return;
|
|
305
|
+
pi.events?.on(COMMAND_QUERY, (data: unknown) => {
|
|
306
|
+
if (data && typeof data === "object" && "registered" in data) data.registered = true;
|
|
307
|
+
});
|
|
240
308
|
pi.registerCommand("pix", {
|
|
241
|
-
description: "pix: open shared settings (edit pix.json)",
|
|
242
|
-
|
|
309
|
+
description: "pix: open shared settings (edit pix.json); /pix <tab> opens that tab",
|
|
310
|
+
getArgumentCompletions: (prefix: string) => {
|
|
311
|
+
const p = prefix.trim().toLowerCase();
|
|
312
|
+
const items = [...TABS, ...packageSettings(pi).map((entry) => entry.label)]
|
|
313
|
+
.map((label) => label.toLowerCase())
|
|
314
|
+
.filter((name) => name.startsWith(p))
|
|
315
|
+
.map((name) => ({ value: name, label: name }));
|
|
316
|
+
return items.length ? items : null;
|
|
317
|
+
},
|
|
318
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
243
319
|
// Re-parse pix.json so section parsers remain the single validation source.
|
|
244
320
|
await runtime.reload({ origin: "command", source: "pix" });
|
|
321
|
+
const panels = packageSettings(pi);
|
|
322
|
+
const settings = visibleSettings(pi);
|
|
323
|
+
// Pi owns the active theme (settings.json); `/pix` only cycles it through `ctx.ui.setTheme`.
|
|
324
|
+
const themeNames = (ctx.ui.getAllThemes?.() ?? []).map((t) => t.name).sort();
|
|
325
|
+
const themeRow: ThemeRow | undefined =
|
|
326
|
+
themeNames.length > 1
|
|
327
|
+
? {
|
|
328
|
+
kind: "theme",
|
|
329
|
+
section: "Pretty",
|
|
330
|
+
label: "theme",
|
|
331
|
+
values: themeNames,
|
|
332
|
+
current: () => ctx.ui.theme.name ?? "",
|
|
333
|
+
}
|
|
334
|
+
: undefined;
|
|
245
335
|
// SAFETY: Runtime UI supports custom overlays beyond the published command context type.
|
|
246
336
|
const ui = ctx.ui as unknown as {
|
|
247
337
|
custom?: (f: unknown, opts?: unknown) => Promise<unknown>;
|
|
@@ -249,7 +339,12 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
|
|
|
249
339
|
};
|
|
250
340
|
|
|
251
341
|
if (typeof ui.custom !== "function") {
|
|
252
|
-
ui.notify(
|
|
342
|
+
ui.notify(
|
|
343
|
+
[buildSummary(runtime, settings), ...panels.map((panel) => `[${panel.label}]`)].join(
|
|
344
|
+
"\n",
|
|
345
|
+
),
|
|
346
|
+
"info",
|
|
347
|
+
);
|
|
253
348
|
return;
|
|
254
349
|
}
|
|
255
350
|
|
|
@@ -272,7 +367,77 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
|
|
|
272
367
|
let visibleBodyLines = 1;
|
|
273
368
|
let maxBodyOffset = 0;
|
|
274
369
|
let inspectingPage = false;
|
|
275
|
-
|
|
370
|
+
const tabs = [
|
|
371
|
+
...TABS.filter(
|
|
372
|
+
(name) => name !== "Footer" || settings.some((row) => row.section === "Footer"),
|
|
373
|
+
),
|
|
374
|
+
...panels.map((entry) => entry.label),
|
|
375
|
+
].sort((a, b) => tabRank(a) - tabRank(b));
|
|
376
|
+
const wanted = (args ?? "").trim().toLowerCase();
|
|
377
|
+
let tab = tabs.find((name) => name.toLowerCase() === wanted) ?? "Settings";
|
|
378
|
+
let embedded:
|
|
379
|
+
| { render(width: number): string[]; handleInput(data: string): void; dispose?(): void }
|
|
380
|
+
| undefined;
|
|
381
|
+
let leavePanel: (() => void) | undefined;
|
|
382
|
+
let panelGeneration = 0;
|
|
383
|
+
let panelShrink = 1;
|
|
384
|
+
const mountPanel = () => {
|
|
385
|
+
const generation = ++panelGeneration;
|
|
386
|
+
leavePanel?.();
|
|
387
|
+
embedded?.dispose?.();
|
|
388
|
+
embedded = undefined;
|
|
389
|
+
leavePanel = undefined;
|
|
390
|
+
const panel = panels.find((entry) => entry.label === tab);
|
|
391
|
+
if (!panel) return;
|
|
392
|
+
const panelContext = {
|
|
393
|
+
...ctx,
|
|
394
|
+
ui: {
|
|
395
|
+
...ctx.ui,
|
|
396
|
+
custom: async (
|
|
397
|
+
factory: (
|
|
398
|
+
tui: unknown,
|
|
399
|
+
theme: unknown,
|
|
400
|
+
kb: unknown,
|
|
401
|
+
done: (value: unknown) => void,
|
|
402
|
+
) => NonNullable<typeof embedded>,
|
|
403
|
+
) => {
|
|
404
|
+
if (generation !== panelGeneration) return null;
|
|
405
|
+
return new Promise<unknown>((resolve) => {
|
|
406
|
+
leavePanel = () => resolve(null);
|
|
407
|
+
// The /pix tab row takes one line; render() shrinks the panel's terminal until both fit.
|
|
408
|
+
const panelTui = {
|
|
409
|
+
...tui,
|
|
410
|
+
terminal: {
|
|
411
|
+
...tui.terminal,
|
|
412
|
+
get rows() {
|
|
413
|
+
return Math.max(
|
|
414
|
+
1,
|
|
415
|
+
(tui.terminal?.rows ?? process.stdout.rows ?? 24) - panelShrink,
|
|
416
|
+
);
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
embedded = factory(panelTui, theme, kb, (value: unknown) => {
|
|
421
|
+
if (generation !== panelGeneration) return resolve(null);
|
|
422
|
+
if (value == null) {
|
|
423
|
+
embedded = undefined;
|
|
424
|
+
done(null);
|
|
425
|
+
}
|
|
426
|
+
resolve(value);
|
|
427
|
+
});
|
|
428
|
+
tui.requestRender();
|
|
429
|
+
});
|
|
430
|
+
},
|
|
431
|
+
},
|
|
432
|
+
} as unknown as ExtensionCommandContext;
|
|
433
|
+
void panel.open(panelContext).catch((error) => {
|
|
434
|
+
if (generation === panelGeneration)
|
|
435
|
+
ui.notify(
|
|
436
|
+
`pix: ${panel.label}: ${error instanceof Error ? error.message : String(error)}`,
|
|
437
|
+
"error",
|
|
438
|
+
);
|
|
439
|
+
});
|
|
440
|
+
};
|
|
276
441
|
let binaries: ReturnType<typeof createBinariesTab> | undefined;
|
|
277
442
|
const binariesTab = () => {
|
|
278
443
|
binaries ??= createBinariesTab({
|
|
@@ -283,25 +448,44 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
|
|
|
283
448
|
return binaries;
|
|
284
449
|
};
|
|
285
450
|
const tabBar = () =>
|
|
286
|
-
|
|
287
|
-
name
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
451
|
+
tabs
|
|
452
|
+
.map((name) =>
|
|
453
|
+
name === tab
|
|
454
|
+
? theme.fg("accent", theme.bold(` ${name} `))
|
|
455
|
+
: theme.fg("muted", ` ${name} `),
|
|
456
|
+
)
|
|
457
|
+
.join(theme.fg("muted", "│"));
|
|
291
458
|
const switchTab = (direction: -1 | 1) => {
|
|
292
|
-
const next = (
|
|
293
|
-
tab =
|
|
459
|
+
const next = (tabs.indexOf(tab) + direction + tabs.length) % tabs.length;
|
|
460
|
+
tab = tabs[next] ?? "Settings";
|
|
294
461
|
selected = 0;
|
|
295
462
|
bodyOffset = 0;
|
|
296
463
|
inspectingPage = false;
|
|
297
464
|
if (tab === "Binaries") binariesTab().refresh();
|
|
465
|
+
mountPanel();
|
|
298
466
|
};
|
|
299
467
|
|
|
300
|
-
|
|
301
|
-
|
|
468
|
+
// `/pix <tab>` opens straight into that tab.
|
|
469
|
+
if (tab === "Binaries") binariesTab().refresh();
|
|
470
|
+
mountPanel();
|
|
471
|
+
|
|
472
|
+
const rows = (): Row[] => {
|
|
473
|
+
const list = settings.filter((r) => (r.section === "Footer") === (tab === "Footer"));
|
|
474
|
+
return tab === "Settings" && themeRow ? [themeRow, ...list] : list;
|
|
475
|
+
};
|
|
302
476
|
const cycle = (direction: -1 | 1) => {
|
|
303
477
|
const row = rows()[selected];
|
|
304
478
|
if (!row) return;
|
|
479
|
+
if (isThemeRow(row)) {
|
|
480
|
+
const cur = row.values.indexOf(row.current());
|
|
481
|
+
const start = cur === -1 && direction === -1 ? 0 : cur;
|
|
482
|
+
const next = row.values[(start + direction + row.values.length) % row.values.length];
|
|
483
|
+
const result = next ? ctx.ui.setTheme(next) : undefined;
|
|
484
|
+
if (result && !result.success)
|
|
485
|
+
ui.notify(`pix: theme ${next}: ${result.error ?? "failed"}`, "error");
|
|
486
|
+
tui.requestRender();
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
305
489
|
// Functional updater: the runtime queue serializes writes, and the
|
|
306
490
|
// callback sees the latest committed value — so rapid presses each
|
|
307
491
|
// advance exactly one step instead of collapsing on a stale snapshot.
|
|
@@ -370,6 +554,26 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
|
|
|
370
554
|
maxBodyOffset = result.maxBodyOffset;
|
|
371
555
|
return result.lines;
|
|
372
556
|
}
|
|
557
|
+
if (!TABS.some((name) => name === tab)) {
|
|
558
|
+
if (embedded) {
|
|
559
|
+
let lines = embedded.render(width);
|
|
560
|
+
// Keep the composite inside the same budget as built-in tabs.
|
|
561
|
+
for (let tries = 0; lines.length + 1 > frame.maxHeight && tries < 8; tries++) {
|
|
562
|
+
panelShrink += lines.length + 1 - frame.maxHeight;
|
|
563
|
+
lines = embedded.render(width);
|
|
564
|
+
}
|
|
565
|
+
const panelWidth = visibleWidth(lines[0] ?? "");
|
|
566
|
+
lines.splice(1, 0, ...frameRows(panelWidth, [tabBar()], frame).slice(1, -1));
|
|
567
|
+
return lines;
|
|
568
|
+
}
|
|
569
|
+
return frameModal({
|
|
570
|
+
...frame,
|
|
571
|
+
header: [tabBar(), ""],
|
|
572
|
+
body: [theme.fg("dim", ` Loading ${tab} settings…`)],
|
|
573
|
+
footer: ["", tabGuide + guideSep + guide("esc", "close")],
|
|
574
|
+
bodyOffset: 0,
|
|
575
|
+
}).lines;
|
|
576
|
+
}
|
|
373
577
|
const settings = rows();
|
|
374
578
|
const labelW = Math.max(...settings.map((r) => r.label.length));
|
|
375
579
|
const body: string[] = [];
|
|
@@ -386,8 +590,8 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
|
|
|
386
590
|
const sel = i === selected;
|
|
387
591
|
const cursor = sel ? theme.fg("accent", "→") : " ";
|
|
388
592
|
const label = theme.fg(sel ? "accent" : "text", row.label.padEnd(labelW));
|
|
389
|
-
const value = row.read(runtime.get(row.handle));
|
|
390
|
-
const isDefault = value === row.read(row.handle.defaults);
|
|
593
|
+
const value = isThemeRow(row) ? row.current() : row.read(runtime.get(row.handle));
|
|
594
|
+
const isDefault = isThemeRow(row) || value === row.read(row.handle.defaults);
|
|
391
595
|
settingBodyLines[i] = body.length;
|
|
392
596
|
body.push(`${cursor} ${label} ${theme.fg(isDefault ? "dim" : "success", value)}`);
|
|
393
597
|
}
|
|
@@ -415,6 +619,11 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
|
|
|
415
619
|
maxBodyOffset = result.maxBodyOffset;
|
|
416
620
|
return result.lines;
|
|
417
621
|
},
|
|
622
|
+
dispose: () => {
|
|
623
|
+
panelGeneration++;
|
|
624
|
+
leavePanel?.();
|
|
625
|
+
embedded?.dispose?.();
|
|
626
|
+
},
|
|
418
627
|
invalidate: () => {},
|
|
419
628
|
handleInput: (data: string) => {
|
|
420
629
|
if (tab === "Binaries" && binariesTab().editing) {
|
|
@@ -435,6 +644,10 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
|
|
|
435
644
|
tui.requestRender();
|
|
436
645
|
return;
|
|
437
646
|
}
|
|
647
|
+
if (!TABS.some((name) => name === tab) && embedded) {
|
|
648
|
+
embedded.handleInput(data);
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
438
651
|
const pageUp = kb?.matches(data, "tui.select.pageUp") || matchesKey(data, Key.pageUp);
|
|
439
652
|
const pageDown =
|
|
440
653
|
kb?.matches(data, "tui.select.pageDown") || matchesKey(data, Key.pageDown);
|