gentle-pi 4.3.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/README.md +8 -5
  2. package/assets/agents/gentle-ai-worker.md +1 -1
  3. package/assets/design-canvas/canvas.css +648 -0
  4. package/assets/design-canvas/canvas.js +1497 -0
  5. package/assets/design-canvas/index.html +88 -0
  6. package/assets/design-canvas/inspector.js +290 -0
  7. package/assets/orchestrator-delegation-reference.md +2 -2
  8. package/assets/orchestrator-verification.md +1 -1
  9. package/assets/orchestrator.md +2 -2
  10. package/bin/engram-guard +1 -1
  11. package/bin/gentle-shell.mjs +101 -151
  12. package/docs/bundled-install.md +47 -42
  13. package/docs/delegated-verification.md +6 -0
  14. package/docs/gentle-agents-activity.md +5 -1
  15. package/docs/gentle-music.md +61 -0
  16. package/docs/gentle-shell.md +77 -7
  17. package/docs/install-wizard.md +30 -6
  18. package/docs/permission-modes.md +7 -2
  19. package/docs/readme-reference.md +19 -15
  20. package/docs/session-profile-format.md +26 -0
  21. package/docs/voice.md +259 -0
  22. package/docs/yolo-mode.md +17 -2
  23. package/extensions/ask-user-choice.ts +4 -1
  24. package/extensions/gentle-agents.ts +173 -38
  25. package/extensions/gentle-ai.ts +214 -69
  26. package/extensions/gentle-design.ts +158 -0
  27. package/extensions/gentle-music.ts +289 -0
  28. package/extensions/gentle-shell.ts +332 -73
  29. package/extensions/gentle-voice.ts +11 -0
  30. package/extensions/session-scratch.ts +41 -22
  31. package/extensions/skill-registry.ts +63 -8
  32. package/lib/agent-profiles.ts +26 -0
  33. package/lib/agents-config.ts +35 -0
  34. package/lib/agents-protocol.ts +57 -1
  35. package/lib/agents-runner.ts +52 -3
  36. package/lib/agents-session-transport.ts +101 -6
  37. package/lib/agents-widget.ts +14 -1
  38. package/lib/auto-permission-memory.ts +48 -18
  39. package/lib/auto-permission-runtime.ts +21 -6
  40. package/lib/clipboard-image.ts +147 -0
  41. package/lib/command-palette-catalog.ts +2 -0
  42. package/lib/commit-slice-signal.ts +1 -1
  43. package/lib/context-backstop.ts +36 -0
  44. package/lib/design-canvas-server.ts +947 -0
  45. package/lib/design-comments.ts +437 -0
  46. package/lib/design-handoffs.ts +313 -0
  47. package/lib/design-open-browser.ts +58 -0
  48. package/lib/design-sources.ts +120 -0
  49. package/lib/external-editor.ts +31 -0
  50. package/lib/gentle-design.ts +513 -0
  51. package/lib/gentle-shell-bundled.ts +123 -49
  52. package/lib/gentle-shell-launcher.ts +9 -4
  53. package/lib/gentle-shell-startup-update.ts +61 -9
  54. package/lib/gentle-shell-uninstall.ts +33 -1
  55. package/lib/image-attachments.ts +150 -0
  56. package/lib/inprocess-reviewer.ts +33 -2
  57. package/lib/nan-provider.ts +3 -0
  58. package/lib/native-review-cli.ts +11 -4
  59. package/lib/notification-audio-native.ts +149 -13
  60. package/lib/notification-audio.ts +4 -0
  61. package/lib/notification-pulse-worker.ts +82 -18
  62. package/lib/notification-service.ts +5 -1
  63. package/lib/orchestrator-presence.ts +78 -8
  64. package/lib/orchestrator-scope.ts +10 -0
  65. package/lib/orchestrator-state.ts +11 -3
  66. package/lib/permission-mode-policy.ts +2 -2
  67. package/lib/review-host-relay.ts +11 -2
  68. package/lib/review-reminder-receipt.ts +24 -1
  69. package/lib/session-profile-append-controller.ts +713 -0
  70. package/lib/session-profile-disk-reader.ts +182 -0
  71. package/lib/session-profile-persistence.ts +15 -7
  72. package/lib/session-scratch.ts +52 -3
  73. package/lib/session-worktree-registry.ts +7 -0
  74. package/lib/shell-bar.ts +4 -3
  75. package/lib/shell-changes.ts +81 -5
  76. package/lib/shell-prompt.ts +4 -4
  77. package/lib/shell-sidebar-layout.ts +26 -6
  78. package/lib/shell-todo.ts +1 -1
  79. package/lib/verifier-admission.ts +5 -4
  80. package/lib/vim-editor-adapter.ts +11 -8
  81. package/lib/voice-artifacts.ts +188 -0
  82. package/lib/voice-command.ts +576 -0
  83. package/lib/voice-install.ts +455 -0
  84. package/lib/voice-platform.ts +99 -0
  85. package/lib/voice-push-to-talk.ts +262 -0
  86. package/lib/voice-session.ts +460 -0
  87. package/lib/voice-settings.ts +50 -0
  88. package/lib/voice-ui.ts +158 -0
  89. package/lib/yolo-session-policy.ts +58 -22
  90. package/package.json +1 -1
  91. package/runtime/gentle-shell-bundled.mjs +122 -48
  92. package/runtime/gentle-shell-launcher.mjs +9 -4
  93. package/runtime/gentle-shell-startup-update.mjs +61 -9
  94. package/runtime/gentle-shell-uninstall.mjs +33 -1
  95. package/runtime/native-review-cli.mjs +11 -4
  96. package/scripts/bundled-engram-migration.mjs +0 -6
  97. package/scripts/gentle-ai-installer.mjs +10 -10
  98. package/scripts/installer-downloads.mjs +7 -7
  99. package/scripts/installer-preflight.mjs +5 -3
  100. package/scripts/installer-windows.mjs +37 -7
  101. package/scripts/main-channel.mjs +91 -17
  102. package/scripts/pi-update.mjs +34 -6
  103. package/scripts/verify-package-files.mjs +26 -2
  104. package/scripts/voice-worker.cjs +476 -0
  105. package/skills/design/SKILL.md +110 -0
  106. package/tests/agent-profiles.test.ts +24 -0
  107. package/tests/agents-config.test.ts +61 -0
  108. package/tests/agents-fake-child.ts +8 -1
  109. package/tests/agents-protocol.test.ts +32 -0
  110. package/tests/agents-runner.test.ts +226 -5
  111. package/tests/agents-session-transport.test.ts +53 -3
  112. package/tests/ask-user-choice.test.ts +30 -0
  113. package/tests/auto-mode.test.ts +17 -0
  114. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  115. package/tests/auto-permission-runtime.test.ts +85 -1
  116. package/tests/autonomous-guard.test.ts +20 -0
  117. package/tests/bundled-install.test.ts +3 -3
  118. package/tests/clipboard-image.test.ts +114 -0
  119. package/tests/command-palette.test.ts +1 -1
  120. package/tests/context-backstop.test.ts +70 -4
  121. package/tests/delegate-for-reason-contract.test.ts +2 -1
  122. package/tests/design-canvas-assets.test.ts +710 -0
  123. package/tests/design-canvas-server.test.ts +1096 -0
  124. package/tests/design-comments.test.ts +201 -0
  125. package/tests/design-handoffs.test.ts +282 -0
  126. package/tests/design-open-browser.test.ts +70 -0
  127. package/tests/design-sources.test.ts +132 -0
  128. package/tests/engram-safety.test.ts +132 -57
  129. package/tests/external-editor.test.ts +84 -0
  130. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
  131. package/tests/gentle-agents.test.ts +365 -20
  132. package/tests/gentle-ai-binary.test.ts +1 -1
  133. package/tests/gentle-ai-installer.test.ts +51 -51
  134. package/tests/gentle-ai.test.ts +292 -13
  135. package/tests/gentle-design.test.ts +1313 -0
  136. package/tests/gentle-music.test.ts +335 -0
  137. package/tests/gentle-shell-bundled.test.ts +301 -248
  138. package/tests/gentle-shell-launcher.test.ts +28 -2
  139. package/tests/gentle-shell-startup-update.test.ts +77 -1
  140. package/tests/gentle-shell-uninstall.test.ts +59 -0
  141. package/tests/gentle-shell.test.ts +183 -8
  142. package/tests/gentle-todo.test.ts +11 -0
  143. package/tests/image-attachments.test.ts +119 -0
  144. package/tests/image-paste-chips.test.ts +236 -0
  145. package/tests/inprocess-reviewer.test.ts +55 -2
  146. package/tests/installer-bundles.test.ts +12 -6
  147. package/tests/installer-posix-bootstrap.test.ts +8 -8
  148. package/tests/installer-preflight.test.ts +16 -0
  149. package/tests/installer-server.test.ts +2 -2
  150. package/tests/installer-windows-bootstrap.test.ts +61 -3
  151. package/tests/installer-windows-diagnostics.test.ts +111 -0
  152. package/tests/main-channel-upgrade.test.ts +232 -2
  153. package/tests/main-channel.test.ts +140 -21
  154. package/tests/nan-provider.test.ts +36 -0
  155. package/tests/native-review-capability-contract.test.ts +11 -4
  156. package/tests/native-review-parity.test.ts +1 -1
  157. package/tests/notification-audio-native.test.ts +128 -9
  158. package/tests/notification-customize.test.ts +17 -5
  159. package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
  160. package/tests/orchestrator-budget.test.ts +5 -2
  161. package/tests/orchestrator-presence.test.ts +100 -2
  162. package/tests/orchestrator-rdd-ownership.test.ts +2 -2
  163. package/tests/orchestrator-scope.test.ts +14 -0
  164. package/tests/orchestrator-state.test.ts +29 -0
  165. package/tests/package-files.test.ts +34 -0
  166. package/tests/package-manifest.test.ts +6 -6
  167. package/tests/permission-mode-policy.test.ts +10 -1
  168. package/tests/pi-update.test.ts +99 -4
  169. package/tests/rdd-aware-verification-contract.test.ts +1 -1
  170. package/tests/review-host-relay-routing.test.ts +88 -1
  171. package/tests/review-host-relay.test.ts +9 -2
  172. package/tests/review-json-arguments.test.ts +20 -3
  173. package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
  174. package/tests/review-relay-transport-agent.test.ts +26 -0
  175. package/tests/review-reminder-receipt.test.ts +31 -1
  176. package/tests/session-changes.test.ts +2 -1
  177. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  178. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  179. package/tests/session-profile-append-controller.test.ts +731 -0
  180. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  181. package/tests/session-profile-disk-reader.test.ts +311 -0
  182. package/tests/session-scratch.test.ts +68 -2
  183. package/tests/session-worktree-registry.test.ts +13 -1
  184. package/tests/shell-bar.test.ts +14 -2
  185. package/tests/shell-changes.test.ts +148 -1
  186. package/tests/shell-prompt.test.ts +3 -3
  187. package/tests/shell-render-cache.test.ts +134 -1
  188. package/tests/shell-sidebar-layout.test.ts +50 -0
  189. package/tests/short-check-routing-contract.test.ts +1 -1
  190. package/tests/skill-collision-prefixes.test.ts +3 -1
  191. package/tests/skill-registry-watcher.test.ts +17 -5
  192. package/tests/skill-registry.test.ts +47 -3
  193. package/tests/task-size-routing-contract.test.ts +1 -1
  194. package/tests/test-strength-contract.test.ts +1 -1
  195. package/tests/verifier-admission.test.ts +18 -1
  196. package/tests/voice-command.test.ts +773 -0
  197. package/tests/voice-install.test.ts +323 -0
  198. package/tests/voice-platform.test.ts +176 -0
  199. package/tests/voice-push-to-talk.test.ts +444 -0
  200. package/tests/voice-session.test.ts +434 -0
  201. package/tests/voice-settings.test.ts +49 -0
  202. package/tests/voice-ui.test.ts +97 -0
  203. package/tests/voice-worker.test.ts +445 -0
  204. package/tests/weak-model-economy-prompt.test.ts +32 -0
  205. package/tests/windows-hidden-processes.test.ts +25 -2
  206. package/tests/yolo-customize.test.ts +5 -1
  207. package/tests/yolo-mode.test.ts +1 -1
  208. package/tests/yolo-session-policy-capture.test.ts +104 -0
