osdy-pi 1.3.0 β 1.4.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/README.md +23 -2
- package/extensions/osdy-pi/agent-coexistence-setup.test.ts +163 -0
- package/extensions/osdy-pi/agent-coexistence-setup.ts +196 -0
- package/extensions/osdy-pi/runtime.test.ts +35 -1
- package/extensions/osdy-pi/runtime.ts +63 -1
- package/extensions/osdy-pi/ui.ts +2 -8
- package/extensions/osdy-pi/working-animation.test.ts +101 -0
- package/extensions/osdy-pi/working-animation.ts +27 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -33,6 +33,15 @@ Osdy Pi gives [Pi](https://github.com/earendil-works/pi) a themed, responsive te
|
|
|
33
33
|
pi install git:github.com/OsdyOrtiz/Osdy-Pi
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
+
Already installed a pinned `npm:osdy-pi@1.3.0` or `npm:osdy-pi@1.4.0` in **normal Pi**? Once 1.4.1 is published, replace the older pin (no uninstall or `--local` needed):
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pi install npm:osdy-pi@1.4.1
|
|
40
|
+
pi list
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Check that `pi list` shows `npm:osdy-pi@1.4.1`, then restart Pi or run `/reload`. `pi update` does not move a pinned npm version. Installing Osdy does not automatically install Joker; use the [explicit agents setup](#explicit-joker-agents-setup-in-normal-pi) after updating if you want Joker in normal Pi.
|
|
44
|
+
|
|
36
45
|
2. Start Pi:
|
|
37
46
|
|
|
38
47
|
```bash
|
|
@@ -99,6 +108,18 @@ git clone https://github.com/Gentleman-Programming/gentle-pi.git
|
|
|
99
108
|
osdy-pi gentle setup "$(pwd)/gentle-pi"
|
|
100
109
|
```
|
|
101
110
|
|
|
111
|
+
## Explicit Joker agents setup in normal Pi
|
|
112
|
+
|
|
113
|
+
After `pi install npm:osdy-pi`, open **normal personal Pi** (not `osdy`'s isolated profile) and run:
|
|
114
|
+
|
|
115
|
+
```text
|
|
116
|
+
/osdy-pi agents setup
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Review the confirmation: it names `~/.pi/agent/settings.json`, installs `npm:pi-subagents-j0k3r` using Pi's package command, and adds only `-extensions/gentle-agents.ts` to each eligible Gentle package entry. It preserves unrelated settings and Gentle resources; if no Gentle package is present, it only installs Joker. After success, Pi reloads resources (restart Pi if reload fails). `/osdy-pi agents status` checks the personal Joker/Gentle setup without writing. Cancel changes nothing; rerunning is safe. This command is available inside Pi after the Pi package installβno global npm CLI or package postinstall hook is required.
|
|
120
|
+
|
|
121
|
+
Gentle package detection supports `npm:gentle-pi` and validated local checkouts (absolute, settings-relative, `~/`, and `file:` paths). Remote Git/HTTPS Gentle sources are not reconciled: when recognized, setup stops before installing Joker rather than reporting a false success. Register a local Gentle checkout or npm Gentle source in personal Pi first; the existing `osdy-pi gentle setup /absolute/path/to/gentle-pi` command can configure local coexistence if you have that CLI. Setup refuses an isolated/custom `$PI_CODING_AGENT_DIR` or a project-local Gentle override because a personal filter cannot reliably control those contexts. Run it in normal Pi without a conflicting project package; an invalid settings file or failed install produces an error rather than silently applying only part of the filter. It does not edit credentials.
|
|
122
|
+
|
|
102
123
|
## Gentle coexistence setup
|
|
103
124
|
|
|
104
125
|
To load a local `gentle-pi` checkout while keeping Osdy Pi's UI authoritative, run:
|
|
@@ -196,7 +217,7 @@ Before rename or removal, close this Pi process when it uses the target and **ma
|
|
|
196
217
|
| Header and mascot | Independently selectable `osdy-theme`/`neon` headers and `current`/`bts` mascots |
|
|
197
218
|
| Messages | Pi-native, theme-aware user and assistant message cards |
|
|
198
219
|
| Input | Responsive auto editor by default, with selectable simple Pi-native or extended framed modes |
|
|
199
|
-
| Status |
|
|
220
|
+
| Status | Theme-aware Braille spinner and animated working label, responsive footer metrics, dynamic extension statuses, and Codex subscription quota with low-capacity emphasis |
|
|
200
221
|
| Git | Working-tree summary and a centered, filterable diff panel |
|
|
201
222
|
| Audio | Optional event sounds on macOS and Windows |
|
|
202
223
|
|
|
@@ -314,7 +335,7 @@ The default `auto` editor mode preserves the responsive behavior: it uses the fr
|
|
|
314
335
|
|
|
315
336
|
In auto or extended mode at a non-small width, the framed editor shows the model and thinking level in its title and session usage in its footer. For an account-profile launch, the left title shows the active profile name instead of `Osdy-Pi`. When the native editor is effective (simple mode or any small terminal), the Osdy footer instead shows model, active profile when present, thinking, and usage rows before its path/branch and status rows. It uses the currently active Pi/Osdy theme palette; no separate editor theme selector exists. Usage covers input, output, cache read, cache write when present, cost, and context. If Pi supports autocomplete, the editor uses Pi's native autocomplete rendering while the completion UI is visible.
|
|
316
337
|
|
|
317
|
-
|
|
338
|
+
While work is active, Osdy Pi shows the original Braille spinner above the editor in the selected theme's accent color. A highlight travels across the `Working...` and `Running ...` letters using the active theme's accent and text colors; the spinner and label update when the theme changes. Osdy Pi hides Pi's built-in working row while enabled to avoid a duplicate indicator.
|
|
318
339
|
|
|
319
340
|
## Working tree and diff
|
|
320
341
|
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { registerHooks } from "node:module";
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
|
|
9
|
+
registerHooks({ resolve(specifier, context, nextResolve) {
|
|
10
|
+
if (specifier === "./agent-coexistence-setup.js" && context.parentURL?.endsWith(".test.ts"))
|
|
11
|
+
return { shortCircuit: true, url: new URL("./agent-coexistence-setup.ts", context.parentURL).href };
|
|
12
|
+
return nextResolve(specifier, context);
|
|
13
|
+
} });
|
|
14
|
+
const { setupJokerAgents } = await import("./agent-coexistence-setup.js");
|
|
15
|
+
|
|
16
|
+
async function fixture() {
|
|
17
|
+
const root = await mkdtemp(join(tmpdir(), "osdy-joker-"));
|
|
18
|
+
const agentDir = join(root, ".pi", "agent");
|
|
19
|
+
const gentle = join(root, "gentle");
|
|
20
|
+
await mkdir(agentDir, { recursive: true });
|
|
21
|
+
await mkdir(gentle);
|
|
22
|
+
await writeFile(join(gentle, "package.json"), '{"name":"gentle-pi"}');
|
|
23
|
+
const settingsPath = join(agentDir, "settings.json");
|
|
24
|
+
const packages = ["npm:other", { source: gentle, extensions: ["-extensions/gentle-todo.ts"], themes: [], skills: ["skills/*"] }, "npm:gentle-pi@1.2.0", { source: "npm:gentle-pi", extensions: ["-extensions/gentle-agents.ts"] }];
|
|
25
|
+
await writeFile(settingsPath, JSON.stringify({ theme: "dark", packages }));
|
|
26
|
+
return { root, agentDir, gentle, settingsPath, packages };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const base = (f: Awaited<ReturnType<typeof fixture>>) => ({
|
|
30
|
+
agentDir: f.agentDir, home: f.root, cwd: f.root, env: {},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
void test("installs Joker then narrowly reconciles every Gentle entry using latest settings", async () => {
|
|
34
|
+
const f = await fixture();
|
|
35
|
+
let calls = 0;
|
|
36
|
+
const install = async () => {
|
|
37
|
+
calls++;
|
|
38
|
+
const latest = JSON.parse(await readFile(f.settingsPath, "utf8")) as { packages: unknown[] };
|
|
39
|
+
latest.packages.push("npm:pi-subagents-j0k3r", "npm:newly-added");
|
|
40
|
+
await writeFile(f.settingsPath, JSON.stringify(latest));
|
|
41
|
+
};
|
|
42
|
+
await setupJokerAgents({ ...base(f), install });
|
|
43
|
+
const settings = JSON.parse(await readFile(f.settingsPath, "utf8")) as { packages: unknown[]; theme: string };
|
|
44
|
+
assert.equal(calls, 1);
|
|
45
|
+
assert.deepEqual(settings.packages, [
|
|
46
|
+
"npm:other",
|
|
47
|
+
{ source: f.gentle, extensions: ["-extensions/gentle-todo.ts", "-extensions/gentle-agents.ts"], themes: [], skills: ["skills/*"] },
|
|
48
|
+
{ source: "npm:gentle-pi@1.2.0", extensions: ["-extensions/gentle-agents.ts"] },
|
|
49
|
+
{ source: "npm:gentle-pi", extensions: ["-extensions/gentle-agents.ts"] },
|
|
50
|
+
"npm:pi-subagents-j0k3r", "npm:newly-added",
|
|
51
|
+
]);
|
|
52
|
+
assert.equal(settings.theme, "dark");
|
|
53
|
+
await setupJokerAgents({ ...base(f), install: () => Promise.reject(new Error("duplicate install")) });
|
|
54
|
+
assert.equal((await readFile(f.settingsPath, "utf8")).includes("duplicate"), false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
void test("relative Gentle sources resolve from settings and project overrides fail closed", async () => {
|
|
58
|
+
const f = await fixture();
|
|
59
|
+
const local = join(f.agentDir, "gentle-local");
|
|
60
|
+
await mkdir(local);
|
|
61
|
+
await writeFile(join(local, "package.json"), '{"name":"gentle-pi"}');
|
|
62
|
+
await writeFile(f.settingsPath, '{"packages":["./gentle-local","npm:pi-subagents-j0k3r"]}');
|
|
63
|
+
await setupJokerAgents({ ...base(f), install: () => Promise.reject(new Error("should skip")) });
|
|
64
|
+
const result = JSON.parse(await readFile(f.settingsPath, "utf8")) as { packages: unknown[] };
|
|
65
|
+
assert.deepEqual(result.packages[0], { source: "./gentle-local", extensions: ["-extensions/gentle-agents.ts"] });
|
|
66
|
+
const project = join(f.root, "project");
|
|
67
|
+
await mkdir(join(project, ".pi", "gentle"), { recursive: true });
|
|
68
|
+
await writeFile(join(project, ".pi", "gentle", "package.json"), '{"name":"gentle-pi"}');
|
|
69
|
+
await writeFile(join(project, ".pi", "settings.json"), '{"packages":["./gentle"]}');
|
|
70
|
+
await assert.rejects(setupJokerAgents({ ...base(f), cwd: project }), /project-local Gentle/i);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
void test("bare, tilde, and file URL local Gentle entries get the narrow personal filter", async () => {
|
|
74
|
+
const f = await fixture();
|
|
75
|
+
const bare = join(f.agentDir, "gentle");
|
|
76
|
+
await mkdir(bare);
|
|
77
|
+
await writeFile(join(bare, "package.json"), '{"name":"gentle-pi"}');
|
|
78
|
+
await writeFile(f.settingsPath, JSON.stringify({ packages: ["gentle", "~/gentle", pathToFileURL(f.gentle).href, "npm:pi-subagents-j0k3r"] }));
|
|
79
|
+
const result = await setupJokerAgents({ ...base(f), install: () => Promise.reject(new Error("no install")) });
|
|
80
|
+
assert.equal(result.gentleCount, 3);
|
|
81
|
+
const settings = JSON.parse(await readFile(f.settingsPath, "utf8")) as { packages: Array<{ source: string; extensions: string[] }> };
|
|
82
|
+
assert.deepEqual(settings.packages.slice(0, 3).map((entry) => entry.extensions), Array(3).fill(["-extensions/gentle-agents.ts"]));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
void test("bare project Gentle override and ambiguous Gentle local path fail before install", async () => {
|
|
86
|
+
const f = await fixture();
|
|
87
|
+
const project = join(f.root, "project");
|
|
88
|
+
await mkdir(join(project, ".pi", "gentle"), { recursive: true });
|
|
89
|
+
await writeFile(join(project, ".pi", "gentle", "package.json"), '{"name":"gentle-pi"}');
|
|
90
|
+
await writeFile(join(project, ".pi", "settings.json"), '{"packages":["gentle"]}');
|
|
91
|
+
let calls = 0;
|
|
92
|
+
const install = () => { calls++; return Promise.resolve(); };
|
|
93
|
+
await assert.rejects(setupJokerAgents({ ...base(f), cwd: project, install }), /project-local Gentle/i);
|
|
94
|
+
await writeFile(join(project, ".pi", "settings.json"), '{"packages":[]}');
|
|
95
|
+
await writeFile(f.settingsPath, '{"packages":["gentle","npm:pi-subagents-j0k3r"]}');
|
|
96
|
+
await assert.rejects(setupJokerAgents({ ...base(f), install }), /Gentle.*resolve|Gentle.*source/i);
|
|
97
|
+
assert.equal(calls, 0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
void test("recognizable remote Gentle sources fail closed before install, but unrelated Git packages remain", async () => {
|
|
101
|
+
const f = await fixture();
|
|
102
|
+
let calls = 0;
|
|
103
|
+
const install = () => { calls++; return Promise.resolve(); };
|
|
104
|
+
for (const remote of [
|
|
105
|
+
"git:github.com/Gentleman-Programming/gentle-pi",
|
|
106
|
+
"https://github.com/Gentleman-Programming/gentle-pi.git",
|
|
107
|
+
]) {
|
|
108
|
+
await writeFile(f.settingsPath, JSON.stringify({ packages: [remote, "git:github.com/owner/other-package"] }));
|
|
109
|
+
await assert.rejects(setupJokerAgents({ ...base(f), install }), /remote Gentle.*local.*npm/i);
|
|
110
|
+
const project = join(f.root, "project");
|
|
111
|
+
await mkdir(join(project, ".pi"), { recursive: true });
|
|
112
|
+
await writeFile(join(project, ".pi", "settings.json"), JSON.stringify({ packages: [remote] }));
|
|
113
|
+
await writeFile(f.settingsPath, '{"packages":["npm:pi-subagents-j0k3r"]}');
|
|
114
|
+
await assert.rejects(setupJokerAgents({ ...base(f), cwd: project, install }), /remote Gentle.*local.*npm/i);
|
|
115
|
+
await writeFile(join(project, ".pi", "settings.json"), '{"packages":[]}');
|
|
116
|
+
}
|
|
117
|
+
assert.equal(calls, 0);
|
|
118
|
+
await writeFile(f.settingsPath, '{"packages":["git:github.com/owner/other-package","npm:pi-subagents-j0k3r"]}');
|
|
119
|
+
const result = await setupJokerAgents({ ...base(f), install });
|
|
120
|
+
assert.equal(result.gentleCount, 0);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
void test("invalid Gentle filters or duplicate Joker declarations block setup before further writes", async () => {
|
|
124
|
+
const f = await fixture();
|
|
125
|
+
let calls = 0;
|
|
126
|
+
const install = () => { calls++; return Promise.resolve(); };
|
|
127
|
+
await writeFile(f.settingsPath, '{"packages":[{"source":"npm:gentle-pi","extensions":false}]}');
|
|
128
|
+
await assert.rejects(setupJokerAgents({ ...base(f), install }), /Gentle extensions/i);
|
|
129
|
+
await writeFile(f.settingsPath, '{"packages":["npm:pi-subagents-j0k3r","npm:pi-subagents-j0k3r"]}');
|
|
130
|
+
await assert.rejects(setupJokerAgents({ ...base(f), install }), /duplicate Joker/i);
|
|
131
|
+
assert.equal(calls, 0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
void test("without Gentle, only Joker installation changes settings", async () => {
|
|
135
|
+
const f = await fixture();
|
|
136
|
+
await writeFile(f.settingsPath, '{"packages":["npm:other"]}');
|
|
137
|
+
await setupJokerAgents({ ...base(f), install: async () => {
|
|
138
|
+
await writeFile(f.settingsPath, '{"packages":["npm:other","npm:pi-subagents-j0k3r"]}');
|
|
139
|
+
} });
|
|
140
|
+
assert.deepEqual(JSON.parse(await readFile(f.settingsPath, "utf8")) as unknown, { packages: ["npm:other", "npm:pi-subagents-j0k3r"] });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
void test("rejects alternate agent directories and project-local Gentle overrides before install", async () => {
|
|
144
|
+
const f = await fixture();
|
|
145
|
+
let calls = 0;
|
|
146
|
+
const install = () => { calls++; return Promise.resolve(); };
|
|
147
|
+
await assert.rejects(setupJokerAgents({ ...base(f), agentDir: join(f.root, "isolated"), install }), /personal|agent/i);
|
|
148
|
+
await assert.rejects(setupJokerAgents({ ...base(f), env: { PI_CODING_AGENT_DIR: f.agentDir }, install }), /override|normal/i);
|
|
149
|
+
const project = join(f.root, "project");
|
|
150
|
+
await mkdir(join(project, ".pi"), { recursive: true });
|
|
151
|
+
await writeFile(join(project, ".pi", "settings.json"), '{"packages":["npm:gentle-pi"]}');
|
|
152
|
+
await assert.rejects(setupJokerAgents({ ...base(f), cwd: project, install }), /project|Gentle/i);
|
|
153
|
+
assert.equal(calls, 0);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
void test("failed installer or malformed latest settings cannot rewrite personal settings", async () => {
|
|
157
|
+
const f = await fixture();
|
|
158
|
+
const original = await readFile(f.settingsPath, "utf8");
|
|
159
|
+
await assert.rejects(setupJokerAgents({ ...base(f), install: () => Promise.reject(new Error("install failed")) }), /install failed/);
|
|
160
|
+
assert.equal(await readFile(f.settingsPath, "utf8"), original);
|
|
161
|
+
await assert.rejects(setupJokerAgents({ ...base(f), install: async () => { await writeFile(f.settingsPath, "{bad"); } }), /settings/i);
|
|
162
|
+
assert.equal(await readFile(f.settingsPath, "utf8"), "{bad");
|
|
163
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { lstat, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const runFile = promisify(execFile);
|
|
10
|
+
const JOKER = "npm:pi-subagents-j0k3r";
|
|
11
|
+
const GENTLE_EXCLUSION = "-extensions/gentle-agents.ts";
|
|
12
|
+
|
|
13
|
+
type Settings = Record<string, unknown> & { packages?: unknown[] };
|
|
14
|
+
|
|
15
|
+
type SetupOptions = {
|
|
16
|
+
agentDir: string;
|
|
17
|
+
home?: string;
|
|
18
|
+
cwd: string;
|
|
19
|
+
env?: NodeJS.ProcessEnv;
|
|
20
|
+
install?: () => Promise<void>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function object(value: unknown): value is Record<string, unknown> {
|
|
24
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function source(entry: unknown): string | undefined {
|
|
28
|
+
if (typeof entry === "string") return entry;
|
|
29
|
+
return object(entry) && typeof entry.source === "string" ? entry.source : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function npmGentle(entry: unknown): boolean {
|
|
33
|
+
return /^npm:gentle-pi(?:@[^\s]+)?$/i.test(source(entry) ?? "");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function joker(entry: unknown): boolean {
|
|
37
|
+
return /^npm:pi-subagents-j0k3r(?:@[^\s]+)?$/i.test(source(entry) ?? "");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function gentle(entry: unknown, settingsDir: string, home: string): Promise<boolean> {
|
|
41
|
+
if (npmGentle(entry)) return true;
|
|
42
|
+
const value = source(entry);
|
|
43
|
+
if (!value || /^npm:/i.test(value)) return false;
|
|
44
|
+
if (/^(?:git:|https?:\/\/)/i.test(value)) {
|
|
45
|
+
if (/(?:^|\/)gentle-pi(?:\.git)?(?:[@#?][^\s]*)?$/i.test(value))
|
|
46
|
+
throw new Error("Remote Gentle source unsupported: register a local gentle-pi checkout or npm:gentle-pi in personal Pi, then rerun agents setup.");
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
let path: string;
|
|
50
|
+
try {
|
|
51
|
+
if (value.startsWith("file:")) path = fileURLToPath(value);
|
|
52
|
+
else if (value === "~" || value.startsWith("~/")) path = resolve(home, value.slice(value === "~" ? 1 : 2));
|
|
53
|
+
else if (!value.includes(":")) path = resolve(settingsDir, value);
|
|
54
|
+
else return false;
|
|
55
|
+
} catch {
|
|
56
|
+
throw new Error(`Gentle source cannot be resolved: ${value}`);
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const manifest: unknown = JSON.parse(await readFile(join(path, "package.json"), "utf8"));
|
|
60
|
+
return object(manifest) && manifest.name === "gentle-pi";
|
|
61
|
+
} catch {
|
|
62
|
+
if (/(?:^|[\\/])gentle(?:-pi)?$/i.test(path))
|
|
63
|
+
throw new Error(`Gentle source cannot be resolved: ${value}`);
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function validateGentleFilters(entry: unknown): void {
|
|
69
|
+
if (!object(entry) || entry.extensions === undefined) return;
|
|
70
|
+
if (!Array.isArray(entry.extensions) || !entry.extensions.every((value: unknown) => typeof value === "string"))
|
|
71
|
+
throw new Error("Gentle extensions filter must be an array of strings.");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function regularSettings(path: string): Promise<Settings> {
|
|
75
|
+
if (!(await lstat(path)).isFile()) throw new Error("Personal settings.json must be a regular file, not a link.");
|
|
76
|
+
let value: unknown;
|
|
77
|
+
try {
|
|
78
|
+
value = JSON.parse(await readFile(path, "utf8"));
|
|
79
|
+
} catch {
|
|
80
|
+
throw new Error("Personal settings.json must contain valid JSON.");
|
|
81
|
+
}
|
|
82
|
+
if (!object(value) || (value.packages !== undefined && !Array.isArray(value.packages)))
|
|
83
|
+
throw new Error("Personal settings.json must have an object root and array packages.");
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function assertNoProjectGentle(cwd: string, home: string): Promise<void> {
|
|
88
|
+
let directory = resolve(cwd);
|
|
89
|
+
while (true) {
|
|
90
|
+
const local = join(directory, ".pi", "settings.json");
|
|
91
|
+
let text: string;
|
|
92
|
+
try {
|
|
93
|
+
text = await readFile(local, "utf8");
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (object(error) && error.code === "ENOENT") {
|
|
96
|
+
const parent = dirname(directory);
|
|
97
|
+
if (parent === directory) return;
|
|
98
|
+
directory = parent;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
let settings: unknown;
|
|
104
|
+
try { settings = JSON.parse(text); }
|
|
105
|
+
catch { throw new Error(`Project settings invalid at ${local}; cannot guarantee Gentle filtering.`); }
|
|
106
|
+
if (!object(settings) || (settings.packages !== undefined && !Array.isArray(settings.packages)))
|
|
107
|
+
throw new Error(`Project settings invalid at ${local}; cannot guarantee Gentle filtering.`);
|
|
108
|
+
for (const entry of settings.packages ?? []) {
|
|
109
|
+
if (await gentle(entry, dirname(local), home)) throw new Error(`Project-local Gentle package at ${local} can override the personal filter.`);
|
|
110
|
+
}
|
|
111
|
+
const parent = dirname(directory);
|
|
112
|
+
if (parent === directory) return;
|
|
113
|
+
directory = parent;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function installJoker(): Promise<void> {
|
|
118
|
+
await runFile("pi", ["install", JOKER], { cwd: homedir(), maxBuffer: 4096 });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function inspectJokerAgents(options: Omit<SetupOptions, "install">): Promise<{ jokerInstalled: boolean; gentleCount: number; filtered: boolean }> {
|
|
122
|
+
const target = await validateTarget(options);
|
|
123
|
+
const settings = await regularSettings(join(target, "settings.json"));
|
|
124
|
+
const packages = settings.packages ?? [];
|
|
125
|
+
const flags = await Promise.all(packages.map((entry) => gentle(entry, target, options.home ?? homedir())));
|
|
126
|
+
return {
|
|
127
|
+
jokerInstalled: packages.some(joker),
|
|
128
|
+
gentleCount: flags.filter(Boolean).length,
|
|
129
|
+
filtered: flags.every((isGentle, index) => !isGentle ||
|
|
130
|
+
(object(packages[index]) && Array.isArray(packages[index].extensions) && packages[index].extensions.includes(GENTLE_EXCLUSION))),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function validateTarget(options: Omit<SetupOptions, "install">): Promise<string> {
|
|
135
|
+
const home = resolve(options.home ?? homedir());
|
|
136
|
+
const target = resolve(options.agentDir);
|
|
137
|
+
if (options.env?.PI_CODING_AGENT_DIR !== undefined || !isAbsolute(options.agentDir))
|
|
138
|
+
throw new Error("Agents setup requires normal personal Pi; agent directory override or isolated profile is unsupported.");
|
|
139
|
+
let personal: boolean;
|
|
140
|
+
try {
|
|
141
|
+
personal = (await realpath(target)) === (await realpath(join(home, ".pi", "agent")));
|
|
142
|
+
} catch {
|
|
143
|
+
personal = false;
|
|
144
|
+
}
|
|
145
|
+
if (!personal)
|
|
146
|
+
throw new Error("Agents setup requires the normal personal Pi agent directory, not an isolated profile.");
|
|
147
|
+
await assertNoProjectGentle(options.cwd, home);
|
|
148
|
+
return target;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function setupJokerAgents(options: SetupOptions): Promise<{ installed: boolean; changed: boolean; gentleCount: number }> {
|
|
152
|
+
const target = await validateTarget(options);
|
|
153
|
+
const path = join(target, "settings.json");
|
|
154
|
+
const before = await regularSettings(path);
|
|
155
|
+
const original = before.packages ?? [];
|
|
156
|
+
if (original.some((entry) => entry !== null && typeof entry !== "string" && !object(entry)))
|
|
157
|
+
throw new Error("Personal settings packages contain an invalid entry.");
|
|
158
|
+
const initialGentle = await Promise.all(original.map((entry) => gentle(entry, target, options.home ?? homedir())));
|
|
159
|
+
if (original.filter(joker).length > 1) throw new Error("Personal settings contain duplicate Joker entries.");
|
|
160
|
+
const alreadyJoker = original.some(joker);
|
|
161
|
+
for (const [index, isGentle] of initialGentle.entries()) {
|
|
162
|
+
if (isGentle) validateGentleFilters(original[index]);
|
|
163
|
+
}
|
|
164
|
+
const filtered = initialGentle.every((isGentle, index) => !isGentle ||
|
|
165
|
+
(object(original[index]) && Array.isArray(original[index].extensions) && original[index].extensions.includes(GENTLE_EXCLUSION)));
|
|
166
|
+
if (alreadyJoker && filtered) return { installed: false, changed: false, gentleCount: initialGentle.filter(Boolean).length };
|
|
167
|
+
if (!alreadyJoker) await (options.install ?? installJoker)();
|
|
168
|
+
// Install owns only Joker. Re-read the latest settings instead of replaying a stale snapshot.
|
|
169
|
+
await assertNoProjectGentle(options.cwd, options.home ?? homedir());
|
|
170
|
+
const latest = await regularSettings(path);
|
|
171
|
+
const packages = latest.packages ?? [];
|
|
172
|
+
if (!packages.some(joker)) throw new Error("Pi install did not register Joker in personal settings; no Gentle filters were changed.");
|
|
173
|
+
if (packages.filter(joker).length > 1) throw new Error("Personal settings contain duplicate Joker entries.");
|
|
174
|
+
const gentleFlags = await Promise.all(packages.map((entry) => gentle(entry, target, options.home ?? homedir())));
|
|
175
|
+
let changed = false;
|
|
176
|
+
const updated = packages.map((entry, index) => {
|
|
177
|
+
if (!gentleFlags[index]) return entry;
|
|
178
|
+
if (typeof entry !== "string" && !object(entry)) throw new Error("Gentle package entry is invalid.");
|
|
179
|
+
const item = typeof entry === "string" ? { source: entry } : entry;
|
|
180
|
+
validateGentleFilters(item);
|
|
181
|
+
const extensions = item.extensions as string[] | undefined;
|
|
182
|
+
if (extensions?.includes(GENTLE_EXCLUSION)) return entry;
|
|
183
|
+
changed = true;
|
|
184
|
+
return { ...item, extensions: [...(extensions ?? []), GENTLE_EXCLUSION] };
|
|
185
|
+
});
|
|
186
|
+
if (changed) {
|
|
187
|
+
const temporary = join(target, `.settings.${randomUUID()}.tmp`);
|
|
188
|
+
try {
|
|
189
|
+
await writeFile(temporary, `${JSON.stringify({ ...latest, packages: updated }, null, 2)}\n`, { flag: "wx", mode: 0o600 });
|
|
190
|
+
await rename(temporary, path);
|
|
191
|
+
} finally {
|
|
192
|
+
await unlink(temporary).catch(() => {});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return { installed: !alreadyJoker, changed, gentleCount: gentleFlags.filter(Boolean).length };
|
|
196
|
+
}
|
|
@@ -3,7 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
3
3
|
import { registerHooks } from "node:module";
|
|
4
4
|
import test from "node:test";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import ts from "typescript";
|
|
8
8
|
|
|
9
9
|
registerHooks({
|
|
@@ -43,6 +43,7 @@ const { PLUGIN_EVENTS, subscribeQuestionPromptAudioNotification } =
|
|
|
43
43
|
const {
|
|
44
44
|
createActiveSessionRefresh,
|
|
45
45
|
getOsdyCommandCompletions,
|
|
46
|
+
handleAgentsSetupCommand,
|
|
46
47
|
refreshCodexUsage,
|
|
47
48
|
} = await import("./runtime.js");
|
|
48
49
|
|
|
@@ -80,6 +81,39 @@ class TestSessionContextProvider {
|
|
|
80
81
|
}
|
|
81
82
|
}
|
|
82
83
|
|
|
84
|
+
void test("agents setup requires interactive confirmation and reloads only after success", async () => {
|
|
85
|
+
const notices: string[] = [];
|
|
86
|
+
let installs = 0;
|
|
87
|
+
let reloads = 0;
|
|
88
|
+
let accepted = false;
|
|
89
|
+
const ui = {
|
|
90
|
+
confirm: (_title: string, message: string) => {
|
|
91
|
+
assert.match(message, /pi-subagents-j0k3r/);
|
|
92
|
+
assert.match(message, /gentle-agents\.ts/);
|
|
93
|
+
assert.match(message, /settings\.json/);
|
|
94
|
+
return Promise.resolve(accepted);
|
|
95
|
+
},
|
|
96
|
+
notify: (message: string) => { notices.push(message); },
|
|
97
|
+
};
|
|
98
|
+
const ctx = { mode: "tui", hasUI: true, cwd: "/tmp", ui, reload: () => { reloads++; return Promise.resolve(); } } as unknown as ExtensionCommandContext;
|
|
99
|
+
const setup = () => { installs++; return Promise.resolve({ installed: true, changed: true, gentleCount: 1 }); };
|
|
100
|
+
await handleAgentsSetupCommand("setup", ctx, setup);
|
|
101
|
+
assert.equal(installs, 0);
|
|
102
|
+
accepted = true;
|
|
103
|
+
await handleAgentsSetupCommand("setup", ctx, setup);
|
|
104
|
+
assert.equal(installs, 1);
|
|
105
|
+
assert.equal(reloads, 1);
|
|
106
|
+
await handleAgentsSetupCommand("status", ctx, setup, () => Promise.resolve({ jokerInstalled: true, gentleCount: 1, filtered: true }));
|
|
107
|
+
assert.ok(notices.some((notice) => notice.includes("agent exclusion complete")));
|
|
108
|
+
await handleAgentsSetupCommand("invalid", ctx, setup);
|
|
109
|
+
assert.equal(installs, 1);
|
|
110
|
+
assert.ok(notices.some((notice) => notice.includes("Usage:")));
|
|
111
|
+
assert.deepEqual(getOsdyCommandCompletions("agents"), [
|
|
112
|
+
{ value: "agents setup", label: "agents setup" },
|
|
113
|
+
{ value: "agents status", label: "agents status" },
|
|
114
|
+
]);
|
|
115
|
+
});
|
|
116
|
+
|
|
83
117
|
void test("header command completes only catalog choices and is persisted through session wiring", () => {
|
|
84
118
|
assert.deepEqual(getOsdyCommandCompletions("header"), [
|
|
85
119
|
{ value: "header osdy-theme", label: "header osdy-theme" },
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
ExtensionAPI,
|
|
3
|
+
ExtensionCommandContext,
|
|
3
4
|
ExtensionContext,
|
|
4
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { inspectJokerAgents, setupJokerAgents } from "./agent-coexistence-setup.js";
|
|
5
9
|
import { createAudioEventRouter } from "./audio-event-router.js";
|
|
6
10
|
import { registerAudioNotificationFlags } from "./audio-notification-config.js";
|
|
7
11
|
import { createAudioNotificationService } from "./audio-notification-service.js";
|
|
@@ -250,6 +254,7 @@ export function getOsdyCommandCompletions(prefix: string) {
|
|
|
250
254
|
"on",
|
|
251
255
|
"off",
|
|
252
256
|
"status",
|
|
257
|
+
"agents",
|
|
253
258
|
"sound",
|
|
254
259
|
"working-tree",
|
|
255
260
|
"editor",
|
|
@@ -258,6 +263,9 @@ export function getOsdyCommandCompletions(prefix: string) {
|
|
|
258
263
|
"diff",
|
|
259
264
|
].map((value) => ({ value, label: value }));
|
|
260
265
|
}
|
|
266
|
+
if (trimmed === "agents") {
|
|
267
|
+
return ["agents setup", "agents status"].map((value) => ({ value, label: value }));
|
|
268
|
+
}
|
|
261
269
|
if (trimmed === "sound") {
|
|
262
270
|
return [{ value: "sound setup", label: "sound setup" }];
|
|
263
271
|
}
|
|
@@ -302,6 +310,7 @@ export function getOsdyCommandCompletions(prefix: string) {
|
|
|
302
310
|
"on",
|
|
303
311
|
"off",
|
|
304
312
|
"status",
|
|
313
|
+
"agents",
|
|
305
314
|
"sound",
|
|
306
315
|
"working-tree",
|
|
307
316
|
"editor",
|
|
@@ -312,6 +321,12 @@ export function getOsdyCommandCompletions(prefix: string) {
|
|
|
312
321
|
.filter((value) => value.startsWith(valuePrefix))
|
|
313
322
|
.map((value) => ({ value, label: value }));
|
|
314
323
|
}
|
|
324
|
+
if (parts[0] === "agents" && parts.length === 2) {
|
|
325
|
+
const valuePrefix = parts[1] ?? "";
|
|
326
|
+
return ["setup", "status"]
|
|
327
|
+
.filter((value) => value.startsWith(valuePrefix))
|
|
328
|
+
.map((value) => ({ value: `agents ${value}`, label: `agents ${value}` }));
|
|
329
|
+
}
|
|
315
330
|
if (parts[0] === "sound" && parts.length === 2) {
|
|
316
331
|
const valuePrefix = parts[1] ?? "";
|
|
317
332
|
return ["setup"]
|
|
@@ -692,6 +707,49 @@ function registerUsageCommand(
|
|
|
692
707
|
});
|
|
693
708
|
}
|
|
694
709
|
|
|
710
|
+
export async function handleAgentsSetupCommand(
|
|
711
|
+
action: string | undefined,
|
|
712
|
+
ctx: ExtensionCommandContext,
|
|
713
|
+
setup: typeof setupJokerAgents = setupJokerAgents,
|
|
714
|
+
inspect: typeof inspectJokerAgents = inspectJokerAgents,
|
|
715
|
+
): Promise<void> {
|
|
716
|
+
if (action !== "setup" && action !== "status") {
|
|
717
|
+
ctx.ui.notify("Usage: /osdy-pi agents setup|status", "warning");
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
if (!ctx.hasUI) {
|
|
721
|
+
ctx.ui.notify("Agents setup requires an interactive confirmation.", "warning");
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
const target = join(homedir(), ".pi", "agent");
|
|
725
|
+
if (action === "status") {
|
|
726
|
+
try {
|
|
727
|
+
const status = await inspect({ agentDir: target, cwd: ctx.cwd, env: process.env });
|
|
728
|
+
ctx.ui.notify(`Joker ${status.jokerInstalled ? "installed" : "not installed"}; ${status.gentleCount} Gentle entries; agent exclusion ${status.filtered ? "complete" : "incomplete"}.`, "info");
|
|
729
|
+
} catch (error) {
|
|
730
|
+
ctx.ui.notify(`Agents status unavailable: ${error instanceof Error ? error.message : "unknown error"}`, "warning");
|
|
731
|
+
}
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
const confirmed = await ctx.ui.confirm(
|
|
735
|
+
"Set up Joker agents in normal Pi?",
|
|
736
|
+
`Target: ${join(target, "settings.json")}\nInstall npm:pi-subagents-j0k3r with Pi and exclude only -extensions/gentle-agents.ts from eligible Gentle packages. No isolated profile or credentials are changed.`,
|
|
737
|
+
);
|
|
738
|
+
if (!confirmed) return;
|
|
739
|
+
try {
|
|
740
|
+
const result = await setup({ agentDir: target, cwd: ctx.cwd, env: process.env });
|
|
741
|
+
ctx.ui.notify(`Joker agents ready (${result.gentleCount} Gentle entries checked). Reloading resources.`, "info");
|
|
742
|
+
} catch (error) {
|
|
743
|
+
ctx.ui.notify(`Agents setup failed: ${error instanceof Error ? error.message : "unknown error"}. Check settings and retry after resolving the error.`, "error");
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
try {
|
|
747
|
+
await ctx.reload();
|
|
748
|
+
} catch {
|
|
749
|
+
ctx.ui.notify("Agents setup saved, but reload failed; restart Pi to apply the changes.", "warning");
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
695
753
|
function registerCommand(
|
|
696
754
|
pi: ExtensionAPI,
|
|
697
755
|
state: OsdyState,
|
|
@@ -709,6 +767,10 @@ function registerCommand(
|
|
|
709
767
|
getArgumentCompletions: getOsdyCommandCompletions,
|
|
710
768
|
handler: async (args, ctx) => {
|
|
711
769
|
const [action = "status", ...rest] = parseCommandArgs(args);
|
|
770
|
+
if (action === "agents") {
|
|
771
|
+
await handleAgentsSetupCommand(rest.length === 1 ? rest[0] : undefined, ctx);
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
712
774
|
if (["enable", "on"].includes(action)) {
|
|
713
775
|
await enableOsdyPi(
|
|
714
776
|
pi,
|
|
@@ -797,7 +859,7 @@ function registerCommand(
|
|
|
797
859
|
return;
|
|
798
860
|
}
|
|
799
861
|
ctx.ui.notify(
|
|
800
|
-
`Usage: /osdy-pi enable|disable|on|off|status | mascot ${MASCOT_CHOICES.join("|")}|status | header ${HEADER_VARIANT_CHOICES.join("|")}|status | editor auto|extended|simple|on|off|toggle|status | sound setup | working-tree ... | diff`,
|
|
862
|
+
`Usage: /osdy-pi agents setup|status | enable|disable|on|off|status | mascot ${MASCOT_CHOICES.join("|")}|status | header ${HEADER_VARIANT_CHOICES.join("|")}|status | editor auto|extended|simple|on|off|toggle|status | sound setup | working-tree ... | diff`,
|
|
801
863
|
"warning",
|
|
802
864
|
);
|
|
803
865
|
},
|
package/extensions/osdy-pi/ui.ts
CHANGED
|
@@ -39,9 +39,9 @@ import {
|
|
|
39
39
|
STACKED_HEADER_MIN_ROWS,
|
|
40
40
|
type MascotConfig,
|
|
41
41
|
type ScaledHeaderArt,
|
|
42
|
-
WORKING_SPINNER_FRAMES,
|
|
43
42
|
} from "./constants.js";
|
|
44
43
|
import { formatPath } from "./format.js";
|
|
44
|
+
import { renderWorkingWidget } from "./working-animation.js";
|
|
45
45
|
import { contextUsageData, modelLabel } from "./metrics.js";
|
|
46
46
|
import {
|
|
47
47
|
formatModelMetadata,
|
|
@@ -363,13 +363,7 @@ class OsdyWorkingWidget implements Component {
|
|
|
363
363
|
}
|
|
364
364
|
|
|
365
365
|
render(width: number): string[] {
|
|
366
|
-
|
|
367
|
-
const frame =
|
|
368
|
-
WORKING_SPINNER_FRAMES[this.state.frame % WORKING_SPINNER_FRAMES.length] ??
|
|
369
|
-
WORKING_SPINNER_FRAMES[0] ??
|
|
370
|
-
"β ";
|
|
371
|
-
const line = `${this.theme.fg("accent", frame)} ${this.theme.fg("muted", this.state.label)}`;
|
|
372
|
-
return [fitCenterVisible(line, width)];
|
|
366
|
+
return renderWorkingWidget(this.state, this.theme, width);
|
|
373
367
|
}
|
|
374
368
|
|
|
375
369
|
invalidate(): void {}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { registerHooks } from "node:module";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
5
|
+
import type { SimpleTheme, WorkingWidgetState } from "./types.js";
|
|
6
|
+
|
|
7
|
+
registerHooks({
|
|
8
|
+
resolve(specifier, context, nextResolve) {
|
|
9
|
+
if (
|
|
10
|
+
context.parentURL?.includes("/extensions/osdy-pi/") &&
|
|
11
|
+
specifier.startsWith("./") &&
|
|
12
|
+
specifier.endsWith(".js")
|
|
13
|
+
) {
|
|
14
|
+
return nextResolve(`${specifier.slice(0, -3)}.ts`, context);
|
|
15
|
+
}
|
|
16
|
+
return nextResolve(specifier, context);
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// @ts-expect-error Node's native TypeScript runner resolves test-only TypeScript source imports.
|
|
21
|
+
const { renderWorkingWidget } = await import("./working-animation.ts");
|
|
22
|
+
// @ts-expect-error Node's native TypeScript runner resolves test-only TypeScript source imports.
|
|
23
|
+
const { WORKING_SPINNER_FRAMES } = await import("./constants.ts");
|
|
24
|
+
|
|
25
|
+
function state(label: string, frame = 0, active = true): WorkingWidgetState {
|
|
26
|
+
return { active, label, frame, timer: undefined, tui: undefined };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function theme(base: string, accent: string, muted: string): SimpleTheme {
|
|
30
|
+
return {
|
|
31
|
+
fg(name, text) {
|
|
32
|
+
const color = name === "accent" ? accent : name === "muted" ? muted : base;
|
|
33
|
+
return `\u001B[${color}m${text}\u001B[0m`;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const palette = theme("37", "33", "90");
|
|
39
|
+
void test("original Braille frames advance every 80ms tick, wrap at ten, and use theme accent", () => {
|
|
40
|
+
assert.equal(WORKING_SPINNER_FRAMES.length, 10);
|
|
41
|
+
for (let frame = 0; frame <= WORKING_SPINNER_FRAMES.length; frame += 1) {
|
|
42
|
+
const glyph = WORKING_SPINNER_FRAMES[frame % WORKING_SPINNER_FRAMES.length] ?? "";
|
|
43
|
+
const spinner = palette.fg("accent", glyph);
|
|
44
|
+
const line = renderWorkingWidget(state("Working...", frame), palette, 40)[0] ?? "";
|
|
45
|
+
assert.ok(line.includes(`${spinner} `), `frame ${frame} uses accent Braille ${glyph}`);
|
|
46
|
+
assert.equal(visibleWidth(spinner), 1);
|
|
47
|
+
}
|
|
48
|
+
const at = (frame: number) => renderWorkingWidget(state("Working...", frame), palette, 40)[0] ?? "";
|
|
49
|
+
assert.ok(at(0).includes(`${palette.fg("accent", WORKING_SPINNER_FRAMES[0] ?? "")} ${palette.fg("accent", "W")}${palette.fg("text", "o")}`));
|
|
50
|
+
assert.ok(at(1).includes(`${palette.fg("accent", WORKING_SPINNER_FRAMES[1] ?? "")} ${palette.fg("text", "W")}${palette.fg("accent", "o")}`));
|
|
51
|
+
const runningAt = (frame: number) => renderWorkingWidget(state("Running build...", frame), palette, 40)[0] ?? "";
|
|
52
|
+
assert.notEqual(runningAt(0), runningAt(10), "letter wave advances when the spinner wraps");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
void test("Running label skips spaces, wraps its one highlighted grapheme, and follows theme on next render", () => {
|
|
56
|
+
const label = "Running build...";
|
|
57
|
+
const changedTheme = theme("36", "35", "34");
|
|
58
|
+
const first = renderWorkingWidget(state(label, 7), palette, 40)[0] ?? "";
|
|
59
|
+
const changed = renderWorkingWidget(state(label, 7), changedTheme, 40)[0] ?? "";
|
|
60
|
+
assert.ok(first.includes(`${palette.fg("accent", WORKING_SPINNER_FRAMES[7] ?? "")} `));
|
|
61
|
+
assert.ok(changed.includes(`${changedTheme.fg("accent", WORKING_SPINNER_FRAMES[7] ?? "")} `));
|
|
62
|
+
assert.ok(first.includes(`${palette.fg("text", "g")} ${palette.fg("accent", "b")}`));
|
|
63
|
+
assert.ok(changed.includes(`${changedTheme.fg("text", "g")} ${changedTheme.fg("accent", "b")}`));
|
|
64
|
+
assert.ok(changed.includes(changedTheme.fg("text", "R")));
|
|
65
|
+
assert.ok(renderWorkingWidget(state("A B", 1), palette, 20)[0]?.includes(
|
|
66
|
+
`${palette.fg("text", "A")} ${palette.fg("accent", "B")}`,
|
|
67
|
+
));
|
|
68
|
+
assert.ok(renderWorkingWidget(state("AB", 2), palette, 20)[0]?.includes(
|
|
69
|
+
`${palette.fg("accent", "A")}${palette.fg("text", "B")}`,
|
|
70
|
+
));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
void test("tab-separated label skips whitespace when advancing the highlight", () => {
|
|
74
|
+
const label = "A\tB";
|
|
75
|
+
const line = renderWorkingWidget(state(label, 1), palette, 20)[0] ?? "";
|
|
76
|
+
assert.ok(line.includes(`${palette.fg("accent", WORKING_SPINNER_FRAMES[1] ?? "")} ${palette.fg("text", "A")}\t${palette.fg("accent", "B")}`));
|
|
77
|
+
assert.equal(visibleWidth(line.trimStart()), 2 + visibleWidth(label));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
void test("combining marks and ZWJ emoji remain whole graphemes in the traveling wave", () => {
|
|
81
|
+
const label = "A\u0301π©βπ» B";
|
|
82
|
+
for (const [frame, expected] of [
|
|
83
|
+
[0, `${palette.fg("accent", "A\u0301")}${palette.fg("text", "π©βπ»")} ${palette.fg("text", "B")}`],
|
|
84
|
+
[1, `${palette.fg("text", "A\u0301")}${palette.fg("accent", "π©βπ»")} ${palette.fg("text", "B")}`],
|
|
85
|
+
[2, `${palette.fg("text", "A\u0301")}${palette.fg("text", "π©βπ»")} ${palette.fg("accent", "B")}`],
|
|
86
|
+
] as const) {
|
|
87
|
+
const line = renderWorkingWidget(state(label, frame), palette, 40)[0] ?? "";
|
|
88
|
+
assert.ok(line.includes(`${palette.fg("accent", WORKING_SPINNER_FRAMES[frame] ?? "")} ${expected}`), `frame ${frame} preserves graphemes`);
|
|
89
|
+
assert.equal(visibleWidth(line.trimStart()), 2 + visibleWidth(label));
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
void test("inactive widget renders nothing; narrow output fits and full output stays centered", () => {
|
|
94
|
+
assert.deepEqual(renderWorkingWidget(state("Working...", 18, false), palette, 20), []);
|
|
95
|
+
for (const frame of [0, 2, 4, 6]) {
|
|
96
|
+
const line = renderWorkingWidget(state("Working...", frame), palette, 5)[0] ?? "";
|
|
97
|
+
assert.equal(visibleWidth(line), 5);
|
|
98
|
+
}
|
|
99
|
+
const centered = renderWorkingWidget(state("Working..."), palette, 20)[0] ?? "";
|
|
100
|
+
assert.equal(centered.match(/^ */)?.[0].length, 4);
|
|
101
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { WORKING_SPINNER_FRAMES } from "./constants.js";
|
|
2
|
+
import type { SimpleTheme, WorkingWidgetState } from "./types.js";
|
|
3
|
+
import { fitCenterVisible } from "./utils.js";
|
|
4
|
+
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
5
|
+
const isWhitespace = (segment: string) => /\s/u.test(segment);
|
|
6
|
+
|
|
7
|
+
/** Render the theme-accent Braille spinner beside a theme-aware traveling letter highlight. */
|
|
8
|
+
export function renderWorkingWidget(
|
|
9
|
+
state: WorkingWidgetState,
|
|
10
|
+
theme: SimpleTheme,
|
|
11
|
+
width: number,
|
|
12
|
+
): string[] {
|
|
13
|
+
if (!state.active) return [];
|
|
14
|
+
const spinner = WORKING_SPINNER_FRAMES[state.frame % WORKING_SPINNER_FRAMES.length] ?? "";
|
|
15
|
+
const segments = [...graphemes.segment(state.label)].map(({ segment }) => segment);
|
|
16
|
+
const letterCount = segments.filter((segment) => !isWhitespace(segment)).length;
|
|
17
|
+
const highlighted = letterCount > 0 ? state.frame % letterCount : -1;
|
|
18
|
+
let letterIndex = 0;
|
|
19
|
+
const label = segments
|
|
20
|
+
.map((segment) => {
|
|
21
|
+
if (isWhitespace(segment)) return segment;
|
|
22
|
+
const color = letterIndex++ === highlighted ? "accent" : "text";
|
|
23
|
+
return theme.fg(color, segment);
|
|
24
|
+
})
|
|
25
|
+
.join("");
|
|
26
|
+
return [fitCenterVisible(`${theme.fg("accent", spinner)} ${label}`, width)];
|
|
27
|
+
}
|