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
|
@@ -1890,8 +1890,34 @@ test("quoteForCmdExe leaves a plain token untouched", () => {
|
|
|
1890
1890
|
assert.equal(quoteForCmdExe("pi"), "pi");
|
|
1891
1891
|
});
|
|
1892
1892
|
|
|
1893
|
-
test("quoteForCmdExe quotes a token with a space and
|
|
1894
|
-
assert.equal(quoteForCmdExe(
|
|
1893
|
+
test("quoteForCmdExe quotes a token with a space and the other cmd.exe specials", () => {
|
|
1894
|
+
assert.equal(quoteForCmdExe("say hi now"), '"say hi now"');
|
|
1895
|
+
assert.equal(quoteForCmdExe("a&b"), '"a&b"');
|
|
1896
|
+
});
|
|
1897
|
+
|
|
1898
|
+
// cmd.exe has no backslash escape: a `"` inside a quoted token ends the
|
|
1899
|
+
// quoted section, so everything after it is parsed as command syntax. The
|
|
1900
|
+
// simple quoting cannot represent such a token, so it refuses it instead of
|
|
1901
|
+
// emitting a command line that lets the argument inject commands.
|
|
1902
|
+
test("quoteForCmdExe rejects a token with a double quote", () => {
|
|
1903
|
+
for (const token of ['say "hi" now', 'x" & whoami & rem "', '"', 'a"b']) {
|
|
1904
|
+
assert.throws(() => quoteForCmdExe(token), /double quote/);
|
|
1905
|
+
}
|
|
1906
|
+
});
|
|
1907
|
+
|
|
1908
|
+
test("planSpawn refuses a win32 .cmd launch whose argument carries a double quote", () => {
|
|
1909
|
+
const hostile = 'x" & whoami & rem "';
|
|
1910
|
+
for (const command of ["C:\\Program Files\\nodejs\\pi.cmd", "C:\\pi\\pi.BAT"]) {
|
|
1911
|
+
assert.throws(() => planSpawn({ command, args: ["--version", hostile], platform: "win32" }), /double quote/);
|
|
1912
|
+
}
|
|
1913
|
+
assert.throws(() => planSpawn({ command: 'C:\\pi\\a"b.cmd', args: [], platform: "win32" }), /double quote/);
|
|
1914
|
+
});
|
|
1915
|
+
|
|
1916
|
+
test("planSpawn passes a double quote through when no cmd.exe command line is built", () => {
|
|
1917
|
+
const args = ["--prompt", 'say "hi"'];
|
|
1918
|
+
assert.deepEqual(planSpawn({ command: "C:\\pi\\pi.exe", args, platform: "win32" }), { command: "C:\\pi\\pi.exe", args, shell: false });
|
|
1919
|
+
assert.deepEqual(planSpawn({ command: "/usr/bin/pi", args, platform: "linux" }), { command: "/usr/bin/pi", args, shell: false });
|
|
1920
|
+
assert.deepEqual(planSpawn({ command: "/usr/local/bin/pi.cmd", args, platform: "darwin" }), { command: "/usr/local/bin/pi.cmd", args, shell: false });
|
|
1895
1921
|
});
|
|
1896
1922
|
|
|
1897
1923
|
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";
|
|
@@ -163,6 +163,9 @@ function fakePi(script: GitScript[] = [{ numstat: "", porcelain: "" }], commands
|
|
|
163
163
|
if (args.includes("worktree")) return { stdout: "worktree /repo\0branch refs/heads/main\0\0", stderr: "", code: 0, killed: false };
|
|
164
164
|
// Roots outside the worktree list read as a detached HEAD.
|
|
165
165
|
if (args.includes("symbolic-ref")) return { stdout: "", stderr: "", code: 1, killed: false };
|
|
166
|
+
// The unborn-HEAD ask is cheap metadata, not a scan round: the script
|
|
167
|
+
// models one numstat+porcelain pair per round, exactly like real Git.
|
|
168
|
+
if (args.includes("rev-parse")) return { stdout: "", stderr: "", code: 0, killed: false };
|
|
166
169
|
const isNumstat = args.includes("diff");
|
|
167
170
|
const step = script[Math.min(isNumstat ? round : round++, script.length - 1)];
|
|
168
171
|
return { stdout: isNumstat ? step.numstat : step.porcelain, stderr: "", code: 0, killed: false };
|
|
@@ -2048,7 +2051,7 @@ test("gentleShell shows working while the agent runs and queued when messages wa
|
|
|
2048
2051
|
const editor = installedPrompt(ctx, ui, handlers);
|
|
2049
2052
|
|
|
2050
2053
|
for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
|
|
2051
|
-
assert.match(stripAnsi(editor.render(60)[0]), /^╭─
|
|
2054
|
+
assert.match(stripAnsi(editor.render(60)[0]), /^╭─ [✿❀❁✾] working… ─+╮$/);
|
|
2052
2055
|
pending.value = true;
|
|
2053
2056
|
assert.match(stripAnsi(editor.render(60)[0]), /^╭─ [✿❀❁✾] queued ─+╮$/);
|
|
2054
2057
|
for (const handler of handlers.get("agent_end") ?? []) handler({}, ctx);
|
|
@@ -2821,7 +2824,7 @@ test("vim selector exposes enable, disable and status; status and cancellation d
|
|
|
2821
2824
|
} finally { editor.dispose(); }
|
|
2822
2825
|
});
|
|
2823
2826
|
|
|
2824
|
-
test("animations command reports without writing and switches the live
|
|
2827
|
+
test("animations command reports without writing and switches the live watchdog", async (t) => {
|
|
2825
2828
|
const configHome = scopedDoubleEscCancelConfigHome(t);
|
|
2826
2829
|
const delays: number[] = [];
|
|
2827
2830
|
let active = 0;
|
|
@@ -2839,9 +2842,9 @@ test("animations command reports without writing and switches the live pulse", a
|
|
|
2839
2842
|
assert.match(ui.notices.at(-1)!, /animations: quality/);
|
|
2840
2843
|
assert.equal(existsSync(join(configHome, "animations.json")), false);
|
|
2841
2844
|
for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
|
|
2842
|
-
assert.deepEqual(delays, [2000,
|
|
2845
|
+
assert.deepEqual(delays, [2000, 1000]);
|
|
2843
2846
|
await command.handler("performance", ctx);
|
|
2844
|
-
assert.deepEqual(delays, [2000,
|
|
2847
|
+
assert.deepEqual(delays, [2000, 1000, 1000]);
|
|
2845
2848
|
assert.equal(active, 2);
|
|
2846
2849
|
await command.handler("potato", ctx);
|
|
2847
2850
|
assert.equal(active, 1);
|
|
@@ -2850,7 +2853,7 @@ test("animations command reports without writing and switches the live pulse", a
|
|
|
2850
2853
|
await command.handler("invalid", ctx);
|
|
2851
2854
|
assert.equal(JSON.parse(readFileSync(join(configHome, "animations.json"), "utf8")).policy, "potato");
|
|
2852
2855
|
await command.handler("quality", ctx);
|
|
2853
|
-
assert.deepEqual(delays, [2000,
|
|
2856
|
+
assert.deepEqual(delays, [2000, 1000, 1000, 1000]);
|
|
2854
2857
|
for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
|
|
2855
2858
|
assert.equal(active, 1);
|
|
2856
2859
|
editor.dispose();
|
|
@@ -2939,7 +2942,7 @@ test("animations with no argument opens a selectable menu and applies the chosen
|
|
|
2939
2942
|
assert.equal(existsSync(join(dismissHome, "animations.json")), false);
|
|
2940
2943
|
});
|
|
2941
2944
|
|
|
2942
|
-
test("prompt
|
|
2945
|
+
test("prompt installs the silence watchdog unref'd and releases it at settlement", async (t) => {
|
|
2943
2946
|
const configHome = scopedDoubleEscCancelConfigHome(t);
|
|
2944
2947
|
writeFileSync(join(configHome, "animations.json"), '{"schema":"gentle-pi.animations/v1","policy":"quality"}');
|
|
2945
2948
|
const delays: number[] = [];
|
|
@@ -2957,7 +2960,7 @@ test("prompt uses the compact banner cadence and releases its unref timer at set
|
|
|
2957
2960
|
const editor = installedPrompt(ctx, ui, handlers);
|
|
2958
2961
|
assert.equal(active, 1);
|
|
2959
2962
|
for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
|
|
2960
|
-
assert.deepEqual(delays, [2000,
|
|
2963
|
+
assert.deepEqual(delays, [2000, 1000]);
|
|
2961
2964
|
assert.equal(unrefs, 2);
|
|
2962
2965
|
for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
|
|
2963
2966
|
assert.equal(active, 1);
|
|
@@ -2966,6 +2969,109 @@ test("prompt uses the compact banner cadence and releases its unref timer at set
|
|
|
2966
2969
|
assert.equal(active, 0);
|
|
2967
2970
|
});
|
|
2968
2971
|
|
|
2972
|
+
// gentle-shell#994: the working animation is render-driven. Frames the host
|
|
2973
|
+
// already paints while streaming advance the petal for free; a low-cadence
|
|
2974
|
+
// watchdog repaints only silent phases (long tool runs with no output).
|
|
2975
|
+
interface RecordedInterval { tick: () => void; delay: number; stopped: boolean }
|
|
2976
|
+
|
|
2977
|
+
function recordIntervals(t: TestContext): RecordedInterval[] {
|
|
2978
|
+
const intervals: RecordedInterval[] = [];
|
|
2979
|
+
t.mock.method(globalThis, "setInterval", (tick: () => void, delay: number) => {
|
|
2980
|
+
const timer: RecordedInterval = { tick, delay, stopped: false };
|
|
2981
|
+
intervals.push(timer);
|
|
2982
|
+
return { unref() {}, timer };
|
|
2983
|
+
});
|
|
2984
|
+
t.mock.method(globalThis, "clearInterval", (handle: { timer: RecordedInterval }) => { handle.timer.stopped = true; });
|
|
2985
|
+
return intervals;
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
function petalGlyphOf(editor: GentlePromptEditor): string {
|
|
2989
|
+
return stripAnsi(editor.render(60)[0]).match(/[✿❀❁✾]/)?.[0] ?? "";
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
test("streaming frames advance the working petal and the quality watchdog stays silent while renders flow", (t) => {
|
|
2993
|
+
const configHome = scopedDoubleEscCancelConfigHome(t);
|
|
2994
|
+
writeFileSync(join(configHome, "animations.json"), '{"schema":"gentle-pi.animations/v1","policy":"quality"}');
|
|
2995
|
+
const intervals = recordIntervals(t);
|
|
2996
|
+
const renders = t.mock.method(fakeTui, "requestRender", () => {});
|
|
2997
|
+
const { pi, handlers } = fakePi();
|
|
2998
|
+
gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome }, { now: () => 1_000_000 });
|
|
2999
|
+
const { ctx, ui } = fakeContext();
|
|
3000
|
+
const editor = installedPrompt(ctx, ui, handlers);
|
|
3001
|
+
for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
|
|
3002
|
+
const watchdog = intervals.find((entry) => entry.delay === 1000);
|
|
3003
|
+
assert.ok(watchdog, "working installs the 1000ms silence watchdog");
|
|
3004
|
+
const glyphs = [0, 1, 2, 3].map(() => petalGlyphOf(editor));
|
|
3005
|
+
assert.ok(new Set(glyphs).size > 1, `each streamed frame must advance the petal, got ${glyphs.join("")}`);
|
|
3006
|
+
const before = renders.mock.callCount();
|
|
3007
|
+
watchdog!.tick();
|
|
3008
|
+
assert.equal(renders.mock.callCount(), before, "renders are already flowing, so the watchdog must not force one");
|
|
3009
|
+
editor.dispose();
|
|
3010
|
+
assert.equal(watchdog!.stopped, true);
|
|
3011
|
+
});
|
|
3012
|
+
|
|
3013
|
+
test("silent working phases repaint only through the 1000ms watchdog window", (t) => {
|
|
3014
|
+
const configHome = scopedDoubleEscCancelConfigHome(t);
|
|
3015
|
+
writeFileSync(join(configHome, "animations.json"), '{"schema":"gentle-pi.animations/v1","policy":"quality"}');
|
|
3016
|
+
const intervals = recordIntervals(t);
|
|
3017
|
+
const renders = t.mock.method(fakeTui, "requestRender", () => {});
|
|
3018
|
+
let clock = 1_000_000;
|
|
3019
|
+
const { pi, handlers } = fakePi();
|
|
3020
|
+
gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome }, { now: () => clock });
|
|
3021
|
+
const { ctx, ui } = fakeContext();
|
|
3022
|
+
const editor = installedPrompt(ctx, ui, handlers);
|
|
3023
|
+
for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
|
|
3024
|
+
const watchdog = intervals.find((entry) => entry.delay === 1000);
|
|
3025
|
+
assert.ok(watchdog, "working installs the 1000ms silence watchdog");
|
|
3026
|
+
const baseline = petalGlyphOf(editor);
|
|
3027
|
+
const before = renders.mock.callCount();
|
|
3028
|
+
watchdog!.tick();
|
|
3029
|
+
assert.equal(renders.mock.callCount(), before, "no frame painted within the window, but the window has not elapsed yet");
|
|
3030
|
+
clock += 1000;
|
|
3031
|
+
watchdog!.tick();
|
|
3032
|
+
assert.equal(renders.mock.callCount(), before + 1, "silence past the window forces exactly one repaint");
|
|
3033
|
+
assert.notEqual(petalGlyphOf(editor), baseline, "the watchdog advanced the petal tick");
|
|
3034
|
+
const afterForced = renders.mock.callCount();
|
|
3035
|
+
watchdog!.tick();
|
|
3036
|
+
assert.equal(renders.mock.callCount(), afterForced, "the watchdog's own repaint counts as render traffic for the next window");
|
|
3037
|
+
editor.dispose();
|
|
3038
|
+
});
|
|
3039
|
+
|
|
3040
|
+
test("potato installs no working watchdog and the petal never advances", (t) => {
|
|
3041
|
+
const configHome = scopedDoubleEscCancelConfigHome(t);
|
|
3042
|
+
writeFileSync(join(configHome, "animations.json"), '{"schema":"gentle-pi.animations/v1","policy":"potato"}');
|
|
3043
|
+
const intervals = recordIntervals(t);
|
|
3044
|
+
const { pi, handlers } = fakePi();
|
|
3045
|
+
gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
|
|
3046
|
+
const { ctx, ui } = fakeContext();
|
|
3047
|
+
const editor = installedPrompt(ctx, ui, handlers);
|
|
3048
|
+
for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
|
|
3049
|
+
assert.ok(!intervals.some((entry) => entry.delay !== 2000), "potato must not animate: no watchdog interval at all");
|
|
3050
|
+
const glyphs = [0, 1, 2].map(() => petalGlyphOf(editor));
|
|
3051
|
+
assert.deepEqual(glyphs, ["✿", "✿", "✿"], "potato keeps the petal static");
|
|
3052
|
+
editor.dispose();
|
|
3053
|
+
});
|
|
3054
|
+
|
|
3055
|
+
test("the working watchdog is cleared on settlement and on dispose", (t) => {
|
|
3056
|
+
const configHome = scopedDoubleEscCancelConfigHome(t);
|
|
3057
|
+
writeFileSync(join(configHome, "animations.json"), '{"schema":"gentle-pi.animations/v1","policy":"quality"}');
|
|
3058
|
+
const intervals = recordIntervals(t);
|
|
3059
|
+
const { pi, handlers } = fakePi();
|
|
3060
|
+
gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
|
|
3061
|
+
const { ctx, ui } = fakeContext();
|
|
3062
|
+
const editor = installedPrompt(ctx, ui, handlers);
|
|
3063
|
+
for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
|
|
3064
|
+
const watchdog = intervals.filter((entry) => entry.delay === 1000).at(-1);
|
|
3065
|
+
assert.ok(watchdog && !watchdog.stopped, "working keeps the watchdog armed");
|
|
3066
|
+
for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
|
|
3067
|
+
assert.equal(watchdog!.stopped, true, "settlement must clear the watchdog");
|
|
3068
|
+
for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
|
|
3069
|
+
const rearmWatchdog = intervals.filter((entry) => entry.delay === 1000).at(-1);
|
|
3070
|
+
assert.ok(rearmWatchdog && rearmWatchdog !== watchdog && !rearmWatchdog.stopped, "the next turn re-arms a fresh watchdog");
|
|
3071
|
+
editor.dispose();
|
|
3072
|
+
assert.equal(rearmWatchdog!.stopped, true, "dispose must clear the watchdog");
|
|
3073
|
+
});
|
|
3074
|
+
|
|
2969
3075
|
// ---------------------------------------------------------------------------
|
|
2970
3076
|
// double-esc-cancel (issue #1163): opt-in, off by default. While the prompt
|
|
2971
3077
|
// is working and autocomplete is hidden, the first Esc is swallowed and the
|
|
@@ -4811,6 +4917,9 @@ test("new sessions do not inherit another session's worktrees", async () => {
|
|
|
4811
4917
|
const first=fakeContext();
|
|
4812
4918
|
await fire(h.handlers,"session_start",first.ctx);
|
|
4813
4919
|
await h.tools.get("session_worktree_register")!.execute("r",{path:"/linked"},undefined,undefined,first.ctx);
|
|
4920
|
+
// The registration kicks off its own async scan; settle it so its Git calls
|
|
4921
|
+
// cannot land after the next session clears the shared call log.
|
|
4922
|
+
await changesSettled();
|
|
4814
4923
|
await fire(h.handlers,"session_shutdown",first.ctx);
|
|
4815
4924
|
const next=fakeContext({entries:[...first.ctx.sessionManager.getEntries()]});
|
|
4816
4925
|
(next.ctx.sessionManager as any).getSessionId=()=>"new-session";
|
|
@@ -6376,3 +6485,69 @@ for (const kind of ["unset", "empty", "bundled"] as const) {
|
|
|
6376
6485
|
assert.equal(read(), "other (repo)", "the pinned profile exists only in that config home's store");
|
|
6377
6486
|
});
|
|
6378
6487
|
}
|
|
6488
|
+
|
|
6489
|
+
// gentle-shell#2464: a pasted image is an atomic chip in the draft and a real
|
|
6490
|
+
// image attachment on submit, never a temp-file path the model must `read`.
|
|
6491
|
+
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]);
|
|
6492
|
+
const chipKeybindings = { matches: (data: string, action: string) => action === "app.clipboard.pasteImage" && data === "\x1bv" };
|
|
6493
|
+
|
|
6494
|
+
async function pastedChipPrompt(model?: ExtensionContext["model"]) {
|
|
6495
|
+
const { pi, handlers } = fakePi();
|
|
6496
|
+
gentleShell(pi, {}, { readClipboardImage: async () => ({ bytes: CHIP_PNG, mimeType: "image/png" }) });
|
|
6497
|
+
const { ctx, ui } = fakeContext();
|
|
6498
|
+
if (model) (ctx as { model: unknown }).model = model;
|
|
6499
|
+
const editor = installedPrompt(ctx, ui, handlers, chipKeybindings);
|
|
6500
|
+
editor.onPasteImage = () => assert.fail("Pi's path-inserting paste must not run when the clipboard holds an image");
|
|
6501
|
+
editor.setText("what is this?");
|
|
6502
|
+
editor.handleInput("\x1bv");
|
|
6503
|
+
await settle();
|
|
6504
|
+
const input = (text: string, source = "interactive") => handlers.get("input")?.[0]?.({ type: "input", text, source }, ctx);
|
|
6505
|
+
// Pi's real order: the editor empties its draft, then onSubmit hands the
|
|
6506
|
+
// text to session.prompt(), which runs the input handlers.
|
|
6507
|
+
const submit = () => {
|
|
6508
|
+
let sent = "";
|
|
6509
|
+
editor.onSubmit = (text) => { sent = text; };
|
|
6510
|
+
editor.handleInput("\r");
|
|
6511
|
+
return input(sent);
|
|
6512
|
+
};
|
|
6513
|
+
return { editor, ui, input, submit };
|
|
6514
|
+
}
|
|
6515
|
+
|
|
6516
|
+
test("pasted image chips are submitted as real image attachments (gentle-shell#2464)", async () => {
|
|
6517
|
+
const { editor, ui, input, submit } = await pastedChipPrompt();
|
|
6518
|
+
assert.equal(editor.getText(), "what is this? [Image#1]");
|
|
6519
|
+
const strip = (ui.widgets.get("gentle-shell-image-attachments") as (tui: unknown, theme: unknown) => { render(width: number): string[] })(fakeTui, plainTheme);
|
|
6520
|
+
assert.equal(stripAnsi(strip.render(80)[0] ?? ""), "[Image#1] png · 2×1 · 24 B");
|
|
6521
|
+
assert.deepEqual(submit(), {
|
|
6522
|
+
action: "transform",
|
|
6523
|
+
text: "what is this? [Image#1]",
|
|
6524
|
+
images: [{ type: "image", data: Buffer.from(CHIP_PNG).toString("base64"), mimeType: "image/png" }],
|
|
6525
|
+
});
|
|
6526
|
+
// Sent images are released: recalling the same text later is ordinary text.
|
|
6527
|
+
assert.equal(editor.getText(), "");
|
|
6528
|
+
assert.deepEqual(strip.render(80), []);
|
|
6529
|
+
assert.deepEqual(input("what is this? [Image#1]"), { action: "continue" });
|
|
6530
|
+
});
|
|
6531
|
+
|
|
6532
|
+
test("a pasted image chip deleted before submit is never sent (gentle-shell#2464)", async () => {
|
|
6533
|
+
const { editor, submit } = await pastedChipPrompt();
|
|
6534
|
+
editor.handleInput("\x7f");
|
|
6535
|
+
assert.equal(editor.getText(), "what is this? ");
|
|
6536
|
+
assert.deepEqual(submit(), { action: "continue" });
|
|
6537
|
+
});
|
|
6538
|
+
|
|
6539
|
+
test("pasted image chips leave non-interactive input and deleted chips untouched (gentle-shell#2464)", async () => {
|
|
6540
|
+
const { input } = await pastedChipPrompt();
|
|
6541
|
+
assert.deepEqual(input("[Image#1] from an extension", "extension"), { action: "continue" });
|
|
6542
|
+
assert.deepEqual(input("the chip was deleted"), { action: "continue" });
|
|
6543
|
+
});
|
|
6544
|
+
|
|
6545
|
+
test("pasted image chips warn instead of sending images a text-only model rejects (gentle-shell#2464)", async () => {
|
|
6546
|
+
const { ui, input } = await pastedChipPrompt({ id: "text-only", provider: "acme", input: ["text"] } as unknown as ExtensionContext["model"]);
|
|
6547
|
+
assert.deepEqual(input("what is this? [Image#1]"), {
|
|
6548
|
+
action: "transform",
|
|
6549
|
+
text: "what is this? [Image#1 omitted: the current model does not accept images]",
|
|
6550
|
+
images: [],
|
|
6551
|
+
});
|
|
6552
|
+
assert.ok(ui.notices.some((notice) => /1 pasted image was not sent: the current model does not accept images/.test(notice)));
|
|
6553
|
+
});
|
|
@@ -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
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { describeAttachment, formatBytes, ImageAttachments, imageToken } from "../lib/image-attachments.ts";
|
|
4
|
+
|
|
5
|
+
// A 2×1 PNG header: signature, IHDR length/type, width 2, height 1.
|
|
6
|
+
const PNG_2X1 = new Uint8Array([
|
|
7
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
8
|
+
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
|
9
|
+
0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01,
|
|
10
|
+
0x08, 0x06, 0x00, 0x00, 0x00,
|
|
11
|
+
]);
|
|
12
|
+
const png = () => ({ bytes: PNG_2X1, mimeType: "image/png" });
|
|
13
|
+
const base64 = Buffer.from(PNG_2X1).toString("base64");
|
|
14
|
+
|
|
15
|
+
const graphemes = (text: string) => [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text)];
|
|
16
|
+
|
|
17
|
+
test("pasted images get sequential whitespace-free chips and keep one in-memory copy", () => {
|
|
18
|
+
const attachments = new ImageAttachments();
|
|
19
|
+
const first = attachments.add(png());
|
|
20
|
+
const second = attachments.add(png());
|
|
21
|
+
assert.equal(first.token, "[Image#1]");
|
|
22
|
+
assert.equal(second.token, imageToken(2));
|
|
23
|
+
assert.doesNotMatch(first.token, /\s/);
|
|
24
|
+
assert.deepEqual(first.dimensions, { widthPx: 2, heightPx: 1 });
|
|
25
|
+
assert.equal(first.base64, base64);
|
|
26
|
+
assert.equal(first.byteLength, PNG_2X1.length);
|
|
27
|
+
assert.equal("bytes" in first, false);
|
|
28
|
+
assert.equal(attachments.size, 2);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("referenced chips follow draft order, appear once, and ignore unregistered tokens", () => {
|
|
32
|
+
const attachments = new ImageAttachments();
|
|
33
|
+
attachments.add(png());
|
|
34
|
+
attachments.add(png());
|
|
35
|
+
const ids = attachments.referenced("see [Image#2] then [Image#9] and [Image#1] [Image#2]").map((attachment) => attachment.id);
|
|
36
|
+
assert.deepEqual(ids, [2, 1]);
|
|
37
|
+
assert.deepEqual(attachments.referenced("the chip was deleted"), []);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("submitting a draft attaches every chip still in the text as a real image", () => {
|
|
41
|
+
const attachments = new ImageAttachments();
|
|
42
|
+
attachments.add(png());
|
|
43
|
+
attachments.add(png());
|
|
44
|
+
const submitted = attachments.submit("what changed in [Image#2]?", true);
|
|
45
|
+
assert.deepEqual(submitted, {
|
|
46
|
+
text: "what changed in [Image#2]?",
|
|
47
|
+
images: [{ type: "image", data: base64, mimeType: "image/png" }],
|
|
48
|
+
omitted: 0,
|
|
49
|
+
});
|
|
50
|
+
// The deleted chip (#1) was never sent; both are released once the editor is empty.
|
|
51
|
+
assert.equal(attachments.size, 0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("numbers never repeat, so a chip recalled from history cannot bind to a newer image", () => {
|
|
55
|
+
const attachments = new ImageAttachments();
|
|
56
|
+
attachments.add(png());
|
|
57
|
+
attachments.submit("describe [Image#1]", true);
|
|
58
|
+
const next = attachments.add(png());
|
|
59
|
+
assert.equal(next.token, "[Image#2]");
|
|
60
|
+
// Up-arrow recall of the earlier prompt: its chip is no longer registered.
|
|
61
|
+
assert.deepEqual(attachments.referenced("describe [Image#1]"), []);
|
|
62
|
+
assert.deepEqual(attachments.spans("describe [Image#1]"), []);
|
|
63
|
+
attachments.clear();
|
|
64
|
+
assert.equal(attachments.add(png()).token, "[Image#3]");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a submit Pi runs late keeps the chips of the draft already in the editor", () => {
|
|
68
|
+
const attachments = new ImageAttachments();
|
|
69
|
+
attachments.add(png());
|
|
70
|
+
attachments.add(png());
|
|
71
|
+
attachments.add(png());
|
|
72
|
+
// Queued during compaction: `[Image#1]` is sent after `[Image#2]` was pasted
|
|
73
|
+
// into a new draft; `[Image#3]` was pasted and deleted again.
|
|
74
|
+
const submitted = attachments.submit("look [Image#1]", true, "and now [Image#2]");
|
|
75
|
+
assert.equal(submitted?.images.length, 1);
|
|
76
|
+
assert.deepEqual(attachments.referenced("and now [Image#2]").map((attachment) => attachment.id), [2]);
|
|
77
|
+
assert.equal(attachments.size, 1);
|
|
78
|
+
assert.equal(attachments.submit("and now [Image#2]", true)?.images.length, 1);
|
|
79
|
+
assert.equal(attachments.size, 0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("a model without image input gets a plain note instead of a silent drop", () => {
|
|
83
|
+
const attachments = new ImageAttachments();
|
|
84
|
+
attachments.add(png());
|
|
85
|
+
const submitted = attachments.submit("look at [Image#1] and [Image#4]", false);
|
|
86
|
+
assert.deepEqual(submitted, {
|
|
87
|
+
text: "look at [Image#1 omitted: the current model does not accept images] and [Image#4]",
|
|
88
|
+
images: [],
|
|
89
|
+
omitted: 1,
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("drafts without registered chips pass through untouched and release stale images", () => {
|
|
94
|
+
const attachments = new ImageAttachments();
|
|
95
|
+
assert.equal(attachments.submit("plain [Image#1] text", true), undefined);
|
|
96
|
+
attachments.add(png());
|
|
97
|
+
assert.equal(attachments.submit("chip deleted before submit", true), undefined);
|
|
98
|
+
assert.equal(attachments.size, 0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("a registered chip is one segment, so one Backspace removes it", () => {
|
|
102
|
+
const attachments = new ImageAttachments();
|
|
103
|
+
const text = "a [Image#1] b";
|
|
104
|
+
// No registered chip yet: the host segmentation is returned as-is.
|
|
105
|
+
const native = graphemes(text);
|
|
106
|
+
assert.equal(attachments.segment(text, native), native);
|
|
107
|
+
attachments.add(png());
|
|
108
|
+
const merged = [...attachments.segment(text, graphemes(text))].map((segment) => segment.segment);
|
|
109
|
+
assert.deepEqual(merged, ["a", " ", "[Image#1]", " ", "b"]);
|
|
110
|
+
assert.deepEqual(attachments.spans(text), [{ start: 2, end: 11 }]);
|
|
111
|
+
const unregistered = "x [Image#7]";
|
|
112
|
+
assert.equal([...attachments.segment(unregistered, graphemes(unregistered))].length, unregistered.length);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("chip descriptions name format, pixel size and weight", () => {
|
|
116
|
+
assert.equal(describeAttachment({ mimeType: "image/png", byteLength: 86_016, dimensions: { widthPx: 1280, heightPx: 720 } }), "png · 1280×720 · 84 KB");
|
|
117
|
+
assert.equal(describeAttachment({ mimeType: "image/jpeg", byteLength: 512 }), "jpg · 512 B");
|
|
118
|
+
assert.equal(formatBytes(3 * 1024 * 1024), "3.0 MB");
|
|
119
|
+
});
|