gentle-pi 4.3.0 → 4.4.0
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 +8 -5
- package/assets/agents/gentle-ai-worker.md +1 -1
- package/assets/design-canvas/canvas.css +648 -0
- package/assets/design-canvas/canvas.js +1497 -0
- package/assets/design-canvas/index.html +88 -0
- package/assets/design-canvas/inspector.js +290 -0
- package/assets/orchestrator-delegation-reference.md +2 -2
- package/assets/orchestrator-verification.md +1 -1
- package/assets/orchestrator.md +2 -2
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +101 -151
- package/docs/bundled-install.md +47 -42
- package/docs/delegated-verification.md +6 -0
- package/docs/gentle-agents-activity.md +5 -1
- package/docs/gentle-music.md +61 -0
- package/docs/gentle-shell.md +77 -7
- package/docs/install-wizard.md +30 -6
- package/docs/permission-modes.md +7 -2
- package/docs/readme-reference.md +19 -15
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +17 -2
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/gentle-agents.ts +173 -38
- package/extensions/gentle-ai.ts +214 -69
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-music.ts +289 -0
- package/extensions/gentle-shell.ts +332 -73
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/session-scratch.ts +41 -22
- package/extensions/skill-registry.ts +63 -8
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +57 -1
- package/lib/agents-runner.ts +52 -3
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-widget.ts +14 -1
- package/lib/auto-permission-memory.ts +48 -18
- package/lib/auto-permission-runtime.ts +21 -6
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +2 -0
- package/lib/commit-slice-signal.ts +1 -1
- package/lib/context-backstop.ts +36 -0
- package/lib/design-canvas-server.ts +947 -0
- package/lib/design-comments.ts +437 -0
- package/lib/design-handoffs.ts +313 -0
- package/lib/design-open-browser.ts +58 -0
- package/lib/design-sources.ts +120 -0
- package/lib/external-editor.ts +31 -0
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +123 -49
- package/lib/gentle-shell-launcher.ts +9 -4
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +33 -1
- package/lib/image-attachments.ts +150 -0
- package/lib/inprocess-reviewer.ts +33 -2
- package/lib/nan-provider.ts +3 -0
- package/lib/native-review-cli.ts +11 -4
- package/lib/notification-audio-native.ts +149 -13
- package/lib/notification-audio.ts +4 -0
- package/lib/notification-pulse-worker.ts +82 -18
- package/lib/notification-service.ts +5 -1
- package/lib/orchestrator-presence.ts +78 -8
- package/lib/orchestrator-scope.ts +10 -0
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- package/lib/review-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +52 -3
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +4 -3
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +26 -6
- package/lib/shell-todo.ts +1 -1
- package/lib/verifier-admission.ts +5 -4
- package/lib/vim-editor-adapter.ts +11 -8
- package/lib/voice-artifacts.ts +188 -0
- package/lib/voice-command.ts +576 -0
- package/lib/voice-install.ts +455 -0
- package/lib/voice-platform.ts +99 -0
- package/lib/voice-push-to-talk.ts +262 -0
- package/lib/voice-session.ts +460 -0
- package/lib/voice-settings.ts +50 -0
- package/lib/voice-ui.ts +158 -0
- package/lib/yolo-session-policy.ts +58 -22
- package/package.json +1 -1
- package/runtime/gentle-shell-bundled.mjs +122 -48
- package/runtime/gentle-shell-launcher.mjs +9 -4
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +33 -1
- package/runtime/native-review-cli.mjs +11 -4
- package/scripts/bundled-engram-migration.mjs +0 -6
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +5 -3
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +91 -17
- package/scripts/pi-update.mjs +34 -6
- package/scripts/verify-package-files.mjs +26 -2
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agents-config.test.ts +61 -0
- package/tests/agents-fake-child.ts +8 -1
- package/tests/agents-protocol.test.ts +32 -0
- package/tests/agents-runner.test.ts +226 -5
- package/tests/agents-session-transport.test.ts +53 -3
- package/tests/ask-user-choice.test.ts +30 -0
- package/tests/auto-mode.test.ts +17 -0
- package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
- package/tests/auto-permission-runtime.test.ts +85 -1
- package/tests/autonomous-guard.test.ts +20 -0
- package/tests/bundled-install.test.ts +3 -3
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/command-palette.test.ts +1 -1
- package/tests/context-backstop.test.ts +70 -4
- package/tests/delegate-for-reason-contract.test.ts +2 -1
- package/tests/design-canvas-assets.test.ts +710 -0
- package/tests/design-canvas-server.test.ts +1096 -0
- package/tests/design-comments.test.ts +201 -0
- package/tests/design-handoffs.test.ts +282 -0
- package/tests/design-open-browser.test.ts +70 -0
- package/tests/design-sources.test.ts +132 -0
- package/tests/engram-safety.test.ts +132 -57
- package/tests/external-editor.test.ts +84 -0
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
- package/tests/gentle-agents.test.ts +365 -20
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +292 -13
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +335 -0
- package/tests/gentle-shell-bundled.test.ts +301 -248
- package/tests/gentle-shell-launcher.test.ts +28 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +59 -0
- package/tests/gentle-shell.test.ts +183 -8
- package/tests/gentle-todo.test.ts +11 -0
- package/tests/image-attachments.test.ts +119 -0
- package/tests/image-paste-chips.test.ts +236 -0
- package/tests/inprocess-reviewer.test.ts +55 -2
- package/tests/installer-bundles.test.ts +12 -6
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +16 -0
- package/tests/installer-server.test.ts +2 -2
- package/tests/installer-windows-bootstrap.test.ts +61 -3
- package/tests/installer-windows-diagnostics.test.ts +111 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/nan-provider.test.ts +36 -0
- package/tests/native-review-capability-contract.test.ts +11 -4
- package/tests/native-review-parity.test.ts +1 -1
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
- package/tests/orchestrator-budget.test.ts +5 -2
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +2 -2
- package/tests/orchestrator-scope.test.ts +14 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +6 -6
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update.test.ts +99 -4
- package/tests/rdd-aware-verification-contract.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +88 -1
- package/tests/review-host-relay.test.ts +9 -2
- package/tests/review-json-arguments.test.ts +20 -3
- package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- package/tests/session-changes.test.ts +2 -1
- package/tests/session-profile-append-controller-fork.test.ts +458 -0
- package/tests/session-profile-append-controller-pi.test.ts +290 -0
- package/tests/session-profile-append-controller.test.ts +731 -0
- package/tests/session-profile-disk-reader-pi.test.ts +183 -0
- package/tests/session-profile-disk-reader.test.ts +311 -0
- package/tests/session-scratch.test.ts +68 -2
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +14 -2
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-prompt.test.ts +3 -3
- package/tests/shell-render-cache.test.ts +134 -1
- package/tests/shell-sidebar-layout.test.ts +50 -0
- package/tests/short-check-routing-contract.test.ts +1 -1
- package/tests/skill-collision-prefixes.test.ts +3 -1
- package/tests/skill-registry-watcher.test.ts +17 -5
- package/tests/skill-registry.test.ts +47 -3
- package/tests/task-size-routing-contract.test.ts +1 -1
- package/tests/test-strength-contract.test.ts +1 -1
- package/tests/verifier-admission.test.ts +18 -1
- package/tests/voice-command.test.ts +773 -0
- package/tests/voice-install.test.ts +323 -0
- package/tests/voice-platform.test.ts +176 -0
- package/tests/voice-push-to-talk.test.ts +444 -0
- package/tests/voice-session.test.ts +434 -0
- package/tests/voice-settings.test.ts +49 -0
- package/tests/voice-ui.test.ts +97 -0
- package/tests/voice-worker.test.ts +445 -0
- package/tests/weak-model-economy-prompt.test.ts +32 -0
- package/tests/windows-hidden-processes.test.ts +25 -2
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +1 -1
- package/tests/yolo-session-policy-capture.test.ts +104 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { delimiter, join } from "node:path";
|
|
6
|
+
import test, { type TestContext } from "node:test";
|
|
7
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import gentleMusic, {
|
|
9
|
+
findOnPath,
|
|
10
|
+
formatMusicStatus,
|
|
11
|
+
GENTLE_STREAM_SPEC,
|
|
12
|
+
MUSIC_COMMAND_NAME,
|
|
13
|
+
musicTuiArgs,
|
|
14
|
+
parseMusicArgs,
|
|
15
|
+
resolveGentleStream,
|
|
16
|
+
type MusicSpawn,
|
|
17
|
+
} from "../extensions/gentle-music.ts";
|
|
18
|
+
|
|
19
|
+
// /gentle:music hands the terminal to gentle-stream's own TUI, the way Pi's
|
|
20
|
+
// external editor does, and controls the background player with
|
|
21
|
+
// `gentle-stream ctl …` without taking the terminal.
|
|
22
|
+
|
|
23
|
+
type Handler = (event: unknown, ctx: ExtensionContext) => unknown;
|
|
24
|
+
type Command = { description: string; handler(args: string, ctx: ExtensionContext): Promise<void> };
|
|
25
|
+
|
|
26
|
+
function fakePi() {
|
|
27
|
+
const commands = new Map<string, Command>();
|
|
28
|
+
const handlers = new Map<string, Handler[]>();
|
|
29
|
+
const pi = {
|
|
30
|
+
registerCommand: (name: string, registration: Command) => commands.set(name, registration),
|
|
31
|
+
registerShortcut: () => assert.fail("/gentle:music registers no shortcut"),
|
|
32
|
+
on: (event: string, handler: Handler) => handlers.set(event, [...(handlers.get(event) ?? []), handler]),
|
|
33
|
+
} as unknown as ExtensionAPI;
|
|
34
|
+
const fire = async (event: string, payload: unknown, ctx: ExtensionContext) => {
|
|
35
|
+
for (const handler of handlers.get(event) ?? []) await handler(payload, ctx);
|
|
36
|
+
};
|
|
37
|
+
return { pi, commands, fire };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function fakeContext(mode = "tui", model: { provider: string; id: string } | null = { provider: "anthropic", id: "claude-sonnet-5-5" }, failing?: "stop" | "start") {
|
|
41
|
+
const notices: Array<[string, string | undefined]> = [];
|
|
42
|
+
const calls: string[] = [];
|
|
43
|
+
let settled = false;
|
|
44
|
+
const tui = {
|
|
45
|
+
stop: () => { calls.push("stop"); if (failing === "stop") throw new Error("terminal busy"); },
|
|
46
|
+
start: () => { calls.push("start"); if (failing === "start") throw new Error("terminal gone"); },
|
|
47
|
+
requestRender: (force?: boolean) => calls.push(`render:${force === true}`),
|
|
48
|
+
};
|
|
49
|
+
const ctx = {
|
|
50
|
+
hasUI: true,
|
|
51
|
+
mode,
|
|
52
|
+
model: model ?? undefined,
|
|
53
|
+
ui: {
|
|
54
|
+
notify: (message: string, type?: string) => notices.push([message, type]),
|
|
55
|
+
custom: (factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: unknown) => void) => unknown) =>
|
|
56
|
+
new Promise((resolve) => {
|
|
57
|
+
factory(tui, {}, {}, (value) => { settled = true; resolve(value); });
|
|
58
|
+
}),
|
|
59
|
+
},
|
|
60
|
+
} as unknown as ExtensionContext;
|
|
61
|
+
return { ctx, notices, calls, settled: () => settled };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type Outcome = { code?: number | null; error?: Error; stdout?: string; stderr?: string; hang?: boolean };
|
|
65
|
+
|
|
66
|
+
function fakeSpawn(outcomes: Outcome[], calls: string[] = []) {
|
|
67
|
+
const spawned: Array<{ command: string; args: readonly string[]; stdio: unknown }> = [];
|
|
68
|
+
const spawn: MusicSpawn = (command, args, options) => {
|
|
69
|
+
spawned.push({ command, args, stdio: options.stdio });
|
|
70
|
+
calls.push(`spawn:${args.join(" ")}`);
|
|
71
|
+
const outcome = outcomes.shift() ?? { code: 0 };
|
|
72
|
+
const child = Object.assign(new EventEmitter(), {
|
|
73
|
+
stdout: new EventEmitter(),
|
|
74
|
+
stderr: new EventEmitter(),
|
|
75
|
+
kill: () => { calls.push("kill"); child.emit("close", null); return true; },
|
|
76
|
+
});
|
|
77
|
+
if (!outcome.hang) {
|
|
78
|
+
setImmediate(() => {
|
|
79
|
+
if (outcome.error) { child.emit("error", outcome.error); return; }
|
|
80
|
+
if (outcome.stdout) child.stdout.emit("data", Buffer.from(outcome.stdout));
|
|
81
|
+
if (outcome.stderr) child.stderr.emit("data", Buffer.from(outcome.stderr));
|
|
82
|
+
child.emit("close", outcome.code ?? 0);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return child;
|
|
86
|
+
};
|
|
87
|
+
return { spawn, spawned };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const onPath = (names: Record<string, string>) => (name: string) => names[name];
|
|
91
|
+
|
|
92
|
+
function fakeSignals() {
|
|
93
|
+
const listeners = new Set<() => void>();
|
|
94
|
+
return {
|
|
95
|
+
listeners,
|
|
96
|
+
signals: { on: (_signal: "SIGINT", listener: () => void) => listeners.add(listener), off: (_signal: "SIGINT", listener: () => void) => listeners.delete(listener) },
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
test("executable resolution prefers gentle-stream, then uvx, then uv, else nothing", () => {
|
|
101
|
+
assert.deepEqual(resolveGentleStream(onPath({ "gentle-stream": "/bin/gentle-stream", uvx: "/bin/uvx" })), { command: "/bin/gentle-stream", prefix: [] });
|
|
102
|
+
assert.deepEqual(resolveGentleStream(onPath({ uvx: "/bin/uvx", uv: "/bin/uv" })), { command: "/bin/uvx", prefix: ["--from", GENTLE_STREAM_SPEC, "gentle-stream"] });
|
|
103
|
+
assert.deepEqual(resolveGentleStream(onPath({ uv: "/bin/uv" })), { command: "/bin/uv", prefix: ["tool", "run", "--from", GENTLE_STREAM_SPEC, "gentle-stream"] });
|
|
104
|
+
assert.equal(resolveGentleStream(onPath({})), undefined);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("findOnPath finds executable files only, honoring PATHEXT on Windows", (t: TestContext) => {
|
|
108
|
+
const root = mkdtempSync(join(tmpdir(), "gentle-music-path-"));
|
|
109
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
110
|
+
const a = join(root, "a");
|
|
111
|
+
const b = join(root, "b");
|
|
112
|
+
mkdirSync(a);
|
|
113
|
+
mkdirSync(b);
|
|
114
|
+
writeFileSync(join(a, "gentle-stream"), "not executable");
|
|
115
|
+
chmodSync(join(a, "gentle-stream"), 0o644);
|
|
116
|
+
writeFileSync(join(b, "gentle-stream"), "#!/bin/sh\n");
|
|
117
|
+
chmodSync(join(b, "gentle-stream"), 0o755);
|
|
118
|
+
mkdirSync(join(a, "uvx"));
|
|
119
|
+
const env = { PATH: [a, b].join(delimiter) };
|
|
120
|
+
if (process.platform !== "win32") assert.equal(findOnPath("gentle-stream", env, "darwin"), join(b, "gentle-stream"));
|
|
121
|
+
assert.equal(findOnPath("uvx", env, "darwin"), undefined, "a directory is not an executable");
|
|
122
|
+
assert.equal(findOnPath("missing", env, "darwin"), undefined);
|
|
123
|
+
assert.equal(findOnPath("gentle-stream", {}, "darwin"), undefined);
|
|
124
|
+
writeFileSync(join(a, "uv.EXE"), "");
|
|
125
|
+
assert.equal(findOnPath("uv", { Path: a, PATHEXT: ".COM;.EXE" }, "win32"), join(a, "uv.EXE"));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("TUI arguments carry the session model and the mood as one argument", () => {
|
|
129
|
+
assert.deepEqual(musicTuiArgs({ model: { provider: "anthropic", id: "claude-sonnet-5-5" }, mood: "rainy \"lo-fi\" night; $HOME" }), ["--model", "anthropic/claude-sonnet-5-5", "--mood", "rainy \"lo-fi\" night; $HOME"]);
|
|
130
|
+
assert.deepEqual(musicTuiArgs({ model: { provider: "anthropic", id: "claude-sonnet-5-5" } }), ["--model", "anthropic/claude-sonnet-5-5"]);
|
|
131
|
+
assert.deepEqual(musicTuiArgs({ model: undefined, mood: "calm" }), ["--mood", "calm"]);
|
|
132
|
+
assert.deepEqual(musicTuiArgs({ model: { provider: "", id: "x" } }), []);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("only an exact single control word is a subcommand; anything else is a mood", () => {
|
|
136
|
+
assert.deepEqual(parseMusicArgs(""), { kind: "tui" });
|
|
137
|
+
assert.deepEqual(parseMusicArgs(" "), { kind: "tui" });
|
|
138
|
+
for (const command of ["next", "pause", "play", "stop", "status"]) assert.deepEqual(parseMusicArgs(` ${command} `), { kind: "ctl", command });
|
|
139
|
+
assert.deepEqual(parseMusicArgs("next to the sea"), { kind: "tui", mood: "next to the sea" });
|
|
140
|
+
assert.deepEqual(parseMusicArgs("toggle"), { kind: "tui", mood: "toggle" });
|
|
141
|
+
assert.deepEqual(parseMusicArgs(" dark synthwave "), { kind: "tui", mood: "dark synthwave" });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("status output becomes a one-line summary", () => {
|
|
145
|
+
assert.equal(formatMusicStatus('{"running": false}\n'), "nothing is playing");
|
|
146
|
+
assert.equal(formatMusicStatus('{"running": true, "playing": true, "name": "Neon Rain", "genre": "synthwave", "mood": "night"}'), "♪ Neon Rain · synthwave · playing");
|
|
147
|
+
assert.equal(formatMusicStatus('{"running": true, "playing": false, "name": "Neon Rain", "genre": "synthwave", "mood": ""}'), "♪ Neon Rain · synthwave · paused");
|
|
148
|
+
assert.equal(formatMusicStatus('{"running": true, "playing": true, "name": "", "genre": ""}'), "♪ playing");
|
|
149
|
+
assert.equal(formatMusicStatus("garbage"), undefined);
|
|
150
|
+
assert.equal(formatMusicStatus("[]"), undefined);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("registers /gentle:music without a shortcut", () => {
|
|
154
|
+
const { pi, commands } = fakePi();
|
|
155
|
+
gentleMusic(pi, { which: onPath({}) });
|
|
156
|
+
assert.equal(MUSIC_COMMAND_NAME, "gentle:music");
|
|
157
|
+
assert.match(commands.get(MUSIC_COMMAND_NAME)!.description, /gentle-stream/);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("non-TUI modes get a notice and spawn nothing", async () => {
|
|
161
|
+
const { pi, commands } = fakePi();
|
|
162
|
+
const { spawn, spawned } = fakeSpawn([]);
|
|
163
|
+
gentleMusic(pi, { which: onPath({ "gentle-stream": "/bin/gentle-stream" }), spawn });
|
|
164
|
+
for (const mode of ["rpc", "json", "print"]) {
|
|
165
|
+
const context = fakeContext(mode);
|
|
166
|
+
await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
|
|
167
|
+
assert.deepEqual(context.notices, [["/gentle:music needs the interactive terminal.", "warning"]]);
|
|
168
|
+
assert.deepEqual(context.calls, []);
|
|
169
|
+
}
|
|
170
|
+
assert.equal(spawned.length, 0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("without gentle-stream or uv the command explains how to install and does nothing else", async () => {
|
|
174
|
+
const { pi, commands } = fakePi();
|
|
175
|
+
const { spawn, spawned } = fakeSpawn([]);
|
|
176
|
+
gentleMusic(pi, { which: onPath({}), spawn });
|
|
177
|
+
const context = fakeContext();
|
|
178
|
+
await commands.get(MUSIC_COMMAND_NAME)!.handler("calm", context.ctx);
|
|
179
|
+
assert.equal(spawned.length, 0);
|
|
180
|
+
assert.deepEqual(context.calls, []);
|
|
181
|
+
assert.equal(context.notices.length, 1);
|
|
182
|
+
const [message, type] = context.notices[0]!;
|
|
183
|
+
assert.equal(type, "warning");
|
|
184
|
+
assert.match(message, /https:\/\/docs\.astral\.sh\/uv/);
|
|
185
|
+
assert.ok(message.includes(`uv tool install '${GENTLE_STREAM_SPEC}'`));
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("the TUI gets the terminal, then Pi is restarted with a forced repaint", async () => {
|
|
189
|
+
const { pi, commands } = fakePi();
|
|
190
|
+
const context = fakeContext();
|
|
191
|
+
const { spawn, spawned } = fakeSpawn([{ code: 0 }], context.calls);
|
|
192
|
+
const { listeners, signals } = fakeSignals();
|
|
193
|
+
let sigintWhileRunning = 0;
|
|
194
|
+
gentleMusic(pi, { which: onPath({ "gentle-stream": "/bin/gentle-stream" }), spawn: (command, args, options) => { sigintWhileRunning = listeners.size; return spawn(command, args, options); }, signals });
|
|
195
|
+
await commands.get(MUSIC_COMMAND_NAME)!.handler("rainy night", context.ctx);
|
|
196
|
+
assert.deepEqual(context.calls, ["stop", "spawn:--model anthropic/claude-sonnet-5-5 --mood rainy night", "start", "render:true"]);
|
|
197
|
+
assert.deepEqual(spawned[0], { command: "/bin/gentle-stream", args: ["--model", "anthropic/claude-sonnet-5-5", "--mood", "rainy night"], stdio: "inherit" });
|
|
198
|
+
assert.equal(sigintWhileRunning, 1, "Pi ignores Ctrl+C while the TUI runs");
|
|
199
|
+
assert.equal(listeners.size, 0, "the SIGINT guard is removed afterwards");
|
|
200
|
+
assert.deepEqual(context.notices, []);
|
|
201
|
+
assert.equal(context.settled(), true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("the uvx fallback runs gentle-stream from the published spec", async () => {
|
|
205
|
+
const { pi, commands } = fakePi();
|
|
206
|
+
const context = fakeContext("tui", null);
|
|
207
|
+
const { spawn, spawned } = fakeSpawn([{ code: 0 }], context.calls);
|
|
208
|
+
gentleMusic(pi, { which: onPath({ uvx: "/bin/uvx" }), spawn, signals: fakeSignals().signals });
|
|
209
|
+
await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
|
|
210
|
+
assert.deepEqual(spawned[0]!.args, ["--from", GENTLE_STREAM_SPEC, "gentle-stream"]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("Pi is restored and the failure reported when the TUI exits non-zero or cannot start", async () => {
|
|
214
|
+
for (const [outcome, pattern] of [[{ code: 2 }, /exited with code 2/], [{ error: new Error("spawn ENOENT") }, /could not start.*spawn ENOENT/]] as const) {
|
|
215
|
+
const { pi, commands } = fakePi();
|
|
216
|
+
const context = fakeContext();
|
|
217
|
+
const { spawn } = fakeSpawn([outcome], context.calls);
|
|
218
|
+
const { listeners, signals } = fakeSignals();
|
|
219
|
+
gentleMusic(pi, { which: onPath({ "gentle-stream": "/bin/gentle-stream" }), spawn, signals });
|
|
220
|
+
await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
|
|
221
|
+
assert.deepEqual(context.calls.filter((call) => !call.startsWith("spawn")), ["stop", "start", "render:true"]);
|
|
222
|
+
assert.equal(listeners.size, 0);
|
|
223
|
+
assert.equal(context.notices.length, 1);
|
|
224
|
+
assert.match(context.notices[0]![0], pattern);
|
|
225
|
+
assert.equal(context.notices[0]![1], "error");
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("Pi is restored even when spawn itself throws", async () => {
|
|
230
|
+
const { pi, commands } = fakePi();
|
|
231
|
+
const context = fakeContext();
|
|
232
|
+
const { listeners, signals } = fakeSignals();
|
|
233
|
+
gentleMusic(pi, { which: onPath({ "gentle-stream": "/bin/gentle-stream" }), spawn: () => { throw new Error("EPERM"); }, signals });
|
|
234
|
+
await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
|
|
235
|
+
assert.deepEqual(context.calls, ["stop", "start", "render:true"]);
|
|
236
|
+
assert.equal(listeners.size, 0);
|
|
237
|
+
assert.match(context.notices[0]![0], /could not start.*EPERM/);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("control words run ctl with captured output and never take the terminal", async () => {
|
|
241
|
+
const { pi, commands } = fakePi();
|
|
242
|
+
const context = fakeContext();
|
|
243
|
+
const { spawn, spawned } = fakeSpawn([
|
|
244
|
+
{ code: 0, stdout: '{"running": true, "playing": true, "name": "Neon Rain", "genre": "synthwave", "mood": "night"}\n' },
|
|
245
|
+
{ code: 0 },
|
|
246
|
+
{ code: 1, stderr: "gentle-stream: nothing is playing\n" },
|
|
247
|
+
{ code: 0, stdout: "not json" },
|
|
248
|
+
], context.calls);
|
|
249
|
+
gentleMusic(pi, { which: onPath({ uvx: "/bin/uvx" }), spawn });
|
|
250
|
+
const run = (args: string) => commands.get(MUSIC_COMMAND_NAME)!.handler(args, context.ctx);
|
|
251
|
+
await run("status");
|
|
252
|
+
await run("next");
|
|
253
|
+
await run("pause");
|
|
254
|
+
await run("status");
|
|
255
|
+
assert.deepEqual(spawned.map((entry) => entry.args.slice(3)), [["ctl", "status"], ["ctl", "next"], ["ctl", "pause"], ["ctl", "status"]]);
|
|
256
|
+
assert.ok(spawned.every((entry) => JSON.stringify(entry.stdio) === JSON.stringify(["ignore", "pipe", "pipe"])));
|
|
257
|
+
assert.ok(!context.calls.includes("stop"), "ctl never stops the TUI");
|
|
258
|
+
assert.deepEqual(context.notices, [
|
|
259
|
+
["♪ Neon Rain · synthwave · playing", "info"],
|
|
260
|
+
["♪ next track", "info"],
|
|
261
|
+
["gentle-stream: nothing is playing", "warning"],
|
|
262
|
+
["gentle-stream status was not readable: not json", "warning"],
|
|
263
|
+
]);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("a control word without gentle-stream or uv explains how to install", async () => {
|
|
267
|
+
const { pi, commands } = fakePi();
|
|
268
|
+
const { spawn, spawned } = fakeSpawn([]);
|
|
269
|
+
gentleMusic(pi, { which: onPath({}), spawn });
|
|
270
|
+
const context = fakeContext();
|
|
271
|
+
await commands.get(MUSIC_COMMAND_NAME)!.handler("next", context.ctx);
|
|
272
|
+
assert.equal(spawned.length, 0);
|
|
273
|
+
assert.match(context.notices[0]![0], /uv tool install/);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("quitting Pi stops the music only after /gentle:music was used", async () => {
|
|
277
|
+
const unused = fakePi();
|
|
278
|
+
const quiet = fakeSpawn([]);
|
|
279
|
+
gentleMusic(unused.pi, { which: onPath({ "gentle-stream": "/bin/gentle-stream" }), spawn: quiet.spawn, signals: fakeSignals().signals, usage: { used: false } });
|
|
280
|
+
await unused.fire("session_shutdown", { type: "session_shutdown", reason: "quit" }, fakeContext().ctx);
|
|
281
|
+
assert.equal(quiet.spawned.length, 0);
|
|
282
|
+
|
|
283
|
+
const usage = { used: false };
|
|
284
|
+
const { pi, commands, fire } = fakePi();
|
|
285
|
+
const context = fakeContext();
|
|
286
|
+
const { spawn, spawned } = fakeSpawn([{ code: 0 }, { code: 0 }], context.calls);
|
|
287
|
+
gentleMusic(pi, { which: onPath({ "gentle-stream": "/bin/gentle-stream" }), spawn, signals: fakeSignals().signals, usage });
|
|
288
|
+
await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
|
|
289
|
+
await fire("session_shutdown", { type: "session_shutdown", reason: "new" }, context.ctx);
|
|
290
|
+
assert.equal(spawned.length, 1, "switching sessions keeps the music playing");
|
|
291
|
+
await fire("session_shutdown", { type: "session_shutdown", reason: "quit" }, context.ctx);
|
|
292
|
+
assert.deepEqual(spawned[1], { command: "/bin/gentle-stream", args: ["ctl", "stop"], stdio: ["ignore", "pipe", "pipe"] });
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("the shutdown stop never blocks longer than its budget", async () => {
|
|
296
|
+
const { pi, commands, fire } = fakePi();
|
|
297
|
+
const context = fakeContext();
|
|
298
|
+
const { spawn } = fakeSpawn([{ code: 0 }, { hang: true }], context.calls);
|
|
299
|
+
gentleMusic(pi, { which: onPath({ "gentle-stream": "/bin/gentle-stream" }), spawn, signals: fakeSignals().signals, usage: { used: false }, shutdownTimeoutMs: 20 });
|
|
300
|
+
await commands.get(MUSIC_COMMAND_NAME)!.handler("status", context.ctx);
|
|
301
|
+
const started = Date.now();
|
|
302
|
+
await fire("session_shutdown", { type: "session_shutdown", reason: "quit" }, context.ctx);
|
|
303
|
+
assert.ok(Date.now() - started < 1000);
|
|
304
|
+
assert.ok(context.calls.includes("kill"), "the hung ctl is killed");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("a failing terminal handover still settles the command, reports it and removes the Ctrl+C guard", async () => {
|
|
308
|
+
for (const failing of ["stop", "start"] as const) {
|
|
309
|
+
const { pi, commands } = fakePi();
|
|
310
|
+
const context = fakeContext("tui", undefined, failing);
|
|
311
|
+
const { spawn, spawned } = fakeSpawn([{ code: 0 }], context.calls);
|
|
312
|
+
const { listeners, signals } = fakeSignals();
|
|
313
|
+
gentleMusic(pi, { which: onPath({ "gentle-stream": "/bin/gentle-stream" }), spawn, signals });
|
|
314
|
+
await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
|
|
315
|
+
assert.equal(context.settled(), true, `done is called when tui.${failing}() throws`);
|
|
316
|
+
assert.equal(listeners.size, 0, "the SIGINT guard is removed");
|
|
317
|
+
assert.equal(spawned.length, failing === "stop" ? 0 : 1);
|
|
318
|
+
assert.equal(context.notices.length, 1);
|
|
319
|
+
assert.match(context.notices[0]![0], failing === "stop" ? /terminal busy/ : /terminal gone/);
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("Windows .cmd and .bat shims are not picked, so resolution falls back to uvx", (t: TestContext) => {
|
|
324
|
+
const dir = mkdtempSync(join(tmpdir(), "gentle-music-win-"));
|
|
325
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
326
|
+
writeFileSync(join(dir, "gentle-stream.CMD"), "@echo off\n");
|
|
327
|
+
writeFileSync(join(dir, "uvx.BAT"), "@echo off\n");
|
|
328
|
+
writeFileSync(join(dir, "uv.EXE"), "");
|
|
329
|
+
const env = { Path: dir, PATHEXT: ".COM;.EXE;.BAT;.CMD" };
|
|
330
|
+
assert.equal(findOnPath("gentle-stream", env, "win32"), undefined);
|
|
331
|
+
assert.equal(findOnPath("uvx", env, "win32"), undefined);
|
|
332
|
+
assert.equal(findOnPath("uv", env, "win32"), join(dir, "uv.EXE"));
|
|
333
|
+
const resolved = resolveGentleStream((name) => findOnPath(name, env, "win32"));
|
|
334
|
+
assert.equal(resolved?.command, join(dir, "uv.EXE"));
|
|
335
|
+
});
|