gentle-pi 4.2.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 (334) hide show
  1. package/README.md +340 -138
  2. package/assets/agents/gentle-ai-explore.md +1 -0
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -1
  5. package/assets/design-canvas/canvas.css +648 -0
  6. package/assets/design-canvas/canvas.js +1497 -0
  7. package/assets/design-canvas/index.html +88 -0
  8. package/assets/design-canvas/inspector.js +290 -0
  9. package/assets/orchestrator-delegation-reference.md +163 -0
  10. package/assets/orchestrator-delegation.md +25 -176
  11. package/assets/orchestrator-tracking.md +1 -1
  12. package/assets/orchestrator-verification.md +14 -8
  13. package/assets/orchestrator-writer.md +5 -1
  14. package/assets/orchestrator.md +11 -11
  15. package/bin/engram-guard +1 -1
  16. package/bin/gentle-shell.mjs +237 -139
  17. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  18. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
  19. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
  20. package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
  21. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
  22. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
  23. package/docs/assets/diagrams/agent-orchestration.svg +54 -15
  24. package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
  25. package/docs/assets/diagrams/measured-routing.svg +49 -0
  26. package/docs/assets/diagrams/native-review.svg +52 -12
  27. package/docs/assets/diagrams/odd-workflow.svg +128 -66
  28. package/docs/assets/diagrams/permission-modes.svg +51 -0
  29. package/docs/assets/features/audio-notifications.png +0 -0
  30. package/docs/assets/features/command-palette.png +0 -0
  31. package/docs/bundled-install.md +65 -30
  32. package/docs/delegated-verification.md +16 -8
  33. package/docs/gentle-agents-activity.md +16 -6
  34. package/docs/gentle-music.md +61 -0
  35. package/docs/gentle-shell-uninstall.md +8 -1
  36. package/docs/gentle-shell.md +80 -9
  37. package/docs/install-wizard.md +37 -6
  38. package/docs/permission-modes.md +8 -3
  39. package/docs/readme-reference.md +120 -28
  40. package/docs/session-profile-format.md +26 -0
  41. package/docs/voice.md +259 -0
  42. package/docs/yolo-mode.md +38 -10
  43. package/extensions/ask-user-choice.ts +4 -1
  44. package/extensions/codegraph-tools.ts +68 -6
  45. package/extensions/gentle-agents.ts +270 -48
  46. package/extensions/gentle-ai.ts +490 -113
  47. package/extensions/gentle-design.ts +158 -0
  48. package/extensions/gentle-herdr-activity.ts +2 -3
  49. package/extensions/gentle-music.ts +289 -0
  50. package/extensions/gentle-shell.ts +359 -85
  51. package/extensions/gentle-todo.ts +43 -9
  52. package/extensions/gentle-voice.ts +11 -0
  53. package/extensions/quiet-tools.ts +1 -1
  54. package/extensions/session-scratch.ts +384 -0
  55. package/extensions/skill-registry.ts +63 -8
  56. package/extensions/startup-banner.ts +2 -3
  57. package/lib/agent-home.ts +8 -3
  58. package/lib/agent-profiles.ts +26 -0
  59. package/lib/agents-config.ts +35 -0
  60. package/lib/agents-protocol.ts +60 -1
  61. package/lib/agents-runner.ts +107 -9
  62. package/lib/agents-session-transport.ts +101 -6
  63. package/lib/agents-view.ts +3 -1
  64. package/lib/agents-widget.ts +14 -1
  65. package/lib/auto-permission-memory.ts +48 -18
  66. package/lib/auto-permission-runtime.ts +21 -6
  67. package/lib/bounded-writer-admission.ts +51 -0
  68. package/lib/clipboard-image.ts +147 -0
  69. package/lib/command-palette-catalog.ts +2 -4
  70. package/lib/commit-slice-signal.ts +100 -0
  71. package/lib/context-backstop.ts +120 -0
  72. package/lib/delivered-test-lint.ts +80 -0
  73. package/lib/design-canvas-server.ts +947 -0
  74. package/lib/design-comments.ts +437 -0
  75. package/lib/design-handoffs.ts +313 -0
  76. package/lib/design-open-browser.ts +58 -0
  77. package/lib/design-sources.ts +120 -0
  78. package/lib/destructive-command-guard.ts +120 -9
  79. package/lib/external-editor.ts +31 -0
  80. package/lib/gauge-theme.ts +146 -0
  81. package/lib/gentle-ai-binary.ts +28 -7
  82. package/lib/gentle-design.ts +513 -0
  83. package/lib/gentle-shell-bundled.ts +146 -36
  84. package/lib/gentle-shell-import-pi.ts +233 -0
  85. package/lib/gentle-shell-launcher.ts +154 -15
  86. package/lib/gentle-shell-startup-update.ts +61 -9
  87. package/lib/gentle-shell-uninstall.ts +51 -2
  88. package/lib/herdr-activity.ts +24 -0
  89. package/lib/image-attachments.ts +150 -0
  90. package/lib/inprocess-reviewer.ts +33 -2
  91. package/lib/nan-provider.ts +3 -0
  92. package/lib/native-review-cli.ts +20 -5
  93. package/lib/notification-audio-native.ts +149 -13
  94. package/lib/notification-audio.ts +4 -0
  95. package/lib/notification-pulse-worker.ts +82 -18
  96. package/lib/notification-service.ts +5 -1
  97. package/lib/orchestrator-discovery.ts +5 -1
  98. package/lib/orchestrator-presence.ts +87 -8
  99. package/lib/orchestrator-scope.ts +31 -3
  100. package/lib/orchestrator-state.ts +11 -3
  101. package/lib/permission-mode-policy.ts +2 -2
  102. package/lib/provider-contract-bundle.ts +99 -53
  103. package/lib/review-candidate-view-owner.ts +29 -19
  104. package/lib/review-candidate-view.ts +36 -2
  105. package/lib/review-host-relay.ts +11 -2
  106. package/lib/review-reminder-receipt.ts +24 -1
  107. package/lib/review-risk-assessment.ts +43 -38
  108. package/lib/review-session-standing-permission.ts +3 -1
  109. package/lib/session-profile-append-controller.ts +713 -0
  110. package/lib/session-profile-disk-reader.ts +182 -0
  111. package/lib/session-profile-freeze.ts +30 -1
  112. package/lib/session-profile-persistence.ts +15 -7
  113. package/lib/session-scratch.ts +523 -0
  114. package/lib/session-worktree-registry.ts +7 -0
  115. package/lib/shell-bar.ts +29 -28
  116. package/lib/shell-changes-view.ts +80 -13
  117. package/lib/shell-changes.ts +81 -5
  118. package/lib/shell-gauge.ts +49 -6
  119. package/lib/shell-prompt.ts +4 -4
  120. package/lib/shell-sidebar-layout.ts +26 -6
  121. package/lib/shell-todo.ts +24 -5
  122. package/lib/shell-usage-view.ts +5 -1
  123. package/lib/shell-usage.ts +7 -7
  124. package/lib/verifier-admission.ts +30 -0
  125. package/lib/vim-editor-adapter.ts +11 -8
  126. package/lib/voice-artifacts.ts +188 -0
  127. package/lib/voice-command.ts +576 -0
  128. package/lib/voice-install.ts +455 -0
  129. package/lib/voice-platform.ts +99 -0
  130. package/lib/voice-push-to-talk.ts +262 -0
  131. package/lib/voice-session.ts +460 -0
  132. package/lib/voice-settings.ts +50 -0
  133. package/lib/voice-ui.ts +158 -0
  134. package/lib/yolo-session-policy.ts +58 -22
  135. package/lib/yolo-temp-scratch.ts +69 -0
  136. package/package.json +1 -2
  137. package/runtime/gentle-ai-binary.mjs +28 -7
  138. package/runtime/gentle-shell-bundled.mjs +145 -35
  139. package/runtime/gentle-shell-import-pi.mjs +234 -0
  140. package/runtime/gentle-shell-launcher.mjs +152 -13
  141. package/runtime/gentle-shell-startup-update.mjs +61 -9
  142. package/runtime/gentle-shell-uninstall.mjs +51 -2
  143. package/runtime/native-review-cli.mjs +19 -4
  144. package/runtime/review-risk-assessment.mjs +43 -38
  145. package/scripts/build-runtime-modules.mjs +1 -0
  146. package/scripts/bundled-engram-migration.mjs +119 -0
  147. package/scripts/gentle-ai-installer.mjs +10 -10
  148. package/scripts/installer-downloads.mjs +7 -7
  149. package/scripts/installer-preflight.mjs +5 -3
  150. package/scripts/installer-server.mjs +14 -4
  151. package/scripts/installer-windows.mjs +37 -7
  152. package/scripts/main-channel.mjs +94 -19
  153. package/scripts/pi-update.mjs +34 -6
  154. package/scripts/run-test-suite.mjs +5 -0
  155. package/scripts/verify-package-files.mjs +43 -13
  156. package/scripts/voice-worker.cjs +476 -0
  157. package/skills/design/SKILL.md +110 -0
  158. package/skills/gentle-ai/SKILL.md +3 -3
  159. package/skills/work-unit-commits/SKILL.md +5 -3
  160. package/tests/agent-profiles.test.ts +24 -0
  161. package/tests/agent-risk-escalation.test.ts +37 -10
  162. package/tests/agents-config.test.ts +61 -0
  163. package/tests/agents-fake-child.ts +8 -1
  164. package/tests/agents-protocol.test.ts +32 -0
  165. package/tests/agents-runner.test.ts +485 -7
  166. package/tests/agents-session-transport.test.ts +53 -3
  167. package/tests/ask-user-choice.test.ts +30 -0
  168. package/tests/auto-mode.test.ts +17 -0
  169. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  170. package/tests/auto-permission-runtime.test.ts +85 -1
  171. package/tests/autonomous-guard.test.ts +142 -0
  172. package/tests/bounded-writer-admission.test.ts +11 -3
  173. package/tests/bundled-install.test.ts +3 -3
  174. package/tests/card-content-policy.test.ts +19 -9
  175. package/tests/child-safety.test.ts +39 -0
  176. package/tests/clipboard-image.test.ts +114 -0
  177. package/tests/codegraph-tools.test.ts +123 -2
  178. package/tests/command-palette.test.ts +15 -5
  179. package/tests/commit-slice-signal.test.ts +162 -0
  180. package/tests/context-backstop.test.ts +225 -0
  181. package/tests/delegate-for-reason-contract.test.ts +92 -24
  182. package/tests/delivered-test-lint.test.ts +105 -0
  183. package/tests/design-canvas-assets.test.ts +710 -0
  184. package/tests/design-canvas-server.test.ts +1096 -0
  185. package/tests/design-comments.test.ts +201 -0
  186. package/tests/design-handoffs.test.ts +282 -0
  187. package/tests/design-open-browser.test.ts +70 -0
  188. package/tests/design-sources.test.ts +132 -0
  189. package/tests/destructive-command-guard.test.ts +85 -1
  190. package/tests/engram-safety.test.ts +132 -57
  191. package/tests/external-editor.test.ts +84 -0
  192. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
  193. package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
  194. package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
  195. package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
  196. package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
  197. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
  198. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
  199. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
  200. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
  201. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
  202. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
  203. package/tests/gauge-theme.test.ts +120 -0
  204. package/tests/gentle-agents.test.ts +645 -21
  205. package/tests/gentle-ai-binary.test.ts +1 -1
  206. package/tests/gentle-ai-child-guards.test.ts +61 -0
  207. package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
  208. package/tests/gentle-ai-installer.test.ts +51 -51
  209. package/tests/gentle-ai.test.ts +292 -13
  210. package/tests/gentle-design.test.ts +1313 -0
  211. package/tests/gentle-music.test.ts +335 -0
  212. package/tests/gentle-shell-bin.test.ts +163 -0
  213. package/tests/gentle-shell-bundled.test.ts +392 -176
  214. package/tests/gentle-shell-import-pi.test.ts +262 -0
  215. package/tests/gentle-shell-launcher.test.ts +107 -2
  216. package/tests/gentle-shell-startup-update.test.ts +77 -1
  217. package/tests/gentle-shell-uninstall.test.ts +89 -0
  218. package/tests/gentle-shell.test.ts +225 -17
  219. package/tests/gentle-todo.test.ts +190 -0
  220. package/tests/headless-delivery-question.test.ts +58 -0
  221. package/tests/herdr-activity.test.ts +34 -1
  222. package/tests/image-attachments.test.ts +119 -0
  223. package/tests/image-paste-chips.test.ts +236 -0
  224. package/tests/inprocess-reviewer.test.ts +55 -2
  225. package/tests/installer-bundles.test.ts +12 -6
  226. package/tests/installer-posix-bootstrap.test.ts +8 -8
  227. package/tests/installer-preflight.test.ts +16 -0
  228. package/tests/installer-server.test.ts +10 -2
  229. package/tests/installer-windows-bootstrap.test.ts +94 -7
  230. package/tests/installer-windows-diagnostics.test.ts +111 -0
  231. package/tests/main-channel-upgrade.test.ts +232 -2
  232. package/tests/main-channel.test.ts +140 -21
  233. package/tests/nan-provider.test.ts +36 -0
  234. package/tests/native-review-capability-contract.test.ts +17 -4
  235. package/tests/native-review-parity.test.ts +23 -2
  236. package/tests/no-self-pause-contract.test.ts +5 -2
  237. package/tests/notification-audio-native.test.ts +128 -9
  238. package/tests/notification-customize.test.ts +17 -5
  239. package/tests/odd-integration.test.ts +5 -1
  240. package/tests/odd-routing-canonical-ratchet.test.ts +128 -162
  241. package/tests/odd-routing-contract.test.ts +115 -30
  242. package/tests/orchestrator-budget.test.ts +22 -8
  243. package/tests/orchestrator-discovery.test.ts +29 -0
  244. package/tests/orchestrator-presence.test.ts +100 -2
  245. package/tests/orchestrator-rdd-ownership.test.ts +3 -1
  246. package/tests/orchestrator-scope.test.ts +80 -0
  247. package/tests/orchestrator-state.test.ts +29 -0
  248. package/tests/orchestrator-status-publication-contract.test.ts +12 -8
  249. package/tests/package-files.test.ts +34 -0
  250. package/tests/package-manifest.test.ts +24 -20
  251. package/tests/permission-mode-policy.test.ts +10 -1
  252. package/tests/pi-update-launcher.test.ts +21 -2
  253. package/tests/pi-update.test.ts +99 -4
  254. package/tests/primary-session-prompt.test.ts +101 -0
  255. package/tests/provider-contract-bundle.test.ts +118 -0
  256. package/tests/provider-contract-mirror.test.ts +24 -0
  257. package/tests/quiet-tool-rendering.test.ts +23 -0
  258. package/tests/rdd-aware-verification-contract.test.ts +29 -11
  259. package/tests/rdd-status-line.test.ts +79 -0
  260. package/tests/review-agent-end-preflight.test.ts +6 -0
  261. package/tests/review-candidate-view.test.ts +49 -0
  262. package/tests/review-contract-prompt.test.ts +5 -2
  263. package/tests/review-controller-native-routing.test.ts +14 -1
  264. package/tests/review-controller.test.ts +1 -1
  265. package/tests/review-host-relay-routing.test.ts +147 -3
  266. package/tests/review-host-relay.test.ts +9 -2
  267. package/tests/review-json-arguments.test.ts +20 -3
  268. package/tests/review-loadout-rdd-off-sdk.test.ts +258 -0
  269. package/tests/review-owner-identity.test.ts +132 -0
  270. package/tests/review-relay-transport-agent.test.ts +26 -0
  271. package/tests/review-reminder-receipt.test.ts +31 -1
  272. package/tests/review-risk-assessment.test.ts +163 -76
  273. package/tests/review-session-standing-permission-controller.test.ts +14 -1
  274. package/tests/review-session-standing-permission.test.ts +27 -1
  275. package/tests/run-test-suite.test.ts +15 -0
  276. package/tests/runtime-harness.mjs +16 -6
  277. package/tests/session-changes-shell.test.ts +1 -1
  278. package/tests/session-changes.test.ts +2 -1
  279. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  280. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  281. package/tests/session-profile-append-controller.test.ts +731 -0
  282. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  283. package/tests/session-profile-disk-reader.test.ts +311 -0
  284. package/tests/session-profile-freeze.test.ts +23 -0
  285. package/tests/session-scratch.test.ts +1011 -0
  286. package/tests/session-subject-routing-contract.test.ts +2 -1
  287. package/tests/session-worktree-registry.test.ts +13 -1
  288. package/tests/shell-bar.test.ts +21 -2
  289. package/tests/shell-changes-view.test.ts +137 -1
  290. package/tests/shell-changes.test.ts +148 -1
  291. package/tests/shell-gauge.test.ts +42 -0
  292. package/tests/shell-prompt.test.ts +3 -3
  293. package/tests/shell-render-cache.test.ts +134 -1
  294. package/tests/shell-sidebar-layout.test.ts +50 -0
  295. package/tests/short-check-routing-contract.test.ts +1 -1
  296. package/tests/skill-collision-prefixes.test.ts +3 -1
  297. package/tests/skill-registry-watcher.test.ts +17 -5
  298. package/tests/skill-registry.test.ts +47 -3
  299. package/tests/small-task-inline-contract.test.ts +37 -0
  300. package/tests/standalone-config-home.test.ts +324 -0
  301. package/tests/startup-banner.test.ts +5 -4
  302. package/tests/startup-home-repo.test.ts +34 -0
  303. package/tests/support/orchestrator-modules.ts +1 -0
  304. package/tests/task-size-routing-contract.test.ts +70 -10
  305. package/tests/test-scratch-contract.test.ts +102 -0
  306. package/tests/test-strength-contract.test.ts +36 -1
  307. package/tests/tool-batching-contract.test.ts +64 -0
  308. package/tests/verification-proportionality.test.ts +46 -0
  309. package/tests/verifier-admission.test.ts +154 -0
  310. package/tests/verify-agent-contract.test.ts +8 -8
  311. package/tests/voice-command.test.ts +773 -0
  312. package/tests/voice-install.test.ts +323 -0
  313. package/tests/voice-platform.test.ts +176 -0
  314. package/tests/voice-push-to-talk.test.ts +444 -0
  315. package/tests/voice-session.test.ts +434 -0
  316. package/tests/voice-settings.test.ts +49 -0
  317. package/tests/voice-ui.test.ts +97 -0
  318. package/tests/voice-worker.test.ts +445 -0
  319. package/tests/weak-model-economy-prompt.test.ts +32 -0
  320. package/tests/windows-hidden-processes.test.ts +30 -2
  321. package/tests/writer-edit-surface-scope.test.ts +3 -0
  322. package/tests/writer-feature-document-admission.test.ts +156 -0
  323. package/tests/yolo-customize.test.ts +5 -1
  324. package/tests/yolo-mode.test.ts +19 -1
  325. package/tests/yolo-session-policy-capture.test.ts +104 -0
  326. package/tests/yolo-temp-scratch.test.ts +71 -0
  327. package/scripts/mirror-odd-routing.mjs +0 -242
  328. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
  329. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
  330. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
  331. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  332. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
  333. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
  334. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -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";
