gentle-pi 4.3.0 → 4.5.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 +10 -7
- 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 +138 -155
- 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 +82 -0
- package/docs/gentle-shell.md +80 -10
- package/docs/install-wizard.md +30 -6
- package/docs/permission-modes.md +7 -2
- package/docs/readme-reference.md +72 -55
- 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 +187 -40
- package/extensions/gentle-ai.ts +1765 -1185
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-music.ts +354 -0
- package/extensions/gentle-shell.ts +418 -79
- 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 +3 -2
- 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 +141 -57
- package/lib/gentle-shell-launcher.ts +11 -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/jobs-sidebar-state.ts +22 -3
- package/lib/jobs-widget.ts +112 -0
- 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-freeze.ts +2 -2
- 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 +6 -7
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +27 -7
- package/lib/shell-todo.ts +1 -1
- package/lib/verifier-admission.ts +5 -4
- package/lib/vim-editor-adapter.ts +14 -10
- 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 +140 -56
- package/runtime/gentle-shell-launcher.mjs +11 -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/bundled-install.mjs +209 -13
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +31 -7
- package/scripts/installer-preflight.mjs +6 -4
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +91 -17
- package/scripts/pi-update.mjs +37 -7
- 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 +276 -5
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/command-palette.test.ts +4 -4
- 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 +369 -24
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +928 -448
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +452 -0
- package/tests/gentle-shell-bundled.test.ts +321 -248
- package/tests/gentle-shell-launcher.test.ts +29 -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 +227 -27
- 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-downloads.test.ts +32 -0
- 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/jobs-widget.test.ts +138 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/model-profiles-preservation.test.ts +2 -2
- 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 +104 -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/runtime-harness.mjs +188 -211
- 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-profile-freeze.test.ts +6 -4
- package/tests/session-scratch.test.ts +68 -2
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +18 -6
- 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 +67 -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/vim-editor-adapter.test.ts +2 -2
- 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
|
@@ -214,6 +214,7 @@ test("help text documents the bundled upgrade, its rollback and the pinned Pi up
|
|
|
214
214
|
assert.ok(text.includes(" gentle-shell upgrade --rollback"));
|
|
215
215
|
assert.match(text, /bundled install.*release channel only.*--rollback switches back to the previous kept version/s);
|
|
216
216
|
assert.match(text, /In a bundled install Pi ships pinned: 'update' without a package\n\s+runs 'gentle-shell upgrade'/);
|
|
217
|
+
assert.match(text, /'update --latest' installs the newest published Pi instead,\n\s+without the release's verified lockfile\./);
|
|
217
218
|
});
|
|
218
219
|
|
|
219
220
|
test("parseLauncherArgs treats home as a plain passthrough token when it is not argv[0]", () => {
|
|
@@ -1890,8 +1891,34 @@ test("quoteForCmdExe leaves a plain token untouched", () => {
|
|
|
1890
1891
|
assert.equal(quoteForCmdExe("pi"), "pi");
|
|
1891
1892
|
});
|
|
1892
1893
|
|
|
1893
|
-
test("quoteForCmdExe quotes a token with a space and
|
|
1894
|
-
assert.equal(quoteForCmdExe(
|
|
1894
|
+
test("quoteForCmdExe quotes a token with a space and the other cmd.exe specials", () => {
|
|
1895
|
+
assert.equal(quoteForCmdExe("say hi now"), '"say hi now"');
|
|
1896
|
+
assert.equal(quoteForCmdExe("a&b"), '"a&b"');
|
|
1897
|
+
});
|
|
1898
|
+
|
|
1899
|
+
// cmd.exe has no backslash escape: a `"` inside a quoted token ends the
|
|
1900
|
+
// quoted section, so everything after it is parsed as command syntax. The
|
|
1901
|
+
// simple quoting cannot represent such a token, so it refuses it instead of
|
|
1902
|
+
// emitting a command line that lets the argument inject commands.
|
|
1903
|
+
test("quoteForCmdExe rejects a token with a double quote", () => {
|
|
1904
|
+
for (const token of ['say "hi" now', 'x" & whoami & rem "', '"', 'a"b']) {
|
|
1905
|
+
assert.throws(() => quoteForCmdExe(token), /double quote/);
|
|
1906
|
+
}
|
|
1907
|
+
});
|
|
1908
|
+
|
|
1909
|
+
test("planSpawn refuses a win32 .cmd launch whose argument carries a double quote", () => {
|
|
1910
|
+
const hostile = 'x" & whoami & rem "';
|
|
1911
|
+
for (const command of ["C:\\Program Files\\nodejs\\pi.cmd", "C:\\pi\\pi.BAT"]) {
|
|
1912
|
+
assert.throws(() => planSpawn({ command, args: ["--version", hostile], platform: "win32" }), /double quote/);
|
|
1913
|
+
}
|
|
1914
|
+
assert.throws(() => planSpawn({ command: 'C:\\pi\\a"b.cmd', args: [], platform: "win32" }), /double quote/);
|
|
1915
|
+
});
|
|
1916
|
+
|
|
1917
|
+
test("planSpawn passes a double quote through when no cmd.exe command line is built", () => {
|
|
1918
|
+
const args = ["--prompt", 'say "hi"'];
|
|
1919
|
+
assert.deepEqual(planSpawn({ command: "C:\\pi\\pi.exe", args, platform: "win32" }), { command: "C:\\pi\\pi.exe", args, shell: false });
|
|
1920
|
+
assert.deepEqual(planSpawn({ command: "/usr/bin/pi", args, platform: "linux" }), { command: "/usr/bin/pi", args, shell: false });
|
|
1921
|
+
assert.deepEqual(planSpawn({ command: "/usr/local/bin/pi.cmd", args, platform: "darwin" }), { command: "/usr/local/bin/pi.cmd", args, shell: false });
|
|
1895
1922
|
});
|
|
1896
1923
|
|
|
1897
1924
|
test("quoteForCmdExe quotes an empty token", () => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { test } from "node:test";
|
|
3
3
|
import { parseLauncherArgs } from "../lib/gentle-shell-launcher.ts";
|
|
4
|
-
import { findStartupUpdate, shouldCheckStartupUpdate } from "../lib/gentle-shell-startup-update.ts";
|
|
4
|
+
import { findStartupUpdate, parseStartupReleaseCache, shouldCheckStartupUpdate, writeStartupReleaseCache } from "../lib/gentle-shell-startup-update.ts";
|
|
5
5
|
|
|
6
6
|
const eligible = {
|
|
7
7
|
args: parseLauncherArgs([]), env: {}, bundled: true, inputTTY: true, outputTTY: true,
|
|
@@ -83,3 +83,79 @@ test("custom deadline is bounded and settled lookups clear the timeout", async (
|
|
|
83
83
|
t.mock.timers.tick(20);
|
|
84
84
|
assert.equal(settledSignal?.aborted, false);
|
|
85
85
|
});
|
|
86
|
+
|
|
87
|
+
// A fresh cache keeps the everyday launch off the network entirely: the offer
|
|
88
|
+
// (or its absence) is decided from the recorded lookup, and the live lookup —
|
|
89
|
+
// whose result is the only thing worth caching — runs only when the cache is
|
|
90
|
+
// stale, missing, or malformed.
|
|
91
|
+
test("a fresh cache decides the offer without touching the network", async () => {
|
|
92
|
+
const offline = async (): Promise<string> => { throw new Error("lookup must not run on a fresh cache"); };
|
|
93
|
+
const fresh = { checkedAt: Date.now() - 60 * 60 * 1000, latest: "2.0.0" };
|
|
94
|
+
assert.deepEqual(await findStartupUpdate({ current: "1.0.0", lookup: offline, cache: fresh, ttlMs: 24 * 60 * 60 * 1000 }), {
|
|
95
|
+
current: "1.0.0", latest: "2.0.0", notesUrl: "https://github.com/Gentleman-Programming/gentle-shell/releases/tag/v2.0.0",
|
|
96
|
+
});
|
|
97
|
+
assert.equal(await findStartupUpdate({ current: "2.0.0", lookup: offline, cache: fresh, ttlMs: 24 * 60 * 60 * 1000 }), undefined);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("a fresh cache respects the skipped release exactly like a live lookup", async () => {
|
|
101
|
+
const offline = async (): Promise<string> => { throw new Error("lookup must not run on a fresh cache"); };
|
|
102
|
+
assert.equal(await findStartupUpdate({ current: "1.0.0", skipped: "2.0.0", lookup: offline, cache: { checkedAt: Date.now() - 1000, latest: "2.0.0" } }), undefined);
|
|
103
|
+
assert.equal((await findStartupUpdate({ current: "1.0.0", skipped: "2.0.0", lookup: offline, cache: { checkedAt: Date.now() - 1000, latest: "2.0.1" } }))?.latest, "2.0.1");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("a stale, missing or malformed cache runs the live lookup and records its answer", async () => {
|
|
107
|
+
const written: string[] = [];
|
|
108
|
+
const opts = {
|
|
109
|
+
current: "1.0.0",
|
|
110
|
+
lookup: async () => "2.0.1",
|
|
111
|
+
ttlMs: 1000,
|
|
112
|
+
cacheWrite: (latest: string) => { written.push(latest); },
|
|
113
|
+
};
|
|
114
|
+
// Stale beyond the TTL.
|
|
115
|
+
assert.equal((await findStartupUpdate({ ...opts, cache: { checkedAt: Date.now() - 1001, latest: "2.0.0" } }))?.latest, "2.0.1");
|
|
116
|
+
// No cache at all.
|
|
117
|
+
assert.equal((await findStartupUpdate({ ...opts }))?.latest, "2.0.1");
|
|
118
|
+
// A cache whose latest never parsed: never trusted, refreshed instead.
|
|
119
|
+
assert.equal((await findStartupUpdate({ ...opts, cache: { checkedAt: Date.now() - 1, latest: "garbage" } }))?.latest, "2.0.1");
|
|
120
|
+
assert.deepEqual(written, ["2.0.1", "2.0.1", "2.0.1"]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("an unanswered lookup is never cached", async () => {
|
|
124
|
+
const written: string[] = [];
|
|
125
|
+
assert.equal(await findStartupUpdate({ current: "1.0.0", lookup: async () => undefined, cacheWrite: (latest) => { written.push(latest); } }), undefined);
|
|
126
|
+
assert.equal(await findStartupUpdate({ current: "1.0.0", lookup: async () => { throw new Error("offline"); }, cacheWrite: (latest) => { written.push(latest); } }), undefined);
|
|
127
|
+
assert.deepEqual(written, []);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("the cache ages against the injected clock, not the wall", async () => {
|
|
131
|
+
const now = 1_700_000_000_000;
|
|
132
|
+
const offline = async (): Promise<string> => { throw new Error("lookup must not run on a fresh cache"); };
|
|
133
|
+
// Exactly at the TTL boundary the cache is already stale: the check errs
|
|
134
|
+
// toward the live lookup, never toward skipping it.
|
|
135
|
+
assert.equal((await findStartupUpdate({ current: "1.0.0", lookup: async () => "2.0.0", ttlMs: 1000, nowMs: now, cache: { checkedAt: now - 1000, latest: "2.0.0" } }))?.latest, "2.0.0");
|
|
136
|
+
assert.deepEqual(await findStartupUpdate({ current: "1.0.0", lookup: offline, ttlMs: 1000, nowMs: now - 1, cache: { checkedAt: now - 1000, latest: "2.0.0" } }).then((offer) => offer?.latest), "2.0.0");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("parseStartupReleaseCache accepts the recorded shape and rejects everything else", () => {
|
|
140
|
+
assert.deepEqual(parseStartupReleaseCache({ checkedAt: "2026-01-01T00:00:00.000Z", latest: "2.0.1" }), { checkedAt: Date.parse("2026-01-01T00:00:00.000Z"), latest: "2.0.1" });
|
|
141
|
+
assert.deepEqual(parseStartupReleaseCache({ checkedAt: 1_700_000_000_000, latest: "2.0.1" }), { checkedAt: 1_700_000_000_000, latest: "2.0.1" });
|
|
142
|
+
for (const bad of [undefined, null, "x", [], {}, { checkedAt: "nope", latest: "2.0.1" }, { checkedAt: 1_700_000_000_000 }, { checkedAt: 1_700_000_000_000, latest: "" }]) {
|
|
143
|
+
assert.equal(parseStartupReleaseCache(bad), undefined);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("writeStartupReleaseCache records one lookup alongside the existing config keys", () => {
|
|
148
|
+
const stored: string[] = [];
|
|
149
|
+
const config = JSON.stringify({ skippedStartupRelease: "9.9.9", other: { nested: true } });
|
|
150
|
+
writeStartupReleaseCache(() => config, (text) => stored.push(text), "2.0.1", 1_700_000_000_000);
|
|
151
|
+
const saved = JSON.parse(stored[0]);
|
|
152
|
+
assert.equal(saved.skippedStartupRelease, "9.9.9");
|
|
153
|
+
assert.deepEqual(saved.other, { nested: true });
|
|
154
|
+
assert.equal(saved.startupReleaseCheck.latest, "2.0.1");
|
|
155
|
+
assert.equal(saved.startupReleaseCheck.checkedAt, new Date(1_700_000_000_000).toISOString());
|
|
156
|
+
// An absent config file starts a minimal one.
|
|
157
|
+
writeStartupReleaseCache(() => undefined, (text) => stored.push(text), "2.0.1");
|
|
158
|
+
assert.deepEqual(JSON.parse(stored[1]).startupReleaseCheck.latest, "2.0.1");
|
|
159
|
+
// An invalid config must fail loudly, never overwrite it silently.
|
|
160
|
+
assert.throws(() => writeStartupReleaseCache(() => "{ broken", () => {}, "2.0.1"));
|
|
161
|
+
});
|
|
@@ -977,6 +977,65 @@ test("bundled on Windows: the user Path entry goes first, the running node.exe,
|
|
|
977
977
|
} finally { w.cleanup(); }
|
|
978
978
|
});
|
|
979
979
|
|
|
980
|
+
// #2469: a home that self-uninstall keeps must not keep Pi pointed at the npm
|
|
981
|
+
// the uninstall removed: a stale `npmCommand` makes every later `pi install`
|
|
982
|
+
// fail with ENOENT naming the deleted node.exe.
|
|
983
|
+
const bundledNpmCommand = (w: BundledWorld) => [w.node, join(dirname(w.node), "gentle-shell", "npm"), "--update-notifier=false"];
|
|
984
|
+
|
|
985
|
+
const bundledNpmSettings = (w: BundledWorld, npmCommand: unknown) => `${JSON.stringify({ theme: "dark", npmCommand }, null, " ")}\n`;
|
|
986
|
+
|
|
987
|
+
test("bundled: keeping the home removes the npmCommand that names the removed prefix and keeps the rest of settings.json", posixOnly, async () => {
|
|
988
|
+
const w = bundledWorld();
|
|
989
|
+
try {
|
|
990
|
+
const settings = join(w.shared.agent, "settings.json");
|
|
991
|
+
write(settings, bundledNpmSettings(w, bundledNpmCommand(w)));
|
|
992
|
+
assert.equal(await w.uninstall(["--yes"]), 0, w.err.join("\n"));
|
|
993
|
+
assert.deepEqual(JSON.parse(readFileSync(settings, "utf8")), { theme: "dark" });
|
|
994
|
+
assert.ok(w.out.join("\n").includes(`npmCommand from ${settings}`), w.out.join("\n"));
|
|
995
|
+
} finally { w.cleanup(); }
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
test("bundled: keeping the home leaves settings.json alone when npmCommand does not name the prefix", posixOnly, async () => {
|
|
999
|
+
const w = bundledWorld();
|
|
1000
|
+
try {
|
|
1001
|
+
const settings = join(w.shared.agent, "settings.json");
|
|
1002
|
+
const text = bundledNpmSettings(w, ["/usr/local/bin/node", "--update-notifier=false"]);
|
|
1003
|
+
write(settings, text);
|
|
1004
|
+
assert.equal(await w.uninstall(["--yes"]), 0, w.err.join("\n"));
|
|
1005
|
+
assert.equal(readFileSync(settings, "utf8"), text);
|
|
1006
|
+
} finally { w.cleanup(); }
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
test("bundled: keeping the home never rewrites a settings.json that is not a JSON object", posixOnly, async () => {
|
|
1010
|
+
const w = bundledWorld();
|
|
1011
|
+
try {
|
|
1012
|
+
const settings = join(w.shared.agent, "settings.json");
|
|
1013
|
+
write(settings, "not json at all\n");
|
|
1014
|
+
assert.equal(await w.uninstall(["--yes"]), 0, w.err.join("\n"));
|
|
1015
|
+
assert.equal(readFileSync(settings, "utf8"), "not json at all\n");
|
|
1016
|
+
} finally { w.cleanup(); }
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
test("bundled on Windows: keeping the home also removes the npmCommand naming the removed prefix", async () => {
|
|
1020
|
+
const w = bundledWorld({ platform: "win32" });
|
|
1021
|
+
try {
|
|
1022
|
+
const settings = join(w.shared.agent, "settings.json");
|
|
1023
|
+
write(settings, `${JSON.stringify({ npmCommand: bundledNpmCommand(w) }, null, " ")}\n`);
|
|
1024
|
+
assert.equal(await w.uninstall(["--yes"]), 0, w.err.join("\n"));
|
|
1025
|
+
assert.equal(readFileSync(settings, "utf8"), "{}\n");
|
|
1026
|
+
assert.ok(w.out.join("\n").includes(`npmCommand from ${settings}`), w.out.join("\n"));
|
|
1027
|
+
} finally { w.cleanup(); }
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
test("bundled: --include-shared removes the home with its settings, so no npmCommand cleanup is needed", posixOnly, async () => {
|
|
1031
|
+
const w = bundledWorld();
|
|
1032
|
+
try {
|
|
1033
|
+
write(join(w.shared.agent, "settings.json"), bundledNpmSettings(w, bundledNpmCommand(w)));
|
|
1034
|
+
assert.equal(await w.uninstall(["--yes", "--include-shared"]), 0, w.err.join("\n"));
|
|
1035
|
+
assert.equal(existsSync(join(w.shared.agent, "settings.json")), false);
|
|
1036
|
+
} finally { w.cleanup(); }
|
|
1037
|
+
});
|
|
1038
|
+
|
|
980
1039
|
// #2201: the standalone isolated home keeps its config in ~/.gentle-shell/gentle-ai.
|
|
981
1040
|
test("Gentle Shell's own config folder next to the isolated home is removed; the shared config home keeps its rule", async () => {
|
|
982
1041
|
const w = world();
|
|
@@ -4,7 +4,7 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSy
|
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
-
import test, { after, afterEach } from "node:test";
|
|
7
|
+
import test, { after, afterEach, type TestContext } from "node:test";
|
|
8
8
|
import { initTheme, type ExtensionAPI, type ExtensionContext, type SlashCommandInfo, type SourceInfo } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { CURSOR_MARKER, matchesKey, stripTerminalSequences as stripAnsi, TuiMainScreen, visibleWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
10
10
|
import installGentleShell, { backgroundChangeRefreshesSettled, buildShellBarModel, createActiveProfileReader, changesShortcut, devBinaryCard, extractQueuedText, fetchCodexUsage, fetchNanUsage, loadFileDiff, shellGitRunner, openInExternalEditor, usageShortcut, GentlePromptEditor } from "../extensions/gentle-shell.ts";
|
|
@@ -24,6 +24,7 @@ import type { ShellBarTheme } from "../lib/shell-bar.ts";
|
|
|
24
24
|
import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
25
25
|
import { decodeReviewStatusV3 } from "../lib/review-integration-v2.ts";
|
|
26
26
|
import { REVIEW_SIDEBAR_EVENT } from "../lib/review-sidebar-state.ts";
|
|
27
|
+
import { isJobsSidebarSnapshot } from "../lib/jobs-sidebar-state.ts";
|
|
27
28
|
import type { NativeReviewCli } from "../lib/native-review-cli.ts";
|
|
28
29
|
import { resolveVisualSettings, writeVisualSettings } from "../lib/visual-customization-policy.ts";
|
|
29
30
|
import { resolveAnimationPolicy } from "../lib/animation-policy.ts";
|
|
@@ -111,7 +112,7 @@ const renderers = new Map<string, MessageRenderer>();
|
|
|
111
112
|
const FAKE_SOURCE_INFO: SourceInfo = { path: "extensions/gentle-shell.ts", source: "gentle-shell", scope: "project", origin: "top-level" };
|
|
112
113
|
|
|
113
114
|
const DEFAULT_COMMANDS: SlashCommandInfo[] = [
|
|
114
|
-
{ name: "gentle:
|
|
115
|
+
{ name: "gentle:profiles", description: "Configure models", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
|
|
115
116
|
{ name: "gentle:changes", description: "Browse changes", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
|
|
116
117
|
{ name: "gentle:status", description: "Show Gentle AI status", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
|
|
117
118
|
{ name: "skill-registry:refresh", description: "Regenerate the skill registry", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
|
|
@@ -163,6 +164,9 @@ function fakePi(script: GitScript[] = [{ numstat: "", porcelain: "" }], commands
|
|
|
163
164
|
if (args.includes("worktree")) return { stdout: "worktree /repo\0branch refs/heads/main\0\0", stderr: "", code: 0, killed: false };
|
|
164
165
|
// Roots outside the worktree list read as a detached HEAD.
|
|
165
166
|
if (args.includes("symbolic-ref")) return { stdout: "", stderr: "", code: 1, killed: false };
|
|
167
|
+
// The unborn-HEAD ask is cheap metadata, not a scan round: the script
|
|
168
|
+
// models one numstat+porcelain pair per round, exactly like real Git.
|
|
169
|
+
if (args.includes("rev-parse")) return { stdout: "", stderr: "", code: 0, killed: false };
|
|
166
170
|
const isNumstat = args.includes("diff");
|
|
167
171
|
const step = script[Math.min(isNumstat ? round : round++, script.length - 1)];
|
|
168
172
|
return { stdout: isNumstat ? step.numstat : step.porcelain, stderr: "", code: 0, killed: false };
|
|
@@ -500,7 +504,7 @@ test("review sidebar rejects foreign-session events and unsubscribes on shutdown
|
|
|
500
504
|
}
|
|
501
505
|
});
|
|
502
506
|
|
|
503
|
-
test("jobs snapshots
|
|
507
|
+
test("jobs snapshots feed their own rail card, keep one compact fallback, and reject stale sessions", async () => {
|
|
504
508
|
const { pi, handlers } = fakePi();
|
|
505
509
|
gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" });
|
|
506
510
|
const { ctx, ui } = fakeContext();
|
|
@@ -512,35 +516,56 @@ test("jobs snapshots populate Status, use one compact fallback, and reject stale
|
|
|
512
516
|
const component = factory(tui, plainTheme, data);
|
|
513
517
|
const state = sidebarState(tui as unknown as TUI);
|
|
514
518
|
const rail = state.parts.get("footer")!;
|
|
519
|
+
const jobs = state.parts.get("jobs")!;
|
|
515
520
|
const header = state.parts.get("header")!;
|
|
516
521
|
const publish = (sessionId: string, jobs: unknown) => pi.events.emit("gentle-ai:jobs-sidebar", { sessionId, snapshot: { jobs } });
|
|
517
522
|
try {
|
|
518
523
|
publish("shell-session", [{ id: "job-1", label: "CI for PR 1860", startedAt: 1 }]);
|
|
519
|
-
assert.
|
|
524
|
+
assert.doesNotMatch(rail.render(46).join("\n"), /Jobs|CI for PR 1860/, "the Status card never prints jobs; the jobs card owns them");
|
|
520
525
|
assert.match(rail.render(46).join("\n"), /MCP: 3 servers/);
|
|
521
|
-
assert.
|
|
526
|
+
assert.match(jobs.render(46).join("\n"), /Jobs[\s\S]*CI for PR 1860/);
|
|
527
|
+
publish("shell-session", [{ id: "job-1", label: "CI for PR 1860", startedAt: 1 }, { id: "job-0", label: "Build runtime", startedAt: 1, status: "exited", endedAt: Date.now(), exitCode: 0 }]);
|
|
528
|
+
assert.match(jobs.render(60).join("\n"), /1 running · 1 finished/, "finished tails ride the same snapshot");
|
|
529
|
+
assert.match(jobs.render(60).join("\n"), /✓\s+Build runtime/);
|
|
530
|
+
// The compact count is running-only: a finished tail still paints the
|
|
531
|
+
// card, but the header's ⧗ N jobs derives from running entries alone.
|
|
532
|
+
publish("shell-session", [{ id: "job-2", label: "Fresh tail", startedAt: 1, status: "exited", endedAt: Date.now(), exitCode: 0 }]);
|
|
533
|
+
tui.terminal.columns = 80;
|
|
534
|
+
assert.match(jobs.render(46).join("\n"), /Fresh tail/, "the finished tail still paints the card");
|
|
535
|
+
assert.doesNotMatch(header.render(80).join("\n"), /⧗ 1 job/, "the compact count is running-only");
|
|
536
|
+
tui.terminal.columns = 160;
|
|
537
|
+
publish("shell-session", [{ id: "job-0", label: "Stale tail", startedAt: 1, status: "exited", endedAt: 5, exitCode: 0 }]);
|
|
538
|
+
assert.doesNotMatch(jobs.render(46).join("\n"), /Stale tail/, "a finished job past the TTL paints nothing");
|
|
539
|
+
assert.doesNotMatch(rail.render(46).join("\n"), /1 job/, "the jobs counter never joins the integration statuses while display data exists");
|
|
522
540
|
state.active = true;
|
|
523
541
|
state.ownsHost = () => true;
|
|
524
|
-
assert.doesNotMatch(header.render(160).join("\n"), /1 job/, "the visible
|
|
542
|
+
assert.doesNotMatch(header.render(160).join("\n"), /1 job/, "the visible rail owns job details");
|
|
525
543
|
state.active = false;
|
|
526
544
|
tui.terminal.columns = 80;
|
|
527
|
-
|
|
528
|
-
|
|
545
|
+
publish("shell-session", [{ id: "job-1", label: "CI for PR 1860", startedAt: 1 }]);
|
|
546
|
+
assert.match(header.render(80).join("\n"), /1 job/, "mobile topbar preserves the count of a running job");
|
|
547
|
+
const before = jobs.render(46).join("\n");
|
|
529
548
|
publish("foreign", []);
|
|
530
549
|
publish("shell-session", [{ label: "invalid" }]);
|
|
531
|
-
assert.equal(
|
|
550
|
+
assert.equal(jobs.render(46).join("\n"), before);
|
|
532
551
|
publish("shell-session", []);
|
|
533
|
-
assert.doesNotMatch(
|
|
552
|
+
assert.doesNotMatch(jobs.render(46).join("\n"), /Jobs|CI for PR/);
|
|
534
553
|
assert.doesNotMatch(header.render(80).join("\n"), /1 job/);
|
|
535
554
|
publish("shell-session", [{ id: "job-2", label: "Build runtime", startedAt: 2 }]);
|
|
536
555
|
await fire(handlers, "session_start", ctx);
|
|
537
|
-
assert.doesNotMatch(
|
|
556
|
+
assert.doesNotMatch(state.parts.get("jobs")!.render(46).join("\n"), /Build runtime/);
|
|
538
557
|
await fire(handlers, "session_shutdown", ctx);
|
|
539
558
|
publish("shell-session", [{ id: "job-3", label: "late", startedAt: 3 }]);
|
|
540
|
-
assert.doesNotMatch(
|
|
559
|
+
assert.doesNotMatch(state.parts.get("jobs")!.render(46).join("\n"), /late/);
|
|
541
560
|
} finally { component.dispose(); }
|
|
542
561
|
});
|
|
543
562
|
|
|
563
|
+
test("the jobs snapshot validator admits the finished-tail statuses", () => {
|
|
564
|
+
assert.ok(isJobsSidebarSnapshot({ jobs: [{ id: "job-1", label: "Spawn failed", startedAt: 1, status: "failed", endedAt: 60_000, exitCode: null }] }));
|
|
565
|
+
assert.ok(isJobsSidebarSnapshot({ jobs: [{ id: "job-2", label: "Stopped", startedAt: 1, status: "stopped", endedAt: 2 }] }));
|
|
566
|
+
assert.equal(isJobsSidebarSnapshot({ jobs: [{ id: "job-3", label: "Bad", startedAt: 1, status: "exploded" }] }), false);
|
|
567
|
+
});
|
|
568
|
+
|
|
544
569
|
test("hidden Status leaves only a jobs count in regular bottom and fullscreen header chrome", async (t) => {
|
|
545
570
|
const home = scopedDoubleEscCancelConfigHome(t);
|
|
546
571
|
writeVisualSettings({ ...resolveVisualSettings({ gentlePiConfigHome: home }).settings, statusPlacement: "hidden" }, { gentlePiConfigHome: home });
|
|
@@ -556,6 +581,9 @@ test("hidden Status leaves only a jobs count in regular bottom and fullscreen he
|
|
|
556
581
|
pi.events.emit("gentle-ai:jobs-sidebar", { sessionId: "shell-session", snapshot: { jobs: [{ id: "job-1", label: "CI for PR 1860", startedAt: 1 }] } });
|
|
557
582
|
assert.equal(component.render(80).join("\n"), "⧗ 1 job");
|
|
558
583
|
assert.doesNotMatch(component.render(80).join("\n"), /CI for PR|gpt-5/);
|
|
584
|
+
pi.events.emit("gentle-ai:jobs-sidebar", { sessionId: "shell-session", snapshot: { jobs: [{ id: "job-1", label: "CI for PR 1860", startedAt: 1, status: "exited", endedAt: Date.now(), exitCode: 0 }] } });
|
|
585
|
+
assert.doesNotMatch(component.render(80).join("\n"), /⧗/, "a finished-only tail never paints a ⧗ 0 jobs row");
|
|
586
|
+
pi.events.emit("gentle-ai:jobs-sidebar", { sessionId: "shell-session", snapshot: { jobs: [{ id: "job-1", label: "CI for PR 1860", startedAt: 1 }] } });
|
|
559
587
|
tui.mode = "fullscreen";
|
|
560
588
|
const header = sidebarState(tui as unknown as TUI).parts.get("header")!;
|
|
561
589
|
assert.match(header.render(160).join("\n"), /⧗ 1 job/);
|
|
@@ -2048,7 +2076,7 @@ test("gentleShell shows working while the agent runs and queued when messages wa
|
|
|
2048
2076
|
const editor = installedPrompt(ctx, ui, handlers);
|
|
2049
2077
|
|
|
2050
2078
|
for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
|
|
2051
|
-
assert.match(stripAnsi(editor.render(60)[0]), /^╭─
|
|
2079
|
+
assert.match(stripAnsi(editor.render(60)[0]), /^╭─ [✿❀❁✾] working… ─+╮$/);
|
|
2052
2080
|
pending.value = true;
|
|
2053
2081
|
assert.match(stripAnsi(editor.render(60)[0]), /^╭─ [✿❀❁✾] queued ─+╮$/);
|
|
2054
2082
|
for (const handler of handlers.get("agent_end") ?? []) handler({}, ctx);
|
|
@@ -2165,7 +2193,7 @@ test("vim NORMAL slash uses Pi's command and skill completion without displacing
|
|
|
2165
2193
|
fg: (_color, text) => text, bold: (text) => text, requestRender() {}, pending: () => false,
|
|
2166
2194
|
now: () => 0, doubleEscCancelEnabled: () => false, dispatchQueuedText() {}, tuiVersion: () => INSTALLED_PI,
|
|
2167
2195
|
});
|
|
2168
|
-
const entries = ["gentle:vim", "gentle:
|
|
2196
|
+
const entries = ["gentle:vim", "gentle:profiles", "skill:example"];
|
|
2169
2197
|
const requests: string[] = [];
|
|
2170
2198
|
editor.setAutocompleteProvider({
|
|
2171
2199
|
async getSuggestions(lines: string[], line: number, col: number) {
|
|
@@ -2821,7 +2849,7 @@ test("vim selector exposes enable, disable and status; status and cancellation d
|
|
|
2821
2849
|
} finally { editor.dispose(); }
|
|
2822
2850
|
});
|
|
2823
2851
|
|
|
2824
|
-
test("animations command reports without writing and switches the live
|
|
2852
|
+
test("animations command reports without writing and switches the live watchdog", async (t) => {
|
|
2825
2853
|
const configHome = scopedDoubleEscCancelConfigHome(t);
|
|
2826
2854
|
const delays: number[] = [];
|
|
2827
2855
|
let active = 0;
|
|
@@ -2839,9 +2867,9 @@ test("animations command reports without writing and switches the live pulse", a
|
|
|
2839
2867
|
assert.match(ui.notices.at(-1)!, /animations: quality/);
|
|
2840
2868
|
assert.equal(existsSync(join(configHome, "animations.json")), false);
|
|
2841
2869
|
for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
|
|
2842
|
-
assert.deepEqual(delays, [2000,
|
|
2870
|
+
assert.deepEqual(delays, [2000, 1000]);
|
|
2843
2871
|
await command.handler("performance", ctx);
|
|
2844
|
-
assert.deepEqual(delays, [2000,
|
|
2872
|
+
assert.deepEqual(delays, [2000, 1000, 1000]);
|
|
2845
2873
|
assert.equal(active, 2);
|
|
2846
2874
|
await command.handler("potato", ctx);
|
|
2847
2875
|
assert.equal(active, 1);
|
|
@@ -2850,7 +2878,7 @@ test("animations command reports without writing and switches the live pulse", a
|
|
|
2850
2878
|
await command.handler("invalid", ctx);
|
|
2851
2879
|
assert.equal(JSON.parse(readFileSync(join(configHome, "animations.json"), "utf8")).policy, "potato");
|
|
2852
2880
|
await command.handler("quality", ctx);
|
|
2853
|
-
assert.deepEqual(delays, [2000,
|
|
2881
|
+
assert.deepEqual(delays, [2000, 1000, 1000, 1000]);
|
|
2854
2882
|
for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
|
|
2855
2883
|
assert.equal(active, 1);
|
|
2856
2884
|
editor.dispose();
|
|
@@ -2939,7 +2967,7 @@ test("animations with no argument opens a selectable menu and applies the chosen
|
|
|
2939
2967
|
assert.equal(existsSync(join(dismissHome, "animations.json")), false);
|
|
2940
2968
|
});
|
|
2941
2969
|
|
|
2942
|
-
test("prompt
|
|
2970
|
+
test("prompt installs the silence watchdog unref'd and releases it at settlement", async (t) => {
|
|
2943
2971
|
const configHome = scopedDoubleEscCancelConfigHome(t);
|
|
2944
2972
|
writeFileSync(join(configHome, "animations.json"), '{"schema":"gentle-pi.animations/v1","policy":"quality"}');
|
|
2945
2973
|
const delays: number[] = [];
|
|
@@ -2957,7 +2985,7 @@ test("prompt uses the compact banner cadence and releases its unref timer at set
|
|
|
2957
2985
|
const editor = installedPrompt(ctx, ui, handlers);
|
|
2958
2986
|
assert.equal(active, 1);
|
|
2959
2987
|
for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
|
|
2960
|
-
assert.deepEqual(delays, [2000,
|
|
2988
|
+
assert.deepEqual(delays, [2000, 1000]);
|
|
2961
2989
|
assert.equal(unrefs, 2);
|
|
2962
2990
|
for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
|
|
2963
2991
|
assert.equal(active, 1);
|
|
@@ -2966,6 +2994,109 @@ test("prompt uses the compact banner cadence and releases its unref timer at set
|
|
|
2966
2994
|
assert.equal(active, 0);
|
|
2967
2995
|
});
|
|
2968
2996
|
|
|
2997
|
+
// gentle-shell#994: the working animation is render-driven. Frames the host
|
|
2998
|
+
// already paints while streaming advance the petal for free; a low-cadence
|
|
2999
|
+
// watchdog repaints only silent phases (long tool runs with no output).
|
|
3000
|
+
interface RecordedInterval { tick: () => void; delay: number; stopped: boolean }
|
|
3001
|
+
|
|
3002
|
+
function recordIntervals(t: TestContext): RecordedInterval[] {
|
|
3003
|
+
const intervals: RecordedInterval[] = [];
|
|
3004
|
+
t.mock.method(globalThis, "setInterval", (tick: () => void, delay: number) => {
|
|
3005
|
+
const timer: RecordedInterval = { tick, delay, stopped: false };
|
|
3006
|
+
intervals.push(timer);
|
|
3007
|
+
return { unref() {}, timer };
|
|
3008
|
+
});
|
|
3009
|
+
t.mock.method(globalThis, "clearInterval", (handle: { timer: RecordedInterval }) => { handle.timer.stopped = true; });
|
|
3010
|
+
return intervals;
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
function petalGlyphOf(editor: GentlePromptEditor): string {
|
|
3014
|
+
return stripAnsi(editor.render(60)[0]).match(/[✿❀❁✾]/)?.[0] ?? "";
|
|
3015
|
+
}
|
|
3016
|
+
|
|
3017
|
+
test("streaming frames advance the working petal and the quality watchdog stays silent while renders flow", (t) => {
|
|
3018
|
+
const configHome = scopedDoubleEscCancelConfigHome(t);
|
|
3019
|
+
writeFileSync(join(configHome, "animations.json"), '{"schema":"gentle-pi.animations/v1","policy":"quality"}');
|
|
3020
|
+
const intervals = recordIntervals(t);
|
|
3021
|
+
const renders = t.mock.method(fakeTui, "requestRender", () => {});
|
|
3022
|
+
const { pi, handlers } = fakePi();
|
|
3023
|
+
gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome }, { now: () => 1_000_000 });
|
|
3024
|
+
const { ctx, ui } = fakeContext();
|
|
3025
|
+
const editor = installedPrompt(ctx, ui, handlers);
|
|
3026
|
+
for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
|
|
3027
|
+
const watchdog = intervals.find((entry) => entry.delay === 1000);
|
|
3028
|
+
assert.ok(watchdog, "working installs the 1000ms silence watchdog");
|
|
3029
|
+
const glyphs = [0, 1, 2, 3].map(() => petalGlyphOf(editor));
|
|
3030
|
+
assert.ok(new Set(glyphs).size > 1, `each streamed frame must advance the petal, got ${glyphs.join("")}`);
|
|
3031
|
+
const before = renders.mock.callCount();
|
|
3032
|
+
watchdog!.tick();
|
|
3033
|
+
assert.equal(renders.mock.callCount(), before, "renders are already flowing, so the watchdog must not force one");
|
|
3034
|
+
editor.dispose();
|
|
3035
|
+
assert.equal(watchdog!.stopped, true);
|
|
3036
|
+
});
|
|
3037
|
+
|
|
3038
|
+
test("silent working phases repaint only through the 1000ms watchdog window", (t) => {
|
|
3039
|
+
const configHome = scopedDoubleEscCancelConfigHome(t);
|
|
3040
|
+
writeFileSync(join(configHome, "animations.json"), '{"schema":"gentle-pi.animations/v1","policy":"quality"}');
|
|
3041
|
+
const intervals = recordIntervals(t);
|
|
3042
|
+
const renders = t.mock.method(fakeTui, "requestRender", () => {});
|
|
3043
|
+
let clock = 1_000_000;
|
|
3044
|
+
const { pi, handlers } = fakePi();
|
|
3045
|
+
gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome }, { now: () => clock });
|
|
3046
|
+
const { ctx, ui } = fakeContext();
|
|
3047
|
+
const editor = installedPrompt(ctx, ui, handlers);
|
|
3048
|
+
for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
|
|
3049
|
+
const watchdog = intervals.find((entry) => entry.delay === 1000);
|
|
3050
|
+
assert.ok(watchdog, "working installs the 1000ms silence watchdog");
|
|
3051
|
+
const baseline = petalGlyphOf(editor);
|
|
3052
|
+
const before = renders.mock.callCount();
|
|
3053
|
+
watchdog!.tick();
|
|
3054
|
+
assert.equal(renders.mock.callCount(), before, "no frame painted within the window, but the window has not elapsed yet");
|
|
3055
|
+
clock += 1000;
|
|
3056
|
+
watchdog!.tick();
|
|
3057
|
+
assert.equal(renders.mock.callCount(), before + 1, "silence past the window forces exactly one repaint");
|
|
3058
|
+
assert.notEqual(petalGlyphOf(editor), baseline, "the watchdog advanced the petal tick");
|
|
3059
|
+
const afterForced = renders.mock.callCount();
|
|
3060
|
+
watchdog!.tick();
|
|
3061
|
+
assert.equal(renders.mock.callCount(), afterForced, "the watchdog's own repaint counts as render traffic for the next window");
|
|
3062
|
+
editor.dispose();
|
|
3063
|
+
});
|
|
3064
|
+
|
|
3065
|
+
test("potato installs no working watchdog and the petal never advances", (t) => {
|
|
3066
|
+
const configHome = scopedDoubleEscCancelConfigHome(t);
|
|
3067
|
+
writeFileSync(join(configHome, "animations.json"), '{"schema":"gentle-pi.animations/v1","policy":"potato"}');
|
|
3068
|
+
const intervals = recordIntervals(t);
|
|
3069
|
+
const { pi, handlers } = fakePi();
|
|
3070
|
+
gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
|
|
3071
|
+
const { ctx, ui } = fakeContext();
|
|
3072
|
+
const editor = installedPrompt(ctx, ui, handlers);
|
|
3073
|
+
for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
|
|
3074
|
+
assert.ok(!intervals.some((entry) => entry.delay !== 2000), "potato must not animate: no watchdog interval at all");
|
|
3075
|
+
const glyphs = [0, 1, 2].map(() => petalGlyphOf(editor));
|
|
3076
|
+
assert.deepEqual(glyphs, ["✿", "✿", "✿"], "potato keeps the petal static");
|
|
3077
|
+
editor.dispose();
|
|
3078
|
+
});
|
|
3079
|
+
|
|
3080
|
+
test("the working watchdog is cleared on settlement and on dispose", (t) => {
|
|
3081
|
+
const configHome = scopedDoubleEscCancelConfigHome(t);
|
|
3082
|
+
writeFileSync(join(configHome, "animations.json"), '{"schema":"gentle-pi.animations/v1","policy":"quality"}');
|
|
3083
|
+
const intervals = recordIntervals(t);
|
|
3084
|
+
const { pi, handlers } = fakePi();
|
|
3085
|
+
gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
|
|
3086
|
+
const { ctx, ui } = fakeContext();
|
|
3087
|
+
const editor = installedPrompt(ctx, ui, handlers);
|
|
3088
|
+
for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
|
|
3089
|
+
const watchdog = intervals.filter((entry) => entry.delay === 1000).at(-1);
|
|
3090
|
+
assert.ok(watchdog && !watchdog.stopped, "working keeps the watchdog armed");
|
|
3091
|
+
for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
|
|
3092
|
+
assert.equal(watchdog!.stopped, true, "settlement must clear the watchdog");
|
|
3093
|
+
for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
|
|
3094
|
+
const rearmWatchdog = intervals.filter((entry) => entry.delay === 1000).at(-1);
|
|
3095
|
+
assert.ok(rearmWatchdog && rearmWatchdog !== watchdog && !rearmWatchdog.stopped, "the next turn re-arms a fresh watchdog");
|
|
3096
|
+
editor.dispose();
|
|
3097
|
+
assert.equal(rearmWatchdog!.stopped, true, "dispose must clear the watchdog");
|
|
3098
|
+
});
|
|
3099
|
+
|
|
2969
3100
|
// ---------------------------------------------------------------------------
|
|
2970
3101
|
// double-esc-cancel (issue #1163): opt-in, off by default. While the prompt
|
|
2971
3102
|
// is working and autocomplete is hidden, the first Esc is swallowed and the
|
|
@@ -4811,6 +4942,9 @@ test("new sessions do not inherit another session's worktrees", async () => {
|
|
|
4811
4942
|
const first=fakeContext();
|
|
4812
4943
|
await fire(h.handlers,"session_start",first.ctx);
|
|
4813
4944
|
await h.tools.get("session_worktree_register")!.execute("r",{path:"/linked"},undefined,undefined,first.ctx);
|
|
4945
|
+
// The registration kicks off its own async scan; settle it so its Git calls
|
|
4946
|
+
// cannot land after the next session clears the shared call log.
|
|
4947
|
+
await changesSettled();
|
|
4814
4948
|
await fire(h.handlers,"session_shutdown",first.ctx);
|
|
4815
4949
|
const next=fakeContext({entries:[...first.ctx.sessionManager.getEntries()]});
|
|
4816
4950
|
(next.ctx.sessionManager as any).getSessionId=()=>"new-session";
|
|
@@ -5559,11 +5693,11 @@ test("/gentle:commands shows only curated, registered commands, grouped, by thei
|
|
|
5559
5693
|
assert.match(rendered, /Diagnostics/);
|
|
5560
5694
|
assert.match(rendered, /Skills/);
|
|
5561
5695
|
assert.doesNotMatch(rendered, /\bSDD\b/, "the SDD group has no registered commands and must not appear");
|
|
5562
|
-
assert.match(rendered, /
|
|
5696
|
+
assert.match(rendered, /Models and profiles/);
|
|
5563
5697
|
assert.match(rendered, /Browse changes/);
|
|
5564
5698
|
assert.match(rendered, /Gentle AI status/);
|
|
5565
5699
|
assert.match(rendered, /Refresh skill registry/);
|
|
5566
|
-
assert.doesNotMatch(rendered, /gentle:
|
|
5700
|
+
assert.doesNotMatch(rendered, /gentle:profiles|gentle:changes|gentle:status|skill-registry:refresh/, "raw command names must not leak; only labels are shown");
|
|
5567
5701
|
assert.doesNotMatch(rendered, /gentle:not-in-catalog/);
|
|
5568
5702
|
assert.doesNotMatch(rendered, /skill:foo/);
|
|
5569
5703
|
const changesLine = lines.find((line) => line.includes("Browse changes"));
|
|
@@ -5578,13 +5712,13 @@ test("selecting a command from the palette by its label sends the underlying com
|
|
|
5578
5712
|
const { ctx, ui, overlayReady } = fakeContext();
|
|
5579
5713
|
const opened = commands.get("gentle:commands")!.handler("", ctx);
|
|
5580
5714
|
await overlayReady;
|
|
5581
|
-
// "mod" only matches the "
|
|
5582
|
-
//
|
|
5715
|
+
// "mod" only matches the "Models and profiles" label (it contains "mod"
|
|
5716
|
+
// via "Models"); nothing else in the fixture does.
|
|
5583
5717
|
for (const ch of "mod") ui.overlayView!.handleInput(ch);
|
|
5584
|
-
assert.match(ui.overlayView!.render(100).join("\n"), /
|
|
5718
|
+
assert.match(ui.overlayView!.render(100).join("\n"), /Models and profiles/);
|
|
5585
5719
|
ui.overlayView!.handleInput("\r");
|
|
5586
5720
|
await opened;
|
|
5587
|
-
assert.deepEqual(sentMessages, [{ content: "/gentle:
|
|
5721
|
+
assert.deepEqual(sentMessages, [{ content: "/gentle:profiles", options: { expandPromptTemplates: true } }]);
|
|
5588
5722
|
});
|
|
5589
5723
|
|
|
5590
5724
|
test("escaping the palette sends no message", async () => {
|
|
@@ -5627,7 +5761,7 @@ test("the alt+k shortcut opens the same command palette as the command", async (
|
|
|
5627
5761
|
assert.ok(shortcut, "alt+k not registered");
|
|
5628
5762
|
const opened = shortcut.handler(ctx);
|
|
5629
5763
|
await overlayReady;
|
|
5630
|
-
assert.match(ui.overlayView!.render(100).join("\n"), /
|
|
5764
|
+
assert.match(ui.overlayView!.render(100).join("\n"), /Models and profiles/);
|
|
5631
5765
|
ui.overlayView!.handleInput("\x1b");
|
|
5632
5766
|
await opened;
|
|
5633
5767
|
});
|
|
@@ -6376,3 +6510,69 @@ for (const kind of ["unset", "empty", "bundled"] as const) {
|
|
|
6376
6510
|
assert.equal(read(), "other (repo)", "the pinned profile exists only in that config home's store");
|
|
6377
6511
|
});
|
|
6378
6512
|
}
|
|
6513
|
+
|
|
6514
|
+
// gentle-shell#2464: a pasted image is an atomic chip in the draft and a real
|
|
6515
|
+
// image attachment on submit, never a temp-file path the model must `read`.
|
|
6516
|
+
const CHIP_PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01]);
|
|
6517
|
+
const chipKeybindings = { matches: (data: string, action: string) => action === "app.clipboard.pasteImage" && data === "\x1bv" };
|
|
6518
|
+
|
|
6519
|
+
async function pastedChipPrompt(model?: ExtensionContext["model"]) {
|
|
6520
|
+
const { pi, handlers } = fakePi();
|
|
6521
|
+
gentleShell(pi, {}, { readClipboardImage: async () => ({ bytes: CHIP_PNG, mimeType: "image/png" }) });
|
|
6522
|
+
const { ctx, ui } = fakeContext();
|
|
6523
|
+
if (model) (ctx as { model: unknown }).model = model;
|
|
6524
|
+
const editor = installedPrompt(ctx, ui, handlers, chipKeybindings);
|
|
6525
|
+
editor.onPasteImage = () => assert.fail("Pi's path-inserting paste must not run when the clipboard holds an image");
|
|
6526
|
+
editor.setText("what is this?");
|
|
6527
|
+
editor.handleInput("\x1bv");
|
|
6528
|
+
await settle();
|
|
6529
|
+
const input = (text: string, source = "interactive") => handlers.get("input")?.[0]?.({ type: "input", text, source }, ctx);
|
|
6530
|
+
// Pi's real order: the editor empties its draft, then onSubmit hands the
|
|
6531
|
+
// text to session.prompt(), which runs the input handlers.
|
|
6532
|
+
const submit = () => {
|
|
6533
|
+
let sent = "";
|
|
6534
|
+
editor.onSubmit = (text) => { sent = text; };
|
|
6535
|
+
editor.handleInput("\r");
|
|
6536
|
+
return input(sent);
|
|
6537
|
+
};
|
|
6538
|
+
return { editor, ui, input, submit };
|
|
6539
|
+
}
|
|
6540
|
+
|
|
6541
|
+
test("pasted image chips are submitted as real image attachments (gentle-shell#2464)", async () => {
|
|
6542
|
+
const { editor, ui, input, submit } = await pastedChipPrompt();
|
|
6543
|
+
assert.equal(editor.getText(), "what is this? [Image#1]");
|
|
6544
|
+
const strip = (ui.widgets.get("gentle-shell-image-attachments") as (tui: unknown, theme: unknown) => { render(width: number): string[] })(fakeTui, plainTheme);
|
|
6545
|
+
assert.equal(stripAnsi(strip.render(80)[0] ?? ""), "[Image#1] png · 2×1 · 24 B");
|
|
6546
|
+
assert.deepEqual(submit(), {
|
|
6547
|
+
action: "transform",
|
|
6548
|
+
text: "what is this? [Image#1]",
|
|
6549
|
+
images: [{ type: "image", data: Buffer.from(CHIP_PNG).toString("base64"), mimeType: "image/png" }],
|
|
6550
|
+
});
|
|
6551
|
+
// Sent images are released: recalling the same text later is ordinary text.
|
|
6552
|
+
assert.equal(editor.getText(), "");
|
|
6553
|
+
assert.deepEqual(strip.render(80), []);
|
|
6554
|
+
assert.deepEqual(input("what is this? [Image#1]"), { action: "continue" });
|
|
6555
|
+
});
|
|
6556
|
+
|
|
6557
|
+
test("a pasted image chip deleted before submit is never sent (gentle-shell#2464)", async () => {
|
|
6558
|
+
const { editor, submit } = await pastedChipPrompt();
|
|
6559
|
+
editor.handleInput("\x7f");
|
|
6560
|
+
assert.equal(editor.getText(), "what is this? ");
|
|
6561
|
+
assert.deepEqual(submit(), { action: "continue" });
|
|
6562
|
+
});
|
|
6563
|
+
|
|
6564
|
+
test("pasted image chips leave non-interactive input and deleted chips untouched (gentle-shell#2464)", async () => {
|
|
6565
|
+
const { input } = await pastedChipPrompt();
|
|
6566
|
+
assert.deepEqual(input("[Image#1] from an extension", "extension"), { action: "continue" });
|
|
6567
|
+
assert.deepEqual(input("the chip was deleted"), { action: "continue" });
|
|
6568
|
+
});
|
|
6569
|
+
|
|
6570
|
+
test("pasted image chips warn instead of sending images a text-only model rejects (gentle-shell#2464)", async () => {
|
|
6571
|
+
const { ui, input } = await pastedChipPrompt({ id: "text-only", provider: "acme", input: ["text"] } as unknown as ExtensionContext["model"]);
|
|
6572
|
+
assert.deepEqual(input("what is this? [Image#1]"), {
|
|
6573
|
+
action: "transform",
|
|
6574
|
+
text: "what is this? [Image#1 omitted: the current model does not accept images]",
|
|
6575
|
+
images: [],
|
|
6576
|
+
});
|
|
6577
|
+
assert.ok(ui.notices.some((notice) => /1 pasted image was not sent: the current model does not accept images/.test(notice)));
|
|
6578
|
+
});
|
|
@@ -498,6 +498,17 @@ test("the system prompt carries only a byte-stable todo block; the current list
|
|
|
498
498
|
assert.equal(finishedEvent.systemPromptOptions.appendSystemPrompt, emptyEvent.systemPromptOptions.appendSystemPrompt, "finishing the list must not change the system prompt");
|
|
499
499
|
});
|
|
500
500
|
|
|
501
|
+
// weak-model-economy S1: small tasks skip the todo list (bench C-econ).
|
|
502
|
+
test("the todo block exempts small tasks before the tracking rules", async () => {
|
|
503
|
+
const { pi, fire } = fakePi();
|
|
504
|
+
gentleTodo(pi, {});
|
|
505
|
+
const { ctx } = fakeContext();
|
|
506
|
+
await fire("session_start", ctx);
|
|
507
|
+
const event = promptEvent();
|
|
508
|
+
await fire("before_agent_start", ctx, event);
|
|
509
|
+
assert.ok(event.systemPromptOptions.appendSystemPrompt.startsWith("## Todo list\nA small task (Task Size) uses no todo list: do it and report. For tracked or multi-step work, keep it current with the `todo` tool: mark a task in_progress before starting it,"));
|
|
510
|
+
});
|
|
511
|
+
|
|
501
512
|
/**
|
|
502
513
|
* Drives the extension the way Pi's agent loop does (#2195): every model request
|
|
503
514
|
* runs the context handlers over the persisted transcript, and a custom message
|