gentle-pi 3.7.0 → 4.0.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 (316) hide show
  1. package/README.md +37 -6
  2. package/assets/agents/gentle-ai-explore.md +4 -4
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -9
  5. package/assets/orchestrator-delegation.md +40 -41
  6. package/assets/orchestrator-memory.md +1 -22
  7. package/assets/orchestrator-skills.md +1 -1
  8. package/assets/orchestrator.md +12 -24
  9. package/assets/support/strict-tdd-verify.md +4 -266
  10. package/assets/support/strict-tdd.md +8 -360
  11. package/bin/gentle-shell.mjs +254 -29
  12. package/docs/delegated-verification.md +26 -1
  13. package/docs/gentle-agents-activity.md +24 -0
  14. package/docs/gentle-shell.md +92 -28
  15. package/docs/native-authority-architecture.md +2 -2
  16. package/docs/prompt-history.md +280 -0
  17. package/docs/readme-reference.md +112 -206
  18. package/docs/telemetry.md +1 -1
  19. package/docs/yolo-mode.md +86 -0
  20. package/extensions/child-context.ts +26 -0
  21. package/extensions/child-safety.ts +23 -0
  22. package/extensions/gentle-agents.ts +429 -262
  23. package/extensions/gentle-ai.ts +781 -683
  24. package/extensions/gentle-shell.ts +1375 -88
  25. package/extensions/gentle-stats.ts +101 -0
  26. package/extensions/gentle-todo.ts +18 -7
  27. package/extensions/history/atomic-write.ts +38 -0
  28. package/extensions/history/hide-prompts.ts +183 -0
  29. package/extensions/history/index.ts +1419 -0
  30. package/extensions/history/load-shared-history.ts +39 -0
  31. package/extensions/history/selector-helpers.ts +538 -0
  32. package/extensions/history/session-scan.ts +233 -0
  33. package/extensions/history/store.ts +1119 -0
  34. package/extensions/nan-provider.ts +6 -0
  35. package/extensions/quiet-tools.ts +179 -87
  36. package/extensions/resume-hint.ts +60 -0
  37. package/extensions/skill-registry.ts +16 -12
  38. package/extensions/startup-banner.ts +60 -31
  39. package/lib/agent-assets.ts +604 -0
  40. package/lib/agent-profile-pin.ts +12 -0
  41. package/lib/agents-message-delivery.ts +181 -0
  42. package/lib/agents-protocol.ts +60 -0
  43. package/lib/agents-runner.ts +96 -98
  44. package/lib/agents-view.ts +26 -3
  45. package/lib/agents-widget.ts +16 -9
  46. package/lib/append-system-prompt.ts +21 -0
  47. package/lib/bounded-writer-admission.ts +147 -0
  48. package/lib/card-style-policy.ts +60 -0
  49. package/lib/child-context-files.ts +166 -0
  50. package/lib/codemode-renderer.ts +185 -0
  51. package/lib/command-palette-catalog.ts +3 -9
  52. package/lib/command-palette.ts +25 -14
  53. package/lib/destructive-command-guard.ts +144 -0
  54. package/lib/gentle-ai-elapsed-store.ts +87 -0
  55. package/lib/gentle-ai-renderer.ts +196 -39
  56. package/lib/gentle-shell-launcher.ts +24 -13
  57. package/lib/gentle-shell-resume-hint.ts +176 -0
  58. package/lib/history-capture-policy.ts +95 -0
  59. package/lib/model-routing-authority.ts +5 -1
  60. package/lib/nan-provider.ts +227 -0
  61. package/lib/native-review-cli.ts +49 -108
  62. package/lib/odd-phase-inference.ts +231 -0
  63. package/lib/odd-phase.ts +141 -0
  64. package/lib/overlay-repaint.ts +26 -0
  65. package/lib/pi-tui-keys.ts +53 -0
  66. package/lib/review-candidate-view-owner.ts +67 -17
  67. package/lib/review-candidate-view.ts +112 -25
  68. package/lib/review-reminder-receipt.ts +48 -8
  69. package/lib/review-risk-assessment.ts +156 -11
  70. package/lib/review-sidebar-state.ts +223 -0
  71. package/lib/selection-engine.ts +515 -0
  72. package/lib/session-messaging-grants.ts +135 -0
  73. package/lib/session-worktree-registry.ts +14 -2
  74. package/lib/shell-bar.ts +179 -24
  75. package/lib/shell-card.ts +287 -18
  76. package/lib/shell-changes-view.ts +2 -1
  77. package/lib/shell-prompt.ts +102 -6
  78. package/lib/shell-sidebar-layout.ts +78 -14
  79. package/lib/shell-sidebar.ts +15 -1
  80. package/lib/shell-todo.ts +23 -13
  81. package/lib/shell-usage-view.ts +9 -4
  82. package/lib/shell-usage.ts +66 -10
  83. package/lib/stats-collector.ts +381 -0
  84. package/lib/stats-view.ts +431 -0
  85. package/lib/theme-customization.ts +52 -0
  86. package/lib/vim-editor-adapter.ts +379 -0
  87. package/lib/vim-normal-engine.ts +154 -0
  88. package/lib/vim-operator-engine.ts +416 -0
  89. package/lib/vim-policy.ts +49 -0
  90. package/lib/vim-visual-engine.ts +107 -0
  91. package/lib/visual-customization-policy.ts +108 -0
  92. package/lib/visual-customize-view.ts +330 -0
  93. package/lib/visual-profiles.ts +228 -0
  94. package/lib/yolo-session-policy.ts +240 -0
  95. package/package.json +20 -8
  96. package/runtime/gentle-shell-launcher.mjs +23 -12
  97. package/runtime/gentle-shell-resume-hint.mjs +177 -0
  98. package/runtime/native-review-cli.mjs +49 -108
  99. package/runtime/review-risk-assessment.mjs +154 -9
  100. package/scripts/build-runtime-modules.mjs +1 -0
  101. package/scripts/gentle-ai-installer.mjs +14 -13
  102. package/scripts/mirror-odd-routing.mjs +2 -2
  103. package/scripts/run-test-suite.mjs +76 -0
  104. package/scripts/test-packed-runner.mjs +31 -14
  105. package/scripts/verify-package-files.mjs +11 -22
  106. package/skills/branch-pr/SKILL.md +24 -52
  107. package/skills/chained-pr/SKILL.md +31 -15
  108. package/skills/chained-pr/references/chaining-details.md +31 -20
  109. package/skills/gentle-ai/SKILL.md +9 -15
  110. package/skills/issue-creation/SKILL.md +8 -2
  111. package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
  112. package/skills/work-unit-commits/SKILL.md +4 -3
  113. package/tests/agent-profiles.test.ts +18 -0
  114. package/tests/agents-fake-child.ts +2 -2
  115. package/tests/agents-message-delivery.test.ts +106 -0
  116. package/tests/agents-protocol.test.ts +40 -0
  117. package/tests/agents-runner.test.ts +378 -89
  118. package/tests/agents-view-thread-identity.test.ts +169 -0
  119. package/tests/agents-view.test.ts +8 -2
  120. package/tests/agents-widget.test.ts +154 -15
  121. package/tests/append-system-prompt-route.test.ts +160 -0
  122. package/tests/append-system-prompt.test.ts +46 -0
  123. package/tests/artifact-language.test.ts +19 -213
  124. package/tests/ask-user-question.test.ts +44 -1
  125. package/tests/asset-installation-runtime.test.ts +5 -16
  126. package/tests/autonomous-guard.test.ts +69 -1
  127. package/tests/bounded-writer-admission.test.ts +95 -0
  128. package/tests/branch-pr-skill.test.ts +43 -0
  129. package/tests/card-style-policy.test.ts +55 -0
  130. package/tests/chained-pr-skill.test.ts +124 -0
  131. package/tests/child-context-files.test.ts +255 -0
  132. package/tests/child-safety.test.ts +82 -0
  133. package/tests/codemode-rendering.test.ts +491 -0
  134. package/tests/command-palette.test.ts +39 -3
  135. package/tests/delegated-key-learnings-contract.test.ts +0 -76
  136. package/tests/destructive-command-guard.test.ts +84 -0
  137. package/tests/devbinary/native-review-parity.devtest.ts +170 -2
  138. package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
  139. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
  140. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
  141. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
  142. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
  143. package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
  144. package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
  145. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
  146. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
  147. package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
  148. package/tests/generic-agent-tools.test.ts +54 -0
  149. package/tests/gentle-agents.test.ts +1047 -252
  150. package/tests/gentle-ai-binary.test.ts +3 -3
  151. package/tests/gentle-ai-elapsed-store.test.ts +68 -0
  152. package/tests/gentle-ai-installer.test.ts +68 -54
  153. package/tests/gentle-ai-renderer.test.ts +487 -8
  154. package/tests/gentle-ai.test.ts +288 -65
  155. package/tests/gentle-card-text.ts +2 -1
  156. package/tests/gentle-shell-bin.test.ts +651 -114
  157. package/tests/gentle-shell-launcher.test.ts +99 -52
  158. package/tests/gentle-shell-resume-hint.test.ts +270 -0
  159. package/tests/gentle-shell.test.ts +3839 -187
  160. package/tests/gentle-stats.test.ts +152 -0
  161. package/tests/gentle-theme.test.ts +4 -1
  162. package/tests/gentle-todo.test.ts +80 -8
  163. package/tests/history-atomic-write.test.ts +57 -0
  164. package/tests/history-capture-policy.test.ts +102 -0
  165. package/tests/history-command-registration.test.ts +164 -0
  166. package/tests/history-dedupe-entries.test.ts +123 -0
  167. package/tests/history-delete-backfill.test.ts +190 -0
  168. package/tests/history-delete-confirm.test.ts +460 -0
  169. package/tests/history-dispatch.test.ts +180 -0
  170. package/tests/history-drain-hidden.test.ts +110 -0
  171. package/tests/history-drain-order.test.ts +98 -0
  172. package/tests/history-expanded-globals.test.ts +62 -0
  173. package/tests/history-gc.test.ts +832 -0
  174. package/tests/history-header-layout.test.ts +265 -0
  175. package/tests/history-hide-prompts.test.ts +275 -0
  176. package/tests/history-lazy-windowing.test.ts +508 -0
  177. package/tests/history-legacy-migrate-v2.test.ts +297 -0
  178. package/tests/history-load-shared-history.test.ts +53 -0
  179. package/tests/history-max-results-cap.test.ts +76 -0
  180. package/tests/history-multi-reader.test.ts +203 -0
  181. package/tests/history-off-path.test.ts +170 -0
  182. package/tests/history-openflow-integration.test.ts +173 -0
  183. package/tests/history-overlay-margin.test.ts +326 -0
  184. package/tests/history-preview-layout.test.ts +93 -0
  185. package/tests/history-registry.test.ts +143 -0
  186. package/tests/history-scope-delete.test.ts +411 -0
  187. package/tests/history-search-caret-keys.test.ts +142 -0
  188. package/tests/history-seed-bootstrap.test.ts +170 -0
  189. package/tests/history-seed-regen.test.ts +129 -0
  190. package/tests/history-selector-windowing.test.ts +94 -0
  191. package/tests/history-session-scan-directory.test.ts +87 -0
  192. package/tests/history-session-scan-extract.test.ts +583 -0
  193. package/tests/history-session-writer.test.ts +351 -0
  194. package/tests/history-store-paths.test.ts +79 -0
  195. package/tests/history-tombstone-exact.test.ts +139 -0
  196. package/tests/history-wheel-mouse.test.ts +242 -0
  197. package/tests/inprocess-reviewer.test.ts +29 -19
  198. package/tests/issue-creation-skill.test.ts +61 -0
  199. package/tests/model-routing-authority.test.ts +16 -0
  200. package/tests/nan-provider.test.ts +471 -0
  201. package/tests/native-review-capability-contract.test.ts +7 -1
  202. package/tests/native-review-cli.test.ts +6 -120
  203. package/tests/native-review-parity-runtime.test.ts +100 -3
  204. package/tests/odd-integration.test.ts +67 -0
  205. package/tests/odd-phase-inference.test.ts +213 -0
  206. package/tests/odd-phase-loader.test.ts +253 -0
  207. package/tests/odd-phase.test.ts +307 -0
  208. package/tests/odd-routing-canonical-ratchet.test.ts +11 -6
  209. package/tests/odd-routing-contract.test.ts +86 -35
  210. package/tests/orchestrator-budget.test.ts +14 -39
  211. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  212. package/tests/overlay-repaint.test.ts +74 -0
  213. package/tests/package-manifest.test.ts +252 -115
  214. package/tests/packed-runner-owned-path.test.ts +46 -0
  215. package/tests/persona-single-channel.test.ts +6 -6
  216. package/tests/provider-defect-handoff.test.ts +3 -11
  217. package/tests/quiet-bash-runtime.test.ts +76 -0
  218. package/tests/quiet-tool-rendering.test.ts +409 -184
  219. package/tests/rdd-aware-verification-contract.test.ts +76 -1
  220. package/tests/rdd-status-line.test.ts +9 -4
  221. package/tests/resume-hint-extension.test.ts +122 -0
  222. package/tests/review-agent-end-preflight.test.ts +176 -12
  223. package/tests/review-candidate-owner-retry.test.ts +22 -1
  224. package/tests/review-candidate-view.test.ts +298 -0
  225. package/tests/review-contract-prompt.test.ts +108 -43
  226. package/tests/review-controller-lock-status.test.ts +0 -1
  227. package/tests/review-controller-native-routing.test.ts +611 -5
  228. package/tests/review-controller-workspace-root.test.ts +163 -4
  229. package/tests/review-host-relay-routing.test.ts +338 -2
  230. package/tests/review-integration-v2-forward.test.ts +200 -0
  231. package/tests/review-ledger-contract.test.ts +10 -34
  232. package/tests/review-reminder-receipt.test.ts +47 -1
  233. package/tests/review-risk-assessment.test.ts +498 -6
  234. package/tests/review-sidebar-state.test.ts +402 -0
  235. package/tests/run-test-suite.test.ts +124 -0
  236. package/tests/runtime-harness.mjs +145 -786
  237. package/tests/runtime-metrics-children.test.ts +16 -23
  238. package/tests/selection-engine.test.ts +421 -0
  239. package/tests/session-messaging-grants.test.ts +255 -0
  240. package/tests/session-worktree-registry.test.ts +77 -0
  241. package/tests/shell-bar.test.ts +382 -1
  242. package/tests/shell-card.test.ts +353 -1
  243. package/tests/shell-changes-view.test.ts +52 -0
  244. package/tests/shell-prompt.test.ts +94 -2
  245. package/tests/shell-sidebar-layout.test.ts +325 -21
  246. package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
  247. package/tests/shell-todo.test.ts +87 -1
  248. package/tests/shell-usage-view.test.ts +27 -0
  249. package/tests/shell-usage.test.ts +73 -0
  250. package/tests/skill-registry.test.ts +50 -1
  251. package/tests/startup-banner.test.ts +130 -2
  252. package/tests/stats-collector.test.ts +195 -0
  253. package/tests/stats-view.test.ts +202 -0
  254. package/tests/telemetry-trigger.test.ts +81 -20
  255. package/tests/theme-customization.test.ts +72 -0
  256. package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
  257. package/tests/vim-editor-adapter.test.ts +804 -0
  258. package/tests/vim-normal-engine.test.ts +101 -0
  259. package/tests/vim-operator-engine.test.ts +215 -0
  260. package/tests/vim-policy.test.ts +19 -0
  261. package/tests/vim-visual-engine.test.ts +52 -0
  262. package/tests/visual-customization-policy.test.ts +110 -0
  263. package/tests/visual-customize-view.test.ts +418 -0
  264. package/tests/visual-profiles.test.ts +87 -0
  265. package/tests/yolo-customize.test.ts +256 -0
  266. package/tests/yolo-mode-runtime.test.ts +161 -0
  267. package/tests/yolo-mode.test.ts +261 -0
  268. package/tests/yolo-session-policy.test.ts +59 -0
  269. package/themes/Gentle.json +2 -1
  270. package/themes/Gentleman-Cute.json +2 -1
  271. package/themes/Gentleman-Sexy.json +2 -1
  272. package/assets/agents/sdd-apply.md +0 -159
  273. package/assets/agents/sdd-archive.md +0 -228
  274. package/assets/agents/sdd-design.md +0 -49
  275. package/assets/agents/sdd-explore.md +0 -48
  276. package/assets/agents/sdd-init.md +0 -56
  277. package/assets/agents/sdd-onboard.md +0 -52
  278. package/assets/agents/sdd-proposal.md +0 -64
  279. package/assets/agents/sdd-remediate.md +0 -37
  280. package/assets/agents/sdd-research.md +0 -49
  281. package/assets/agents/sdd-spec.md +0 -192
  282. package/assets/agents/sdd-status.md +0 -54
  283. package/assets/agents/sdd-tasks.md +0 -108
  284. package/assets/agents/sdd-verify.md +0 -124
  285. package/assets/chains/sdd-full.chain.md +0 -83
  286. package/assets/chains/sdd-plan.chain.md +0 -56
  287. package/assets/chains/sdd-verify.chain.md +0 -43
  288. package/assets/sdd-orchestrator-workflow.md +0 -319
  289. package/assets/support/sdd-status-contract.md +0 -77
  290. package/docs/assets/diagrams/sdd-cycle.svg +0 -14
  291. package/extensions/sdd-init.ts +0 -816
  292. package/lib/openspec-deltas.ts +0 -156
  293. package/lib/sdd-preflight.ts +0 -1066
  294. package/lib/sdd-research-capabilities.ts +0 -94
  295. package/lib/sdd-status.ts +0 -26
  296. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
  297. package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
  298. package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
  299. package/tests/openspec-deltas.test.ts +0 -209
  300. package/tests/sdd-agent-tools.test.ts +0 -156
  301. package/tests/sdd-archive-replay.test.ts +0 -82
  302. package/tests/sdd-classical-continuation.test.ts +0 -74
  303. package/tests/sdd-execution-routing-contract.test.ts +0 -44
  304. package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
  305. package/tests/sdd-native-managed-uptake.test.ts +0 -243
  306. package/tests/sdd-no-attempts-contract.test.ts +0 -15
  307. package/tests/sdd-odd-integration.test.ts +0 -33
  308. package/tests/sdd-optional-research.test.ts +0 -124
  309. package/tests/sdd-planning-routing-contract.test.ts +0 -45
  310. package/tests/sdd-preflight-rpc-input.test.ts +0 -125
  311. package/tests/sdd-preflight.test.ts +0 -541
  312. package/tests/sdd-research-capabilities.test.ts +0 -114
  313. package/tests/sdd-research-live.test.ts +0 -241
  314. package/tests/sdd-selection-transport.test.ts +0 -653
  315. package/tests/sdd-status.test.ts +0 -9
  316. package/tests/sdd-task-truth.test.ts +0 -43
