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
@@ -0,0 +1,773 @@
1
+ import assert from "node:assert/strict";
2
+ import nodeTest from "node:test";
3
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
4
+ import gentleVoice from "../extensions/gentle-voice.ts";
5
+ import { VoiceInstallError, type VoiceInstallProgress, type VoiceInstallResult } from "../lib/voice-install.ts";
6
+ import { VoiceSessionError, type VoiceSessionState, type VoiceTranscript } from "../lib/voice-session.ts";
7
+ import { VOICE_COMMAND_NAME, type VoiceCommandDeps, type VoiceDictation } from "../lib/voice-command.ts";
8
+ import type { PushToTalkTimers } from "../lib/voice-push-to-talk.ts";
9
+
10
+ // /gentle:voice through its registered command, with fake UI, acquisition and
11
+ // session adapters: nothing here downloads, loads native code or opens a
12
+ // microphone.
13
+
14
+ type Handler = (event: unknown, ctx: unknown) => unknown;
15
+ type Component = { render(width: number): string[]; handleInput?(data: string): void };
16
+
17
+ // A broken lifecycle shows up as a hang; fail it instead of waiting forever.
18
+ const test = (name: string, fn: () => Promise<void>) => nodeTest(name, { timeout: 5_000 }, fn);
19
+
20
+ const ENTER = "\r";
21
+ const ESC = "\x1b";
22
+ const VOICE_HOME = "/home/user/.pi/gentle-ai/voice";
23
+
24
+ function deferred<T>() {
25
+ let resolve!: (value: T) => void;
26
+ let reject!: (error: unknown) => void;
27
+ const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
28
+ return { promise, resolve, reject };
29
+ }
30
+
31
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
32
+
33
+ async function until(check: () => boolean, attempts = 200) {
34
+ for (let attempt = 0; attempt < attempts && !check(); attempt++) await tick();
35
+ assert.ok(check(), "condition not reached");
36
+ }
37
+
38
+ function fakePi() {
39
+ const commands = new Map<string, { description: string; handler(args: string, ctx: ExtensionCommandContext): Promise<void> }>();
40
+ const handlers = new Map<string, Handler[]>();
41
+ const counts = { tools: 0, sends: 0 };
42
+ const pi = {
43
+ registerCommand: (name: string, registration: { description: string; handler(args: string, ctx: ExtensionCommandContext): Promise<void> }) => commands.set(name, registration),
44
+ registerTool: () => { counts.tools++; },
45
+ registerShortcut: () => { counts.tools++; },
46
+ sendUserMessage: () => { counts.sends++; },
47
+ sendMessage: () => { counts.sends++; },
48
+ on: (event: string, handler: Handler) => handlers.set(event, [...(handlers.get(event) ?? []), handler]),
49
+ } as unknown as ExtensionAPI;
50
+ const fire = async (event: string, ctx: unknown = {}) => {
51
+ for (const handler of handlers.get(event) ?? []) await handler({}, ctx);
52
+ };
53
+ return { pi, commands, handlers, counts, fire };
54
+ }
55
+
56
+ interface UiScript {
57
+ select?: (title: string, options: string[]) => string | undefined;
58
+ confirm?: (title: string, message: string) => boolean;
59
+ }
60
+
61
+ function fakeContext(script: UiScript = {}, mode = "tui", hasUI = true) {
62
+ const notices: Array<{ message: string; type?: string }> = [];
63
+ const selects: Array<{ title: string; options: string[] }> = [];
64
+ const confirms: Array<{ title: string; message: string }> = [];
65
+ const events: string[] = [];
66
+ const renders: Array<boolean | undefined> = [];
67
+ let editor = "draft: ";
68
+ let component: Component | undefined;
69
+ let open = false;
70
+ const listeners: Array<(data: string) => { consume?: boolean; data?: string } | undefined> = [];
71
+ const statuses: Array<string | undefined> = [];
72
+ const terminal = { subscribes: 0, unsubscribes: 0 };
73
+ const tui = { terminal: { rows: 30, columns: 100 }, requestRender: (force?: boolean) => renders.push(force) };
74
+ const theme = { fg: (_role: string, text: string) => text, bold: (text: string) => text };
75
+ const ctx = {
76
+ hasUI,
77
+ mode,
78
+ cwd: "/work/alpha",
79
+ ui: {
80
+ notify: (message: string, type?: string) => { notices.push({ message, type }); events.push(`notify:${message}`); },
81
+ select: async (title: string, options: string[]) => { selects.push({ title, options }); return script.select?.(title, options); },
82
+ confirm: async (title: string, message: string) => { confirms.push({ title, message }); return script.confirm?.(title, message) ?? false; },
83
+ custom: (factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: unknown) => void) => Component) =>
84
+ new Promise((resolve) => {
85
+ open = true;
86
+ events.push("open");
87
+ component = factory(tui, theme, {}, (value) => { open = false; events.push("close"); resolve(value); });
88
+ }),
89
+ pasteToEditor: (text: string) => { editor += text; events.push(`paste:${text}`); },
90
+ getEditorText: () => editor,
91
+ setEditorText: (text: string) => { editor = text; },
92
+ onTerminalInput: (listener: (data: string) => { consume?: boolean; data?: string } | undefined) => {
93
+ terminal.subscribes++;
94
+ listeners.push(listener);
95
+ return () => {
96
+ const index = listeners.indexOf(listener);
97
+ if (index >= 0) { listeners.splice(index, 1); terminal.unsubscribes++; }
98
+ };
99
+ },
100
+ setStatus: (key: string, text: string | undefined) => { if (key === "gentle-voice") statuses.push(text); },
101
+ },
102
+ } as unknown as ExtensionCommandContext;
103
+ // Raw input as Pi's TUI routes it: listeners first, then the focused editor,
104
+ // which appends text and treats DEL as Backspace.
105
+ const type = (data: string) => {
106
+ let current = data;
107
+ for (const listener of [...listeners]) {
108
+ const result = listener(current);
109
+ if (result?.consume) return;
110
+ if (result?.data !== undefined) current = result.data;
111
+ }
112
+ if (current === "\x7f") editor = editor.slice(0, -1);
113
+ else editor += current;
114
+ };
115
+ return { ctx, notices, selects, confirms, events, renders, statuses, terminal, type, editor: () => editor, component: () => component, open: () => open };
116
+ }
117
+
118
+ class FakeSession implements VoiceDictation {
119
+ state: VoiceSessionState = "idle";
120
+ readonly calls: string[] = [];
121
+ readonly started = deferred<void>();
122
+ readonly result = deferred<VoiceTranscript>();
123
+ readonly onStateChange: (state: VoiceSessionState) => void;
124
+ constructor(onStateChange: (state: VoiceSessionState) => void) {
125
+ this.onStateChange = onStateChange;
126
+ this.result.promise.catch(() => {});
127
+ }
128
+ emit(state: VoiceSessionState) {
129
+ this.state = state;
130
+ this.onStateChange(state);
131
+ }
132
+ async start() {
133
+ this.calls.push("start");
134
+ this.emit("starting");
135
+ await this.started.promise;
136
+ this.emit("recording");
137
+ }
138
+ async stop() {
139
+ this.calls.push("stop");
140
+ if (this.state === "recording") this.emit("transcribing");
141
+ const transcript = await this.result.promise;
142
+ this.emit("finished");
143
+ return transcript;
144
+ }
145
+ async cancel() {
146
+ this.calls.push("cancel");
147
+ this.emit("finished");
148
+ }
149
+ }
150
+
151
+ function manualClock() {
152
+ let now = 0;
153
+ let nextId = 1;
154
+ const pending = new Map<number, { at: number; callback: () => void }>();
155
+ const timers: PushToTalkTimers = {
156
+ setTimeout: (callback, ms) => {
157
+ const id = nextId++;
158
+ pending.set(id, { at: now + ms, callback });
159
+ return id;
160
+ },
161
+ clearTimeout: (handle) => { pending.delete(handle as number); },
162
+ now: () => now,
163
+ // Runs right after the timer that deferred it, as setImmediate would with no queued input.
164
+ defer: (callback) => { deferred.push(callback); },
165
+ };
166
+ const deferred: Array<() => void> = [];
167
+ const advance = (ms: number) => {
168
+ const target = now + ms;
169
+ for (;;) {
170
+ let due: [number, { at: number; callback: () => void }] | undefined;
171
+ for (const entry of pending) if (entry[1].at <= target && (!due || entry[1].at < due[1].at)) due = entry;
172
+ if (!due) break;
173
+ pending.delete(due[0]);
174
+ now = due[1].at;
175
+ due[1].callback();
176
+ while (deferred.length > 0) deferred.shift()!();
177
+ }
178
+ now = target;
179
+ };
180
+ return { timers, advance };
181
+ }
182
+
183
+ interface Harness {
184
+ deps: VoiceCommandDeps;
185
+ calls: string[];
186
+ sessions: FakeSession[];
187
+ installs: Array<{ kind: string; options: Record<string, unknown> }>;
188
+ settings: { enabled: boolean; reads: number; writes: boolean[] };
189
+ clock: ReturnType<typeof manualClock>;
190
+ }
191
+
192
+ function harness(overrides: { platform?: string; arch?: string; installed?: boolean; install?: (kind: "runtime" | "model", options: { signal: AbortSignal; onProgress(progress: VoiceInstallProgress): void }) => Promise<VoiceInstallResult>; env?: NodeJS.ProcessEnv; pushToTalk?: boolean } = {}): Harness {
193
+ const calls: string[] = [];
194
+ const sessions: FakeSession[] = [];
195
+ const installs: Harness["installs"] = [];
196
+ // The toggle store is always faked: no test reads or writes the real config home.
197
+ const settings = { enabled: overrides.pushToTalk ?? false, reads: 0, writes: [] as boolean[] };
198
+ const clock = manualClock();
199
+ const state = overrides.installed ? "installed" : "missing";
200
+ const install = overrides.install ?? (async (kind) => ({ kind, path: `${VOICE_HOME}/${kind}`, alreadyInstalled: false }));
201
+ const deps: VoiceCommandDeps = {
202
+ env: overrides.env ?? {},
203
+ probe: () => { calls.push("probe"); return { platform: overrides.platform ?? "darwin", arch: overrides.arch ?? "arm64" }; },
204
+ readStatus: async (target) => {
205
+ calls.push(`status:${target}`);
206
+ return { voiceHome: VOICE_HOME, model: { kind: "model", state, path: `${VOICE_HOME}/models/m` }, runtime: { kind: "runtime", state, path: `${VOICE_HOME}/runtime/r` } };
207
+ },
208
+ installRuntime: async (options) => { calls.push("install:runtime"); installs.push({ kind: "runtime", options: options as unknown as Record<string, unknown> }); return install("runtime", options); },
209
+ installModel: async (options) => { calls.push("install:model"); installs.push({ kind: "model", options: options as unknown as Record<string, unknown> }); return install("model", options); },
210
+ createSession: (options) => {
211
+ calls.push(`session:${options.target}`);
212
+ const session = new FakeSession(options.onStateChange);
213
+ sessions.push(session);
214
+ return session;
215
+ },
216
+ settings: {
217
+ read: () => { settings.reads++; return settings.enabled; },
218
+ write: (enabled) => { settings.writes.push(enabled); settings.enabled = enabled; },
219
+ },
220
+ pushToTalk: { timers: clock.timers },
221
+ };
222
+ return { deps, calls, sessions, installs, settings, clock };
223
+ }
224
+
225
+ const pick = (pattern: RegExp) => (_title: string, options: string[]) => options.find((option) => pattern.test(option));
226
+
227
+ function register(h: Harness) {
228
+ const pi = fakePi();
229
+ gentleVoice(pi.pi, h.deps);
230
+ return pi;
231
+ }
232
+
233
+ async function run(pi: ReturnType<typeof fakePi>, ui: ReturnType<typeof fakeContext>) {
234
+ return pi.commands.get(VOICE_COMMAND_NAME)!.handler("", ui.ctx);
235
+ }
236
+
237
+ // --- registration and refusals ------------------------------------------------
238
+
239
+ test("registers only /gentle:voice and does no voice work at registration or session start", async () => {
240
+ const h = harness();
241
+ const pi = register(h);
242
+ assert.equal(VOICE_COMMAND_NAME, "gentle:voice");
243
+ assert.deepEqual([...pi.commands.keys()], ["gentle:voice"]);
244
+ assert.match(pi.commands.get(VOICE_COMMAND_NAME)!.description, /dictation/i);
245
+ assert.equal(pi.counts.tools, 0, "no model-callable tool or shortcut");
246
+ await pi.fire("session_start");
247
+ assert.deepEqual(h.calls, []);
248
+ });
249
+
250
+ test("a delegated child session refuses before any probe, status read or UI", async () => {
251
+ const h = harness({ env: { GENTLE_PI_AGENTS_CHILD: "1" }, installed: true });
252
+ const pi = register(h);
253
+ const ui = fakeContext({ select: pick(/dictat/i) });
254
+ await run(pi, ui);
255
+ assert.deepEqual(h.calls, []);
256
+ assert.equal(ui.selects.length, 0);
257
+ assert.match(ui.notices[0]!.message, /delegated/i);
258
+ assert.equal(ui.editor(), "draft: ");
259
+ assert.equal(pi.counts.sends, 0);
260
+ });
261
+
262
+ test("non-TUI modes refuse without voice work", async () => {
263
+ for (const [mode, hasUI] of [["rpc", true], ["print", false]] as const) {
264
+ const h = harness({ installed: true });
265
+ const pi = register(h);
266
+ const ui = fakeContext({ select: pick(/dictat/i) }, mode, hasUI);
267
+ await run(pi, ui);
268
+ assert.deepEqual(h.calls, [], mode);
269
+ assert.equal(ui.selects.length, 0, mode);
270
+ assert.equal(ui.editor(), "draft: ", mode);
271
+ }
272
+ });
273
+
274
+ test("an unsupported platform is explained and offers no download", async () => {
275
+ const h = harness({ platform: "win32", arch: "arm64" });
276
+ const pi = register(h);
277
+ const ui = fakeContext({ select: pick(/download/i), confirm: () => true });
278
+ await run(pi, ui);
279
+ assert.deepEqual(h.calls, ["probe"]);
280
+ assert.equal(ui.selects.length, 0);
281
+ assert.match(ui.notices[0]!.message, /Windows ARM64/);
282
+ assert.equal(ui.notices[0]!.type, "error");
283
+ });
284
+
285
+ // --- installation -------------------------------------------------------------
286
+
287
+ test("a missing install offers the download and declining it downloads nothing", async () => {
288
+ const h = harness({ platform: "win32", arch: "x64" });
289
+ const pi = register(h);
290
+ const ui = fakeContext({ select: pick(/download/i), confirm: () => false });
291
+ await run(pi, ui);
292
+ assert.ok(!ui.selects[0]!.options.some((option) => /start dictation/i.test(option)), "dictation needs an install first");
293
+ const { message } = ui.confirms[0]!;
294
+ assert.match(message, /MB/);
295
+ assert.ok(message.includes(VOICE_HOME), "names the destination");
296
+ assert.match(message, /CC-BY-4\.0/);
297
+ assert.match(message, /NVIDIA/);
298
+ assert.match(message, /Apache-2\.0/);
299
+ assert.match(message, /Visual C\+\+/);
300
+ assert.match(message, /not .*installed by Gentle AI|never (downloads|installs)/i);
301
+ assert.ok(!h.calls.some((call) => call.startsWith("install:")));
302
+ assert.equal(ui.editor(), "draft: ");
303
+ });
304
+
305
+ test("an accepted download installs runtime then model with progress and pinned defaults", async () => {
306
+ const gate = deferred<void>();
307
+ const h = harness({
308
+ install: async (kind, options) => {
309
+ options.onProgress({ component: kind, phase: "download", item: kind === "model" ? "encoder.int8.onnx" : "node-cpal", receivedBytes: 50, totalBytes: 100, overallReceivedBytes: 50, overallTotalBytes: 100 });
310
+ if (kind === "model") await gate.promise;
311
+ return { kind, path: `${VOICE_HOME}/${kind}`, alreadyInstalled: false };
312
+ },
313
+ });
314
+ const pi = register(h);
315
+ const ui = fakeContext({ select: pick(/download/i), confirm: () => true });
316
+ const done = run(pi, ui);
317
+ await until(() => h.calls.includes("install:model"));
318
+ assert.ok(ui.open());
319
+ const screen = ui.component()!.render(80).join("\n");
320
+ assert.match(screen, /50%/);
321
+ assert.match(screen, /encoder\.int8\.onnx/);
322
+ assert.match(screen, /esc/i);
323
+ gate.resolve();
324
+ await done;
325
+ assert.deepEqual(h.calls.filter((call) => call.startsWith("install:")), ["install:runtime", "install:model"]);
326
+ for (const install of h.installs) {
327
+ assert.ok(install.options.signal instanceof AbortSignal);
328
+ for (const key of ["manifest", "manifests", "fetch"]) assert.equal(key in install.options, false, `${install.kind} gets no ${key} override`);
329
+ }
330
+ assert.equal(h.installs[0]!.options.target, "darwin-arm64");
331
+ assert.ok(!ui.open());
332
+ assert.match(ui.notices.at(-1)!.message, /installed/i);
333
+ assert.equal(ui.editor(), "draft: ");
334
+ });
335
+
336
+ test("Esc during a download aborts it and keeps nothing", async () => {
337
+ const h = harness({
338
+ install: (_kind, options) =>
339
+ new Promise((_resolve, reject) => {
340
+ options.signal.addEventListener("abort", () => reject(new VoiceInstallError("aborted", "raw /secret/path")), { once: true });
341
+ }),
342
+ });
343
+ const pi = register(h);
344
+ const ui = fakeContext({ select: pick(/download/i), confirm: () => true });
345
+ const done = run(pi, ui);
346
+ await until(() => h.calls.includes("install:runtime") && ui.open());
347
+ ui.component()!.handleInput!(ESC);
348
+ await done;
349
+ assert.equal((h.installs[0]!.options.signal as AbortSignal).aborted, true);
350
+ assert.ok(!h.calls.includes("install:model"));
351
+ assert.ok(!ui.open());
352
+ assert.match(ui.notices.at(-1)!.message, /cancelled/i);
353
+ assert.ok(!ui.notices.some((notice) => notice.message.includes("/secret/path")), "fixed messages only");
354
+ });
355
+
356
+ test("a failed download reports a fixed message without raw error text", async () => {
357
+ const h = harness({ install: async () => { throw new VoiceInstallError("integrity", "encoder at /home/user/x does not match"); } });
358
+ const pi = register(h);
359
+ const ui = fakeContext({ select: pick(/download/i), confirm: () => true });
360
+ await run(pi, ui);
361
+ const last = ui.notices.at(-1)!;
362
+ assert.equal(last.type, "error");
363
+ assert.match(last.message, /digest|verif/i);
364
+ assert.ok(!last.message.includes("/home/user/x"));
365
+ assert.ok(!ui.open());
366
+ });
367
+
368
+ // --- dictation ----------------------------------------------------------------
369
+
370
+ async function startDictation(h: Harness, pi: ReturnType<typeof fakePi>, ui: ReturnType<typeof fakeContext>) {
371
+ const done = run(pi, ui);
372
+ await until(() => h.sessions.length === 1 && ui.open());
373
+ const session = h.sessions[0]!;
374
+ session.started.resolve();
375
+ await until(() => session.state === "recording");
376
+ return { done, session };
377
+ }
378
+
379
+ test("Enter stops recording, closes the overlay and pastes the exact transcript into the draft", async () => {
380
+ const h = harness({ installed: true });
381
+ const pi = register(h);
382
+ const ui = fakeContext({ select: pick(/start dictation/i) });
383
+ const { done, session } = await startDictation(h, pi, ui);
384
+ assert.match(ui.component()!.render(80).join("\n"), /recording/i);
385
+ ui.component()!.handleInput!(ENTER);
386
+ await until(() => session.calls.includes("stop"));
387
+ assert.match(ui.component()!.render(80).join("\n"), /transcrib/i);
388
+ assert.ok(ui.open(), "stays open until the transcript settles");
389
+ session.result.resolve({ text: "hola canción", truncated: false });
390
+ await done;
391
+ assert.equal(ui.editor(), "draft: hola canción");
392
+ assert.ok(ui.events.indexOf("close") < ui.events.indexOf("paste:hola canción"), "closes before pasting");
393
+ assert.equal(pi.counts.sends, 0, "never submits");
394
+ assert.deepEqual(session.calls, ["start", "stop"]);
395
+ });
396
+
397
+ test("Esc while recording cancels the session and leaves the draft untouched", async () => {
398
+ const h = harness({ installed: true });
399
+ const pi = register(h);
400
+ const ui = fakeContext({ select: pick(/start dictation/i) });
401
+ const { done, session } = await startDictation(h, pi, ui);
402
+ ui.component()!.handleInput!(ESC);
403
+ await done;
404
+ session.result.resolve({ text: "late text", truncated: false });
405
+ await tick();
406
+ assert.ok(session.calls.includes("cancel"));
407
+ assert.ok(!ui.open());
408
+ assert.equal(ui.editor(), "draft: ");
409
+ assert.equal(pi.counts.sends, 0);
410
+ assert.ok(!ui.events.some((event) => event.startsWith("paste:")));
411
+ });
412
+
413
+ test("the recording limit transcribes by itself and delivers without a key press", async () => {
414
+ const h = harness({ installed: true });
415
+ const pi = register(h);
416
+ const ui = fakeContext({ select: pick(/start dictation/i) });
417
+ const { done, session } = await startDictation(h, pi, ui);
418
+ session.emit("transcribing");
419
+ session.result.resolve({ text: "limit reached", truncated: true });
420
+ session.emit("finished");
421
+ await done;
422
+ assert.ok(!ui.open());
423
+ assert.equal(ui.editor(), "draft: limit reached");
424
+ assert.match(ui.notices.map((notice) => notice.message).join("\n"), /limit/i);
425
+ });
426
+
427
+ test("a session failure shows its fixed message with a user action and pastes nothing", async () => {
428
+ const h = harness({ installed: true, platform: "linux", arch: "x64" });
429
+ h.deps.probe = () => ({ platform: "linux", arch: "x64", glibcVersion: "2.39" });
430
+ const pi = register(h);
431
+ const ui = fakeContext({ select: pick(/start dictation/i) });
432
+ const done = run(pi, ui);
433
+ await until(() => h.sessions.length === 1);
434
+ h.sessions[0]!.started.reject(new VoiceSessionError("runtime-load"));
435
+ await done;
436
+ const last = ui.notices.at(-1)!;
437
+ assert.equal(last.type, "error");
438
+ assert.match(last.message, /could not be loaded/);
439
+ assert.match(last.message, /libasound|ALSA/);
440
+ assert.ok(!ui.open());
441
+ assert.equal(ui.editor(), "draft: ");
442
+ });
443
+
444
+ test("a second /gentle:voice while one is active is refused with a notice", async () => {
445
+ const h = harness({ installed: true });
446
+ const pi = register(h);
447
+ const ui = fakeContext({ select: pick(/start dictation/i) });
448
+ const { done, session } = await startDictation(h, pi, ui);
449
+ const second = fakeContext({ select: pick(/start dictation/i) });
450
+ await run(pi, second);
451
+ assert.equal(h.sessions.length, 1, "no parallel session");
452
+ assert.match(second.notices[0]!.message, /already/i);
453
+ ui.component()!.handleInput!(ENTER);
454
+ session.result.resolve({ text: "one", truncated: false });
455
+ await done;
456
+ // The lock is released once the first run settles.
457
+ const third = fakeContext({ select: () => undefined });
458
+ await run(pi, third);
459
+ assert.equal(third.selects.length, 1);
460
+ });
461
+
462
+ test("session shutdown cancels an active dictation and drops a late transcript", async () => {
463
+ const h = harness({ installed: true });
464
+ const pi = register(h);
465
+ const ui = fakeContext({ select: pick(/start dictation/i) });
466
+ const { done, session } = await startDictation(h, pi, ui);
467
+ ui.component()!.handleInput!(ENTER);
468
+ await until(() => session.calls.includes("stop"));
469
+ await pi.fire("session_shutdown");
470
+ session.result.resolve({ text: "too late", truncated: false });
471
+ await done;
472
+ await tick();
473
+ assert.ok(session.calls.includes("cancel"));
474
+ assert.ok(!ui.open());
475
+ assert.equal(ui.editor(), "draft: ");
476
+ assert.equal(pi.counts.sends, 0);
477
+ // The state observer stays safe after teardown.
478
+ assert.doesNotThrow(() => session.onStateChange("finished"));
479
+ });
480
+
481
+ test("session shutdown aborts an active download", async () => {
482
+ const h = harness({
483
+ install: (_kind, options) =>
484
+ new Promise((_resolve, reject) => {
485
+ options.signal.addEventListener("abort", () => reject(new VoiceInstallError("aborted", "cancelled")), { once: true });
486
+ }),
487
+ });
488
+ const pi = register(h);
489
+ const ui = fakeContext({ select: pick(/download/i), confirm: () => true });
490
+ const done = run(pi, ui);
491
+ await until(() => h.calls.includes("install:runtime") && ui.open());
492
+ await pi.fire("session_shutdown");
493
+ await done;
494
+ assert.equal((h.installs[0]!.options.signal as AbortSignal).aborted, true);
495
+ assert.ok(!ui.open());
496
+ assert.ok(!h.calls.includes("install:model"));
497
+ });
498
+
499
+ test("status reports the target, both components and the unverified hardware state", async () => {
500
+ const h = harness({ platform: "linux", arch: "arm64" });
501
+ h.deps.probe = () => ({ platform: "linux", arch: "arm64", glibcVersion: "2.35", wsl: true });
502
+ const pi = register(h);
503
+ const ui = fakeContext({ select: pick(/status/i) });
504
+ await run(pi, ui);
505
+ const message = ui.notices.at(-1)!.message;
506
+ assert.match(message, /linux-arm64/);
507
+ assert.match(message, /unproven/i);
508
+ assert.match(message, /model: missing/i);
509
+ assert.match(message, /runtime: missing/i);
510
+ assert.match(message, /not been verified on hardware/);
511
+ assert.match(message, /libasound/);
512
+ assert.ok(!h.calls.some((call) => call.startsWith("install:") || call.startsWith("session:")));
513
+ });
514
+
515
+ test("a transcript that arrives while cancelling is dropped", async () => {
516
+ const h = harness({ installed: true });
517
+ const pi = register(h);
518
+ const ui = fakeContext({ select: pick(/start dictation/i) });
519
+ const { done, session } = await startDictation(h, pi, ui);
520
+ ui.component()!.handleInput!(ENTER);
521
+ await until(() => session.calls.includes("stop"));
522
+ // As with the real worker, the result line can land before the process closes.
523
+ session.cancel = async () => {
524
+ session.calls.push("cancel");
525
+ session.result.resolve({ text: "raced", truncated: false });
526
+ await tick();
527
+ session.emit("finished");
528
+ };
529
+ ui.component()!.handleInput!(ESC);
530
+ await done;
531
+ assert.equal(ui.editor(), "draft: ");
532
+ assert.ok(!ui.events.some((event) => event.startsWith("paste:")));
533
+ });
534
+
535
+ // --- push-to-talk (hold Space) ------------------------------------------------
536
+
537
+ const SPACE = " ";
538
+ const REPEAT_DELAY = 400;
539
+ const REPEAT_INTERVAL = 40;
540
+ const RELEASE = 250;
541
+
542
+ async function startSession(pi: ReturnType<typeof fakePi>, ui: ReturnType<typeof fakeContext>) {
543
+ await pi.fire("session_start", ui.ctx);
544
+ }
545
+
546
+ // Space pressed and held: the first space, then `repeats` OS auto-repeats.
547
+ function holdSpace(h: Harness, ui: ReturnType<typeof fakeContext>, repeats: number, first = true) {
548
+ if (first) ui.type(SPACE);
549
+ for (let index = 0; index < repeats; index++) {
550
+ h.clock.advance(first && index === 0 ? REPEAT_DELAY : REPEAT_INTERVAL);
551
+ ui.type(SPACE);
552
+ }
553
+ }
554
+
555
+ // Holds Space until a dictation is recording, as on the very first hold of a
556
+ // session: the install check runs lazily while the spaces are still typed.
557
+ async function holdUntilRecording(h: Harness, ui: ReturnType<typeof fakeContext>) {
558
+ holdSpace(h, ui, 4);
559
+ await until(() => h.calls.some((call) => call.startsWith("status:")));
560
+ await tick();
561
+ holdSpace(h, ui, 1, false);
562
+ assert.equal(h.sessions.length, 1, "the hold started one dictation");
563
+ holdSpace(h, ui, 4, false);
564
+ const session = h.sessions[0]!;
565
+ session.started.resolve();
566
+ await until(() => session.state === "recording");
567
+ return session;
568
+ }
569
+
570
+ test("startup arms push-to-talk only when enabled, reading the toggle and nothing else", async () => {
571
+ const enabled = harness({ installed: true, pushToTalk: true });
572
+ const pi = register(enabled);
573
+ const ui = fakeContext();
574
+ await startSession(pi, ui);
575
+ assert.equal(ui.terminal.subscribes, 1);
576
+ assert.equal(enabled.settings.reads, 1);
577
+ assert.deepEqual(enabled.calls, [], "no probe, status read, download or session at startup");
578
+
579
+ const disabled = harness({ installed: true });
580
+ const off = register(disabled);
581
+ const typing = fakeContext();
582
+ await startSession(off, typing);
583
+ assert.equal(typing.terminal.subscribes, 0, "no listener when disabled");
584
+ holdSpace(disabled, typing, 6);
585
+ typing.type("a");
586
+ assert.equal(typing.editor(), `draft: ${SPACE.repeat(7)}a`, "typing is unchanged");
587
+ assert.deepEqual(disabled.calls, []);
588
+ });
589
+
590
+ test("child sessions and non-TUI modes never arm or read the toggle", async () => {
591
+ const child = harness({ installed: true, pushToTalk: true, env: { GENTLE_PI_AGENTS_CHILD: "1" } });
592
+ const childPi = register(child);
593
+ const childUi = fakeContext();
594
+ await startSession(childPi, childUi);
595
+ assert.equal(childUi.terminal.subscribes, 0);
596
+ for (const [mode, hasUI] of [["rpc", true], ["print", false]] as const) {
597
+ const h = harness({ installed: true, pushToTalk: true });
598
+ const pi = register(h);
599
+ const ui = fakeContext({}, mode, hasUI);
600
+ await startSession(pi, ui);
601
+ assert.equal(ui.terminal.subscribes, 0, mode);
602
+ assert.equal(h.settings.reads, 0, mode);
603
+ }
604
+ assert.equal(child.settings.reads, 0);
605
+ });
606
+
607
+ test("holding Space records, releasing it pastes the transcript into the draft and never sends", async () => {
608
+ const h = harness({ installed: true, pushToTalk: true });
609
+ const pi = register(h);
610
+ const ui = fakeContext();
611
+ await startSession(pi, ui);
612
+ const session = await holdUntilRecording(h, ui);
613
+ assert.equal(ui.editor(), "draft: ", "every space typed by the hold was erased");
614
+ assert.match(ui.statuses.at(-1) ?? "", /recording/i);
615
+ assert.ok(!ui.open(), "no overlay steals the editor focus");
616
+ h.clock.advance(RELEASE);
617
+ await until(() => session.calls.includes("stop"));
618
+ assert.match(ui.statuses.at(-1) ?? "", /transcrib/i);
619
+ session.result.resolve({ text: "hola mundo", truncated: false });
620
+ await until(() => ui.editor() === "draft: hola mundo");
621
+ await until(() => ui.statuses.at(-1) === undefined);
622
+ assert.equal(pi.counts.sends, 0);
623
+ assert.deepEqual(session.calls, ["start", "stop"]);
624
+ assert.deepEqual(h.calls.filter((call) => call === "probe").length, 1, "the install is checked once per session");
625
+ // The lock is free again: a second hold starts a second dictation directly.
626
+ holdSpace(h, ui, 4);
627
+ assert.equal(h.sessions.length, 2);
628
+ });
629
+
630
+ test("a release before recording starts stops as soon as recording begins", async () => {
631
+ const h = harness({ installed: true, pushToTalk: true });
632
+ const pi = register(h);
633
+ const ui = fakeContext();
634
+ await startSession(pi, ui);
635
+ holdSpace(h, ui, 4);
636
+ await until(() => h.calls.some((call) => call.startsWith("status:")));
637
+ await tick();
638
+ holdSpace(h, ui, 1, false);
639
+ const session = h.sessions[0]!;
640
+ h.clock.advance(RELEASE);
641
+ assert.ok(!session.calls.includes("stop"));
642
+ session.started.resolve();
643
+ await until(() => session.calls.includes("stop"));
644
+ session.result.resolve({ text: "short", truncated: false });
645
+ await until(() => ui.editor().endsWith("short"));
646
+ });
647
+
648
+ test("Esc during a hold cancels the dictation and leaves the draft unchanged", async () => {
649
+ const h = harness({ installed: true, pushToTalk: true });
650
+ const pi = register(h);
651
+ const ui = fakeContext();
652
+ await startSession(pi, ui);
653
+ const session = await holdUntilRecording(h, ui);
654
+ ui.type(ESC);
655
+ await until(() => session.calls.includes("cancel"));
656
+ session.result.resolve({ text: "dropped", truncated: false });
657
+ await until(() => ui.statuses.at(-1) === undefined);
658
+ await tick();
659
+ assert.equal(ui.editor(), "draft: ", "Esc was consumed and nothing was pasted");
660
+ assert.match(ui.notices.at(-1)!.message, /cancelled/i);
661
+ h.clock.advance(RELEASE);
662
+ assert.ok(!session.calls.includes("stop"));
663
+ });
664
+
665
+ test("not installed: the hold types its spaces, notifies once and disarms", async () => {
666
+ const h = harness({ pushToTalk: true });
667
+ const pi = register(h);
668
+ const ui = fakeContext();
669
+ await startSession(pi, ui);
670
+ holdSpace(h, ui, 4);
671
+ await until(() => ui.notices.length > 0);
672
+ holdSpace(h, ui, 3, false);
673
+ assert.equal(ui.editor(), `draft: ${SPACE.repeat(8)}`, "all eight spaces typed normally");
674
+ assert.equal(h.sessions.length, 0);
675
+ assert.match(ui.notices[0]!.message, /\/gentle:voice/);
676
+ assert.equal(ui.terminal.unsubscribes, 1, "disarmed");
677
+ h.clock.advance(RELEASE);
678
+ holdSpace(h, ui, 4);
679
+ assert.equal(ui.notices.length, 1, "notified once");
680
+ assert.equal(h.settings.writes.length, 0, "the saved choice is kept");
681
+ });
682
+
683
+ test("a hold while /gentle:voice is dictating does not start a second session", async () => {
684
+ const h = harness({ installed: true, pushToTalk: true });
685
+ const pi = register(h);
686
+ const ui = fakeContext({ select: pick(/start dictation/i) });
687
+ await startSession(pi, ui);
688
+ const { done, session } = await startDictation(h, pi, ui);
689
+ holdSpace(h, ui, 6);
690
+ await tick();
691
+ assert.equal(h.sessions.length, 1);
692
+ assert.equal(ui.editor(), `draft: ${SPACE.repeat(7)}`, "spaces pass through");
693
+ ui.component()!.handleInput!(ENTER);
694
+ session.result.resolve({ text: "one", truncated: false });
695
+ await done;
696
+ });
697
+
698
+ test("session shutdown cancels a held dictation, disarms and drops a late transcript", async () => {
699
+ const h = harness({ installed: true, pushToTalk: true });
700
+ const pi = register(h);
701
+ const ui = fakeContext();
702
+ await startSession(pi, ui);
703
+ const session = await holdUntilRecording(h, ui);
704
+ await pi.fire("session_shutdown");
705
+ assert.equal(ui.terminal.unsubscribes, 1);
706
+ await until(() => session.calls.includes("cancel"));
707
+ session.result.resolve({ text: "too late", truncated: false });
708
+ await tick();
709
+ await tick();
710
+ assert.equal(ui.editor(), "draft: ");
711
+ h.clock.advance(RELEASE);
712
+ assert.ok(!session.calls.includes("stop"));
713
+ });
714
+
715
+ test("the /gentle:voice menu toggles push-to-talk, persists it and arms or disarms immediately", async () => {
716
+ const h = harness({ installed: true, pushToTalk: true });
717
+ const pi = register(h);
718
+ const ui = fakeContext({ select: pick(/disable push-to-talk/i) });
719
+ await startSession(pi, ui);
720
+ await run(pi, ui);
721
+ assert.ok(ui.selects[0]!.options.includes("Disable push-to-talk (hold Space)"));
722
+ assert.deepEqual(h.settings.writes, [false]);
723
+ assert.equal(ui.terminal.unsubscribes, 1);
724
+ holdSpace(h, ui, 4);
725
+ assert.equal(h.sessions.length, 0, "disarmed in the live session");
726
+
727
+ const again = fakeContext({ select: pick(/enable push-to-talk/i) });
728
+ await run(pi, again);
729
+ assert.ok(again.selects[0]!.options.includes("Enable push-to-talk (hold Space)"));
730
+ assert.deepEqual(h.settings.writes, [false, true]);
731
+ assert.equal(again.terminal.subscribes, 1, "armed in the live session");
732
+ // A reload reads the persisted choice.
733
+ const reloaded = register(h);
734
+ const fresh = fakeContext();
735
+ await startSession(reloaded, fresh);
736
+ assert.equal(fresh.terminal.subscribes, 1);
737
+ });
738
+
739
+ test("Enable is offered only when voice is installed", async () => {
740
+ const h = harness();
741
+ const pi = register(h);
742
+ const ui = fakeContext({ select: () => undefined });
743
+ await run(pi, ui);
744
+ assert.ok(!ui.selects[0]!.options.some((option) => /push-to-talk/i.test(option)));
745
+ });
746
+
747
+ test("a successful download enables push-to-talk and arms it", async () => {
748
+ const h = harness();
749
+ const pi = register(h);
750
+ const ui = fakeContext({ select: pick(/download/i), confirm: () => true });
751
+ await startSession(pi, ui);
752
+ await run(pi, ui);
753
+ assert.deepEqual(h.settings.writes, [true]);
754
+ assert.equal(ui.terminal.subscribes, 1);
755
+ assert.match(ui.notices.at(-1)!.message, /hold Space/i);
756
+ // Already known installed: the first hold starts directly.
757
+ holdSpace(h, ui, 4);
758
+ assert.equal(h.sessions.length, 1);
759
+ });
760
+
761
+ test("a hold right after a word keeps one space before the pasted transcript", async () => {
762
+ const h = harness({ installed: true, pushToTalk: true });
763
+ const pi = register(h);
764
+ const ui = fakeContext();
765
+ await startSession(pi, ui);
766
+ ui.type("hi");
767
+ const session = await holdUntilRecording(h, ui);
768
+ assert.equal(ui.editor(), "draft: hi ");
769
+ h.clock.advance(RELEASE);
770
+ await until(() => session.calls.includes("stop"));
771
+ session.result.resolve({ text: "there", truncated: false });
772
+ await until(() => ui.editor() === "draft: hi there");
773
+ });