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,262 @@
1
+ import { isKeyRelease } from "@earendil-works/pi-tui";
2
+
3
+ // Hold-Space detection for push-to-talk dictation (S1). With Pi's keyboard
4
+ // flags a terminal reports Space as plain text and never reports its release,
5
+ // so a hold is recognized from OS key auto-repeat, and a release from a gap in
6
+ // that repeat. Auto-repeat has a cadence that typing does not: a long initial
7
+ // delay (macOS at least about 225 ms, X11 660 ms by default), then short,
8
+ // regular repeats. Spaces typed by hand, even fast or in an indent, lack the
9
+ // long first gap or the regular short ones, so they never confirm a hold.
10
+ //
11
+ // Pi's input listeners can consume a chunk or replace it, but cannot inject
12
+ // input later, so a held-back space could never be typed again exactly as the
13
+ // user typed it. Every space therefore goes to the editor untouched: a tap, a
14
+ // double space or a space followed by a letter types exactly as without this
15
+ // feature. A hold is confirmed only after a first gap of auto-repeat delay and
16
+ // `holdRepeats` short, regular repeats, each verified to have added exactly one
17
+ // space to the draft. The following repeats are then rewritten into Backspace,
18
+ // through the editor's own key path, until the spaces the hold typed are gone
19
+ // (one is kept as a separator when the hold followed a word), and later
20
+ // repeats are consumed. A draft
21
+ // that changes in any other way (Vim NORMAL mode, where Space is a motion;
22
+ // autocomplete; another key) stops the run or the erase and never removes
23
+ // anything else.
24
+
25
+ export interface PushToTalkConfig {
26
+ firstRepeatWindowMs: number;
27
+ minFirstGapMs: number;
28
+ maxRepeatGapMs: number;
29
+ maxRepeatSpread: number;
30
+ minDelayRatio: number;
31
+ holdRepeats: number;
32
+ releaseGapFactor: number;
33
+ minReleaseGapMs: number;
34
+ maxReleaseGapMs: number;
35
+ }
36
+
37
+ export const PUSH_TO_TALK_DEFAULTS: Readonly<PushToTalkConfig> = Object.freeze({
38
+ // The first gap (first space to first repeat) must fall within
39
+ // [minFirstGapMs, firstRepeatWindowMs]: the OS initial repeat delay.
40
+ firstRepeatWindowMs: 1000,
41
+ minFirstGapMs: 180,
42
+ // Each repeat gap is at most this long, and the slowest of the last
43
+ // `holdRepeats` gaps is at most `maxRepeatSpread` times the fastest.
44
+ maxRepeatGapMs: 250,
45
+ maxRepeatSpread: 1.6,
46
+ // The first gap is at least this many times the slowest repeat gap, so an
47
+ // evenly paced run of typed spaces is not auto-repeat.
48
+ minDelayRatio: 1.5,
49
+ holdRepeats: 3,
50
+ // No repeat for this many observed repeat intervals, clamped, is a release.
51
+ releaseGapFactor: 2.5,
52
+ minReleaseGapMs: 150,
53
+ maxReleaseGapMs: 400,
54
+ });
55
+
56
+ export interface PushToTalkTimers {
57
+ setTimeout(callback: () => void, ms: number): unknown;
58
+ clearTimeout(handle: unknown): void;
59
+ now(): number;
60
+ // Runs after input that is already queued (setImmediate in production).
61
+ defer(callback: () => void): void;
62
+ }
63
+
64
+ export type PushToTalkOptions = Partial<PushToTalkConfig> & { timers?: PushToTalkTimers };
65
+
66
+ export interface PushToTalkHandlers {
67
+ // The current editor draft, read before the chunk reaches the editor.
68
+ readDraft(): string;
69
+ // A hold was detected; returns true when a dictation started. While it
70
+ // returns false the spaces keep typing and the next repeat asks again.
71
+ onHold(): boolean;
72
+ // Space was released after a started hold.
73
+ onRelease(): void;
74
+ }
75
+
76
+ export type PushToTalkInputResult = { consume?: boolean; data?: string } | undefined;
77
+
78
+ const SPACE = " ";
79
+ const BACKSPACE = "\x7f";
80
+
81
+ // Where `after` is `before` with exactly `count` spaces inserted at one place,
82
+ // or -1. With spaces next to the insertion the place is ambiguous; this is the
83
+ // rightmost one, which is right when typing at the end of the draft.
84
+ export function addedSpacesAt(before: string, after: string, count: number): number {
85
+ if (after.length !== before.length + count) return -1;
86
+ let index = 0;
87
+ while (index < before.length && before[index] === after[index]) index++;
88
+ return after.slice(index, index + count) === SPACE.repeat(count) && after.slice(index + count) === before.slice(index) ? index : -1;
89
+ }
90
+
91
+ export const addedSpaces = (before: string, after: string, count: number): boolean => addedSpacesAt(before, after, count) >= 0;
92
+
93
+ type Phase =
94
+ | { kind: "idle" }
95
+ // Spaces typed so far in this press, verified against the draft before it.
96
+ | { kind: "run"; before: string; typed: number; last: number; firstGap?: number; gaps: number[] }
97
+ // `erase` spaces left to remove; `keep` (0 or 1) stays as a separator.
98
+ | { kind: "holding"; before: string; erase: number; keep: number; releaseGapMs: number };
99
+
100
+ const IDLE: Phase = { kind: "idle" };
101
+
102
+ export class SpaceHoldDetector {
103
+ private phase: Phase = IDLE;
104
+ private timer: unknown;
105
+ private disposed = false;
106
+ private readonly handlers: PushToTalkHandlers;
107
+ private readonly config: PushToTalkConfig;
108
+ private readonly timers: PushToTalkTimers;
109
+
110
+ constructor(handlers: PushToTalkHandlers, options: PushToTalkOptions = {}) {
111
+ this.handlers = handlers;
112
+ const { timers, ...config } = options;
113
+ this.config = { ...PUSH_TO_TALK_DEFAULTS, ...config, holdRepeats: Math.max(1, config.holdRepeats ?? PUSH_TO_TALK_DEFAULTS.holdRepeats) };
114
+ this.timers = timers ?? {
115
+ setTimeout: (callback, ms) => setTimeout(callback, ms),
116
+ clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
117
+ now: () => performance.now(),
118
+ defer: (callback) => { setImmediate(callback); },
119
+ };
120
+ }
121
+
122
+ get holding(): boolean {
123
+ return this.phase.kind === "holding";
124
+ }
125
+
126
+ handleInput(data: string): PushToTalkInputResult {
127
+ if (this.disposed || isKeyRelease(data)) return undefined;
128
+ if (data !== SPACE) {
129
+ if (this.phase.kind === "holding") this.phase.erase = 0;
130
+ else this.reset();
131
+ return undefined;
132
+ }
133
+ const phase = this.phase;
134
+ if (phase.kind === "holding") return this.whileHolding();
135
+ const now = this.timers.now();
136
+ const draft = this.draft();
137
+ if (draft === undefined) {
138
+ this.reset();
139
+ return undefined;
140
+ }
141
+ // A space that does not continue an auto-repeat run starts a new one:
142
+ // earlier spaces become part of the draft before it and are never erased.
143
+ if (phase.kind === "idle" || !this.continues(phase, draft, now - phase.last)) return this.startRun(draft, now);
144
+ const gap = now - phase.last;
145
+ phase.last = now;
146
+ if (phase.firstGap === undefined) phase.firstGap = gap;
147
+ else phase.gaps = [...phase.gaps, gap].slice(-this.config.holdRepeats);
148
+ if (this.isAutoRepeat(phase) && this.askHold()) {
149
+ const at = addedSpacesAt(phase.before, draft, phase.typed);
150
+ // After a word, the first hold space stays as the separator.
151
+ const keep = at > 0 && !/\s/.test(phase.before[at - 1]!) ? 1 : 0;
152
+ const interval = Math.max(...phase.gaps);
153
+ const { releaseGapFactor, minReleaseGapMs, maxReleaseGapMs } = this.config;
154
+ const releaseGapMs = Math.min(maxReleaseGapMs, Math.max(minReleaseGapMs, Math.round(interval * releaseGapFactor)));
155
+ this.phase = { kind: "holding", before: phase.before, erase: phase.typed - keep, keep, releaseGapMs };
156
+ return this.whileHolding();
157
+ }
158
+ if (this.disposed) return undefined;
159
+ phase.typed++;
160
+ this.schedule(this.config.maxRepeatGapMs, () => this.reset());
161
+ return undefined;
162
+ }
163
+
164
+ // Leaves a hold (or a run) without reporting a release.
165
+ end(): void {
166
+ this.reset();
167
+ }
168
+
169
+ dispose(): void {
170
+ this.reset();
171
+ this.disposed = true;
172
+ }
173
+
174
+ private startRun(before: string, now: number): PushToTalkInputResult {
175
+ this.phase = { kind: "run", before, typed: 1, last: now, gaps: [] };
176
+ this.schedule(this.config.firstRepeatWindowMs, () => this.reset());
177
+ return undefined;
178
+ }
179
+
180
+ // Whether this space can be the next auto-repeat of the run.
181
+ private continues(phase: Extract<Phase, { kind: "run" }>, draft: string, gap: number): boolean {
182
+ if (!addedSpaces(phase.before, draft, phase.typed)) return false;
183
+ if (phase.firstGap === undefined) return gap >= this.config.minFirstGapMs && gap <= this.config.firstRepeatWindowMs;
184
+ return gap <= this.config.maxRepeatGapMs;
185
+ }
186
+
187
+ private isAutoRepeat(phase: Extract<Phase, { kind: "run" }>): boolean {
188
+ const { holdRepeats, maxRepeatSpread, minDelayRatio } = this.config;
189
+ if (phase.firstGap === undefined || phase.gaps.length < holdRepeats) return false;
190
+ const slowest = Math.max(...phase.gaps);
191
+ const fastest = Math.min(...phase.gaps);
192
+ return slowest <= Math.max(fastest, 1) * maxRepeatSpread && phase.firstGap >= slowest * minDelayRatio;
193
+ }
194
+
195
+ private whileHolding(): PushToTalkInputResult {
196
+ const phase = this.phase;
197
+ if (phase.kind !== "holding") return undefined;
198
+ this.schedule(phase.releaseGapMs, () => this.maybeRelease());
199
+ const draft = this.draft();
200
+ if (phase.erase > 0 && draft !== undefined && addedSpaces(phase.before, draft, phase.erase + phase.keep)) {
201
+ phase.erase--;
202
+ return { data: BACKSPACE };
203
+ }
204
+ phase.erase = 0;
205
+ return { consume: true };
206
+ }
207
+
208
+ // The release timer can fire before input that queued up while the event
209
+ // loop was blocked; deciding after that input means a late repeat wins.
210
+ private maybeRelease(): void {
211
+ const phase = this.phase;
212
+ this.timers.defer(() => {
213
+ // A repeat in between rescheduled the timer; end() or dispose() left the hold.
214
+ if (this.phase !== phase || this.timer !== undefined || this.disposed) return;
215
+ this.release();
216
+ });
217
+ }
218
+
219
+ private release(): void {
220
+ this.phase = IDLE;
221
+ try {
222
+ this.handlers.onRelease();
223
+ } catch {
224
+ /* the owner reports its own failures */
225
+ }
226
+ }
227
+
228
+ private askHold(): boolean {
229
+ try {
230
+ return this.handlers.onHold() === true;
231
+ } catch {
232
+ return false;
233
+ }
234
+ }
235
+
236
+ private draft(): string | undefined {
237
+ try {
238
+ return this.handlers.readDraft();
239
+ } catch {
240
+ return undefined;
241
+ }
242
+ }
243
+
244
+ private schedule(ms: number, callback: () => void): void {
245
+ this.clear();
246
+ if (this.disposed) return;
247
+ this.timer = this.timers.setTimeout(() => {
248
+ this.timer = undefined;
249
+ callback();
250
+ }, ms);
251
+ }
252
+
253
+ private clear(): void {
254
+ if (this.timer !== undefined) this.timers.clearTimeout(this.timer);
255
+ this.timer = undefined;
256
+ }
257
+
258
+ private reset(): void {
259
+ this.clear();
260
+ this.phase = IDLE;
261
+ }
262
+ }