@@ -88,6 +88,7 @@ interface FakeUi {
88
88
  overlay: unknown;
89
89
  overlayView: { render(width: number): string[]; handleInput(data: string): void } | undefined;
90
90
  closeOverlay: (() => void) | undefined;
91
+ customOptions?: unknown[];
91
92
  }
92
93
 
93
94
  interface GitScript {
@@ -162,6 +163,9 @@ function fakePi(script: GitScript[] = [{ numstat: "", porcelain: "" }], commands
162
163
  if (args.includes("worktree")) return { stdout: "worktree /repo\0branch refs/heads/main\0\0", stderr: "", code: 0, killed: false };
163
164
  // Roots outside the worktree list read as a detached HEAD.
164
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 };
165
169
  const isNumstat = args.includes("diff");
166
170
  const step = script[Math.min(isNumstat ? round : round++, script.length - 1)];
167
171
  return { stdout: isNumstat ? step.numstat : step.porcelain, stderr: "", code: 0, killed: false };
@@ -175,7 +179,7 @@ async function fire(handlers: Map<string, Array<(event: unknown, ctx: ExtensionC
175
179
  }
176
180
 
177
181
  function fakeContext(options: { theme?: typeof plainTheme & { getBgAnsi?(): string }; hasUI?: boolean; entries?: unknown[]; oauth?: boolean; pending?: boolean; idle?: boolean; editorFactory?: unknown; token?: string; select?: (title: string, options: string[]) => Promise<string | undefined> } = {}): { ctx: ExtensionContext; ui: FakeUi; overlayReady: Promise<void> } {
178
- const ui: FakeUi = { footerFactory: undefined, editorFactory: options.editorFactory, widgets: new Map(), widgetSets: 0, workingVisible: undefined, notices: [], overlay: undefined, overlayView: undefined, closeOverlay: undefined };
182
+ const ui: FakeUi = { footerFactory: undefined, editorFactory: options.editorFactory, widgets: new Map(), widgetSets: 0, workingVisible: undefined, notices: [], overlay: undefined, overlayView: undefined, closeOverlay: undefined, customOptions: [] };
179
183
  let resolveOverlay: () => void;
180
184
  const overlayReady = new Promise<void>((resolve) => { resolveOverlay = resolve; });
181
185
  const entries = options.entries ?? [];
@@ -226,8 +230,9 @@ function fakeContext(options: { theme?: typeof plainTheme & { getBgAnsi?(): stri
226
230
  // whatever their `done` callback is given (see ExtensionUIContext.custom
227
231
  // in pi-coding-agent), not always null. `closeOverlay` stays a
228
232
  // null-resolving escape hatch for tests that only need to end the wait.
229
- custom<T>(factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: T) => void) => { render(width: number): string[]; handleInput(data: string): void }) {
233
+ custom<T>(factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: T) => void) => { render(width: number): string[]; handleInput(data: string): void }, options?: unknown) {
230
234
  ui.overlay = factory;
235
+ ui.customOptions?.push(options);
231
236
  return new Promise<T | null>((resolve) => {
232
237
  ui.closeOverlay = () => resolve(null);
233
238
  // An identity bg mock hides focused rows; omit it so overlay navigation
@@ -2046,7 +2051,7 @@ test("gentleShell shows working while the agent runs and queued when messages wa
2046
2051
  const editor = installedPrompt(ctx, ui, handlers);
2047
2052
 
2048
2053
  for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
2049
- assert.match(stripAnsi(editor.render(60)[0]), /^╭─ ✿ working… ─+╮$/);
2054
+ assert.match(stripAnsi(editor.render(60)[0]), /^╭─ [✿❀❁✾] working… ─+╮$/);
2050
2055
  pending.value = true;
2051
2056
  assert.match(stripAnsi(editor.render(60)[0]), /^╭─ [✿❀❁✾] queued ─+╮$/);
2052
2057
  for (const handler of handlers.get("agent_end") ?? []) handler({}, ctx);
@@ -2819,7 +2824,7 @@ test("vim selector exposes enable, disable and status; status and cancellation d
2819
2824
  } finally { editor.dispose(); }
2820
2825
  });
2821
2826
 
2822
- 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) => {
2823
2828
  const configHome = scopedDoubleEscCancelConfigHome(t);
2824
2829
  const delays: number[] = [];
2825
2830
  let active = 0;
@@ -2837,9 +2842,9 @@ test("animations command reports without writing and switches the live pulse", a
2837
2842
  assert.match(ui.notices.at(-1)!, /animations: quality/);
2838
2843
  assert.equal(existsSync(join(configHome, "animations.json")), false);
2839
2844
  for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
2840
- assert.deepEqual(delays, [2000, 80]);
2845
+ assert.deepEqual(delays, [2000, 1000]);
2841
2846
  await command.handler("performance", ctx);
2842
- assert.deepEqual(delays, [2000, 80, 1000]);
2847
+ assert.deepEqual(delays, [2000, 1000, 1000]);
2843
2848
  assert.equal(active, 2);
2844
2849
  await command.handler("potato", ctx);
2845
2850
  assert.equal(active, 1);
@@ -2848,7 +2853,7 @@ test("animations command reports without writing and switches the live pulse", a
2848
2853
  await command.handler("invalid", ctx);
2849
2854
  assert.equal(JSON.parse(readFileSync(join(configHome, "animations.json"), "utf8")).policy, "potato");
2850
2855
  await command.handler("quality", ctx);
2851
- assert.deepEqual(delays, [2000, 80, 1000, 80]);
2856
+ assert.deepEqual(delays, [2000, 1000, 1000, 1000]);
2852
2857
  for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
2853
2858
  assert.equal(active, 1);
2854
2859
  editor.dispose();
@@ -2937,7 +2942,7 @@ test("animations with no argument opens a selectable menu and applies the chosen
2937
2942
  assert.equal(existsSync(join(dismissHome, "animations.json")), false);
2938
2943
  });
2939
2944
 
2940
- 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) => {
2941
2946
  const configHome = scopedDoubleEscCancelConfigHome(t);
2942
2947
  writeFileSync(join(configHome, "animations.json"), '{"schema":"gentle-pi.animations/v1","policy":"quality"}');
2943
2948
  const delays: number[] = [];
@@ -2955,7 +2960,7 @@ test("prompt uses the compact banner cadence and releases its unref timer at set
2955
2960
  const editor = installedPrompt(ctx, ui, handlers);
2956
2961
  assert.equal(active, 1);
2957
2962
  for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
2958
- assert.deepEqual(delays, [2000, 80]);
2963
+ assert.deepEqual(delays, [2000, 1000]);
2959
2964
  assert.equal(unrefs, 2);
2960
2965
  for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
2961
2966
  assert.equal(active, 1);
@@ -2964,6 +2969,109 @@ test("prompt uses the compact banner cadence and releases its unref timer at set
2964
2969
  assert.equal(active, 0);
2965
2970
  });
2966
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
+
2967
3075
  // ---------------------------------------------------------------------------
2968
3076
  // double-esc-cancel (issue #1163): opt-in, off by default. While the prompt
2969
3077
  // is working and autocomplete is hidden, the first Esc is swallowed and the
@@ -4775,11 +4883,43 @@ test("a registered worktree opens Changes as its own group", async () => {
4775
4883
  } finally { ui.closeOverlay?.(); await opened; await fire(handlers,"session_shutdown",ctx); }
4776
4884
  });
4777
4885
 
4886
+ // /gentle:changes opens like Gentle Agents: a full-terminal overlay whose
4887
+ // native fullscreen wrapper still routes mouse events to the changes view.
4888
+ test("Changes opens full screen and the wrapper keeps mouse hit-testing aligned", async () => {
4889
+ const {pi,handlers,commands}=fakePi([{numstat:"",porcelain:" M file.ts\0?? dirty.ts\0"}]);
4890
+ gentleShell(pi,{});
4891
+ const {ctx,ui,overlayReady}=fakeContext();
4892
+ await fire(handlers,"session_start",ctx);
4893
+ const opened=commands.get("gentle:changes")!.handler("",ctx);
4894
+ await overlayReady;
4895
+ const overlay=ui.overlayView as unknown as { render(width: number): string[]; handleInput(data: string): void; handleMouse(event: unknown): unknown };
4896
+ const at=(type: string, x: number, y: number) => ({ type, button: type === "move" ? "none" : "left", x, y, screenX: x, screenY: y, width: 140, height: 40, shift: false, alt: false, ctrl: false });
4897
+ try {
4898
+ assert.deepEqual(ui.customOptions?.at(-1), { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", margin: 0, anchor: "center" } });
4899
+ assert.equal(overlay.render(140).length, 40, "the overlay uses every terminal row");
4900
+ overlay.handleInput("\r");
4901
+ overlay.render(140);
4902
+ const { handled, render } = overlay.handleMouse(at("click", 3, 3)) as { handled?: boolean; render?: boolean };
4903
+ assert.deepEqual({ handled, render }, { handled: true, render: true });
4904
+ assert.match(overlay.render(140)[3]!, /▸ M file\.ts/, "a click through the wrapper selects the file");
4905
+ overlay.handleInput("F");
4906
+ assert.match(overlay.render(140)[1]!, /^│ M file\.ts +│$/, "F switches to diff-only inside the fullscreen overlay");
4907
+ } finally { ui.closeOverlay?.(); await opened; }
4908
+ const reopened=commands.get("gentle:changes")!.handler("",ctx);
4909
+ for (let attempt=0; attempt<100 && ui.overlayView===overlay; attempt++) await new Promise((resolve)=>setTimeout(resolve,5));
4910
+ try {
4911
+ assert.match(ui.overlayView!.render(140)[1]!, /main · repo/, "diff-only mode is not persisted across openings");
4912
+ } finally { ui.closeOverlay?.(); await reopened; await fire(handlers,"session_shutdown",ctx); }
4913
+ });
4914
+
4778
4915
  test("new sessions do not inherit another session's worktrees", async () => {
4779
4916
  const h=fakePi(); gentleShell(h.pi,{});
4780
4917
  const first=fakeContext();
4781
4918
  await fire(h.handlers,"session_start",first.ctx);
4782
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();
4783
4923
  await fire(h.handlers,"session_shutdown",first.ctx);
4784
4924
  const next=fakeContext({entries:[...first.ctx.sessionManager.getEntries()]});
4785
4925
  (next.ctx.sessionManager as any).getSessionId=()=>"new-session";
@@ -4849,7 +4989,7 @@ test("registered canonical root governs real Git discovery, status and diff desp
4849
4989
  assert.ok(await loadFileDiff(bounded, { path: "tracked.txt", added: 70_001, deleted: 1, status: CHANGE_STATUS.MODIFIED }) === DIFF_TOO_LARGE);
4850
4990
  const opened = h.commands.get("gentle:changes")!.handler("", ctx);
4851
4991
  await overlayReady;
4852
- const trees = (ui.overlayView as unknown as { trees: Array<{ root: string; model: { files: Array<{ path: string }> } }> }).trees;
4992
+ const trees = (ui.overlayView as unknown as { children: Array<{ trees: Array<{ root: string; model: { files: Array<{ path: string }> } }> }> }).children[0]!.trees;
4853
4993
  assert.deepEqual(trees.map((tree) => [tree.root, tree.model.files.map((file) => file.path)]), [[selected, ["selected-only.txt", "tracked.txt"]]], "inherited routing never lists the foreign repository");
4854
4994
  ui.closeOverlay?.();
4855
4995
  await opened;
@@ -6281,19 +6421,21 @@ test("an explicit session binding routes the Usage scope ahead of the pin and th
6281
6421
 
6282
6422
  // S9: the config home each of these reads resolves, per case. The OS home is
6283
6423
  // a temporary folder in every case, so no case can reach the real one.
6284
- // "unset" and "empty" are exactly as before: an empty GENTLE_PI_CONFIG_HOME
6285
- // names the current folder. "bundled" stands in for this package running from
6286
- // a bundled install (testing overlay) whose prefix the launcher passed; the
6287
- // user's own GENTLE_PI_CONFIG_HOME is then not this install's.
6424
+ // "unset" and "empty" both name ~/.pi/gentle-ai: an empty GENTLE_PI_CONFIG_HOME
6425
+ // counts as unset (#2201; it used to name the current folder). "bundled" stands
6426
+ // in for this package running from a bundled install (testing overlay) whose
6427
+ // prefix the launcher passed; the user's own GENTLE_PI_CONFIG_HOME is then not
6428
+ // this install's.
6288
6429
  type ConfigHomeCase = "unset" | "empty" | "bundled";
6289
6430
  function configHomeCase(t: { after: (fn: () => void) => void }, kind: ConfigHomeCase): { env: NodeJS.ProcessEnv; dir: string; userConfig: string } {
6290
6431
  const base = realpathSync(mkdtempSync(join(tmpdir(), `config-home-${kind}-`)));
6291
- const saved = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME, GENTLE_SHELL_PREFIX: process.env.GENTLE_SHELL_PREFIX };
6432
+ const saved = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME, GENTLE_SHELL_PREFIX: process.env.GENTLE_SHELL_PREFIX, GENTLE_SHELL_CONFIG_HOME: process.env.GENTLE_SHELL_CONFIG_HOME };
6292
6433
  const cwd = process.cwd();
6293
6434
  process.env.HOME = join(base, "home");
6294
6435
  process.env.USERPROFILE = join(base, "home");
6295
6436
  delete process.env.GENTLE_PI_CONFIG_HOME;
6296
6437
  delete process.env.GENTLE_SHELL_PREFIX;
6438
+ delete process.env.GENTLE_SHELL_CONFIG_HOME;
6297
6439
  t.after(() => {
6298
6440
  process.chdir(cwd);
6299
6441
  setBundledInstallForTesting(undefined);
@@ -6308,7 +6450,7 @@ function configHomeCase(t: { after: (fn: () => void) => void }, kind: ConfigHome
6308
6450
  if (kind === "empty") {
6309
6451
  mkdirSync(join(base, "cwd"));
6310
6452
  process.chdir(join(base, "cwd"));
6311
- return { env: { GENTLE_PI_CONFIG_HOME: "" }, dir: join(base, "cwd"), userConfig };
6453
+ return { env: { GENTLE_PI_CONFIG_HOME: "" }, dir: join(base, "home", ".pi", "gentle-ai"), userConfig };
6312
6454
  }
6313
6455
  const prefix = join(base, "prefix");
6314
6456
  mkdirSync(prefix);
@@ -6343,3 +6485,69 @@ for (const kind of ["unset", "empty", "bundled"] as const) {
6343
6485
  assert.equal(read(), "other (repo)", "the pinned profile exists only in that config home's store");
6344
6486
  });
6345
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
+ });
@@ -3,6 +3,7 @@ import test, { after, before } from "node:test";
3
3
  import { convertToLlm, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import type { Component, TUI } from "@earendil-works/pi-tui";
5
5
  import gentleTodo, { todoCollapseKey, todoEnabled } from "../extensions/gentle-todo.ts";
6
+ import { TODO_PROGRESS_EVENT } from "../lib/shell-todo.ts";
6
7
  import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts";
7
8
  import { stripAnsi } from "../lib/terminal-theme.ts";
8
9
  import { sidebarState } from "../lib/shell-sidebar.ts";
@@ -497,6 +498,177 @@ test("the system prompt carries only a byte-stable todo block; the current list
497
498
  assert.equal(finishedEvent.systemPromptOptions.appendSystemPrompt, emptyEvent.systemPromptOptions.appendSystemPrompt, "finishing the list must not change the system prompt");
498
499
  });
499
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
+
512
+ /**
513
+ * Drives the extension the way Pi's agent loop does (#2195): every model request
514
+ * runs the context handlers over the persisted transcript, and a custom message
515
+ * sent with triggerTurn false while streaming is appended to the transcript at
516
+ * turn_end, after the tool results (agent-session `_flushPendingCustomMessages`).
517
+ */
518
+ function liveSession() {
519
+ const base = fakePi();
520
+ const queued: ContextMessage[] = [];
521
+ const pi = {
522
+ ...base.pi,
523
+ sendMessage(message: { customType: string; content: string; display: boolean }, options: { triggerTurn?: boolean }) {
524
+ assert.equal(options.triggerTurn, false, "todo state must never start a turn");
525
+ queued.push({ role: "custom", customType: message.customType, content: message.content, display: message.display, timestamp: 0 });
526
+ },
527
+ } as unknown as ExtensionAPI;
528
+ gentleTodo(pi, {});
529
+ const { ctx } = fakeContext();
530
+ const transcript: ContextMessage[] = [{ role: "user", content: "Work on it", timestamp: 1 }];
531
+ const requests: ContextMessage[][] = [];
532
+ let call = 0;
533
+ const request = async () => {
534
+ const result = await base.fire("context", ctx, { messages: [...transcript] }) as { messages: ContextMessage[] } | undefined;
535
+ const messages = result?.messages ?? [...transcript];
536
+ requests.push(messages);
537
+ return messages;
538
+ };
539
+ /** One model request whose assistant reply calls `todo` (when params are given) or `read`. */
540
+ const turn = async (todo?: Record<string, unknown>) => {
541
+ await request();
542
+ call += 1;
543
+ const id = `call-${call}`;
544
+ const name = todo ? "todo" : "read";
545
+ const assistant = { role: "assistant", content: [{ type: "toolCall", id, name, arguments: todo ?? {} }], timestamp: 0 };
546
+ transcript.push(assistant);
547
+ if (todo) await base.tools.get("todo")!.execute(id, todo, undefined, undefined, ctx);
548
+ transcript.push({ role: "toolResult", content: [{ type: "text", text: "ok" }], timestamp: 0 });
549
+ await base.fire("turn_end", ctx, { message: assistant, toolResults: [] });
550
+ transcript.push(...queued.splice(0));
551
+ };
552
+ const assertAppendOnly = (from: number) => {
553
+ for (let index = from + 1; index < requests.length; index++) {
554
+ const previous = requests[index - 1]!;
555
+ assert.deepEqual(requests[index]!.slice(0, previous.length), previous, `request ${index} must extend request ${index - 1} byte for byte`);
556
+ }
557
+ };
558
+ const visibleState = (messages: ContextMessage[]) => [...messages].reverse().find((message) => message.customType === "gentle-todo-state")?.content;
559
+ return { ctx, fire: base.fire, transcript, requests, request, turn, assertAppendOnly, visibleState };
560
+ }
561
+
562
+ test("a todo write in a running tool loop reaches the transcript before the next request, which stays append-only (#2195)", async () => {
563
+ const live = liveSession();
564
+ await live.fire("session_start", live.ctx);
565
+ await live.turn({ action: "write", tasks: [{ title: "Fix the bug" }, { title: "Ship it" }] });
566
+ await live.turn();
567
+ await live.turn();
568
+ await live.turn({ action: "update", id: 1, status: "done" });
569
+ await live.turn();
570
+ await live.request();
571
+ assert.match(live.visibleState(live.requests.at(-1)!) as string, /1\. \[done\] Fix the bug\n2\. \[pending\] Ship it/);
572
+ live.assertAppendOnly(0);
573
+ });
574
+
575
+ test("closing every task does not leave a stale open list in the transcript (#2195)", async () => {
576
+ for (const close of [{ action: "update", id: 1, status: "done" }, { action: "update", id: 1, status: "dropped" }, { action: "clear" }]) {
577
+ const live = liveSession();
578
+ await live.fire("session_start", live.ctx);
579
+ await live.turn({ action: "write", tasks: [{ title: "Fix the bug" }] });
580
+ await live.turn(close);
581
+ await live.turn();
582
+ const messages = await live.request();
583
+ assert.equal(live.visibleState(messages), "Current todo list: no open tasks.", `${JSON.stringify(close)}: the model must not see the closed task as still open`);
584
+ live.assertAppendOnly(0);
585
+ }
586
+ });
587
+
588
+ test("an open list missing from the transcript (compaction, branch switch) is persisted again once, then requests stay append-only (#2195)", async () => {
589
+ const live = liveSession();
590
+ await live.fire("session_start", live.ctx);
591
+ await live.turn({ action: "write", tasks: [{ title: "Fix the bug" }] });
592
+ // Compaction summarises the head of the conversation, including the persisted state.
593
+ live.transcript.splice(0, live.transcript.length, { role: "user", content: "Summary of earlier work", timestamp: 2 });
594
+ const healedFrom = live.requests.length;
595
+ await live.turn();
596
+ await live.turn();
597
+ await live.turn();
598
+ await live.request();
599
+ for (const messages of live.requests.slice(healedFrom)) assert.match(String(live.visibleState(messages)), /\[pending\] Fix the bug/);
600
+ assert.equal(live.transcript.filter((message) => message.customType === "gentle-todo-state").length, 1, "the missing state is persisted exactly once");
601
+ live.assertAppendOnly(healedFrom + 1);
602
+ });
603
+
604
+ test("consecutive context requests with an unchanged Todo list preserve prefix stability (#2195)", async () => {
605
+ const { pi, tools, fire } = fakePi();
606
+ gentleTodo(pi, {});
607
+ const { ctx } = fakeContext();
608
+ await fire("session_start", ctx);
609
+ await tools.get("todo")!.execute("c1", { action: "write", tasks: [{ title: "Fix the bug" }] }, undefined, undefined, ctx);
610
+
611
+ // Request 1: first model request
612
+ const userMsg = { role: "user", content: "Work on it", timestamp: 100 };
613
+ const req1 = await fire("context", ctx, { messages: [userMsg] }) as { messages: Array<{ role: string; customType?: string }> };
614
+ assert.ok(req1, "req1 must return context messages");
615
+ assert.ok(req1.messages.some((m) => (m as { customType?: string }).customType === "gentle-todo-state"), "req1 must include gentle-todo-state");
616
+
617
+ // Model executes a tool (read), producing assistant message and tool result
618
+ const assistantMsg = { role: "assistant", content: [{ type: "toolCall", id: "read-1", name: "read", arguments: {} }], timestamp: 200 };
619
+ const toolResultMsg = { role: "toolResult", toolCallId: "read-1", content: [{ type: "text", text: "file content" }], timestamp: 300 };
620
+
621
+ // Request 2: next model request with unchanged todo list.
622
+ // The input messages to context include the transcript with the already-delivered state message
623
+ const inputReq2 = [...req1.messages, assistantMsg, toolResultMsg];
624
+ const req2 = await fire("context", ctx, { messages: inputReq2 }) as { messages: Array<{ role: string; customType?: string }> } | undefined;
625
+
626
+ // req2 must not append a redundant trailing gentle-todo-state that would invalidate prefix caching
627
+ const effectiveReq2 = req2?.messages ?? inputReq2;
628
+ assert.deepEqual(
629
+ effectiveReq2.slice(0, req1.messages.length),
630
+ req1.messages,
631
+ "req1 must be an exact prefix of req2 to preserve prompt caching",
632
+ );
633
+ // No duplicate trailing state message at the end of req2
634
+ const stateMessages = effectiveReq2.filter((m) => (m as { customType?: string }).customType === "gentle-todo-state");
635
+ assert.equal(stateMessages.length, 1, "must not append duplicate gentle-todo-state when unchanged");
636
+ });
637
+
638
+ test("todo write and update persist gentle-todo-state custom message to transcript (#2195)", async () => {
639
+ const sent: Array<{ message: unknown; options: unknown }> = [];
640
+ const base = fakePi();
641
+ const pi = {
642
+ ...base.pi,
643
+ sendMessage(message: unknown, options: unknown) {
644
+ sent.push({ message, options });
645
+ },
646
+ } as unknown as ExtensionAPI;
647
+ gentleTodo(pi, {});
648
+ const { ctx } = fakeContext();
649
+ await base.fire("session_start", ctx);
650
+
651
+ // 1. todo write persists state
652
+ await base.tools.get("todo")!.execute("c1", { action: "write", tasks: [{ title: "Task 1" }] }, undefined, undefined, ctx);
653
+ assert.equal(sent.length, 1);
654
+ assert.deepEqual(sent[0]!.options, { triggerTurn: false });
655
+ const firstMsg = sent[0]!.message as { role: string; customType: string; content: string; display: boolean };
656
+ assert.equal(firstMsg.customType, "gentle-todo-state");
657
+ assert.equal(firstMsg.display, false);
658
+ assert.match(firstMsg.content, /1\. \[pending\] Task 1/);
659
+
660
+ // 2. todo update persists new state
661
+ await base.tools.get("todo")!.execute("c2", { action: "update", id: 1, status: "in_progress", note: "doing" }, undefined, undefined, ctx);
662
+ assert.equal(sent.length, 2);
663
+ assert.deepEqual(sent[1]!.options, { triggerTurn: false });
664
+ const secondMsg = sent[1]!.message as { content: string };
665
+ assert.match(secondMsg.content, /1\. \[in_progress\] Task 1 — doing/);
666
+
667
+ // 3. todo list does NOT persist extra messages
668
+ await base.tools.get("todo")!.execute("c3", { action: "list" }, undefined, undefined, ctx);
669
+ assert.equal(sent.length, 2, "read actions must not emit state messages");
670
+ });
671
+
500
672
  test("before_agent_start is idempotent: a todo block already present in appendSystemPrompt is not duplicated", async () => {
501
673
  const { pi, tools, fire } = fakePi();
502
674
  gentleTodo(pi, {});
@@ -612,3 +784,21 @@ test("a list left with only dropped tasks open finishes and clears; a blocked-on
612
784
  }
613
785
  assert.match(widget()![2], /⊘ Deploy · waiting for an admin/);
614
786
  });
787
+
788
+ // Harness roadmap T6: the todo list is the session's progress, so the runtime
789
+ // publishes it for peers instead of asking the model to call orchestrator_session_id.
790
+ test("a todo write emits the session's progress; list and errors emit nothing", async () => {
791
+ const { pi, tools } = fakePi();
792
+ const emitted: Array<{ name: string; data: unknown }> = [];
793
+ Object.assign(pi, { events: { emit: (name: string, data: unknown) => { emitted.push({ name, data }); }, on: () => () => {} } });
794
+ gentleTodo(pi, {});
795
+ const { ctx } = fakeContext();
796
+ const todo = (params: unknown) => tools.get("todo")!.execute("call", params, undefined, undefined, ctx);
797
+ await todo({ action: "write", tasks: [{ title: "Write parser", status: "done" }, { title: "Wire CLI", status: "in_progress" }, { title: "Docs" }] });
798
+ assert.deepEqual(emitted, [{ name: TODO_PROGRESS_EVENT, data: { sessionId: "s1", done: 1, total: 3, current: "Wire CLI" } }]);
799
+ await todo({ action: "list" });
800
+ await todo({ action: "update", id: 99, status: "done" });
801
+ assert.equal(emitted.length, 1, "list and a failed update publish nothing");
802
+ await todo({ action: "update", id: 2, status: "done" });
803
+ assert.deepEqual(emitted.at(-1)?.data, { sessionId: "s1", done: 2, total: 3, current: null });
804
+ });
@@ -0,0 +1,58 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { __testing } from "../extensions/gentle-ai.ts";
4
+ import { INTERACTIVE_HOST_ENV } from "../lib/rpc-host.ts";
5
+
6
+ // Bench gate-p53-r1-BS40 x6 run 3 (headless RPC): the ask-on-risk delivery
7
+ // question ran before any code was written; ask_user_question answered
8
+ // "unavailable outside the interactive TUI", the orchestrator printed the menu
9
+ // and stopped, and the round ended with "No code changed yet". When no human
10
+ // can answer, the delivery question resolves to the recommended chain instead.
11
+
12
+ const HEADLESS_DELIVERY = "No human can answer questions in this session";
13
+
14
+ function promptFor(mode: string | undefined): string {
15
+ return __testing.buildGentlePrompt("neutral", process.cwd(), undefined, undefined, mode);
16
+ }
17
+
18
+ function withInteractiveHost<T>(value: string | undefined, run: () => T): T {
19
+ const previous = process.env[INTERACTIVE_HOST_ENV];
20
+ if (value === undefined) delete process.env[INTERACTIVE_HOST_ENV];
21
+ else process.env[INTERACTIVE_HOST_ENV] = value;
22
+ try {
23
+ return run();
24
+ } finally {
25
+ if (previous === undefined) delete process.env[INTERACTIVE_HOST_ENV];
26
+ else process.env[INTERACTIVE_HOST_ENV] = previous;
27
+ }
28
+ }
29
+
30
+ test("headless host modes resolve the ask-on-risk delivery question to the feature-branch chain without asking", () => {
31
+ withInteractiveHost(undefined, () => {
32
+ for (const mode of ["rpc", "print", "json"]) {
33
+ const prompt = promptFor(mode);
34
+ assert.ok(prompt.includes(HEADLESS_DELIVERY), `${mode} must say no human can answer`);
35
+ assert.match(prompt, /ask-on-risk/, `${mode} must name the ask-on-risk delivery question`);
36
+ assert.match(prompt, /`feature-branch-chain`/, `${mode} must name the chain it resolves to`);
37
+ assert.match(prompt, /feature document/, `${mode} must record the choice in the feature document`);
38
+ assert.match(prompt, /consent/, `${mode} must keep consent prompts out of the default`);
39
+ }
40
+ });
41
+ });
42
+
43
+ test("interactive hosts keep asking the delivery question", () => {
44
+ withInteractiveHost(undefined, () => {
45
+ for (const mode of ["tui", undefined]) assert.ok(!promptFor(mode).includes(HEADLESS_DELIVERY), `${String(mode)} keeps the question`);
46
+ });
47
+ withInteractiveHost("1", () => {
48
+ assert.ok(!promptFor("rpc").includes(HEADLESS_DELIVERY), "an interactive RPC host keeps the question");
49
+ });
50
+ });
51
+
52
+ test("the reference documents the headless delivery default", async () => {
53
+ const { readFileSync } = await import("node:fs");
54
+ const reference = readFileSync(new URL("../docs/readme-reference.md", import.meta.url), "utf8");
55
+ for (const term of ["no human can answer questions", "`GENTLE_SHELL_INTERACTIVE_HOST=1`", "`ask-on-risk` does not ask", "`feature-branch-chain`"]) {
56
+ assert.ok(reference.includes(term), `docs/readme-reference.md is missing ${term}`);
57
+ }
58
+ });