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,434 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHash } from "node:crypto";
3
+ import { EventEmitter } from "node:events";
4
+ import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ import { PassThrough } from "node:stream";
7
+ import test from "node:test";
8
+ import { fileURLToPath } from "node:url";
9
+ import { gzipSync } from "node:zlib";
10
+ import { VOICE_TARGETS, type VoiceModelManifest, type VoiceNpmPackage, type VoiceRuntimeManifest, type VoiceTarget } from "../lib/voice-artifacts.ts";
11
+ import { installVoiceModel, installVoiceRuntime, voiceComponentPath, type VoiceFetch } from "../lib/voice-install.ts";
12
+ import { createVoiceSession, VOICE_WORKER_SCHEMA, VoiceSessionError, type VoiceSessionOptions, type VoiceSessionState, type VoiceWorkerSpawnOptions } from "../lib/voice-session.ts";
13
+
14
+ // The voice session (S1-S3) owns one worker process per dictation. Most tests
15
+ // replace the process with a scripted fake; the last ones run the real
16
+ // scripts/voice-worker.cjs against fake capture and recognizer modules that the
17
+ // real installer placed in an owned scratch voice home. No native module,
18
+ // microphone or network is used.
19
+
20
+ const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)));
21
+ const hostTarget = VOICE_TARGETS.find((target) => target === `${process.platform}-${process.arch}`);
22
+
23
+ const FAKE_ASR = `exports.OfflineRecognizer = class {
24
+ createStream() { const stream = { samples: 0, acceptWaveform: (wave) => { stream.samples += wave.samples.length; } }; return stream; }
25
+ decode() {}
26
+ getResult(stream) { return { text: stream.samples > 0 ? " fake transcript " : "" }; }
27
+ };
28
+ `;
29
+ const FAKE_CPAL = `exports.convenience = {
30
+ getDefaultInputDevice: () => ({ deviceId: "fake" }),
31
+ getDefaultInputConfig: () => ({ sampleRate: 16000, channels: 1, sampleFormat: "f32" }),
32
+ getSupportedFormats: () => ["f32"],
33
+ createInputStream: async (options) => {
34
+ const timer = setInterval(() => options.onData(new Float32Array(1600).fill(0.1)), 5);
35
+ return { close: async () => clearInterval(timer) };
36
+ },
37
+ };
38
+ `;
39
+
40
+ function tarball(files: Record<string, string>): Buffer {
41
+ const blocks: Buffer[] = [];
42
+ for (const [name, content] of Object.entries(files)) {
43
+ const data = Buffer.from(content);
44
+ const header = Buffer.alloc(512);
45
+ header.write(`package/${name}`, 0);
46
+ header.write("0000644\0", 100);
47
+ header.write(`${data.length.toString(8).padStart(11, "0")}\0`, 124);
48
+ header.fill(0x20, 148, 156);
49
+ header.write("0", 156);
50
+ header.write("ustar\0", 257);
51
+ let sum = 0;
52
+ for (const byte of header) sum += byte;
53
+ header.write(`${sum.toString(8).padStart(6, "0")}\0 `, 148);
54
+ blocks.push(header, data, Buffer.alloc((512 - (data.length % 512)) % 512));
55
+ }
56
+ blocks.push(Buffer.alloc(1024));
57
+ return gzipSync(Buffer.concat(blocks));
58
+ }
59
+
60
+ interface Fixture {
61
+ voiceHome: string;
62
+ target: VoiceTarget;
63
+ manifests: { model: VoiceModelManifest; runtime: VoiceRuntimeManifest };
64
+ }
65
+
66
+ // Installs a fake runtime and model through the real installer, so status,
67
+ // markers and layout are exactly what /gentle:voice will find.
68
+ async function installFixture(t: test.TestContext, target: VoiceTarget): Promise<Fixture> {
69
+ const base = join(repoRoot, "tmp");
70
+ mkdirSync(base, { recursive: true });
71
+ const dir = mkdtempSync(join(base, "voice-session-"));
72
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
73
+ const voiceHome = join(dir, "voice");
74
+ const bodies = new Map<string, Buffer>();
75
+ const pkg = (name: string, files: Record<string, string>): VoiceNpmPackage => {
76
+ const data = tarball(files);
77
+ const tarballUrl = `https://registry.npmjs.org/${name}/-/${name}-0.0.0.tgz`;
78
+ bodies.set(tarballUrl, data);
79
+ return {
80
+ name,
81
+ version: "0.0.0",
82
+ license: "MIT",
83
+ tarball: tarballUrl,
84
+ size: data.length,
85
+ integrity: `sha512-${createHash("sha512").update(data).digest("base64")}`,
86
+ unpackedSize: Object.values(files).reduce((sum, content) => sum + Buffer.byteLength(content), 0),
87
+ fileCount: Object.keys(files).length,
88
+ requiredFiles: Object.keys(files),
89
+ };
90
+ };
91
+ const runtime: VoiceRuntimeManifest = {
92
+ id: `test-runtime-${target}`,
93
+ target,
94
+ packages: [pkg("sherpa-onnx-node", { "package.json": "{}", "non-streaming-asr.js": FAKE_ASR }), pkg("node-cpal", { "package.json": "{}", "index.js": FAKE_CPAL })],
95
+ redirects: [],
96
+ };
97
+ const model: VoiceModelManifest = { id: "test-model", revision: "test", baseUrl: "https://huggingface.co/test", files: [], redirects: [] };
98
+ for (const name of ["encoder.int8.onnx", "decoder.int8.onnx", "joiner.int8.onnx", "tokens.txt"]) {
99
+ const data = Buffer.from(`fake ${name}`);
100
+ bodies.set(`${model.baseUrl}/${name}`, data);
101
+ model.files.push({ name, size: data.length, sha256: createHash("sha256").update(data).digest("hex") });
102
+ }
103
+ const fetch: VoiceFetch = async (url) => {
104
+ const body = bodies.get(url);
105
+ return body ? new Response(new Uint8Array(body), { status: 200, headers: { "content-length": String(body.length) } }) : new Response(null, { status: 404 });
106
+ };
107
+ await installVoiceRuntime({ voiceHome, target, manifest: runtime, fetch });
108
+ await installVoiceModel({ voiceHome, manifest: model, fetch });
109
+ return { voiceHome, target, manifests: { model, runtime } };
110
+ }
111
+
112
+ // A scripted worker: records what it is sent and exits when its stdin ends,
113
+ // unless told to hang so only a kill can end it.
114
+ class FakeWorker extends EventEmitter {
115
+ stdin = new PassThrough();
116
+ stdout = new EventEmitter();
117
+ stderr = new EventEmitter();
118
+ received: Array<Record<string, unknown>> = [];
119
+ signals: string[] = [];
120
+ hang = false;
121
+ exited = false;
122
+
123
+ constructor() {
124
+ super();
125
+ this.stdin.setEncoding("utf8");
126
+ let buffered = "";
127
+ this.stdin.on("data", (chunk: string) => {
128
+ buffered += chunk;
129
+ for (let newline; (newline = buffered.indexOf("\n")) !== -1; buffered = buffered.slice(newline + 1)) this.received.push(JSON.parse(buffered.slice(0, newline)));
130
+ });
131
+ this.stdin.on("end", () => {
132
+ if (!this.hang) this.exit();
133
+ });
134
+ }
135
+
136
+ kill(signal: string): boolean {
137
+ this.signals.push(signal);
138
+ setImmediate(() => this.exit());
139
+ return true;
140
+ }
141
+
142
+ exit(): void {
143
+ if (this.exited) return;
144
+ this.exited = true;
145
+ setImmediate(() => this.emit("close", 0, null));
146
+ }
147
+
148
+ say(message: Record<string, unknown>): void {
149
+ this.line(JSON.stringify({ schema: VOICE_WORKER_SCHEMA, ...message }));
150
+ }
151
+
152
+ line(text: string): void {
153
+ this.stdout.emit("data", `${text}\n`);
154
+ }
155
+ }
156
+
157
+ function scripted(fixture: Fixture, options: Partial<VoiceSessionOptions> = {}) {
158
+ const workers: FakeWorker[] = [];
159
+ const launches: Array<{ executable: string; args: string[]; options: VoiceWorkerSpawnOptions }> = [];
160
+ const states: VoiceSessionState[] = [];
161
+ const session = createVoiceSession({
162
+ target: fixture.target,
163
+ voiceHome: fixture.voiceHome,
164
+ manifests: fixture.manifests,
165
+ workerPath: "/fixed/voice-worker.cjs",
166
+ exitGraceMs: 20,
167
+ env: { HOME: "/home/someone", SECRET_TOKEN: "abc", NODE_OPTIONS: "--require /evil.js", PATH: "/usr/bin" },
168
+ onStateChange: (state) => states.push(state),
169
+ spawn: (executable, args, spawnOptions) => {
170
+ launches.push({ executable, args, options: spawnOptions });
171
+ const worker = new FakeWorker();
172
+ workers.push(worker);
173
+ return worker;
174
+ },
175
+ ...options,
176
+ });
177
+ const worker = () => workers[0]!;
178
+ const until = async (predicate: () => boolean) => {
179
+ for (let i = 0; i < 500 && !predicate(); i++) await new Promise((resolve) => setTimeout(resolve, 2));
180
+ assert.ok(predicate(), "timed out waiting for the session");
181
+ };
182
+ return { session, workers, launches, states, worker, until };
183
+ }
184
+
185
+ async function rejectsWith(promise: Promise<unknown>, code: string) {
186
+ await assert.rejects(promise, (error: unknown) => error instanceof VoiceSessionError && error.code === code);
187
+ }
188
+
189
+ test("does nothing until start, and a missing install never launches a worker", async (t) => {
190
+ const base = join(repoRoot, "tmp");
191
+ mkdirSync(base, { recursive: true });
192
+ const dir = mkdtempSync(join(base, "voice-session-"));
193
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
194
+ const voiceHome = join(dir, "voice");
195
+ const fixture: Fixture = { voiceHome, target: "linux-x64", manifests: undefined as never };
196
+ const run = scripted(fixture, { manifests: undefined });
197
+ assert.equal(run.session.state, "idle");
198
+ assert.equal(existsSync(voiceHome), false);
199
+ await rejectsWith(run.session.start(), "not-installed");
200
+ assert.equal(run.session.state, "finished");
201
+ assert.equal(run.launches.length, 0);
202
+ assert.equal(existsSync(voiceHome), false);
203
+ await rejectsWith(run.session.start(), "invalid-state");
204
+
205
+ for (const [options, code] of [[{ nodeVersion: "20.18.0" }, "node-version"], [{ target: "win32-arm64" as VoiceTarget }, "unsupported"]] as const) {
206
+ const other = scripted(fixture, { manifests: undefined, ...options });
207
+ await rejectsWith(other.session.start(), code);
208
+ assert.equal(other.launches.length, 0);
209
+ }
210
+ });
211
+
212
+ test("records, stops and returns the transcript from a fixed, scrubbed worker launch", async (t) => {
213
+ const fixture = await installFixture(t, "linux-x64");
214
+ const run = scripted(fixture);
215
+ const started = run.session.start();
216
+ await run.until(() => run.workers.length === 1 && run.worker().received.length === 1);
217
+
218
+ const [launch] = run.launches;
219
+ assert.equal(launch!.executable, process.execPath);
220
+ assert.deepEqual(launch!.args, ["/fixed/voice-worker.cjs"]);
221
+ const runtimeRoot = voiceComponentPath(fixture.voiceHome, "runtime", fixture.manifests.runtime);
222
+ assert.equal(launch!.options.cwd, runtimeRoot);
223
+ assert.equal(launch!.options.shell, false);
224
+ assert.equal(launch!.options.detached, false);
225
+ assert.deepEqual(Object.keys(launch!.options.env).sort(), ["GENTLE_PI_AGENTS_CHILD", "HOME", "NODE_NO_WARNINGS"]);
226
+
227
+ const request = run.worker().received[0] as any;
228
+ assert.equal(request.type, "start");
229
+ assert.equal(request.runtime.root, runtimeRoot);
230
+ assert.deepEqual(request.runtime.files, ["node_modules/sherpa-onnx-node/package.json", "node_modules/sherpa-onnx-node/non-streaming-asr.js", "node_modules/node-cpal/package.json", "node_modules/node-cpal/index.js"]);
231
+ assert.equal(request.model.root, voiceComponentPath(fixture.voiceHome, "model", fixture.manifests.model));
232
+ assert.deepEqual(request.model.files.encoder, fixture.manifests.model.files[0]);
233
+ assert.equal(request.maxRecordingMs, 120_000);
234
+
235
+ run.worker().say({ type: "recording" });
236
+ await started;
237
+ assert.equal(run.session.state, "recording");
238
+ const stopped = run.session.stop();
239
+ assert.deepEqual(run.worker().received.at(-1), { schema: VOICE_WORKER_SCHEMA, type: "stop" });
240
+ run.worker().say({ type: "transcribing" });
241
+ run.worker().say({ type: "result", text: " dictated text ", truncated: false });
242
+ assert.deepEqual(await stopped, { text: "dictated text", truncated: false });
243
+ assert.deepEqual(run.worker().signals, []);
244
+ assert.deepEqual(run.states, ["starting", "recording", "transcribing", "finished"]);
245
+ });
246
+
247
+ test("cancel discards the transcript, ignores late results and leaves no worker", async (t) => {
248
+ const fixture = await installFixture(t, "linux-x64");
249
+
250
+ await t.test("while recording, from a worker that must be killed", async () => {
251
+ const run = scripted(fixture);
252
+ const started = run.session.start();
253
+ await run.until(() => run.workers.length === 1);
254
+ run.worker().say({ type: "recording" });
255
+ await started;
256
+ run.worker().hang = true;
257
+ await run.session.cancel();
258
+ assert.deepEqual(run.worker().received.at(-1), { schema: VOICE_WORKER_SCHEMA, type: "cancel" });
259
+ assert.deepEqual(run.worker().signals, ["SIGKILL"]);
260
+ assert.equal(run.worker().exited, true);
261
+ run.worker().say({ type: "result", text: "late", truncated: false });
262
+ await rejectsWith(run.session.stop(), "cancelled");
263
+ assert.equal(run.session.state, "finished");
264
+ });
265
+
266
+ await t.test("after the result arrived but before the worker exited", async () => {
267
+ const run = scripted(fixture);
268
+ const started = run.session.start();
269
+ await run.until(() => run.workers.length === 1);
270
+ run.worker().say({ type: "recording" });
271
+ await started;
272
+ run.worker().hang = true;
273
+ const stopped = run.session.stop();
274
+ run.worker().say({ type: "transcribing" });
275
+ run.worker().say({ type: "result", text: "do not keep", truncated: false });
276
+ await run.session.cancel();
277
+ await rejectsWith(stopped, "cancelled");
278
+ });
279
+
280
+ await t.test("while starting", async () => {
281
+ const run = scripted(fixture);
282
+ const started = run.session.start();
283
+ await run.until(() => run.workers.length === 1);
284
+ await run.session.cancel();
285
+ await rejectsWith(started, "cancelled");
286
+ run.worker().say({ type: "recording" });
287
+ assert.equal(run.session.state, "finished");
288
+ await run.session.dispose();
289
+ });
290
+ });
291
+
292
+ test("an invalid worker response stops the worker", async (t) => {
293
+ const fixture = await installFixture(t, "linux-x64");
294
+ const cases: Array<[string, (worker: FakeWorker) => void]> = [
295
+ ["not json", (worker) => worker.line("{oops")],
296
+ ["unknown type", (worker) => worker.say({ type: "progress" })],
297
+ ["extra field", (worker) => worker.say({ type: "recording", path: "/home/someone" })],
298
+ ["wrong schema", (worker) => worker.line(JSON.stringify({ schema: "other", type: "recording" }))],
299
+ ["unknown error code", (worker) => worker.say({ type: "error", code: "EACCES /home/someone" })],
300
+ ["result before recording", (worker) => worker.say({ type: "result", text: "x", truncated: false })],
301
+ ["unterminated oversized line", (worker) => worker.stdout.emit("data", "x".repeat(70 * 1024))],
302
+ ];
303
+ for (const [name, misbehave] of cases) {
304
+ await t.test(name, async () => {
305
+ const run = scripted(fixture);
306
+ const started = run.session.start();
307
+ await run.until(() => run.workers.length === 1);
308
+ misbehave(run.worker());
309
+ await rejectsWith(started, "protocol");
310
+ assert.deepEqual(run.worker().signals, ["SIGKILL"]);
311
+ });
312
+ }
313
+ });
314
+
315
+ test("worker failures surface as fixed messages; stderr is never forwarded", async (t) => {
316
+ const fixture = await installFixture(t, "linux-x64");
317
+ const run = scripted(fixture);
318
+ const started = run.session.start();
319
+ await run.until(() => run.workers.length === 1);
320
+ run.worker().stderr.emit("data", `Error: cannot load ${fixture.voiceHome}/runtime TOKEN=abc\n`.repeat(1000));
321
+ run.worker().say({ type: "error", code: "runtime-load" });
322
+ const error = await started.then(() => undefined, (reason: unknown) => reason);
323
+ assert.ok(error instanceof VoiceSessionError);
324
+ assert.equal(error.code, "runtime-load");
325
+ assert.equal(error.message, "The voice runtime could not be loaded on this system.");
326
+ assert.deepEqual(run.worker().signals, []);
327
+ });
328
+
329
+ test("a model-invalid error while recording ends the session and a later stop reports it", async (t) => {
330
+ // The worker verifies the model digests while it records (T4).
331
+ const fixture = await installFixture(t, "linux-x64");
332
+ const run = scripted(fixture);
333
+ const started = run.session.start();
334
+ await run.until(() => run.workers.length === 1);
335
+ run.worker().say({ type: "recording" });
336
+ await started;
337
+ run.worker().say({ type: "error", code: "model-invalid" });
338
+ await run.until(() => run.session.state === "finished");
339
+ await rejectsWith(run.session.stop(), "model-invalid");
340
+ assert.deepEqual(run.states, ["starting", "recording", "finished"]);
341
+ assert.ok(!run.worker().received.some((message) => message.type === "stop"), "no stop is sent to a finished worker");
342
+ });
343
+
344
+ test("timeouts, a silent transcript and an early exit fail cleanly", async (t) => {
345
+ const fixture = await installFixture(t, "linux-x64");
346
+
347
+ await t.test("start timeout", async () => {
348
+ const run = scripted(fixture, { startTimeoutMs: 20 });
349
+ await rejectsWith(run.session.start(), "timeout");
350
+ assert.deepEqual(run.workers[0]!.signals, ["SIGKILL"]);
351
+ });
352
+
353
+ await t.test("transcribe timeout", async () => {
354
+ const run = scripted(fixture, { transcribeTimeoutMs: 20 });
355
+ const started = run.session.start();
356
+ await run.until(() => run.workers.length === 1);
357
+ run.worker().say({ type: "recording" });
358
+ await started;
359
+ await rejectsWith(run.session.stop(), "timeout");
360
+ assert.deepEqual(run.worker().signals, ["SIGKILL"]);
361
+ });
362
+
363
+ await t.test("empty transcript", async () => {
364
+ const run = scripted(fixture);
365
+ const started = run.session.start();
366
+ await run.until(() => run.workers.length === 1);
367
+ run.worker().say({ type: "recording" });
368
+ await started;
369
+ const stopped = run.session.stop();
370
+ run.worker().say({ type: "transcribing" });
371
+ run.worker().say({ type: "result", text: " ", truncated: false });
372
+ await rejectsWith(stopped, "no-speech");
373
+ });
374
+
375
+ await t.test("exit while recording", async () => {
376
+ const run = scripted(fixture);
377
+ const started = run.session.start();
378
+ await run.until(() => run.workers.length === 1);
379
+ run.worker().say({ type: "recording" });
380
+ await started;
381
+ run.worker().exit();
382
+ await run.until(() => run.session.state === "finished");
383
+ await rejectsWith(run.session.stop(), "worker-exited");
384
+ });
385
+ });
386
+
387
+ test("a transcript split inside UTF-8 characters at any byte is decoded exactly", async (t) => {
388
+ const fixture = await installFixture(t, "linux-x64");
389
+ const text = "canción del niño 🎤 añadida";
390
+ const bytes = Buffer.from(`${JSON.stringify({ schema: VOICE_WORKER_SCHEMA, type: "result", text, truncated: false })}\n`);
391
+ for (let split = 1; split < bytes.length; split++) {
392
+ const run = scripted(fixture);
393
+ const started = run.session.start();
394
+ await run.until(() => run.workers.length === 1);
395
+ run.worker().say({ type: "recording" });
396
+ await started;
397
+ const stopped = run.session.stop();
398
+ run.worker().say({ type: "transcribing" });
399
+ run.worker().stdout.emit("data", bytes.subarray(0, split));
400
+ run.worker().stdout.emit("data", bytes.subarray(split));
401
+ assert.deepEqual(await stopped, { text, truncated: false }, `split at byte ${split}`);
402
+ }
403
+ });
404
+
405
+ test("a transcript produced by the recording limit is returned by a later stop", async (t) => {
406
+ const fixture = await installFixture(t, "linux-x64");
407
+ const run = scripted(fixture, { maxRecordingMs: 1000 });
408
+ const started = run.session.start();
409
+ await run.until(() => run.workers.length === 1);
410
+ assert.equal((run.worker().received[0] as any).maxRecordingMs, 1000);
411
+ run.worker().say({ type: "recording" });
412
+ await started;
413
+ run.worker().say({ type: "transcribing" });
414
+ run.worker().say({ type: "result", text: "cut short", truncated: true });
415
+ run.worker().exit();
416
+ await run.until(() => run.session.state === "finished");
417
+ assert.deepEqual(await run.session.stop(), { text: "cut short", truncated: true });
418
+ });
419
+
420
+ test("the real worker process records and transcribes through fake installed modules", { skip: !hostTarget && "no pinned voice target for this host" }, async (t) => {
421
+ const fixture = await installFixture(t, hostTarget!);
422
+ const session = createVoiceSession({ target: fixture.target, voiceHome: fixture.voiceHome, manifests: fixture.manifests, startTimeoutMs: 10_000, transcribeTimeoutMs: 10_000 });
423
+ await session.start();
424
+ assert.equal(session.state, "recording");
425
+ await new Promise((resolve) => setTimeout(resolve, 150));
426
+ assert.deepEqual(await session.stop(), { text: "fake transcript", truncated: false });
427
+ assert.equal(session.state, "finished");
428
+
429
+ const cancelled = createVoiceSession({ target: fixture.target, voiceHome: fixture.voiceHome, manifests: fixture.manifests, startTimeoutMs: 10_000 });
430
+ await cancelled.start();
431
+ await cancelled.cancel();
432
+ assert.equal(cancelled.state, "finished");
433
+ await rejectsWith(cancelled.stop(), "cancelled");
434
+ });
@@ -0,0 +1,49 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { after } from "node:test";
5
+ import test from "node:test";
6
+ import { readVoicePushToTalk, VOICE_SETTINGS_FILE, VOICE_SETTINGS_SCHEMA, writeVoicePushToTalk } from "../lib/voice-settings.ts";
7
+
8
+ // The persisted push-to-talk toggle, in a scratch config home inside the
9
+ // workspace tmp/ directory.
10
+
11
+ const scratchRoot = join(import.meta.dirname, "..", "tmp");
12
+ mkdirSync(scratchRoot, { recursive: true });
13
+ const homes: string[] = [];
14
+ const scratchHome = () => {
15
+ const home = mkdtempSync(join(scratchRoot, "voice-ptt-"));
16
+ homes.push(home);
17
+ return home;
18
+ };
19
+ after(() => {
20
+ for (const home of homes) rmSync(home, { recursive: true, force: true });
21
+ });
22
+
23
+ test("push-to-talk is off until written, and a written value survives a fresh read", () => {
24
+ const home = scratchHome();
25
+ const options = { gentlePiConfigHome: home };
26
+ assert.equal(readVoicePushToTalk(options), false);
27
+ const path = writeVoicePushToTalk(true, options);
28
+ assert.equal(path, join(home, VOICE_SETTINGS_FILE));
29
+ assert.deepEqual(JSON.parse(readFileSync(path, "utf8")), { schema: VOICE_SETTINGS_SCHEMA, pushToTalk: true });
30
+ // A reload only re-reads the file.
31
+ assert.equal(readVoicePushToTalk({ gentlePiConfigHome: home }), true);
32
+ writeVoicePushToTalk(false, options);
33
+ assert.equal(readVoicePushToTalk(options), false);
34
+ });
35
+
36
+ test("a malformed or foreign settings file reads as off", () => {
37
+ const home = scratchHome();
38
+ const path = join(home, VOICE_SETTINGS_FILE);
39
+ for (const raw of ["not json", "[]", `{"schema":"${VOICE_SETTINGS_SCHEMA}","pushToTalk":"yes"}`, `{"schema":"other","pushToTalk":true}`, `{"schema":"${VOICE_SETTINGS_SCHEMA}","pushToTalk":true,"extra":1}`]) {
40
+ writeFileSync(path, raw);
41
+ assert.equal(readVoicePushToTalk({ gentlePiConfigHome: home }), false, raw);
42
+ }
43
+ });
44
+
45
+ test("the config home follows the environment when no home is given", () => {
46
+ const home = scratchHome();
47
+ writeVoicePushToTalk(true, { gentlePiConfigHome: home });
48
+ assert.equal(readVoicePushToTalk({ env: { GENTLE_PI_CONFIG_HOME: home } }), true);
49
+ });
@@ -0,0 +1,97 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { visibleWidth } from "@earendil-works/pi-tui";
4
+ import { pushToTalkStatus, VoiceDictationView, VoiceProgressView } from "../lib/voice-ui.ts";
5
+
6
+ // The compact /gentle:voice overlays: rendering within the width, the keys
7
+ // each state accepts, and no repaint after disposal.
8
+
9
+ const ENTER = "\r";
10
+ const ESC = "\x1b";
11
+ // Kitty keyboard protocol release event for Enter (codepoint 13, event type 3).
12
+ const ENTER_RELEASE = "\x1b[13;1:3u";
13
+ const theme = { fg: (_role: string, text: string) => text, bold: (text: string) => text };
14
+
15
+ function counters() {
16
+ const counts = { renders: 0, stops: 0, cancels: 0 };
17
+ return {
18
+ counts,
19
+ requestRender: () => { counts.renders++; },
20
+ onStop: () => { counts.stops++; },
21
+ onCancel: () => { counts.cancels++; },
22
+ };
23
+ }
24
+
25
+ test("progress view shows the item, percentage and transferred size within the width", () => {
26
+ const c = counters();
27
+ const view = new VoiceProgressView({ theme, requestRender: c.requestRender, onCancel: c.onCancel });
28
+ view.update({ component: "model", phase: "download", item: "encoder.int8.onnx", receivedBytes: 1, totalBytes: 2, overallReceivedBytes: 335_000_000, overallTotalBytes: 670_000_000 });
29
+ assert.equal(c.counts.renders, 1);
30
+ for (const width of [80, 30]) {
31
+ const lines = view.render(width);
32
+ assert.ok(lines.every((line) => visibleWidth(line) <= width), `fits ${width}`);
33
+ const screen = lines.join("\n");
34
+ assert.match(screen, /50%/);
35
+ if (width === 80) {
36
+ assert.match(screen, /encoder\.int8\.onnx/);
37
+ assert.match(screen, /335 MB of 670 MB/);
38
+ }
39
+ }
40
+ });
41
+
42
+ test("progress view cancels once on Esc and ignores other keys", () => {
43
+ const c = counters();
44
+ const view = new VoiceProgressView({ theme, requestRender: c.requestRender, onCancel: c.onCancel });
45
+ view.handleInput(ENTER);
46
+ assert.equal(c.counts.cancels, 0);
47
+ view.handleInput(ESC);
48
+ view.handleInput(ESC);
49
+ assert.equal(c.counts.cancels, 1);
50
+ assert.match(view.render(80).join("\n"), /cancelling/i);
51
+ });
52
+
53
+ test("dictation view accepts Enter only while recording and Esc once in any state", () => {
54
+ const c = counters();
55
+ const view = new VoiceDictationView({ theme, requestRender: c.requestRender, onStop: c.onStop, onCancel: c.onCancel });
56
+ assert.match(view.render(80).join("\n"), /starting|preparing/i);
57
+ view.handleInput(ENTER);
58
+ assert.equal(c.counts.stops, 0, "no stop before recording");
59
+ view.setState("recording");
60
+ assert.match(view.render(80).join("\n"), /recording/i);
61
+ assert.match(view.render(80).join("\n"), /enter/i);
62
+ view.handleInput(ENTER_RELEASE);
63
+ assert.equal(c.counts.stops, 0, "key release ignored");
64
+ view.handleInput(ENTER);
65
+ view.handleInput(ENTER);
66
+ assert.equal(c.counts.stops, 1);
67
+ view.setState("transcribing");
68
+ assert.match(view.render(80).join("\n"), /transcrib/i);
69
+ view.handleInput(ESC);
70
+ view.handleInput(ESC);
71
+ assert.equal(c.counts.cancels, 1);
72
+ for (const width of [80, 24]) assert.ok(view.render(width).every((line) => visibleWidth(line) <= width));
73
+ });
74
+
75
+ test("a disposed view ignores state changes and keys without repainting", () => {
76
+ const c = counters();
77
+ const view = new VoiceDictationView({ theme, requestRender: c.requestRender, onStop: c.onStop, onCancel: c.onCancel });
78
+ view.setState("recording");
79
+ const before = c.counts.renders;
80
+ view.dispose();
81
+ assert.doesNotThrow(() => view.setState("finished"));
82
+ view.handleInput(ENTER);
83
+ view.handleInput(ESC);
84
+ assert.equal(c.counts.renders, before);
85
+ assert.equal(c.counts.stops + c.counts.cancels, 0);
86
+ const throwing = new VoiceDictationView({ theme, requestRender: () => { throw new Error("dead terminal"); }, onStop: c.onStop, onCancel: c.onCancel });
87
+ assert.doesNotThrow(() => throwing.setState("recording"));
88
+ });
89
+
90
+ test("push-to-talk status names each phase in one line and how to finish", () => {
91
+ assert.match(pushToTalkStatus("starting"), /keep holding Space/i);
92
+ assert.match(pushToTalkStatus("recording"), /recording/i);
93
+ assert.match(pushToTalkStatus("recording"), /release Space/i);
94
+ assert.match(pushToTalkStatus("recording"), /esc cancel/i);
95
+ assert.match(pushToTalkStatus("transcribing"), /transcrib/i);
96
+ for (const state of ["idle", "starting", "recording", "transcribing", "finished"] as const) assert.ok(!pushToTalkStatus(state).includes("\n"), state);
97
+ });