@xynogen/pix-runtime 0.10.2 → 0.12.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/LICENSE +21 -0
- package/README.md +206 -11
- package/package.json +8 -1
- package/src/audio.ts +298 -0
- package/src/binaries/catalog.ts +279 -0
- package/src/binaries/ensure.ts +294 -0
- package/src/binaries/index.ts +43 -0
- package/src/binaries/resolve.ts +199 -0
- package/src/binaries/store.ts +149 -0
- package/src/binaries-tab.ts +364 -0
- package/src/exec.ts +365 -0
- package/src/extension.ts +35 -3
- package/src/icon-catalog.ts +1 -0
- package/src/os.ts +250 -0
- package/src/paths.ts +87 -0
- package/src/persistence.ts +122 -28
- package/src/pix-command.ts +110 -10
- package/src/platform.ts +75 -0
- package/src/runtime.ts +126 -80
- package/src/safe-path.ts +101 -0
- package/src/testing.ts +19 -3
- package/src/user-shell.ts +51 -0
- package/src/which.ts +21 -4
package/src/paths.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* paths.ts — canonical Pi/pix directories, pure and env-injectable.
|
|
3
|
+
*
|
|
4
|
+
* `agentDir()` mirrors Pi's `getAgentDir()` (PI_CODING_AGENT_DIR, `~`-expanded,
|
|
5
|
+
* else `~/.pi/agent`) so code that runs outside a Pi process (CLIs, tests) and
|
|
6
|
+
* code inside it agree. `binDir()` is the same folder Pi downloads fd/rg into.
|
|
7
|
+
*
|
|
8
|
+
* Home resolution never trusts `HOME` alone: it is unset in a Windows Pi process,
|
|
9
|
+
* which silently turned `HOME ?? ""` paths into cwd-relative ones.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { homedir, tmpdir } from "node:os";
|
|
13
|
+
import { dirname, join, resolve } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* User home. For the live process this is exactly `os.homedir()` (what Pi's
|
|
18
|
+
* `getAgentDir` uses). An injected env (tests) is read instead: USERPROFILE on
|
|
19
|
+
* Windows, HOME elsewhere, falling back to `os.homedir()`.
|
|
20
|
+
*/
|
|
21
|
+
export function homeDir(
|
|
22
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
23
|
+
os: NodeJS.Platform = process.platform,
|
|
24
|
+
): string {
|
|
25
|
+
if (env === process.env) return homedir();
|
|
26
|
+
const fromEnv = os === "win32" ? env.USERPROFILE : env.HOME;
|
|
27
|
+
return fromEnv || homedir();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Expand a leading `~` / `~/` / `~\` against {@link homeDir}. */
|
|
31
|
+
export function expandHome(path: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
32
|
+
if (path === "~") return homeDir(env);
|
|
33
|
+
if (path.startsWith("~/") || path.startsWith("~\\")) return join(homeDir(env), path.slice(2));
|
|
34
|
+
return path;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Pi agent dir: `PI_CODING_AGENT_DIR` (tilde-expanded) or `~/.pi/agent`. */
|
|
38
|
+
export function agentDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
39
|
+
const override = env.PI_CODING_AGENT_DIR;
|
|
40
|
+
if (override) return expandHome(override, env);
|
|
41
|
+
return join(homeDir(env), ".pi", "agent");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Pi's managed binary dir (`<agentDir>/bin`), shared with Pi's fd/rg downloads. */
|
|
45
|
+
export function binDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
46
|
+
return join(agentDir(env), "bin");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Project config dir: `<cwd>/.pi` (Pi's project `settings.json`, `mcp.json`,
|
|
51
|
+
* `agents/`, `plans/`, `lsp.json`). With no `cwd` it returns the relative `.pi`,
|
|
52
|
+
* for paths that resolve against the process cwd or show in text.
|
|
53
|
+
*/
|
|
54
|
+
export function projectDir(cwd = "."): string {
|
|
55
|
+
return join(cwd, ".pi");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* OS temp dir, shared with every other program. Never delete it whole, only
|
|
60
|
+
* your own entries in it. For the live process this is exactly `os.tmpdir()`.
|
|
61
|
+
* An injected env (tests) is read instead: TEMP/TMP on Windows, TMPDIR
|
|
62
|
+
* elsewhere, falling back to `os.tmpdir()`.
|
|
63
|
+
*/
|
|
64
|
+
export function tempDir(
|
|
65
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
66
|
+
os: NodeJS.Platform = process.platform,
|
|
67
|
+
): string {
|
|
68
|
+
if (env === process.env) return tmpdir();
|
|
69
|
+
const fromEnv = os === "win32" ? env.TEMP || env.TMP : env.TMPDIR;
|
|
70
|
+
return fromEnv || tmpdir();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Absolute path to a file shipped next to a module: `moduleFile(import.meta.url, "..", "SOP.md")`.
|
|
75
|
+
* Use it for package assets (SOP.md, skills/). It does not use `URL.pathname`, which gives
|
|
76
|
+
* `/C:/...` on Windows. It does not use `require.resolve("<pkg>/package.json")`, which Node
|
|
77
|
+
* rejects when `exports` omits it.
|
|
78
|
+
*/
|
|
79
|
+
export function moduleFile(moduleUrl: string, ...segments: string[]): string {
|
|
80
|
+
return resolve(dirname(fileURLToPath(moduleUrl)), ...segments);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Pi cache root: `$XDG_CACHE_HOME/pi` or `~/.cache/pi`. */
|
|
84
|
+
export function cacheDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
85
|
+
const xdg = env.XDG_CACHE_HOME;
|
|
86
|
+
return join(xdg || join(homeDir(env), ".cache"), "pi");
|
|
87
|
+
}
|
package/src/persistence.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import {
|
|
10
10
|
closeSync,
|
|
11
11
|
existsSync,
|
|
12
|
+
linkSync,
|
|
12
13
|
mkdirSync,
|
|
13
14
|
openSync,
|
|
14
15
|
readFileSync,
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
writeFileSync,
|
|
19
20
|
writeSync,
|
|
20
21
|
} from "node:fs";
|
|
22
|
+
import { hostname } from "node:os";
|
|
21
23
|
import { dirname, join } from "node:path";
|
|
22
24
|
import type { RawDocument } from "./schema.ts";
|
|
23
25
|
|
|
@@ -38,19 +40,62 @@ export class ConfigLockError extends ConfigWriteError {
|
|
|
38
40
|
}
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
/** The config file is not valid JSON or not a JSON object. Never overwrite it. */
|
|
44
|
+
export class ConfigParseError extends Error {
|
|
45
|
+
constructor(message: string) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.name = "ConfigParseError";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
41
51
|
/** Filesystem adapter — tests inject an in-memory or temp-dir implementation. */
|
|
42
52
|
export interface StorageAdapter {
|
|
43
53
|
readonly path: string;
|
|
44
54
|
readRaw(): string | undefined;
|
|
45
|
-
/**
|
|
46
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Read-modify-write under the cross-process lock: `fn` gets the current file
|
|
57
|
+
* text and returns the new contents (atomically written) or undefined (no
|
|
58
|
+
* write). Another process cannot write between the read and the write.
|
|
59
|
+
*/
|
|
60
|
+
transact(fn: (raw: string | undefined) => string | undefined): void;
|
|
47
61
|
ensureDir(): void;
|
|
48
62
|
}
|
|
49
63
|
|
|
50
|
-
|
|
64
|
+
// A healthy holder keeps the lock for milliseconds (read + write + rename).
|
|
65
|
+
// Stale must be shorter than the retry budget, or a crashed holder makes every
|
|
66
|
+
// writer fail until it expires.
|
|
67
|
+
const LOCK_STALE_MS = 4_000;
|
|
51
68
|
const LOCK_RETRY_MS = 25;
|
|
52
69
|
const LOCK_MAX_RETRIES = 200; // ~5s budget
|
|
53
70
|
|
|
71
|
+
interface LockOwner {
|
|
72
|
+
pid: number;
|
|
73
|
+
host: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseOwner(text: string): LockOwner | undefined {
|
|
77
|
+
try {
|
|
78
|
+
const o = JSON.parse(text) as Partial<LockOwner>;
|
|
79
|
+
return typeof o.pid === "number" && typeof o.host === "string"
|
|
80
|
+
? { pid: o.pid, host: o.host }
|
|
81
|
+
: undefined;
|
|
82
|
+
} catch {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** True only when the owner ran on this host and its process is gone. */
|
|
88
|
+
function ownerIsDead(owner: LockOwner | undefined): boolean {
|
|
89
|
+
if (!owner || owner.host !== hostname() || owner.pid === process.pid) return false;
|
|
90
|
+
try {
|
|
91
|
+
process.kill(owner.pid, 0);
|
|
92
|
+
return false;
|
|
93
|
+
} catch (err) {
|
|
94
|
+
// EPERM: the process exists under another user.
|
|
95
|
+
return (err as NodeJS.ErrnoException).code === "ESRCH";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
54
99
|
/** Node/Bun filesystem storage rooted at `<agentDir>/pix.json`. */
|
|
55
100
|
export class FileStorage implements StorageAdapter {
|
|
56
101
|
readonly path: string;
|
|
@@ -75,28 +120,63 @@ export class FileStorage implements StorageAdapter {
|
|
|
75
120
|
}
|
|
76
121
|
|
|
77
122
|
private acquireLock(): void {
|
|
123
|
+
const me = JSON.stringify({ pid: process.pid, host: hostname() } satisfies LockOwner);
|
|
78
124
|
for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
|
|
79
125
|
try {
|
|
80
126
|
const fd = openSync(this.lockPath, "wx", 0o600);
|
|
81
|
-
writeSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }));
|
|
82
|
-
closeSync(fd);
|
|
83
|
-
return;
|
|
84
|
-
} catch {
|
|
85
|
-
// Reclaim a stale lock only when older than the threshold.
|
|
86
127
|
try {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
} catch {
|
|
93
|
-
// Lock vanished between open and stat — retry immediately.
|
|
94
|
-
continue;
|
|
128
|
+
writeSync(fd, me);
|
|
129
|
+
} finally {
|
|
130
|
+
closeSync(fd);
|
|
95
131
|
}
|
|
96
|
-
|
|
132
|
+
return;
|
|
133
|
+
} catch (err) {
|
|
134
|
+
// Only EEXIST means "another writer holds it". EACCES/EROFS/ENOSPC will
|
|
135
|
+
// not clear by waiting, so fail at once with the real cause.
|
|
136
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST")
|
|
137
|
+
throw new ConfigLockError(`could not create ${this.lockPath}`, err);
|
|
97
138
|
}
|
|
139
|
+
if (this.reclaimIfStale()) continue;
|
|
140
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_RETRY_MS);
|
|
141
|
+
}
|
|
142
|
+
throw new ConfigLockError(`could not acquire ${this.lockPath} (held by another process)`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Remove the lock when its owner process is dead (same host) or it is older
|
|
147
|
+
* than {@link LOCK_STALE_MS}. Returns true when the caller should retry now.
|
|
148
|
+
*
|
|
149
|
+
* The lock moves away by rename, which is atomic, so only one waiter takes a
|
|
150
|
+
* given lock file. If that file is not the one judged stale (another waiter
|
|
151
|
+
* already reclaimed it and a new holder wrote a fresh lock), it goes back with
|
|
152
|
+
* linkSync, which never overwrites.
|
|
153
|
+
* ponytail: a third writer can still lock in the few microseconds between the
|
|
154
|
+
* rename and the link. Then the restored holder loses mutual exclusion once.
|
|
155
|
+
* Upgrade path: an OS file lock (flock / LockFileEx) through a native addon.
|
|
156
|
+
*/
|
|
157
|
+
private reclaimIfStale(): boolean {
|
|
158
|
+
let text: string;
|
|
159
|
+
let mtimeMs: number;
|
|
160
|
+
try {
|
|
161
|
+
text = readFileSync(this.lockPath, "utf-8");
|
|
162
|
+
mtimeMs = statSync(this.lockPath).mtimeMs;
|
|
163
|
+
} catch {
|
|
164
|
+
return true; // The lock vanished between open and read: retry at once.
|
|
165
|
+
}
|
|
166
|
+
if (!ownerIsDead(parseOwner(text)) && Date.now() - mtimeMs <= LOCK_STALE_MS) return false;
|
|
167
|
+
const moved = `${this.lockPath}.stale-${process.pid}-${Math.random().toString(36).slice(2)}`;
|
|
168
|
+
try {
|
|
169
|
+
renameSync(this.lockPath, moved);
|
|
170
|
+
} catch {
|
|
171
|
+
return true; // Another waiter took it first.
|
|
98
172
|
}
|
|
99
|
-
|
|
173
|
+
try {
|
|
174
|
+
if (readFileSync(moved, "utf-8") !== text) linkSync(moved, this.lockPath);
|
|
175
|
+
} catch {
|
|
176
|
+
// EEXIST: a newer lock exists, which is correct. Unreadable: treat as stale.
|
|
177
|
+
}
|
|
178
|
+
rmSync(moved, { force: true });
|
|
179
|
+
return true;
|
|
100
180
|
}
|
|
101
181
|
|
|
102
182
|
private releaseLock(): void {
|
|
@@ -107,9 +187,19 @@ export class FileStorage implements StorageAdapter {
|
|
|
107
187
|
}
|
|
108
188
|
}
|
|
109
189
|
|
|
110
|
-
|
|
190
|
+
transact(fn: (raw: string | undefined) => string | undefined): void {
|
|
111
191
|
this.ensureDir();
|
|
112
192
|
this.acquireLock();
|
|
193
|
+
try {
|
|
194
|
+
const contents = fn(this.readRaw());
|
|
195
|
+
if (contents !== undefined) this.writeAtomic(contents);
|
|
196
|
+
} finally {
|
|
197
|
+
this.releaseLock();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Temp file + rename. The caller holds the lock. */
|
|
202
|
+
private writeAtomic(contents: string): void {
|
|
113
203
|
const tmp = `${this.path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
|
|
114
204
|
try {
|
|
115
205
|
writeFileSync(tmp, contents, { mode: 0o600 });
|
|
@@ -121,8 +211,6 @@ export class FileStorage implements StorageAdapter {
|
|
|
121
211
|
/* ignore */
|
|
122
212
|
}
|
|
123
213
|
throw new ConfigWriteError(`write failed: ${this.path}`, err);
|
|
124
|
-
} finally {
|
|
125
|
-
this.releaseLock();
|
|
126
214
|
}
|
|
127
215
|
}
|
|
128
216
|
}
|
|
@@ -149,16 +237,22 @@ export class WriteQueue {
|
|
|
149
237
|
|
|
150
238
|
// ── Raw document read/parse ──────────────────────────────────────────────────
|
|
151
239
|
|
|
240
|
+
/**
|
|
241
|
+
* Parse the config file. Missing or empty means `{}`. Invalid JSON or a
|
|
242
|
+
* non-object throws {@link ConfigParseError}: a hand-edit typo must never be
|
|
243
|
+
* read as "all defaults" and then written back over the user's file.
|
|
244
|
+
*/
|
|
152
245
|
export function parseRawDocument(text: string | undefined): RawDocument {
|
|
153
|
-
if (!text) return {};
|
|
246
|
+
if (!text?.trim()) return {};
|
|
247
|
+
let parsed: unknown;
|
|
154
248
|
try {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
: {};
|
|
159
|
-
} catch {
|
|
160
|
-
return {};
|
|
249
|
+
parsed = JSON.parse(text);
|
|
250
|
+
} catch (err) {
|
|
251
|
+
throw new ConfigParseError(`invalid JSON: ${(err as Error).message}`);
|
|
161
252
|
}
|
|
253
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
254
|
+
throw new ConfigParseError("top level is not a JSON object");
|
|
255
|
+
return parsed as RawDocument;
|
|
162
256
|
}
|
|
163
257
|
|
|
164
258
|
export function serializeRawDocument(doc: RawDocument): string {
|
package/src/pix-command.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* Headless hosts get a notify summary instead of the overlay.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { dirname } from "node:path";
|
|
9
10
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
10
11
|
import {
|
|
11
12
|
Key,
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
visibleWidth,
|
|
16
17
|
wrapTextWithAnsi,
|
|
17
18
|
} from "@earendil-works/pi-tui";
|
|
19
|
+
import { createBinariesTab } from "./binaries-tab.ts";
|
|
18
20
|
import { icon } from "./icon-catalog.ts";
|
|
19
21
|
import type { PixRuntime } from "./runtime.ts";
|
|
20
22
|
import type { DeepPartial, SectionHandle } from "./schema.ts";
|
|
@@ -184,6 +186,13 @@ const SETTINGS: SettingRow<unknown>[] = [
|
|
|
184
186
|
}),
|
|
185
187
|
];
|
|
186
188
|
|
|
189
|
+
/** Env whose agent dir is the folder holding pix.json (binary.json lives beside it). */
|
|
190
|
+
function binariesEnv(runtime: PixRuntime): NodeJS.ProcessEnv {
|
|
191
|
+
return { ...process.env, PI_CODING_AGENT_DIR: dirname(runtime.path) };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const TABS = ["Settings", "Binaries"] as const;
|
|
195
|
+
|
|
187
196
|
function buildSummary(runtime: PixRuntime): string {
|
|
188
197
|
const lines = [`Pix Settings (${runtime.path})`, ""];
|
|
189
198
|
let lastSection = "";
|
|
@@ -197,6 +206,12 @@ function buildSummary(runtime: PixRuntime): string {
|
|
|
197
206
|
const isDefault = value === row.read(row.handle.defaults);
|
|
198
207
|
lines.push(` ${row.label}: ${value}${isDefault ? "" : " *"}`);
|
|
199
208
|
}
|
|
209
|
+
const binaries = createBinariesTab({
|
|
210
|
+
env: binariesEnv(runtime),
|
|
211
|
+
theme: { fg: (_c, t) => t, bold: (t) => t },
|
|
212
|
+
requestRender: () => {},
|
|
213
|
+
});
|
|
214
|
+
lines.push("", "[Binaries]", ...binaries.summary());
|
|
200
215
|
return lines.join("\n");
|
|
201
216
|
}
|
|
202
217
|
|
|
@@ -236,6 +251,29 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
|
|
|
236
251
|
let visibleBodyLines = 1;
|
|
237
252
|
let maxBodyOffset = 0;
|
|
238
253
|
let inspectingPage = false;
|
|
254
|
+
let tab: (typeof TABS)[number] = "Settings";
|
|
255
|
+
let binaries: ReturnType<typeof createBinariesTab> | undefined;
|
|
256
|
+
const binariesTab = () => {
|
|
257
|
+
binaries ??= createBinariesTab({
|
|
258
|
+
env: binariesEnv(runtime),
|
|
259
|
+
theme,
|
|
260
|
+
requestRender: () => tui.requestRender(),
|
|
261
|
+
});
|
|
262
|
+
return binaries;
|
|
263
|
+
};
|
|
264
|
+
const tabBar = () =>
|
|
265
|
+
TABS.map((name) =>
|
|
266
|
+
name === tab
|
|
267
|
+
? theme.fg("accent", theme.bold(` ${name} `))
|
|
268
|
+
: theme.fg("muted", ` ${name} `),
|
|
269
|
+
).join(theme.fg("muted", "│"));
|
|
270
|
+
const switchTab = (direction: -1 | 1) => {
|
|
271
|
+
const next = (TABS.indexOf(tab) + direction + TABS.length) % TABS.length;
|
|
272
|
+
tab = TABS[next] ?? "Settings";
|
|
273
|
+
bodyOffset = 0;
|
|
274
|
+
inspectingPage = false;
|
|
275
|
+
if (tab === "Binaries") binariesTab().refresh();
|
|
276
|
+
};
|
|
239
277
|
|
|
240
278
|
const cycle = (direction: -1 | 1) => {
|
|
241
279
|
const row = SETTINGS[selected];
|
|
@@ -274,6 +312,40 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
|
|
|
274
312
|
|
|
275
313
|
return {
|
|
276
314
|
render: (width: number) => {
|
|
315
|
+
const frame = {
|
|
316
|
+
width,
|
|
317
|
+
maxHeight: modalHeight(
|
|
318
|
+
tui.terminal?.rows,
|
|
319
|
+
runtime.get(prettySection).maxRenderHeight,
|
|
320
|
+
),
|
|
321
|
+
title: `${icon("settings")} Pix Settings`,
|
|
322
|
+
titleColor: (s: string) => theme.fg("accent", theme.bold(s)),
|
|
323
|
+
color: (s: string) => theme.fg("accent", s),
|
|
324
|
+
bg: (s: string) => theme.bg("customMessageBg", s),
|
|
325
|
+
};
|
|
326
|
+
const tabGuide = guide("tab", "switch tab");
|
|
327
|
+
if (tab === "Binaries") {
|
|
328
|
+
const bt = binariesTab();
|
|
329
|
+
const view = bt.view(width);
|
|
330
|
+
const hints = view.footer.map(([key, action]) => guide(key, action)).join(guideSep);
|
|
331
|
+
const result = frameModal({
|
|
332
|
+
...frame,
|
|
333
|
+
header: [tabBar(), "", ...view.header],
|
|
334
|
+
body: view.body,
|
|
335
|
+
footer: [
|
|
336
|
+
"",
|
|
337
|
+
bt.editing
|
|
338
|
+
? hints
|
|
339
|
+
: hints + guideSep + tabGuide + guideSep + guide("esc", "close"),
|
|
340
|
+
],
|
|
341
|
+
bodyOffset,
|
|
342
|
+
selectedBodyLine: inspectingPage ? undefined : view.selectedBodyLine,
|
|
343
|
+
});
|
|
344
|
+
bodyOffset = result.bodyOffset;
|
|
345
|
+
visibleBodyLines = Math.max(1, result.visibleBodyLines);
|
|
346
|
+
maxBodyOffset = result.maxBodyOffset;
|
|
347
|
+
return result.lines;
|
|
348
|
+
}
|
|
277
349
|
const labelW = Math.max(...SETTINGS.map((r) => r.label.length));
|
|
278
350
|
const body: string[] = [];
|
|
279
351
|
const settingBodyLines: number[] = [];
|
|
@@ -295,14 +367,8 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
|
|
|
295
367
|
body.push(`${cursor} ${label} ${theme.fg(isDefault ? "dim" : "success", value)}`);
|
|
296
368
|
}
|
|
297
369
|
const result = frameModal({
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
tui.terminal?.rows,
|
|
301
|
-
runtime.get(prettySection).maxRenderHeight,
|
|
302
|
-
),
|
|
303
|
-
title: `${icon("settings")} Pix Settings`,
|
|
304
|
-
titleColor: (s: string) => theme.fg("accent", theme.bold(s)),
|
|
305
|
-
header: [""],
|
|
370
|
+
...frame,
|
|
371
|
+
header: [tabBar(), ""],
|
|
306
372
|
body,
|
|
307
373
|
footer: [
|
|
308
374
|
"",
|
|
@@ -312,12 +378,12 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
|
|
|
312
378
|
guideSep +
|
|
313
379
|
guide("PgUp/PgDn", "inspect") +
|
|
314
380
|
guideSep +
|
|
381
|
+
tabGuide +
|
|
382
|
+
guideSep +
|
|
315
383
|
guide("esc", "close"),
|
|
316
384
|
],
|
|
317
385
|
bodyOffset,
|
|
318
386
|
selectedBodyLine: inspectingPage ? undefined : settingBodyLines[selected],
|
|
319
|
-
color: (s: string) => theme.fg("accent", s),
|
|
320
|
-
bg: (s: string) => theme.bg("customMessageBg", s),
|
|
321
387
|
});
|
|
322
388
|
bodyOffset = result.bodyOffset;
|
|
323
389
|
visibleBodyLines = Math.max(1, result.visibleBodyLines);
|
|
@@ -326,6 +392,24 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
|
|
|
326
392
|
},
|
|
327
393
|
invalidate: () => {},
|
|
328
394
|
handleInput: (data: string) => {
|
|
395
|
+
if (tab === "Binaries" && binariesTab().editing) {
|
|
396
|
+
// The filter input takes ↑↓ to move through its matches.
|
|
397
|
+
const nav = !binariesTab().editingPath;
|
|
398
|
+
binariesTab().handleInput(data, {
|
|
399
|
+
up: nav && kb.matches(data, "tui.select.up"),
|
|
400
|
+
down: nav && kb.matches(data, "tui.select.down"),
|
|
401
|
+
enter: false,
|
|
402
|
+
});
|
|
403
|
+
inspectingPage = false;
|
|
404
|
+
tui.requestRender();
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const shiftTab = matchesKey(data, Key.shift(Key.tab));
|
|
408
|
+
if (shiftTab || matchesKey(data, Key.tab)) {
|
|
409
|
+
switchTab(shiftTab ? -1 : 1);
|
|
410
|
+
tui.requestRender();
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
329
413
|
const pageUp = kb?.matches(data, "tui.select.pageUp") || matchesKey(data, Key.pageUp);
|
|
330
414
|
const pageDown =
|
|
331
415
|
kb?.matches(data, "tui.select.pageDown") || matchesKey(data, Key.pageDown);
|
|
@@ -340,9 +424,25 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
|
|
|
340
424
|
// for letters and special keys alike — raw string compares like
|
|
341
425
|
// `data === "k"` silently fail under the Kitty keyboard protocol.
|
|
342
426
|
if (kb.matches(data, "tui.select.cancel")) {
|
|
427
|
+
if (tab === "Binaries" && binariesTab().clearFilter()) {
|
|
428
|
+
tui.requestRender();
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
343
431
|
done(null);
|
|
344
432
|
return;
|
|
345
433
|
}
|
|
434
|
+
if (tab === "Binaries") {
|
|
435
|
+
const handled = binariesTab().handleInput(data, {
|
|
436
|
+
up: kb.matches(data, "tui.select.up"),
|
|
437
|
+
down: kb.matches(data, "tui.select.down"),
|
|
438
|
+
enter: kb.matches(data, "tui.select.confirm"),
|
|
439
|
+
});
|
|
440
|
+
if (handled) {
|
|
441
|
+
inspectingPage = false;
|
|
442
|
+
tui.requestRender();
|
|
443
|
+
}
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
346
446
|
if (kb.matches(data, "tui.select.up")) move(-1);
|
|
347
447
|
else if (kb.matches(data, "tui.select.down")) move(1);
|
|
348
448
|
else if (matchesKey(data, Key.left)) cycle(-1);
|
package/src/platform.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* platform.ts — one host description for binary selection and OS branches.
|
|
3
|
+
*
|
|
4
|
+
* Pure except for two cached probes (glibc via `process.report`, WSL via
|
|
5
|
+
* `/proc/version`); every input is injectable so tests cover all hosts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
|
|
10
|
+
export type HostOs = "win32" | "linux" | "darwin" | "android";
|
|
11
|
+
export type HostArch = "x64" | "arm64";
|
|
12
|
+
|
|
13
|
+
export interface HostPlatform {
|
|
14
|
+
os: HostOs;
|
|
15
|
+
arch: HostArch;
|
|
16
|
+
/** Linux C library; undefined elsewhere. */
|
|
17
|
+
libc?: "glibc" | "musl";
|
|
18
|
+
/** Linux kernel under Windows Subsystem for Linux. */
|
|
19
|
+
wsl: boolean;
|
|
20
|
+
/** Termux on Android. */
|
|
21
|
+
termux: boolean;
|
|
22
|
+
/** Executable suffix. */
|
|
23
|
+
exe: "" | ".exe";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface HostProbe {
|
|
27
|
+
platform?: NodeJS.Platform;
|
|
28
|
+
arch?: string;
|
|
29
|
+
env?: NodeJS.ProcessEnv;
|
|
30
|
+
/** glibc runtime version, or undefined on musl. */
|
|
31
|
+
glibcVersion?: () => string | undefined;
|
|
32
|
+
/** Contents of `/proc/version`, or undefined when unreadable. */
|
|
33
|
+
procVersion?: () => string | undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function defaultGlibc(): string | undefined {
|
|
37
|
+
try {
|
|
38
|
+
const report = process.report?.getReport() as { header?: { glibcVersionRuntime?: string } };
|
|
39
|
+
return report?.header?.glibcVersionRuntime;
|
|
40
|
+
} catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function defaultProcVersion(): string | undefined {
|
|
46
|
+
try {
|
|
47
|
+
return readFileSync("/proc/version", "utf-8");
|
|
48
|
+
} catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Describe the current host (or an injected one). */
|
|
54
|
+
export function hostPlatform(probe: HostProbe = {}): HostPlatform {
|
|
55
|
+
const platform = probe.platform ?? process.platform;
|
|
56
|
+
const env = probe.env ?? process.env;
|
|
57
|
+
const arch: HostArch = (probe.arch ?? process.arch) === "arm64" ? "arm64" : "x64";
|
|
58
|
+
const termux = Boolean(env.TERMUX_VERSION) || platform === "android";
|
|
59
|
+
if (platform === "win32") return { os: "win32", arch, wsl: false, termux: false, exe: ".exe" };
|
|
60
|
+
if (platform === "darwin") return { os: "darwin", arch, wsl: false, termux: false, exe: "" };
|
|
61
|
+
if (termux) return { os: "android", arch, wsl: false, termux: true, exe: "" };
|
|
62
|
+
const glibc = (probe.glibcVersion ?? defaultGlibc)();
|
|
63
|
+
const wsl =
|
|
64
|
+
Boolean(env.WSL_DISTRO_NAME || env.WSLENV) ||
|
|
65
|
+
/microsoft|wsl/i.test((probe.procVersion ?? defaultProcVersion)() ?? "");
|
|
66
|
+
return { os: "linux", arch, libc: glibc ? "glibc" : "musl", wsl, termux: false, exe: "" };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let cached: HostPlatform | undefined;
|
|
70
|
+
|
|
71
|
+
/** Cached {@link hostPlatform} for the running process. */
|
|
72
|
+
export function currentPlatform(): HostPlatform {
|
|
73
|
+
cached ??= hostPlatform();
|
|
74
|
+
return cached;
|
|
75
|
+
}
|