@@ -0,0 +1,444 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { Editor, isKeyRelease } from "@earendil-works/pi-tui";
4
+ import { PUSH_TO_TALK_DEFAULTS, SpaceHoldDetector, type PushToTalkTimers } from "../lib/voice-push-to-talk.ts";
5
+
6
+ // Hold-Space detection on raw terminal input. Chunks go through the detector
7
+ // and then, as Pi's TUI does, to an editor: a real pi-tui Editor where the
8
+ // exact typing path matters, fake editors for Vim NORMAL mode and autocomplete.
9
+ // Timers are a manual clock; nothing here records audio.
10
+
11
+ const BACKSPACE = "\x7f";
12
+
13
+ // Deferred callbacks stand for setImmediate: they run after the input that is
14
+ // already queued. With `holdDeferred` a test types that input first.
15
+ function manualClock() {
16
+ let now = 0;
17
+ let nextId = 1;
18
+ let holdDeferred = false;
19
+ const pending = new Map<number, { at: number; callback: () => void }>();
20
+ const deferred: Array<() => void> = [];
21
+ const flush = () => {
22
+ while (deferred.length > 0) deferred.shift()!();
23
+ };
24
+ const timers: PushToTalkTimers = {
25
+ setTimeout: (callback, ms) => {
26
+ const id = nextId++;
27
+ pending.set(id, { at: now + ms, callback });
28
+ return id;
29
+ },
30
+ clearTimeout: (handle) => { pending.delete(handle as number); },
31
+ now: () => now,
32
+ defer: (callback) => { deferred.push(callback); },
33
+ };
34
+ const advance = (ms: number) => {
35
+ const target = now + ms;
36
+ for (;;) {
37
+ let due: [number, { at: number; callback: () => void }] | undefined;
38
+ for (const entry of pending) if (entry[1].at <= target && (!due || entry[1].at < due[1].at)) due = entry;
39
+ if (!due) break;
40
+ pending.delete(due[0]);
41
+ now = due[1].at;
42
+ due[1].callback();
43
+ if (!holdDeferred) flush();
44
+ }
45
+ now = target;
46
+ };
47
+ return {
48
+ timers,
49
+ advance,
50
+ flush,
51
+ deferred: () => deferred.length,
52
+ holdDeferred: (value: boolean) => { holdDeferred = value; },
53
+ pending: () => pending.size,
54
+ };
55
+ }
56
+
57
+ interface Draft {
58
+ text(): string;
59
+ input(data: string): void;
60
+ }
61
+
62
+ function realEditor(text = ""): Draft {
63
+ const editor = new Editor({ terminal: { rows: 24 }, requestRender() {} } as never, { borderColor: (s: string) => s } as never);
64
+ editor.setText(text);
65
+ return { text: () => editor.getText(), input: (data) => editor.handleInput(data) };
66
+ }
67
+
68
+ function setup(draft: Draft, onHold: () => boolean = () => true) {
69
+ const clock = manualClock();
70
+ const calls = { holds: 0, releases: 0 };
71
+ const results: Array<ReturnType<SpaceHoldDetector["handleInput"]>> = [];
72
+ const detector = new SpaceHoldDetector(
73
+ {
74
+ readDraft: () => draft.text(),
75
+ onHold: () => { calls.holds++; return onHold(); },
76
+ onRelease: () => { calls.releases++; },
77
+ },
78
+ { timers: clock.timers },
79
+ );
80
+ // Mirrors TUI.handleTerminalInput: consume stops the chunk, data replaces
81
+ // it, and key releases never reach the editor.
82
+ const type = (data: string) => {
83
+ const result = detector.handleInput(data);
84
+ results.push(result);
85
+ if (result?.consume) return;
86
+ const forwarded = result?.data ?? data;
87
+ if (!isKeyRelease(forwarded)) draft.input(forwarded);
88
+ };
89
+ // One press of Space held down: the first space, then OS auto-repeats.
90
+ const hold = (repeats: number, delay = 400, interval = 40) => {
91
+ type(" ");
92
+ for (let index = 0; index < repeats; index++) {
93
+ clock.advance(index === 0 ? delay : interval);
94
+ type(" ");
95
+ }
96
+ };
97
+ // Spaces typed at the given gaps after a first one.
98
+ const spaces = (gaps: number[]) => {
99
+ type(" ");
100
+ for (const gap of gaps) {
101
+ clock.advance(gap);
102
+ type(" ");
103
+ }
104
+ };
105
+ return { clock, calls, results, detector, type, hold, spaces };
106
+ }
107
+
108
+ test("defaults describe OS auto-repeat cadence", () => {
109
+ assert.deepEqual({ ...PUSH_TO_TALK_DEFAULTS }, {
110
+ firstRepeatWindowMs: 1000,
111
+ minFirstGapMs: 180,
112
+ maxRepeatGapMs: 250,
113
+ maxRepeatSpread: 1.6,
114
+ minDelayRatio: 1.5,
115
+ holdRepeats: 3,
116
+ releaseGapFactor: 2.5,
117
+ minReleaseGapMs: 150,
118
+ maxReleaseGapMs: 400,
119
+ });
120
+ });
121
+
122
+ test("a tap types exactly one space through the editor's own path and never starts a hold", () => {
123
+ const draft = realEditor("hello");
124
+ const s = setup(draft);
125
+ s.type(" ");
126
+ assert.equal(draft.text(), "hello ", "the space is in the draft immediately");
127
+ s.clock.advance(1_000);
128
+ assert.equal(draft.text(), "hello ");
129
+ assert.deepEqual(s.results, [undefined], "the chunk was passed through untouched");
130
+ assert.equal(s.calls.holds, 0);
131
+ assert.equal(s.clock.pending(), 0, "no timer left behind");
132
+ });
133
+
134
+ test("rollover keeps order: a space followed quickly by a letter yields ' a'", () => {
135
+ const draft = realEditor("x");
136
+ const s = setup(draft);
137
+ s.type(" ");
138
+ s.type("a");
139
+ assert.equal(draft.text(), "x a");
140
+ assert.deepEqual(s.results, [undefined, undefined]);
141
+ assert.equal(s.calls.holds, 0);
142
+ });
143
+
144
+ test("pastes, escape sequences and multi-character chunks pass untouched and break a run", () => {
145
+ const draft = realEditor("");
146
+ const s = setup(draft);
147
+ for (const chunk of ["\x1b[200~ \x1b[201~", "\x1b", "\x1b[A", " ", "ab", " \r"]) {
148
+ s.type(" ");
149
+ s.clock.advance(40);
150
+ s.type(chunk);
151
+ s.clock.advance(40);
152
+ s.type(" ");
153
+ s.clock.advance(40);
154
+ }
155
+ assert.ok(s.results.every((result) => result === undefined), "nothing consumed or rewritten");
156
+ assert.equal(s.calls.holds, 0);
157
+ });
158
+
159
+ test("a deliberate fast double space is not a hold", () => {
160
+ const draft = realEditor("end.");
161
+ const s = setup(draft);
162
+ s.type(" ");
163
+ s.clock.advance(120);
164
+ s.type(" ");
165
+ s.clock.advance(1_000);
166
+ s.type("N");
167
+ assert.equal(draft.text(), "end. N");
168
+ assert.equal(s.calls.holds, 0);
169
+ });
170
+
171
+ test("a hold is confirmed after the initial delay and three regular repeats, erases its spaces, then consumes repeats", () => {
172
+ const draft = realEditor("");
173
+ const s = setup(draft);
174
+ s.hold(3);
175
+ assert.equal(s.calls.holds, 0, "two short repeats are not enough");
176
+ s.clock.advance(40);
177
+ s.type(" ");
178
+ assert.equal(s.calls.holds, 1);
179
+ assert.ok(s.detector.holding);
180
+ assert.deepEqual(s.results, [undefined, undefined, undefined, undefined, { data: BACKSPACE }], "the confirming repeat becomes a Backspace");
181
+ for (let index = 0; index < 3; index++) {
182
+ s.clock.advance(40);
183
+ s.type(" ");
184
+ assert.deepEqual(s.results.at(-1), { data: BACKSPACE });
185
+ }
186
+ assert.equal(draft.text(), "", "all four spaces typed by the hold are gone");
187
+ s.clock.advance(40);
188
+ s.type(" ");
189
+ assert.deepEqual(s.results.at(-1), { consume: true });
190
+ assert.equal(s.calls.releases, 0);
191
+ s.clock.advance(PUSH_TO_TALK_DEFAULTS.minReleaseGapMs + 1);
192
+ assert.equal(s.calls.releases, 1, "no repeat for the release gap means Space was released");
193
+ assert.ok(!s.detector.holding);
194
+ assert.equal(s.clock.pending(), 0);
195
+ });
196
+
197
+ test("erasing never removes spaces that were in the draft before the hold", () => {
198
+ const draft = realEditor("hi ");
199
+ const s = setup(draft);
200
+ s.hold(8);
201
+ s.clock.advance(PUSH_TO_TALK_DEFAULTS.maxReleaseGapMs + 1);
202
+ assert.equal(draft.text(), "hi ");
203
+ assert.equal(s.results.filter((result) => result?.data === BACKSPACE).length, 4);
204
+ });
205
+
206
+ test("a first repeat later than the first-repeat window does not count as a hold", () => {
207
+ const draft = realEditor("");
208
+ const s = setup(draft);
209
+ s.hold(4, PUSH_TO_TALK_DEFAULTS.firstRepeatWindowMs + 1);
210
+ assert.equal(s.calls.holds, 0);
211
+ assert.ok(s.results.every((result) => result === undefined));
212
+ });
213
+
214
+ // --- cadence: deliberate typing never looks like auto-repeat (B1) -------------
215
+
216
+ test("three spaces typed at 120 ms then a letter stay exactly as typed and start nothing", () => {
217
+ const draft = realEditor("x");
218
+ const s = setup(draft);
219
+ s.spaces([120, 120]);
220
+ s.clock.advance(120);
221
+ s.type("y");
222
+ assert.equal(draft.text(), "x y");
223
+ assert.equal(s.calls.holds, 0);
224
+ assert.ok(s.results.every((result) => result === undefined));
225
+ });
226
+
227
+ test("a four-space indent typed at 90 ms keeps all four spaces and starts nothing", () => {
228
+ const draft = realEditor("if (x) {\n");
229
+ const s = setup(draft);
230
+ s.spaces([90, 90, 90]);
231
+ s.clock.advance(90);
232
+ s.type("r");
233
+ assert.equal(draft.text(), "if (x) {\n r");
234
+ assert.equal(s.calls.holds, 0);
235
+ assert.ok(s.results.every((result) => result === undefined));
236
+ });
237
+
238
+ test("two to four spaces at any human pace from 90 to 250 ms are typed unchanged", () => {
239
+ for (const count of [2, 3, 4]) {
240
+ for (const gap of [90, 120, 180, 200, 250]) {
241
+ const draft = realEditor("a");
242
+ const s = setup(draft);
243
+ s.spaces(Array.from({ length: count - 1 }, () => gap));
244
+ s.clock.advance(gap);
245
+ s.type("b");
246
+ assert.equal(draft.text(), `a${" ".repeat(count)}b`, `${count} spaces at ${gap} ms`);
247
+ assert.equal(s.calls.holds, 0, `${count} spaces at ${gap} ms`);
248
+ }
249
+ }
250
+ });
251
+
252
+ test("words separated by single spaces and long evenly paced space runs are not holds", () => {
253
+ const draft = realEditor("");
254
+ const s = setup(draft);
255
+ for (const chunk of ["a", " ", "b", " ", "c"]) {
256
+ s.type(chunk);
257
+ s.clock.advance(150);
258
+ }
259
+ assert.equal(draft.text(), "a b c");
260
+ // Even pacing has no long initial delay before the repeats.
261
+ s.spaces([200, 200, 200, 200, 200]);
262
+ assert.equal(s.calls.holds, 0);
263
+ });
264
+
265
+ test("an irregular run is not confirmed until three regular repeats follow", () => {
266
+ const draft = realEditor("");
267
+ const s = setup(draft);
268
+ s.spaces([400, 40, 200, 40]);
269
+ assert.equal(s.calls.holds, 0, "a 200 ms gap among 40 ms repeats is not auto-repeat");
270
+ s.clock.advance(40);
271
+ s.type(" ");
272
+ s.clock.advance(40);
273
+ s.type(" ");
274
+ assert.equal(s.calls.holds, 1, "40, 40, 40 is regular");
275
+ });
276
+
277
+ test("real-shaped auto-repeat confirms: macOS-like 400 ms delay with 33 ms or 90 ms repeats, X11-like 660 ms delay", () => {
278
+ for (const [delay, interval] of [[400, 33], [400, 90], [660, 40], [225, 30]]) {
279
+ const draft = realEditor("");
280
+ const s = setup(draft);
281
+ s.hold(3, delay, interval);
282
+ assert.equal(s.calls.holds, 0, `${delay}/${interval}: not yet`);
283
+ s.clock.advance(interval);
284
+ s.type(" ");
285
+ assert.equal(s.calls.holds, 1, `${delay}/${interval}`);
286
+ assert.ok(s.detector.holding, `${delay}/${interval}`);
287
+ }
288
+ });
289
+
290
+ test("the release gap adapts to the observed repeat interval within 150 to 400 ms", () => {
291
+ for (const [interval, gap] of [[33, 150], [90, 225], [160, 400]]) {
292
+ const draft = realEditor("");
293
+ const s = setup(draft);
294
+ s.hold(4, 400, interval);
295
+ assert.ok(s.detector.holding, `${interval}`);
296
+ s.clock.advance(gap - 1);
297
+ assert.equal(s.calls.releases, 0, `${interval}: not before ${gap} ms`);
298
+ s.clock.advance(2);
299
+ assert.equal(s.calls.releases, 1, `${interval}: released after ${gap} ms`);
300
+ }
301
+ });
302
+
303
+ test("a repeat queued while the release timer fires wins over the release (A2)", () => {
304
+ const draft = realEditor("");
305
+ const s = setup(draft);
306
+ s.hold(4);
307
+ s.clock.holdDeferred(true);
308
+ s.clock.advance(PUSH_TO_TALK_DEFAULTS.minReleaseGapMs + 1);
309
+ assert.equal(s.clock.deferred(), 1, "the release decision waits for queued input");
310
+ assert.equal(s.calls.releases, 0);
311
+ // Input that was queued during an event-loop block arrives first.
312
+ s.type(" ");
313
+ s.clock.flush();
314
+ assert.equal(s.calls.releases, 0, "a repeat arrived, so Space is still held");
315
+ assert.ok(s.detector.holding);
316
+ s.clock.advance(PUSH_TO_TALK_DEFAULTS.minReleaseGapMs + 1);
317
+ s.clock.flush();
318
+ assert.equal(s.calls.releases, 1);
319
+ });
320
+
321
+ test("a hold right after a word keeps one space as the separator (A5)", () => {
322
+ const draft = realEditor("hi");
323
+ const s = setup(draft);
324
+ s.hold(8);
325
+ assert.equal(s.results.filter((result) => result?.data === BACKSPACE).length, 3, "four spaces typed, three erased");
326
+ assert.equal(draft.text(), "hi ");
327
+ s.clock.advance(PUSH_TO_TALK_DEFAULTS.maxReleaseGapMs + 1);
328
+ draft.input("\x1b[200~there\x1b[201~");
329
+ assert.equal(draft.text(), "hi there");
330
+ for (const start of ["", "hi ", "line\n"]) {
331
+ const other = realEditor(start);
332
+ const t = setup(other);
333
+ t.hold(8);
334
+ assert.equal(other.text(), start, `after ${JSON.stringify(start)} every hold space is erased`);
335
+ }
336
+ });
337
+
338
+ test("a refused hold keeps typing spaces, and a later start erases every space the hold added", () => {
339
+ const draft = realEditor("ok");
340
+ let allow = false;
341
+ const s = setup(draft, () => allow);
342
+ s.hold(5);
343
+ assert.equal(draft.text(), "ok ", "refused: six spaces typed normally");
344
+ assert.equal(s.calls.holds, 2);
345
+ allow = true;
346
+ for (let index = 0; index < 6; index++) {
347
+ s.clock.advance(40);
348
+ s.type(" ");
349
+ }
350
+ // Six spaces landed before the start; one stays as the separator after "ok".
351
+ assert.equal(draft.text(), "ok ");
352
+ assert.equal(s.results.filter((result) => result?.data === BACKSPACE).length, 5);
353
+ assert.deepEqual(s.results.at(-1), { consume: true });
354
+ });
355
+
356
+ test("Vim NORMAL mode: Space moves the cursor without growing the draft, so no hold and no rewrite", () => {
357
+ // A NORMAL-mode editor treats Space and Backspace as motions: the text never changes.
358
+ let cursor = 0;
359
+ const text = "normal mode text";
360
+ const draft: Draft = {
361
+ text: () => text,
362
+ input: (data) => {
363
+ if (data === " ") cursor = Math.min(text.length, cursor + 1);
364
+ else if (data === BACKSPACE) cursor = Math.max(0, cursor - 1);
365
+ },
366
+ };
367
+ const s = setup(draft);
368
+ s.hold(8);
369
+ assert.equal(s.calls.holds, 0);
370
+ assert.ok(s.results.every((result) => result === undefined), "every Space reached the editor unchanged");
371
+ assert.equal(cursor, 9);
372
+ });
373
+
374
+ test("a draft change other than the hold's spaces aborts the run without erasing", () => {
375
+ let text = "@fo";
376
+ const draft: Draft = {
377
+ text: () => text,
378
+ input: (data) => {
379
+ // Autocomplete accepts on the first repeat and rewrites the draft.
380
+ if (data === " ") text = text === "@fo " ? "@foo.ts " : `${text} `;
381
+ else if (data === BACKSPACE) text = text.slice(0, -1);
382
+ },
383
+ };
384
+ const s = setup(draft);
385
+ s.hold(4);
386
+ assert.equal(s.calls.holds, 0);
387
+ assert.ok(!s.results.some((result) => result?.data === BACKSPACE));
388
+ });
389
+
390
+ test("a draft change during erasing stops the erase and keeps the rest of the draft", () => {
391
+ const draft = realEditor("abc ");
392
+ const s = setup(draft);
393
+ s.hold(4);
394
+ assert.equal(draft.text(), "abc ");
395
+ draft.input("Z");
396
+ s.clock.advance(40);
397
+ s.type(" ");
398
+ assert.deepEqual(s.results.at(-1), { consume: true }, "no Backspace once the draft moved on");
399
+ assert.equal(draft.text(), "abc Z");
400
+ });
401
+
402
+ test("other input during a hold passes through and stops erasing", () => {
403
+ const draft = realEditor("");
404
+ const s = setup(draft);
405
+ s.hold(4);
406
+ s.type("x");
407
+ assert.equal(s.results.at(-1), undefined);
408
+ s.clock.advance(40);
409
+ s.type(" ");
410
+ assert.deepEqual(s.results.at(-1), { consume: true });
411
+ assert.equal(draft.text(), " x");
412
+ });
413
+
414
+ test("key release reports are ignored and do not break a run", () => {
415
+ const draft = realEditor("");
416
+ const s = setup(draft);
417
+ s.type(" ");
418
+ s.clock.advance(400);
419
+ s.type("\x1b[13;1:3u");
420
+ s.type(" ");
421
+ for (let index = 0; index < 3; index++) {
422
+ s.clock.advance(40);
423
+ s.type(" ");
424
+ }
425
+ assert.equal(s.calls.holds, 1);
426
+ });
427
+
428
+ test("end() leaves a hold without a release, and dispose() clears timers and stops listening", () => {
429
+ const draft = realEditor("");
430
+ const s = setup(draft);
431
+ s.hold(5);
432
+ s.detector.end();
433
+ assert.ok(!s.detector.holding);
434
+ s.clock.advance(1_000);
435
+ assert.equal(s.calls.releases, 0);
436
+ s.type(" ");
437
+ s.detector.dispose();
438
+ assert.equal(s.clock.pending(), 0);
439
+ s.clock.advance(40);
440
+ assert.equal(s.detector.handleInput(" "), undefined);
441
+ s.clock.advance(40);
442
+ assert.equal(s.detector.handleInput(" "), undefined);
443
+ assert.equal(s.calls.holds, 1);
444
+ });