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.
Files changed (208) hide show
  1. package/README.md +8 -5
  2. package/assets/agents/gentle-ai-worker.md +1 -1
  3. package/assets/design-canvas/canvas.css +648 -0
  4. package/assets/design-canvas/canvas.js +1497 -0
  5. package/assets/design-canvas/index.html +88 -0
  6. package/assets/design-canvas/inspector.js +290 -0
  7. package/assets/orchestrator-delegation-reference.md +2 -2
  8. package/assets/orchestrator-verification.md +1 -1
  9. package/assets/orchestrator.md +2 -2
  10. package/bin/engram-guard +1 -1
  11. package/bin/gentle-shell.mjs +101 -151
  12. package/docs/bundled-install.md +47 -42
  13. package/docs/delegated-verification.md +6 -0
  14. package/docs/gentle-agents-activity.md +5 -1
  15. package/docs/gentle-music.md +61 -0
  16. package/docs/gentle-shell.md +77 -7
  17. package/docs/install-wizard.md +30 -6
  18. package/docs/permission-modes.md +7 -2
  19. package/docs/readme-reference.md +19 -15
  20. package/docs/session-profile-format.md +26 -0
  21. package/docs/voice.md +259 -0
  22. package/docs/yolo-mode.md +17 -2
  23. package/extensions/ask-user-choice.ts +4 -1
  24. package/extensions/gentle-agents.ts +173 -38
  25. package/extensions/gentle-ai.ts +214 -69
  26. package/extensions/gentle-design.ts +158 -0
  27. package/extensions/gentle-music.ts +289 -0
  28. package/extensions/gentle-shell.ts +332 -73
  29. package/extensions/gentle-voice.ts +11 -0
  30. package/extensions/session-scratch.ts +41 -22
  31. package/extensions/skill-registry.ts +63 -8
  32. package/lib/agent-profiles.ts +26 -0
  33. package/lib/agents-config.ts +35 -0
  34. package/lib/agents-protocol.ts +57 -1
  35. package/lib/agents-runner.ts +52 -3
  36. package/lib/agents-session-transport.ts +101 -6
  37. package/lib/agents-widget.ts +14 -1
  38. package/lib/auto-permission-memory.ts +48 -18
  39. package/lib/auto-permission-runtime.ts +21 -6
  40. package/lib/clipboard-image.ts +147 -0
  41. package/lib/command-palette-catalog.ts +2 -0
  42. package/lib/commit-slice-signal.ts +1 -1
  43. package/lib/context-backstop.ts +36 -0
  44. package/lib/design-canvas-server.ts +947 -0
  45. package/lib/design-comments.ts +437 -0
  46. package/lib/design-handoffs.ts +313 -0
  47. package/lib/design-open-browser.ts +58 -0
  48. package/lib/design-sources.ts +120 -0
  49. package/lib/external-editor.ts +31 -0
  50. package/lib/gentle-design.ts +513 -0
  51. package/lib/gentle-shell-bundled.ts +123 -49
  52. package/lib/gentle-shell-launcher.ts +9 -4
  53. package/lib/gentle-shell-startup-update.ts +61 -9
  54. package/lib/gentle-shell-uninstall.ts +33 -1
  55. package/lib/image-attachments.ts +150 -0
  56. package/lib/inprocess-reviewer.ts +33 -2
  57. package/lib/nan-provider.ts +3 -0
  58. package/lib/native-review-cli.ts +11 -4
  59. package/lib/notification-audio-native.ts +149 -13
  60. package/lib/notification-audio.ts +4 -0
  61. package/lib/notification-pulse-worker.ts +82 -18
  62. package/lib/notification-service.ts +5 -1
  63. package/lib/orchestrator-presence.ts +78 -8
  64. package/lib/orchestrator-scope.ts +10 -0
  65. package/lib/orchestrator-state.ts +11 -3
  66. package/lib/permission-mode-policy.ts +2 -2
  67. package/lib/review-host-relay.ts +11 -2
  68. package/lib/review-reminder-receipt.ts +24 -1
  69. package/lib/session-profile-append-controller.ts +713 -0
  70. package/lib/session-profile-disk-reader.ts +182 -0
  71. package/lib/session-profile-persistence.ts +15 -7
  72. package/lib/session-scratch.ts +52 -3
  73. package/lib/session-worktree-registry.ts +7 -0
  74. package/lib/shell-bar.ts +4 -3
  75. package/lib/shell-changes.ts +81 -5
  76. package/lib/shell-prompt.ts +4 -4
  77. package/lib/shell-sidebar-layout.ts +26 -6
  78. package/lib/shell-todo.ts +1 -1
  79. package/lib/verifier-admission.ts +5 -4
  80. package/lib/vim-editor-adapter.ts +11 -8
  81. package/lib/voice-artifacts.ts +188 -0
  82. package/lib/voice-command.ts +576 -0
  83. package/lib/voice-install.ts +455 -0
  84. package/lib/voice-platform.ts +99 -0
  85. package/lib/voice-push-to-talk.ts +262 -0
  86. package/lib/voice-session.ts +460 -0
  87. package/lib/voice-settings.ts +50 -0
  88. package/lib/voice-ui.ts +158 -0
  89. package/lib/yolo-session-policy.ts +58 -22
  90. package/package.json +1 -1
  91. package/runtime/gentle-shell-bundled.mjs +122 -48
  92. package/runtime/gentle-shell-launcher.mjs +9 -4
  93. package/runtime/gentle-shell-startup-update.mjs +61 -9
  94. package/runtime/gentle-shell-uninstall.mjs +33 -1
  95. package/runtime/native-review-cli.mjs +11 -4
  96. package/scripts/bundled-engram-migration.mjs +0 -6
  97. package/scripts/gentle-ai-installer.mjs +10 -10
  98. package/scripts/installer-downloads.mjs +7 -7
  99. package/scripts/installer-preflight.mjs +5 -3
  100. package/scripts/installer-windows.mjs +37 -7
  101. package/scripts/main-channel.mjs +91 -17
  102. package/scripts/pi-update.mjs +34 -6
  103. package/scripts/verify-package-files.mjs +26 -2
  104. package/scripts/voice-worker.cjs +476 -0
  105. package/skills/design/SKILL.md +110 -0
  106. package/tests/agent-profiles.test.ts +24 -0
  107. package/tests/agents-config.test.ts +61 -0
  108. package/tests/agents-fake-child.ts +8 -1
  109. package/tests/agents-protocol.test.ts +32 -0
  110. package/tests/agents-runner.test.ts +226 -5
  111. package/tests/agents-session-transport.test.ts +53 -3
  112. package/tests/ask-user-choice.test.ts +30 -0
  113. package/tests/auto-mode.test.ts +17 -0
  114. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  115. package/tests/auto-permission-runtime.test.ts +85 -1
  116. package/tests/autonomous-guard.test.ts +20 -0
  117. package/tests/bundled-install.test.ts +3 -3
  118. package/tests/clipboard-image.test.ts +114 -0
  119. package/tests/command-palette.test.ts +1 -1
  120. package/tests/context-backstop.test.ts +70 -4
  121. package/tests/delegate-for-reason-contract.test.ts +2 -1
  122. package/tests/design-canvas-assets.test.ts +710 -0
  123. package/tests/design-canvas-server.test.ts +1096 -0
  124. package/tests/design-comments.test.ts +201 -0
  125. package/tests/design-handoffs.test.ts +282 -0
  126. package/tests/design-open-browser.test.ts +70 -0
  127. package/tests/design-sources.test.ts +132 -0
  128. package/tests/engram-safety.test.ts +132 -57
  129. package/tests/external-editor.test.ts +84 -0
  130. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
  131. package/tests/gentle-agents.test.ts +365 -20
  132. package/tests/gentle-ai-binary.test.ts +1 -1
  133. package/tests/gentle-ai-installer.test.ts +51 -51
  134. package/tests/gentle-ai.test.ts +292 -13
  135. package/tests/gentle-design.test.ts +1313 -0
  136. package/tests/gentle-music.test.ts +335 -0
  137. package/tests/gentle-shell-bundled.test.ts +301 -248
  138. package/tests/gentle-shell-launcher.test.ts +28 -2
  139. package/tests/gentle-shell-startup-update.test.ts +77 -1
  140. package/tests/gentle-shell-uninstall.test.ts +59 -0
  141. package/tests/gentle-shell.test.ts +183 -8
  142. package/tests/gentle-todo.test.ts +11 -0
  143. package/tests/image-attachments.test.ts +119 -0
  144. package/tests/image-paste-chips.test.ts +236 -0
  145. package/tests/inprocess-reviewer.test.ts +55 -2
  146. package/tests/installer-bundles.test.ts +12 -6
  147. package/tests/installer-posix-bootstrap.test.ts +8 -8
  148. package/tests/installer-preflight.test.ts +16 -0
  149. package/tests/installer-server.test.ts +2 -2
  150. package/tests/installer-windows-bootstrap.test.ts +61 -3
  151. package/tests/installer-windows-diagnostics.test.ts +111 -0
  152. package/tests/main-channel-upgrade.test.ts +232 -2
  153. package/tests/main-channel.test.ts +140 -21
  154. package/tests/nan-provider.test.ts +36 -0
  155. package/tests/native-review-capability-contract.test.ts +11 -4
  156. package/tests/native-review-parity.test.ts +1 -1
  157. package/tests/notification-audio-native.test.ts +128 -9
  158. package/tests/notification-customize.test.ts +17 -5
  159. package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
  160. package/tests/orchestrator-budget.test.ts +5 -2
  161. package/tests/orchestrator-presence.test.ts +100 -2
  162. package/tests/orchestrator-rdd-ownership.test.ts +2 -2
  163. package/tests/orchestrator-scope.test.ts +14 -0
  164. package/tests/orchestrator-state.test.ts +29 -0
  165. package/tests/package-files.test.ts +34 -0
  166. package/tests/package-manifest.test.ts +6 -6
  167. package/tests/permission-mode-policy.test.ts +10 -1
  168. package/tests/pi-update.test.ts +99 -4
  169. package/tests/rdd-aware-verification-contract.test.ts +1 -1
  170. package/tests/review-host-relay-routing.test.ts +88 -1
  171. package/tests/review-host-relay.test.ts +9 -2
  172. package/tests/review-json-arguments.test.ts +20 -3
  173. package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
  174. package/tests/review-relay-transport-agent.test.ts +26 -0
  175. package/tests/review-reminder-receipt.test.ts +31 -1
  176. package/tests/session-changes.test.ts +2 -1
  177. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  178. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  179. package/tests/session-profile-append-controller.test.ts +731 -0
  180. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  181. package/tests/session-profile-disk-reader.test.ts +311 -0
  182. package/tests/session-scratch.test.ts +68 -2
  183. package/tests/session-worktree-registry.test.ts +13 -1
  184. package/tests/shell-bar.test.ts +14 -2
  185. package/tests/shell-changes.test.ts +148 -1
  186. package/tests/shell-prompt.test.ts +3 -3
  187. package/tests/shell-render-cache.test.ts +134 -1
  188. package/tests/shell-sidebar-layout.test.ts +50 -0
  189. package/tests/short-check-routing-contract.test.ts +1 -1
  190. package/tests/skill-collision-prefixes.test.ts +3 -1
  191. package/tests/skill-registry-watcher.test.ts +17 -5
  192. package/tests/skill-registry.test.ts +47 -3
  193. package/tests/task-size-routing-contract.test.ts +1 -1
  194. package/tests/test-strength-contract.test.ts +1 -1
  195. package/tests/verifier-admission.test.ts +18 -1
  196. package/tests/voice-command.test.ts +773 -0
  197. package/tests/voice-install.test.ts +323 -0
  198. package/tests/voice-platform.test.ts +176 -0
  199. package/tests/voice-push-to-talk.test.ts +444 -0
  200. package/tests/voice-session.test.ts +434 -0
  201. package/tests/voice-settings.test.ts +49 -0
  202. package/tests/voice-ui.test.ts +97 -0
  203. package/tests/voice-worker.test.ts +445 -0
  204. package/tests/weak-model-economy-prompt.test.ts +32 -0
  205. package/tests/windows-hidden-processes.test.ts +25 -2
  206. package/tests/yolo-customize.test.ts +5 -1
  207. package/tests/yolo-mode.test.ts +1 -1
  208. 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 escapes an inner double quote", () => {
1894
- assert.equal(quoteForCmdExe('say "hi" now'), '"say \\"hi\\" now"');
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]), /^╭─ ✿ working… ─+╮$/);
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 pulse", async (t) => {
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, 80]);
2845
+ assert.deepEqual(delays, [2000, 1000]);
2843
2846
  await command.handler("performance", ctx);
2844
- assert.deepEqual(delays, [2000, 80, 1000]);
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, 80, 1000, 80]);
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 uses the compact banner cadence and releases its unref timer at settlement", async (t) => {
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, 80]);
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
+ });