@@ -26,7 +26,7 @@ const event = (taskId = "local-task") => childEvent("local-session", taskId, lau
26
26
  });
27
27
 
28
28
  test("installed package definitions retain classification after the actual routing transform", () => {
29
- // installSddAssets/copyDirectoryFiles copies assets verbatim on first install.
29
+ // The installer copies packaged agent assets verbatim on first install.
30
30
  // Isolate the real pure routing writer; do not run an installer or read user agents.
31
31
  const source = readFileSync(new URL("../extensions/gentle-ai.ts", import.meta.url), "utf8");
32
32
  const transform = source.match(/function updateFrontmatterRouting\([\s\S]*?\n\}/)?.[0];
@@ -34,17 +34,9 @@ test("installed package definitions retain classification after the actual routi
34
34
  const route = runInNewContext(`(${stripTypeScriptTypes(transform)})`);
35
35
  for (const entry of readdirSync(new URL("../assets/agents/", import.meta.url)).filter(file => file.endsWith(".md"))) {
36
36
  const file = entry.slice(0, -3);
37
- const className = file === "sdd-proposal" ? "sdd-propose"
38
- : file.startsWith("gentle-ai-") ? file.slice("gentle-ai-".length) : file;
37
+ const className = file.startsWith("gentle-ai-") ? file.slice("gentle-ai-".length) : file;
39
38
  const kind = parseAgentClass(className);
40
- if (file === "sdd-remediate") {
41
- assert.equal(kind, undefined, "remediation stays dark in the existing telemetry taxonomy");
42
- const definition = parseAgentDefinition(readFileSync(new URL(`../assets/agents/${file}.md`, import.meta.url), "utf8"), file, "global");
43
- assert.ok("instructions" in definition);
44
- assert.equal(classifyBuiltinAgent(definition), "unknown");
45
- continue;
46
- }
47
- assert.ok(kind, `${file}: telemetry class`);
39
+ if (!kind) continue; // Review/Judgment Day agents are not child telemetry classes.
48
40
  const asset = new URL(`../assets/agents/${file}.md`, import.meta.url);
49
41
  const content = readFileSync(asset, "utf8");
50
42
  const copied = parseAgentDefinition(content, asset.pathname, "global");
@@ -54,7 +46,7 @@ test("installed package definitions retain classification after the actual routi
54
46
  const parsed = parseAgentDefinition(route(content, entry), asset.pathname, "global");
55
47
  assert.ok("instructions" in parsed);
56
48
  assert.equal(classifyBuiltinAgent(parsed), kind, `${kind}: installed routing`);
57
- const customizedClass = parsed.name === "sdd-proposal" ? "sdd-propose" : parseAgentClass(parsed.name) ?? "unknown";
49
+ const customizedClass = parseAgentClass(parsed.name) ?? "unknown";
58
50
  assert.equal(classifyBuiltinAgent({ ...parsed, instructions: `${parsed.instructions}\nOverride` }), customizedClass);
59
51
  assert.equal(classifyBuiltinAgent({ ...parsed, tools: ["different-tool"] }), customizedClass);
60
52
  assert.equal(classifyBuiltinAgent({ ...parsed, description: "different description" }), customizedClass);
@@ -63,13 +55,13 @@ test("installed package definitions retain classification after the actual routi
63
55
  }
64
56
  });
65
57
 
66
- test("packaged sdd-proposal is encoded only as canonical sdd-propose", () => {
67
- const path = new URL("../assets/agents/sdd-proposal.md", import.meta.url);
58
+ test("packaged named worker is encoded with its public telemetry class", () => {
59
+ const path = new URL("../assets/agents/gentle-ai-worker.md", import.meta.url);
68
60
  const packaged = parseAgentDefinition(readFileSync(path, "utf8"), path.pathname, "global");
69
61
  assert.ok("instructions" in packaged);
70
62
  const selection = launchSelection(packaged, { provider: "openai", id: "gpt-4o" }, "high");
71
- assert.equal(selection.agentClass, "sdd-propose");
72
- const completed = childEvent("local-session", "sdd-propose-task", selection, "completed", {
63
+ assert.equal(selection.agentClass, "worker");
64
+ const completed = childEvent("local-session", "worker-task", selection, "completed", {
73
65
  coverage: "final_assistant_messages_only", agentSettled: true, responses: [response()], droppedResponses: 0,
74
66
  });
75
67
  assert.ok(completed);
@@ -79,15 +71,16 @@ test("packaged sdd-proposal is encoded only as canonical sdd-propose", () => {
79
71
  const snapshot = composition.snapshot();
80
72
  const payload = encodeNativeRuntimeEvent(snapshot.responses, snapshot.launches);
81
73
  assert.ok(payload);
82
- assert.equal(JSON.parse(payload).rows[0].agent_class, "sdd-propose");
83
- assert.ok(!payload.includes("sdd-proposal"));
74
+ assert.equal(JSON.parse(payload).rows[0].agent_class, "worker");
75
+ assert.ok(!payload.includes("gentle-ai-worker"));
84
76
  });
85
77
 
86
- test("schema-approved packaged names survive customization without exposing private names", () => {
87
- const path = new URL("../assets/agents/sdd-apply.md", import.meta.url);
78
+ test("prefixed packaged names require exact fingerprints and do not expose private names", () => {
79
+ const path = new URL("../assets/agents/gentle-ai-verify.md", import.meta.url);
88
80
  const packaged = parseAgentDefinition(readFileSync(path, "utf8"), path.pathname, "global");
89
81
  assert.ok("instructions" in packaged);
90
- assert.equal(classifyBuiltinAgent({ ...packaged, instructions: "customized instructions" }), "sdd-apply");
82
+ assert.equal(classifyBuiltinAgent(packaged), "verify");
83
+ assert.equal(classifyBuiltinAgent({ ...packaged, instructions: "customized instructions" }), "unknown");
91
84
 
92
85
  assert.equal(classifyBuiltinAgent(workerDefinition), "worker");
93
86
  // This is a packaged frontmatter name, but it is absent from the telemetry
@@ -132,7 +125,7 @@ test("launch distribution and each observed combination remain independent and p
132
125
  });
133
126
 
134
127
  test("registered child launch identity survives catalog state and missing response evidence inherits selection", () => {
135
- const path = new URL("../assets/agents/sdd-explore.md", import.meta.url);
128
+ const path = new URL("../assets/agents/gentle-ai-explore.md", import.meta.url);
136
129
  const agent = parseAgentDefinition(readFileSync(path, "utf8"), path.pathname, "global");
137
130
  assert.ok("instructions" in agent);
138
131
  const selection = launchSelection(agent, { provider: "openai-codex", id: "gpt-5.6-terra" }, "high");
@@ -140,7 +133,7 @@ test("registered child launch identity survives catalog state and missing respon
140
133
  usage: { input: 3, output: 2 } } }, { observeResponses: true })
141
134
  .filter(event => event.type === TASK_EVENT.RESPONSE_OBSERVATION);
142
135
  assert.ok(observation?.type === TASK_EVENT.RESPONSE_OBSERVATION);
143
- const completed = childEvent("local-session", "sdd-explore-task", selection, "completed", {
136
+ const completed = childEvent("local-session", "explore-task", selection, "completed", {
144
137
  coverage: "final_assistant_messages_only", agentSettled: true,
145
138
  responses: Array(5).fill(observation.observation), droppedResponses: 0,
146
139
  });
@@ -0,0 +1,421 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { CustomEditor } from "@earendil-works/pi-coding-agent";
4
+ import { GentlePromptEditor } from "../extensions/gentle-shell.ts";
5
+ import { SelectionEngine } from "../lib/selection-engine.ts";
6
+ import { decodePrintableKey } from "../lib/pi-tui-keys.ts";
7
+ import { CURSOR_MARKER, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
8
+ import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts";
9
+ import { stripAnsi } from "../lib/terminal-theme.ts";
10
+
11
+ // Native selection engine tests: drive a real CustomEditor through the
12
+ // SelectionEngine the same way GentlePromptEditor wires it — engine.handleInput
13
+ // in front, native dispatch back into the editor. Covers the ported contract:
14
+ // shift+home/end selection, replace-on-delete, alt+a select all, collapse on
15
+ // movement, zero-width no-op at the edge, highlight + hint rendering, and
16
+ // degraded passthrough.
17
+
18
+ type CtorParams = ConstructorParameters<typeof CustomEditor>;
19
+
20
+ function makeEditor(): CustomEditor {
21
+ const tui = { terminal: { rows: 30, columns: 100 }, requestRender: () => {} } as unknown as CtorParams[0];
22
+ const theme = { borderColor: (s: string) => s, selectList: {} } as unknown as CtorParams[1];
23
+ const kb = { matches: () => false } as unknown as CtorParams[2];
24
+ const editor = new CustomEditor(tui, theme, kb);
25
+ (editor as unknown as { focused: boolean }).focused = true;
26
+ return editor;
27
+ }
28
+
29
+ function floatEditor(bg?: (role: string, text: string) => string): GentlePromptEditor {
30
+ const tui = { terminal: { rows: 30, columns: 100 }, requestRender() {} } as unknown as CtorParams[0];
31
+ const identity = (text: string) => text;
32
+ const theme = { borderColor: identity, selectList: {
33
+ selectedPrefix: identity, selectedText: identity, description: identity, scrollInfo: identity, noMatch: identity,
34
+ } } as unknown as CtorParams[1];
35
+ const editor = new GentlePromptEditor(tui, theme, { matches: () => false } as unknown as CtorParams[2], {
36
+ fg: (_r, t) => t, bg, bold: identity, requestRender() {}, pending: () => false,
37
+ now: () => 0, doubleEscCancelEnabled: () => false, dispatchQueuedText() {},
38
+ });
39
+ editor.focused = true;
40
+ return editor;
41
+ }
42
+
43
+ const promptBg = (_role: string, text: string) => `\x1b[44m${text}\x1b[49m`;
44
+ const click = (x: number, y: number, width: number): TuiMouseEvent => ({
45
+ type: "click", button: "left", x, y, width, height: 30, screenX: x, screenY: y, shift: false, alt: false, ctrl: false,
46
+ });
47
+
48
+ test("T2 float prompt native cursor, selection, replacement and mouse share geometry", () => {
49
+ const previous = cardStyle();
50
+ setCardStyle(CARD_STYLE.FLOAT);
51
+ const editor = floatEditor(promptBg);
52
+ try {
53
+ editor.borderColor = (text) => `\x1b[35m${text}\x1b[39m`;
54
+ editor.setText("abcd");
55
+ editor.handleInput("\x1b[D");
56
+ let rows = editor.render(80);
57
+ assert.match(rows[0], /\x1b\[35m▎\x1b\[39m/);
58
+ assert.equal(visibleWidth(rows[1].split(CURSOR_MARKER)[0]), 6);
59
+ assert.ok(rows[1].includes(`${CURSOR_MARKER}\x1b[7md\x1b[0m\x1b[44m`));
60
+ editor.handleInput("\x1b[1;2H");
61
+ rows = editor.render(80);
62
+ assert.equal(stripAnsi(rows[1].replaceAll(CURSOR_MARKER, "")).trim(), "▎ abcd");
63
+ assert.ok(rows[1].includes(`${CURSOR_MARKER}\x1b[7ma\x1b[0m\x1b[44m\x1b[7mbc\x1b[27md`), "native selection inversion survives the software cursor reset");
64
+ assert.match(rows[2], /3 chars selected/);
65
+ assert.doesNotMatch(rows[2], /╯/);
66
+ assert.ok(rows[2].includes("\x1b[44m"));
67
+ editor.handleInput("X");
68
+ assert.equal(editor.getText(), "Xd");
69
+ editor.setText("abcd");
70
+ editor.render(30);
71
+ editor.handleMouse(click(4, 1, 30));
72
+ assert.deepEqual(editor.getCursor(), { line: 0, col: 1 });
73
+ editor.handleMouse(click(10, 0, 30));
74
+ assert.deepEqual(editor.getCursor(), { line: 0, col: 1 }, "padding row is inert");
75
+ editor.handleMouse(click(10, 2, 30));
76
+ assert.deepEqual(editor.getCursor(), { line: 0, col: 1 }, "hint row is inert");
77
+ editor.borderColor = (text) => `\x1b[36m${text}\x1b[39m`;
78
+ assert.match(editor.render(30)[1], /\x1b\[36m▎\x1b\[39m/, "accent reads the current public mode callback");
79
+ assert.equal(editor.handleMouse({ ...click(3, 1, 30), type: "press" }), undefined, "native drag selection remains unhandled");
80
+ editor.setText("abcdefghijklmnop");
81
+ const wrapped = editor.render(12);
82
+ assert.equal(wrapped.length, 5, "native layout width=6 wraps into three content rows");
83
+ editor.handleMouse(click(5, 2, 12));
84
+ assert.deepEqual(editor.getCursor(), { line: 0, col: 8 });
85
+ editor.handleInput("\x1ba");
86
+ assert.ok(editor.render(10).slice(1, -1).every((row) => row.includes("\x1b[7m")), "selection paints every wrapped content row");
87
+ } finally { editor.dispose(); setCardStyle(previous); }
88
+ });
89
+
90
+ test("T2 float prompt actual native completions retain start row, height and click application", async () => {
91
+ const previous = cardStyle();
92
+ setCardStyle(CARD_STYLE.FLOAT);
93
+ const editor = floatEditor(promptBg);
94
+ editor.setAutocompleteProvider({
95
+ async getSuggestions() { return { prefix: "/", items: [{ value: "/alpha", label: "alpha" }, { value: "/beta", label: "beta" }] }; },
96
+ applyCompletion(_lines, _line, _col, item) { return { lines: [item.value], cursorLine: 0, cursorCol: item.value.length }; },
97
+ });
98
+ try {
99
+ editor.handleInput("/");
100
+ await new Promise<void>((resolve) => setImmediate(resolve));
101
+ assert.equal(editor.isShowingAutocomplete(), true);
102
+ const rows = editor.render(30);
103
+ assert.equal(rows.length, 5);
104
+ assert.match(stripAnsi(rows[3]), /^ ▎ → alpha/);
105
+ assert.match(stripAnsi(rows[4]), /^ ▎ beta/);
106
+ assert.ok(rows.every((row) => visibleWidth(row) === 30));
107
+ assert.equal(visibleWidth(rows[1].split(CURSOR_MARKER)[0]), 4);
108
+ assert.equal(editor.handleMouse(click(4, 4, 30))?.handled, true);
109
+ assert.equal(editor.getText(), "/beta");
110
+ assert.equal(editor.isShowingAutocomplete(), false);
111
+ } finally { editor.dispose(); setCardStyle(previous); }
112
+ });
113
+
114
+ test("T2 float prompt actual editor fallback and neon bytes remain exact at all widths", () => {
115
+ const previous = cardStyle();
116
+ const editor = floatEditor(promptBg);
117
+ const fallback = floatEditor((_role, text) => text);
118
+ try {
119
+ for (const width of [0, 1, 2, 9, 10, 30, 80]) {
120
+ // Pi 0.99.2 wordWrapLine recurses on a wide glyph at layoutWidth=1.
121
+ const text = width < 10 ? "abcd" : "雪abcd";
122
+ editor.setText(text); fallback.setText(text);
123
+ setCardStyle(CARD_STYLE.NEON);
124
+ const neon = editor.render(width);
125
+ setCardStyle(CARD_STYLE.FLOAT);
126
+ assert.deepEqual(fallback.render(width), neon);
127
+ const rows = editor.render(width);
128
+ if (width < 10) assert.deepEqual(rows, neon);
129
+ else assert.match(stripAnsi(rows[0]), /^ ▎ ✿/);
130
+ assert.ok(rows.every((row) => visibleWidth(row) <= width));
131
+ }
132
+ } finally { editor.dispose(); fallback.dispose(); setCardStyle(previous); }
133
+ });
134
+
135
+ const END = "\x1b[F";
136
+ const HOME = "\x1b[H";
137
+ const SHIFT_HOME = "\x1b[1;2H";
138
+ const SHIFT_END = "\x1b[1;2F";
139
+ const RIGHT = "\x1b[C";
140
+ const DEL = "\x1b[3~";
141
+ const BACKSPACE = "\x7f";
142
+ const ALT_A = "\x1ba";
143
+
144
+ function cursorOf(editor: CustomEditor): { line: number; col: number } {
145
+ return (editor as unknown as { getCursor(): { line: number; col: number } }).getCursor();
146
+ }
147
+
148
+ test("shift+home selects to line start; delete replaces the selection atomically", () => {
149
+ const editor = makeEditor();
150
+ const engine = new SelectionEngine(editor);
151
+ const native = (d: string) => editor.handleInput(d);
152
+ editor.setText("hello world");
153
+ editor.handleInput(END);
154
+ engine.handleInput(SHIFT_HOME, native);
155
+ assert.equal(cursorOf(editor).col, 0);
156
+ engine.handleInput(DEL, native);
157
+ assert.equal(editor.getText(), "");
158
+ });
159
+
160
+ test("alt+a selects all; backspace replaces the whole text", () => {
161
+ const editor = makeEditor();
162
+ const engine = new SelectionEngine(editor);
163
+ const native = (d: string) => editor.handleInput(d);
164
+ editor.setText("abc\ndef");
165
+ engine.handleInput(ALT_A, native);
166
+ engine.handleInput(BACKSPACE, native);
167
+ assert.equal(editor.getText(), "");
168
+ assert.equal(cursorOf(editor).line, 0);
169
+ });
170
+
171
+ test("movement collapses the selection; later delete behaves natively", () => {
172
+ const editor = makeEditor();
173
+ const engine = new SelectionEngine(editor);
174
+ const native = (d: string) => editor.handleInput(d);
175
+ editor.setText("hello");
176
+ editor.handleInput(END);
177
+ engine.handleInput(SHIFT_HOME, native);
178
+ engine.handleInput(RIGHT, native);
179
+ engine.handleInput(DEL, native);
180
+ assert.equal(editor.getText(), "hllo");
181
+ });
182
+
183
+ test("shift+end at line end: zero-width selection, delete is a no-op", () => {
184
+ const editor = makeEditor();
185
+ const engine = new SelectionEngine(editor);
186
+ const native = (d: string) => editor.handleInput(d);
187
+ editor.setText("hi");
188
+ editor.handleInput(END);
189
+ engine.handleInput(SHIFT_END, native);
190
+ engine.handleInput(DEL, native);
191
+ assert.equal(editor.getText(), "hi");
192
+ });
193
+
194
+ test("render wraps the selected span in reverse video and shows the hint", () => {
195
+ const tui = { terminal: { rows: 30, columns: 100 }, requestRender: () => {} } as unknown as CtorParams[0];
196
+ const theme = { borderColor: (s: string) => s, selectList: {} } as unknown as CtorParams[1];
197
+ const kb = { matches: () => false } as unknown as CtorParams[2];
198
+ const editor = new GentlePromptEditor(tui, theme, kb, {
199
+ fg: (_color, text) => text,
200
+ bold: (text) => text,
201
+ requestRender: () => {},
202
+ pending: () => false,
203
+ now: () => Date.now(),
204
+ doubleEscCancelEnabled: () => false,
205
+ dispatchQueuedText: () => {},
206
+ });
207
+ (editor as unknown as { focused: boolean }).focused = true;
208
+ const engine = (editor as unknown as { selectionEngine: SelectionEngine }).selectionEngine;
209
+ const native = (d: string) => editor.handleInput(d);
210
+ editor.setText("hello world");
211
+ editor.handleInput(END);
212
+ engine.handleInput(SHIFT_HOME, native);
213
+ const rows = editor.render(80);
214
+ const content = rows[1] ?? "";
215
+ assert.ok(content.includes("\x1b[7m"), "reverse-video span missing");
216
+ const last = rows[rows.length - 1] ?? "";
217
+ assert.ok(last.includes("chars selected"), "selection hint missing on the bottom rule");
218
+ });
219
+
220
+ test("degraded host: pure passthrough, no selection behavior", () => {
221
+ const minimal = {
222
+ getText: () => "",
223
+ setText: (_text: string) => {},
224
+ handleInput: (_data: string) => {},
225
+ render: (_width: number) => [] as string[],
226
+ invalidate: () => {},
227
+ } as unknown as CustomEditor;
228
+ const engine = new SelectionEngine(minimal);
229
+ assert.equal(engine.degraded, true);
230
+ let nativeSeen = "";
231
+ engine.handleInput(SHIFT_HOME, (d) => {
232
+ nativeSeen = d;
233
+ });
234
+ assert.equal(nativeSeen, SHIFT_HOME);
235
+ assert.equal(engine.anchor, null);
236
+ });
237
+
238
+ // --- Focused terminal-key decode + undo-transaction coverage (review round 2):
239
+ // the extension-era suites were replaced by the native engine, so these pin the
240
+ // contracts the review explicitly named: kitty press/repeat/release filtering,
241
+ // CSI-u DEL/C1 delete-only replacement, and exactly-one-undo replacement.
242
+
243
+ const KITTY_A_PRESS = "\x1b[97;1:1u"; // 'a', kitty flag 2, press
244
+ const KITTY_A_RELEASE = "\x1b[97;1:3u"; // 'a', kitty flag 2, release
245
+
246
+ test("kitty CSI-u press replaces the selection; release is dropped and keeps it", () => {
247
+ const editor = makeEditor();
248
+ const engine = new SelectionEngine(editor);
249
+ const native = (d: string) => editor.handleInput(d);
250
+ editor.setText("hello");
251
+ editor.handleInput(END);
252
+ engine.handleInput(SHIFT_HOME, native);
253
+ engine.handleInput(KITTY_A_RELEASE, native);
254
+ assert.equal(editor.getText(), "hello");
255
+ assert.deepEqual(engine.anchor, { line: 0, col: 5 });
256
+ engine.handleInput(KITTY_A_PRESS, native);
257
+ assert.equal(editor.getText(), "a");
258
+ assert.deepEqual(cursorOf(editor), { line: 0, col: 1 });
259
+ });
260
+
261
+ test("repeated shift+home at the line edge keeps the selection", () => {
262
+ const editor = makeEditor();
263
+ const engine = new SelectionEngine(editor);
264
+ const native = (d: string) => editor.handleInput(d);
265
+ editor.setText("hello");
266
+ editor.handleInput(END);
267
+ engine.handleInput(SHIFT_HOME, native);
268
+ engine.handleInput(SHIFT_HOME, native);
269
+ assert.notEqual(engine.anchor, null);
270
+ assert.equal(engine.selectionLength(), 5);
271
+ });
272
+
273
+ test("modifyOtherKeys: printable decodes; ctrl-modified and control codepoints are rejected", () => {
274
+ assert.equal(decodePrintableKey("\x1b[27;1;97~"), "a");
275
+ assert.equal(decodePrintableKey("\x1b[27;5;97~"), undefined);
276
+ assert.equal(decodePrintableKey("\x1b[27;1;27~"), undefined);
277
+ });
278
+
279
+ test("CSI-u DEL and C1 codepoints replace the selection with a pure delete", () => {
280
+ for (const data of ["\x1b[27;1;127~", "\x1b[27;1;155~"]) {
281
+ const editor = makeEditor();
282
+ const engine = new SelectionEngine(editor);
283
+ const native = (d: string) => editor.handleInput(d);
284
+ editor.setText("hi");
285
+ editor.handleInput(END);
286
+ engine.handleInput(SHIFT_HOME, native);
287
+ engine.handleInput(data, native);
288
+ assert.equal(editor.getText(), "", `control byte from ${JSON.stringify(data)} must not be inserted`);
289
+ }
290
+ });
291
+
292
+ test("bracketed paste replaces a multiline interior selection and restores it with one undo", () => {
293
+ const editor = makeEditor();
294
+ const engine = new SelectionEngine(editor);
295
+ const native = (d: string) => editor.handleInput(d);
296
+ editor.setText("prefix old\ntext suffix");
297
+ editor.handleInput(HOME);
298
+ engine.handleInput(SHIFT_END, native);
299
+ engine.handleInput("\x1b[200~new\nlines\x1b[201~", native);
300
+ assert.equal(editor.getText(), "prefix old\nnew\nlines");
301
+ (editor as unknown as { undo(): void }).undo();
302
+ assert.equal(editor.getText(), "prefix old\ntext suffix");
303
+ });
304
+
305
+ test("printable replacement is exactly one undo transaction", () => {
306
+ const editor = makeEditor();
307
+ const engine = new SelectionEngine(editor);
308
+ const native = (d: string) => editor.handleInput(d);
309
+ editor.setText("hello world");
310
+ editor.handleInput(END);
311
+ engine.handleInput(SHIFT_HOME, native);
312
+ const internals = editor as unknown as { pushUndoSnapshot(): void };
313
+ const original = internals.pushUndoSnapshot.bind(editor);
314
+ let snapshots = 0;
315
+ internals.pushUndoSnapshot = () => {
316
+ snapshots += 1;
317
+ original();
318
+ };
319
+ engine.handleInput("x", native);
320
+ assert.equal(editor.getText(), "x");
321
+ assert.equal(snapshots, 1);
322
+ });
323
+
324
+ const PASTE_START = "\x1b[200~";
325
+ const PASTE_END = "\x1b[201~";
326
+ const MAX_INCOMPLETE_PASTE_BYTES = 16 * 1024 * 1024;
327
+
328
+ test("bracketed paste terminator split across six single-byte chunks still completes the paste", () => {
329
+ const editor = makeEditor();
330
+ const engine = new SelectionEngine(editor);
331
+ const native = (d: string) => editor.handleInput(d);
332
+ editor.setText("prefix old\ntext suffix");
333
+ editor.handleInput(HOME);
334
+ engine.handleInput(SHIFT_END, native);
335
+ engine.handleInput(PASTE_START, native);
336
+ engine.handleInput("new\nlines", native);
337
+ for (const byte of PASTE_END) engine.handleInput(byte, native);
338
+ assert.equal(editor.getText(), "prefix old\nnew\nlines");
339
+ (editor as unknown as { undo(): void }).undo();
340
+ assert.equal(editor.getText(), "prefix old\ntext suffix");
341
+ });
342
+
343
+ test("an incomplete bracketed paste that crosses the 16 MiB bound without a terminator is abandoned, never forwarded to native", () => {
344
+ const editor = makeEditor();
345
+ const engine = new SelectionEngine(editor);
346
+ const native = (d: string) => editor.handleInput(d);
347
+ editor.setText("hello");
348
+ engine.handleInput(ALT_A, native);
349
+ engine.handleInput(PASTE_START, native);
350
+ const payload = "z".repeat(MAX_INCOMPLETE_PASTE_BYTES - PASTE_START.length + 1);
351
+ engine.handleInput(payload, native);
352
+ assert.equal(editor.getText(), "hello", "no partial paste bytes are ever forwarded to the native editor");
353
+ engine.handleInput("x", native);
354
+ assert.equal(editor.getText(), "x", "input after abandonment behaves like a fresh keystroke over the still-active selection");
355
+ });
356
+
357
+ test("the 16 MiB incomplete-frame bound counts UTF-8 bytes, not UTF-16 code units", () => {
358
+ const editor = makeEditor();
359
+ const engine = new SelectionEngine(editor);
360
+ const native = (d: string) => editor.handleInput(d);
361
+ editor.setText("hello");
362
+ engine.handleInput(ALT_A, native);
363
+ engine.handleInput(PASTE_START, native);
364
+ // "é" is one UTF-16 code unit but two UTF-8 bytes: 10M code units is under
365
+ // the 16 MiB code-unit count but 20 MB in UTF-8 bytes, over the bound.
366
+ const payload = "é".repeat(10 * 1024 * 1024);
367
+ engine.handleInput(payload, native);
368
+ assert.equal(editor.getText(), "hello", "a multibyte payload under 16M code units but over 16 MiB in UTF-8 bytes must still be abandoned");
369
+ engine.handleInput("x", native);
370
+ assert.equal(editor.getText(), "x");
371
+ });
372
+
373
+ test("many-chunk bracketed paste never rescans the full accumulated buffer for the terminator", () => {
374
+ const editor = makeEditor();
375
+ const engine = new SelectionEngine(editor);
376
+ const native = (d: string) => editor.handleInput(d);
377
+ editor.setText("hello");
378
+ engine.handleInput(ALT_A, native);
379
+ const chunkCount = 1500; // > 1000 chars so the host editor collapses it into its own marker
380
+ // Deterministic regression check (avoids flaky wall-clock timing on slow/CI
381
+ // hardware): instrument String.prototype.indexOf while chunks are buffered
382
+ // and record the length of every string searched for PASTE_END. A
383
+ // full-buffer rescan (the pre-fix O(n^2) bug) would search a string that
384
+ // grows with every chunk; the tail-bounded scan only ever searches
385
+ // `pasteTail + <this chunk>`, so its length stays bounded regardless of how
386
+ // many chunks were already buffered.
387
+ const originalIndexOf = String.prototype.indexOf;
388
+ const searchedLengths: number[] = [];
389
+ // biome-ignore lint: intentional prototype patch, restored in `finally` below.
390
+ String.prototype.indexOf = function (this: string, searchString: string, position?: number): number {
391
+ if (searchString === PASTE_END) searchedLengths.push(this.length);
392
+ return originalIndexOf.call(this, searchString, position);
393
+ };
394
+ try {
395
+ engine.handleInput(PASTE_START, native);
396
+ for (let i = 0; i < chunkCount; i++) engine.handleInput("z", native);
397
+ engine.handleInput(PASTE_END, native);
398
+ } finally {
399
+ String.prototype.indexOf = originalIndexOf;
400
+ }
401
+ assert.match(editor.getText(), /^\[paste #1 \d+ chars\]$/, "the oversized paste still completed (collapsed into the host editor's own marker)");
402
+ // Bound: PASTE_END tail carry (5 chars) + the largest single chunk sent
403
+ // here (the 6-char PASTE_END chunk itself) = 11 chars, never the ~1500+
404
+ // char accumulated buffer a full-buffer rescan would search. The final
405
+ // `native(frame)` call hands the fully assembled frame to the underlying
406
+ // host editor exactly once, and that editor's own paste handling does its
407
+ // own one-time scan over the assembled length — a single unavoidable O(final
408
+ // length) cost outside SelectionEngine, not a per-chunk rescan; exactly one
409
+ // such large search is tolerated below.
410
+ const smallSearches = searchedLengths.filter((len) => len <= 11);
411
+ const largeSearches = searchedLengths.filter((len) => len > 11);
412
+ assert.equal(
413
+ smallSearches.length,
414
+ chunkCount + 2,
415
+ `expected exactly one tail-bounded terminator search per input chunk (start + ${chunkCount} z's + end), saw ${smallSearches.length}`,
416
+ );
417
+ assert.ok(
418
+ largeSearches.length <= 1,
419
+ `expected at most one full-frame scan (the host editor's own one-time assembled-paste handling), saw ${largeSearches.length} large searches (lengths: ${largeSearches.join(", ")}) — a full-buffer rescan during chunk intake regressed`,
420
+ );
421
+ });