@huseynsnmz/pi-board 0.15.3 → 0.16.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 (211) hide show
  1. package/CHANGELOG.md +13 -4
  2. package/CONTRIBUTING.md +7 -0
  3. package/README.md +53 -419
  4. package/SECURITY.md +19 -0
  5. package/docs/how-it-works.md +31 -0
  6. package/docs/reference.md +276 -0
  7. package/package.json +5 -11
  8. package/src/index.ts +1 -1
  9. package/src/runtime/service.mjs +12 -0
  10. package/src/ui/dashboard.ts +14 -8
  11. package/IMPLEMENTATION_PLAN.md +0 -920
  12. package/PRD.md +0 -484
  13. package/PROGRESS.md +0 -142
  14. package/VERIFY.md +0 -115
  15. package/docs/BATCH_SELECTION_READ_FLOW.md +0 -277
  16. package/docs/EXPLORATION.md +0 -187
  17. package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +0 -581
  18. package/docs/RELEASE.md +0 -148
  19. package/docs/superpowers/plans/2026-08-15-screenlog-gc.md +0 -704
  20. package/docs/superpowers/plans/2026-08-16-attach-double-cursor-jiggle-retry.md +0 -499
  21. package/docs/superpowers/plans/2026-08-21-attach-coldstart-jiggle-rearm.md +0 -797
  22. package/docs/superpowers/plans/2026-08-21-dashboard-keypress-lag.md +0 -366
  23. package/docs/superpowers/plans/2026-08-21-issue-14-needs-input-no-auto-done.md +0 -576
  24. package/docs/superpowers/plans/2026-08-22-jiggle-shrink-and-hold.md +0 -337
  25. package/docs/superpowers/plans/2026-08-22-launch-cwd-favorites.md +0 -916
  26. package/docs/superpowers/plans/2026-08-22-question-tool-grouping.md +0 -211
  27. package/docs/superpowers/plans/2026-08-22-rename-state-labels.md +0 -182
  28. package/docs/superpowers/plans/2026-08-22-stable-list-order.md +0 -51
  29. package/docs/superpowers/plans/2026-08-27-locks-acquirelock-spin.md +0 -614
  30. package/docs/superpowers/plans/2026-08-27-pty-runner-test-flaky.md +0 -32
  31. package/docs/superpowers/plans/2026-08-29-code-refs-badges.md +0 -223
  32. package/docs/superpowers/plans/2026-08-30-circular-navigation.md +0 -308
  33. package/docs/superpowers/plans/2026-08-30-post-exit-timing-fix.md +0 -37
  34. package/docs/superpowers/plans/2026-08-30-pty-attach-quality-debt.md +0 -311
  35. package/docs/superpowers/plans/2026-08-30-readme-v2.md +0 -294
  36. package/docs/superpowers/plans/2026-09-01-attach-detach-gate-cursor-anchor.md +0 -284
  37. package/docs/superpowers/plans/2026-09-02-attach-detach-editor-state.md +0 -722
  38. package/docs/superpowers/plans/2026-09-03-code-refs-pr-backlink-narrow.md +0 -551
  39. package/docs/superpowers/plans/2026-09-03-detach-gate-glyph-fallback.md +0 -146
  40. package/docs/superpowers/plans/2026-09-04-evidence-outputpreview.md +0 -209
  41. package/docs/superpowers/plans/2026-09-04-warm-host-reclaim.md +0 -796
  42. package/docs/superpowers/plans/2026-09-05-issue-13-drainnextfollowup-pty-probe.md +0 -114
  43. package/docs/superpowers/plans/2026-09-05-issue-38-windows-wezterm-ime-cursor.md +0 -73
  44. package/docs/superpowers/plans/2026-09-05-issue-39-truncate-codepoint-boundary.md +0 -143
  45. package/docs/superpowers/plans/2026-09-05-issue-61-mention-fallback-guards.md +0 -226
  46. package/docs/superpowers/plans/2026-09-05-issue-63-flaky-manual-completion.md +0 -87
  47. package/docs/superpowers/plans/2026-09-05-issue-64-changelog-release-helper.md +0 -53
  48. package/docs/superpowers/plans/2026-09-05-pty-host-stacking-sock-race.md +0 -731
  49. package/docs/superpowers/plans/2026-09-08-attach-ctrl-left-detach.md +0 -30
  50. package/docs/superpowers/plans/2026-09-08-dashboard-shrink-repaint.md +0 -68
  51. package/docs/superpowers/plans/2026-09-08-issue-11-attach-runtime-desync-heal.md +0 -917
  52. package/docs/superpowers/plans/2026-09-08-legacy-stale-host-recovery.md +0 -125
  53. package/docs/superpowers/plans/2026-09-08-spawn-async-error-swallow.md +0 -56
  54. package/docs/superpowers/plans/2026-09-08-stale-model-attach-guard.md +0 -96
  55. package/docs/superpowers/plans/2026-09-09-claimpid-blocks-replace.md +0 -267
  56. package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +0 -603
  57. package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +0 -252
  58. package/docs/superpowers/plans/2026-09-10-reader-consistency.md +0 -115
  59. package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +0 -469
  60. package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +0 -92
  61. package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +0 -771
  62. package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +0 -299
  63. package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +0 -609
  64. package/docs/superpowers/plans/2026-09-14-terminal-model.md +0 -145
  65. package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +0 -224
  66. package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +0 -341
  67. package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +0 -875
  68. package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +0 -116
  69. package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +0 -517
  70. package/docs/superpowers/plans/2026-09-23-a5-mid-stream-assertion.md +0 -25
  71. package/docs/superpowers/plans/2026-09-23-cwd-browse-tab-drill.md +0 -393
  72. package/docs/superpowers/plans/2026-09-23-terminal-query-forwarding.md +0 -51
  73. package/docs/superpowers/plans/2026-09-23-unconditional-detach-key.md +0 -58
  74. package/docs/superpowers/plans/2026-09-24-control-reconcile-flake.md +0 -698
  75. package/docs/superpowers/plans/2026-09-24-probe-attached-clients.md +0 -605
  76. package/docs/superpowers/plans/2026-09-29-issue-145-holding-semantic-state.md +0 -1183
  77. package/docs/superpowers/plans/2026-09-29-issue-145-user-verification.md +0 -66
  78. package/docs/superpowers/plans/2026-09-30-board-delete-key-x-y-confirm.md +0 -649
  79. package/docs/superpowers/plans/2026-09-30-issue-153-runner-stop-boot-window.md +0 -618
  80. package/docs/superpowers/plans/2026-09-30-issue-95-test-discipline.md +0 -555
  81. package/docs/superpowers/plans/2026-09-30-publish-dispatch-tag-check.md +0 -259
  82. package/docs/superpowers/plans/2026-10-06-attach-settle-scheme-delivery.md +0 -751
  83. package/docs/superpowers/plans/2026-10-09-issue-167-attach-middle-paste.md +0 -509
  84. package/docs/superpowers/plans/2026-10-09-issue-169-fullscreen-mouse-ownership.md +0 -346
  85. package/docs/superpowers/plans/2026-10-10-attach-delete-in-place.md +0 -832
  86. package/docs/superpowers/plans/2026-10-10-issue-174-selection-mirror-paste-source.md +0 -510
  87. package/docs/superpowers/plans/2026-10-14-issue-170-attach-reply-guard.md +0 -771
  88. package/docs/superpowers/plans/2026-10-14-issue-178-accept-registration-race.md +0 -1257
  89. package/docs/superpowers/plans/2026-10-14-issue-184-snapshot-window-contract.md +0 -657
  90. package/docs/superpowers/plans/2026-10-14-issue-185-attach-wheel-alt-screen.md +0 -47
  91. package/docs/superpowers/plans/2026-10-14-issue-187-motionless-drag.md +0 -931
  92. package/docs/superpowers/plans/2026-10-14-issue-189-mixed-input-segments.md +0 -823
  93. package/docs/superpowers/plans/2026-10-15-issue-188-x10-mouse-release.md +0 -526
  94. package/docs/superpowers/plans/2026-10-16-issue-198-attach-wheel-forward-fallback.md +0 -32
  95. package/docs/superpowers/plans/2026-10-18-attach-wheel-replay-pseudo-scrollback.md +0 -437
  96. package/docs/superpowers/plans/2026-10-19-issue-213-reply-guard-csi-key.md +0 -257
  97. package/docs/superpowers/plans/2026-10-19-issue-214-attach-forward-alt-modifier.md +0 -202
  98. package/docs/superpowers/plans/2026-10-21-issue-221-ask-user-question.md +0 -198
  99. package/docs/superpowers/plans/2026-10-23-issue-222-attach-ready.md +0 -290
  100. package/docs/superpowers/plans/2026-10-23-issue-225-monotonic-foreground-state.md +0 -450
  101. package/docs/superpowers/plans/2026-10-23-issue-229-vim-jkl.md +0 -449
  102. package/docs/superpowers/plans/2026-10-23-issue-232-attach-bg-only-cells.md +0 -153
  103. package/docs/superpowers/plans/2026-10-23-lease-heartbeat-staleness.md +0 -537
  104. package/docs/superpowers/plans/2026-10-23-selected-row-highlight.md +0 -405
  105. package/docs/superpowers/plans/2026-10-24-issue-217-windows-pid-reuse-stopping.md +0 -367
  106. package/docs/superpowers/plans/2026-10-24-issue-230-warm-host-background-runs.md +0 -806
  107. package/docs/superpowers/plans/2026-10-24-issue-238-windows-process-identity-token.md +0 -490
  108. package/docs/superpowers/plans/2026-10-24-issue-241-coordinator-root-exit.md +0 -461
  109. package/docs/superpowers/plans/2026-10-25-issue-239-coordinator-replacement-identity.md +0 -504
  110. package/docs/superpowers/plans/2026-10-26-issue-245-key-hints-doc-sync.md +0 -244
  111. package/docs/superpowers/plans/2026-10-27-issue-172-fullscreen-selection-clear.md +0 -344
  112. package/docs/superpowers/plans/2026-10-27-issue-252-select-key-hints.md +0 -209
  113. package/docs/superpowers/plans/2026-10-28-issue-255-coordinator-endpoint-zombie.md +0 -442
  114. package/docs/superpowers/plans/2026-10-28-issue-257-legacy-pty-runner-endpoint-cleanup.md +0 -256
  115. package/docs/superpowers/plans/2026-11-03-cwd-favorites-full-pool.md +0 -266
  116. package/docs/superpowers/specs/2026-08-15-screenlog-gc-design.md +0 -105
  117. package/docs/superpowers/specs/2026-08-16-attach-double-cursor-jiggle-retry-design.md +0 -142
  118. package/docs/superpowers/specs/2026-08-21-attach-coldstart-jiggle-rearm-design.md +0 -110
  119. package/docs/superpowers/specs/2026-08-21-dashboard-keypress-lag-design.md +0 -59
  120. package/docs/superpowers/specs/2026-08-21-issue-14-needs-input-no-auto-done-design.md +0 -109
  121. package/docs/superpowers/specs/2026-08-22-cwd-favorites-design.md +0 -131
  122. package/docs/superpowers/specs/2026-08-22-jiggle-shrink-and-hold-design.md +0 -111
  123. package/docs/superpowers/specs/2026-08-22-question-tool-grouping-design.md +0 -114
  124. package/docs/superpowers/specs/2026-08-22-rename-state-labels-design.md +0 -80
  125. package/docs/superpowers/specs/2026-08-27-locks-acquirelock-spin-design.md +0 -97
  126. package/docs/superpowers/specs/2026-08-27-pty-runner-test-flaky-design.md +0 -35
  127. package/docs/superpowers/specs/2026-08-29-code-refs-badges-design.md +0 -128
  128. package/docs/superpowers/specs/2026-08-30-circular-navigation-design.md +0 -47
  129. package/docs/superpowers/specs/2026-08-30-eprm-atomicwrite-race-design.md +0 -77
  130. package/docs/superpowers/specs/2026-08-30-post-exit-timing-fix-design.md +0 -80
  131. package/docs/superpowers/specs/2026-08-30-pty-attach-quality-debt-design.md +0 -105
  132. package/docs/superpowers/specs/2026-08-30-readme-v2-design.md +0 -115
  133. package/docs/superpowers/specs/2026-09-01-attach-detach-gate-cursor-anchor-design.md +0 -78
  134. package/docs/superpowers/specs/2026-09-02-attach-detach-editor-state-design.md +0 -120
  135. package/docs/superpowers/specs/2026-09-03-code-refs-pr-backlink-narrow-design.md +0 -92
  136. package/docs/superpowers/specs/2026-09-03-detach-gate-glyph-fallback-design.md +0 -99
  137. package/docs/superpowers/specs/2026-09-04-evidence-outputpreview-design.md +0 -50
  138. package/docs/superpowers/specs/2026-09-04-warm-host-reclaim-design.md +0 -106
  139. package/docs/superpowers/specs/2026-09-05-issue-13-drainnextfollowup-pty-probe-design.md +0 -64
  140. package/docs/superpowers/specs/2026-09-05-issue-38-windows-wezterm-ime-design.md +0 -48
  141. package/docs/superpowers/specs/2026-09-05-issue-39-truncate-codepoint-boundary-design.md +0 -64
  142. package/docs/superpowers/specs/2026-09-05-issue-61-mention-fallback-design.md +0 -71
  143. package/docs/superpowers/specs/2026-09-05-issue-63-flaky-manual-completion-design.md +0 -49
  144. package/docs/superpowers/specs/2026-09-05-issue-64-changelog-helper-design.md +0 -76
  145. package/docs/superpowers/specs/2026-09-05-pty-host-stacking-sock-race-design.md +0 -510
  146. package/docs/superpowers/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md +0 -130
  147. package/docs/superpowers/specs/2026-09-08-attach-ctrl-left-detach-design.md +0 -58
  148. package/docs/superpowers/specs/2026-09-08-dashboard-shrink-repaint-design.md +0 -52
  149. package/docs/superpowers/specs/2026-09-08-legacy-stale-host-recovery-design.md +0 -87
  150. package/docs/superpowers/specs/2026-09-08-spawn-async-error-swallow-design.md +0 -56
  151. package/docs/superpowers/specs/2026-09-08-stale-model-attach-guard-design.md +0 -79
  152. package/docs/superpowers/specs/2026-09-09-claimpid-blocks-replace-design.md +0 -147
  153. package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +0 -304
  154. package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +0 -114
  155. package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +0 -120
  156. package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +0 -146
  157. package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +0 -116
  158. package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +0 -84
  159. package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +0 -92
  160. package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +0 -123
  161. package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +0 -204
  162. package/docs/superpowers/specs/2026-09-23-a5-mid-stream-assertion-design.md +0 -43
  163. package/docs/superpowers/specs/2026-09-23-cwd-browse-tab-drill-design.md +0 -95
  164. package/docs/superpowers/specs/2026-09-23-terminal-query-forwarding-design.md +0 -63
  165. package/docs/superpowers/specs/2026-09-23-unconditional-detach-key-design.md +0 -57
  166. package/docs/superpowers/specs/2026-09-24-control-reconcile-flake-design.md +0 -98
  167. package/docs/superpowers/specs/2026-09-24-probe-attached-clients-design.md +0 -190
  168. package/docs/superpowers/specs/2026-09-29-issue-145-holding-semantic-state-design.md +0 -394
  169. package/docs/superpowers/specs/2026-09-30-board-delete-key-x-y-confirm-design.md +0 -86
  170. package/docs/superpowers/specs/2026-09-30-issue-153-runner-stop-boot-window-design.md +0 -75
  171. package/docs/superpowers/specs/2026-09-30-issue-95-test-discipline-design.md +0 -226
  172. package/docs/superpowers/specs/2026-09-30-publish-dispatch-tag-check-design.md +0 -129
  173. package/docs/superpowers/specs/2026-10-06-attach-settle-scheme-delivery-design.md +0 -196
  174. package/docs/superpowers/specs/2026-10-09-issue-167-attach-middle-paste-design.md +0 -125
  175. package/docs/superpowers/specs/2026-10-09-issue-169-fullscreen-mouse-ownership-design.md +0 -130
  176. package/docs/superpowers/specs/2026-10-10-attach-delete-in-place-design.md +0 -224
  177. package/docs/superpowers/specs/2026-10-10-issue-174-selection-mirror-paste-source-design.md +0 -154
  178. package/docs/superpowers/specs/2026-10-14-issue-170-attach-reply-guard-design.md +0 -195
  179. package/docs/superpowers/specs/2026-10-14-issue-178-accept-registration-race-design.md +0 -287
  180. package/docs/superpowers/specs/2026-10-14-issue-184-snapshot-window-contract-design.md +0 -288
  181. package/docs/superpowers/specs/2026-10-14-issue-185-attach-wheel-alt-screen-design.md +0 -126
  182. package/docs/superpowers/specs/2026-10-14-issue-187-motionless-drag-design.md +0 -269
  183. package/docs/superpowers/specs/2026-10-14-issue-189-mixed-input-segments-design.md +0 -369
  184. package/docs/superpowers/specs/2026-10-15-issue-188-x10-mouse-release-design.md +0 -252
  185. package/docs/superpowers/specs/2026-10-16-issue-198-attach-wheel-forward-fallback-design.md +0 -67
  186. package/docs/superpowers/specs/2026-10-18-attach-wheel-replay-pseudo-scrollback-design.md +0 -250
  187. package/docs/superpowers/specs/2026-10-19-issue-213-reply-guard-csi-key-design.md +0 -165
  188. package/docs/superpowers/specs/2026-10-19-issue-214-attach-forward-alt-modifier-design.md +0 -131
  189. package/docs/superpowers/specs/2026-10-20-issue-221-ask-user-question-design.md +0 -270
  190. package/docs/superpowers/specs/2026-10-22-issue-225-monotonic-foreground-state-design.md +0 -225
  191. package/docs/superpowers/specs/2026-10-23-issue-222-attach-ready-design.md +0 -204
  192. package/docs/superpowers/specs/2026-10-23-issue-229-vim-jkl-design.md +0 -90
  193. package/docs/superpowers/specs/2026-10-23-issue-232-attach-bg-only-cells-design.md +0 -146
  194. package/docs/superpowers/specs/2026-10-23-lease-heartbeat-staleness-design.md +0 -271
  195. package/docs/superpowers/specs/2026-10-23-selected-row-highlight-design.md +0 -191
  196. package/docs/superpowers/specs/2026-10-24-issue-217-windows-pid-reuse-stopping-design.md +0 -334
  197. package/docs/superpowers/specs/2026-10-24-issue-230-warm-host-background-runs-design.md +0 -283
  198. package/docs/superpowers/specs/2026-10-24-issue-238-windows-process-identity-token-design.md +0 -286
  199. package/docs/superpowers/specs/2026-10-24-issue-241-coordinator-root-exit-design.md +0 -381
  200. package/docs/superpowers/specs/2026-10-25-issue-239-coordinator-replacement-identity-design.md +0 -331
  201. package/docs/superpowers/specs/2026-10-26-issue-245-key-hints-doc-sync-design.md +0 -72
  202. package/docs/superpowers/specs/2026-10-27-issue-172-fullscreen-selection-clear-design.md +0 -248
  203. package/docs/superpowers/specs/2026-10-27-issue-252-select-key-hints-design.md +0 -95
  204. package/docs/superpowers/specs/2026-10-28-issue-255-coordinator-endpoint-zombie-design.md +0 -380
  205. package/docs/superpowers/specs/2026-10-28-issue-257-legacy-pty-runner-endpoint-cleanup-design.md +0 -197
  206. package/docs/superpowers/specs/2026-11-03-cwd-favorites-full-pool-design.md +0 -106
  207. package/scripts/budget-audit.mjs +0 -135
  208. package/scripts/release.mjs +0 -446
  209. package/scripts/release_helper.mjs +0 -309
  210. package/scripts/run-perf-gate.mjs +0 -40
  211. package/scripts/run-tests.mjs +0 -375
@@ -1,823 +0,0 @@
1
- # Issue #189 — mixed-input segment dispatch: Implementation Plan
2
-
3
- > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
-
5
- **Goal:** In the legacy (regular-TUI) attach path, dispatch one input read segment by segment — mouse
6
- runs to the existing mouse path, terminal-report runs to a pass-through that touches neither the
7
- selection nor the viewport, everything else to today's keyboard chain.
8
-
9
- **Architecture:** A new pure `splitAttachInput(data)` in `src/core/pty-scroll.mjs` (built on the
10
- existing `parseMouseEventPrefix` plus a small, explicitly grounded report table) returns an ordered
11
- array of `mouse | report | key` segments; `PtyAttachComponent.handleInput` becomes a dispatch loop
12
- and the existing keyboard chain moves verbatim into a private `handleKeyInput`. All new behaviour is
13
- covered by pure unit cases plus one component-level scenario (real runner socket + fake PTY + fake
14
- TUI) that drives the public `handleInput` seam.
15
-
16
- **Tech Stack:** Node.js ESM (`.mjs` pure layer), TypeScript (`src/ui/pty-attach.ts`, compiled with
17
- `tsc --noEmit`), `node:test` + `assert/strict`, scenario harnesses run with
18
- `node --experimental-transform-types`.
19
-
20
- **Spec:** `docs/superpowers/specs/2026-10-14-issue-189-mixed-input-segments-design.md` (issue #189,
21
- cross-reviewed twice, converged; sha256 `042e4efc508bab3b497083076c8359d70f259ae79fe4dd2b6758033d19084e15`).
22
-
23
- ## Global Constraints
24
-
25
- - **Worktree only:** every change lands in
26
- `/home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-189-mixed-input-segments` (branch
27
- `issue-189-mixed-input-segments`). Never touch the main checkout; stage files by name (no
28
- `git add -A`).
29
- - **Indentation:** tabs. **Comments and commit messages:** English. Conventional commit subjects
30
- (`feat`/`fix`/`docs`/`test`/`chore`/`refactor`) with the issue reference `(issue #189)`.
31
- - **`parseMouseInputChunk` keeps its literal contract** (all-or-nothing, `null` on any non-mouse
32
- byte): existing assertions in `test/pty-scroll.test.mjs:83-99` and sibling issue #188's planned
33
- cases depend on it. It is re-expressed on a new private `parseMouseRun` but must not change.
34
- - **Do not touch** `parseMouseEventPrefix`, its SGR/X10 branches, `parseMouseEvent`,
35
- `mouseWheelDirection`, `handleLocalMouseEvent`, `handleMouseInputChunk`, `handleMouse`, or any
36
- selection/copy/viewport logic. Sibling issues #187/#188 own edits there.
37
- - **Testability split (hard constraint from spec §7):** all classification lives in the pure module;
38
- the component only switches on the segment kind; modules and helpers touched by the change use
39
- English JSDoc, and no test-only export may be added.
40
- - **No new env knob, dependency, or I/O surface.** No README/CHANGELOG editing (the release tooling
41
- owns the changelog).
42
- - **Definition of done:** `npm run verify` exits 0 (typecheck + perf gate + tests + coverage
43
- thresholds lines 85 / funcs 80 / branches 70 + `pack:dry`).
44
- - **Node ≥ 24** is required by the toolchain; the `node_modules` symlink in the worktree points at
45
- the main checkout's install (gitignored — never commit it).
46
-
47
- ---
48
-
49
- ### Task 1: Pure segment classifier + report table (`splitAttachInput`)
50
-
51
- Spec acceptance: **A1** (segment decision table), **A2** (report positives + deliberate negatives).
52
-
53
- **Files:**
54
- - Modify: `src/core/pty-scroll.mjs` (insert after `parseMouseInputChunk`, which ends at line 95)
55
- - Test: `test/pty-scroll.test.mjs` (add `splitAttachInput` to the import block at lines 3-16, append
56
- two tests at the end of the file)
57
-
58
- **Interfaces:**
59
- - Consumes: existing private `parseMouseEventPrefix(data, offset)` (`src/core/pty-scroll.mjs:31`).
60
- - Produces (Task 2 relies on these exact names/shapes):
61
- - `splitAttachInput(data: string)` → `Array<{kind:"mouse", events: Array<{length:number, raw:string, mouse:{encoding:string, button:number, col:number, row:number, action:string}}>} | {kind:"report", data:string} | {kind:"key", data:string}>`; returns `[]` for falsy input; consecutive same-kind segments are merged; unrecognised/partial escape sequences stay `key`.
62
- - `parseMouseInputChunk(data)` → unchanged behaviour, now implemented via a private `parseMouseRun(data, offset)`.
63
-
64
- - [ ] **Step 1: Write the failing A1 test (segmentation decision table)**
65
-
66
- Append to `test/pty-scroll.test.mjs` (and add `splitAttachInput,` to the import list, keeping it
67
- alphabetical between `shouldOwnOuterMouseMode` and `mouseWheelDirection`-independent entries):
68
-
69
- ```js
70
- test("splitAttachInput decodes ordered mouse / report / key segments (issue #189)", () => {
71
- const kinds = (input) => splitAttachInput(input).map((segment) => segment.kind);
72
- const data = (input) => splitAttachInput(input).map((segment) => (segment.kind === "mouse" ? segment.events.map((entry) => entry.raw) : segment.data));
73
-
74
- assert.deepEqual(splitAttachInput(""), []);
75
- assert.deepEqual(kinds("\x1b[<64;10;20M\x1b[<65;10;20M"), ["mouse"]);
76
- assert.equal(splitAttachInput("\x1b[<64;10;20M\x1b[<65;10;20M")[0].events.length, 2);
77
-
78
- // the issue's repro, both orders
79
- assert.deepEqual(kinds("\x1b[<0;16;2m\x1b[I"), ["mouse", "report"]);
80
- assert.deepEqual(data("\x1b[<0;16;2m\x1b[I"), ["\x1b[<0;16;2m", "\x1b[I"]);
81
- assert.deepEqual(kinds("\x1b[I\x1b[<0;16;2m"), ["report", "mouse"]);
82
- assert.deepEqual(kinds("\x1b[I\x1b[O\x1b[<0;16;2m"), ["report", "mouse"]);
83
- assert.deepEqual(data("\x1b[I\x1b[O\x1b[<0;16;2m"), ["\x1b[I\x1b[O", "\x1b[<0;16;2m"]);
84
-
85
- // mouse/key and report/key adjacency, both orders
86
- assert.deepEqual(kinds("\x1b[<0;16;2mx"), ["mouse", "key"]);
87
- assert.deepEqual(kinds("x\x1b[<0;16;2m"), ["key", "mouse"]);
88
- assert.deepEqual(kinds("\x1b[Ix"), ["report", "key"]);
89
- assert.deepEqual(kinds("x\x1b[I"), ["key", "report"]);
90
-
91
- assert.deepEqual(kinds("hello"), ["key"]);
92
- assert.deepEqual(data("hello"), ["hello"]);
93
-
94
- // adjacent key runs merge: a key is only recognised when it is the whole read (spec §5)
95
- assert.deepEqual(kinds("\x1b[5~\x1b[6~"), ["key"]);
96
- assert.deepEqual(data("\x1b[5~\x1b[6~"), ["\x1b[5~\x1b[6~"]);
97
-
98
- // truncated sequences are not recognised and stay keyboard text
99
- assert.deepEqual(kinds("\x1b[<0;16"), ["key"]);
100
- assert.deepEqual(kinds("\x1b[?997;1"), ["key"]);
101
- assert.deepEqual(kinds("\x1b[<0;16\x1b[I"), ["key", "report"]);
102
-
103
- // the pre-existing all-or-nothing contract is untouched
104
- assert.equal(parseMouseInputChunk("\x1b[<64;10;20Mx"), null);
105
- assert.equal(parseMouseInputChunk("x\x1b[<64;10;20M"), null);
106
- assert.equal(parseMouseInputChunk(""), null);
107
- });
108
- ```
109
-
110
- - [ ] **Step 2: Run it and watch it fail**
111
-
112
- Run: `node --test test/pty-scroll.test.mjs`
113
- Expected: FAIL — `SyntaxError: The requested module '../src/core/pty-scroll.mjs' does not provide an export named 'splitAttachInput'`.
114
-
115
- - [ ] **Step 3: Write the failing A2 test (report positives + deliberate negatives)**
116
-
117
- Append:
118
-
119
- ```js
120
- test("splitAttachInput classifies grounded terminal reports and keeps keys as keys (issue #189)", () => {
121
- const reports = [
122
- "\x1b[I",
123
- "\x1b[O",
124
- "\x1b[?62;4;6;22c",
125
- "\x1b[>0;95;0c",
126
- "\x1b[?1u",
127
- "\x1b[?997;1n",
128
- "\x1b[?997;2n",
129
- "\x1b[6;24;80t",
130
- "\x1b]11;rgb:0d0d/0d0d/0d0d\x07",
131
- "\x1b]11;rgb:0d0d/0d0d/0d0d\x1b\\",
132
- "\x1b]10;#ffffff\x07",
133
- "\x1b]4;3;rgb:0000/0000/0000\x07",
134
- "\x1b]7501;?\x1b\\",
135
- ];
136
- for (const sequence of reports) {
137
- assert.deepEqual(splitAttachInput(sequence), [{ kind: "report", data: sequence }], `report: ${JSON.stringify(sequence)}`);
138
- }
139
-
140
- // deliberately not reports: a keystroke can look like them (spec §3.1(b))
141
- const keys = ["a", "\x1b[A", "\x1b[1;2R", "\x1b[97;5u", "\x1b[1;5D", "\x1b[200~paste\x1b[201~", "\x1bP1+r6375;6375\x1b\\"];
142
- for (const sequence of keys) {
143
- assert.equal(splitAttachInput(sequence)[0].kind, "key", `key: ${JSON.stringify(sequence)}`);
144
- }
145
- });
146
- ```
147
-
148
- - [ ] **Step 4: Run it and watch it fail**
149
-
150
- Run: `node --test test/pty-scroll.test.mjs`
151
- Expected: FAIL — same missing-export error.
152
-
153
- - [ ] **Step 5: Implement the pure helpers**
154
-
155
- In `src/core/pty-scroll.mjs`, replace the current `parseMouseInputChunk` block (lines 79-95) with
156
- the code below (the existing doc comment on `parseMouseInputChunk` must keep stating the
157
- all-or-nothing contract):
158
-
159
- ```js
160
- /**
161
- * Read a maximal run of concatenated mouse reports starting at `offset`. Returns the decoded events
162
- * with their exact raw byte sequences plus the consumed length, or null when no complete report
163
- * starts there.
164
- */
165
- function parseMouseRun(data, offset = 0) {
166
- const events = [];
167
- let position = offset;
168
- for (;;) {
169
- const parsed = parseMouseEventPrefix(data, position);
170
- if (!parsed) break;
171
- events.push(parsed);
172
- position += parsed.length;
173
- }
174
- return events.length > 0 ? { events, length: position - offset } : null;
175
- }
176
-
177
- /**
178
- * Parse an input chunk that consists entirely of one or more concatenated mouse reports.
179
- * Returns each decoded event with its exact raw byte sequence, or null if any non-mouse
180
- * bytes are present in the chunk (all-or-nothing contract, pinned by unit tests and by the
181
- * X10 acceptance of issue #188 — do not relax it).
182
- */
183
- export function parseMouseInputChunk(data) {
184
- if (!data) return null;
185
- const run = parseMouseRun(data);
186
- return run && run.length === data.length ? run.events : null;
187
- }
188
-
189
- /**
190
- * Issue #189: terminal *report* sequences, i.e. bytes the real terminal sends in answer to a query
191
- * instead of in answer to the user. Every query this stack forwards to the real terminal (child
192
- * OSC 11 probes, the host's colour batch, kitty protocol negotiation) is answered on the same stdin
193
- * stream the user types on, so a report is routinely coalesced with keys or mouse reports into one
194
- * read. Reports carry no user intent: they must reach the child unchanged, but they must not be
195
- * treated as typing (no selection clear, no viewport reset).
196
- *
197
- * Grounding of each pattern (pi-tui 1.x, the face this extension runs against):
198
- * - focus in/out: DECSET 1004, enabled by pi's fullscreen TUI; reachable in regular mode only
199
- * through leaked terminal state.
200
- * - `CSI ? ... c`: DA1 reply — the host sends `\x1b[c` as the trailing sentinel of its colour
201
- * batch (`pi-tui tui.js:57`).
202
- * - `CSI ? ... u`: kitty keyboard flag reply to `\x1b[?u` (`pi-tui terminal.js:210`).
203
- * - `CSI ? ... n`: mode/status reports, including the colour-scheme report `\x1b[?997;1n` that Pi
204
- * parses (`pi-tui terminal-colors.js:19`).
205
- * - `CSI ... t`: XTWINOPS replies — the host asks for the cell size with `\x1b[16t`
206
- * (`pi-tui tui.js:603`) and consumes `CSI 6;h;wt`.
207
- * - `OSC ... BEL|ST`: colour replies (OSC 10/11/12, OSC 4) and other reply bodies such as the
208
- * program-status reply (OSC 7501); bodies are capped at 4096 bytes like the query carry.
209
- * - `CSI > ... c` (DA2) has **no** source in this stack — kept as a defensive extra only, because
210
- * no keystroke encodes as a `>`-prefixed CSI.
211
- *
212
- * Deliberately NOT matched, because a keystroke can look like them: `CSI ... R` (cursor-position
213
- * report but also modifier-F3), any `u` final without the `?` prefix (kitty key events),
214
- * `CSI ... $ y` (DECRPM — nothing in this stack queries modes), and DCS replies (no in-repo DCS
215
- * query). Unrecognised bytes keep today's keyboard semantics.
216
- */
217
- const REPORT_PREFIXES = [
218
- /^\x1b\[[IO]/,
219
- /^\x1b\[\?\d+(?:;\d+)*c/,
220
- /^\x1b\[>\d+(?:;\d+)*c/,
221
- /^\x1b\[\?\d+(?:;\d+)*u/,
222
- /^\x1b\[\?\d+(?:;\d+)*n/,
223
- /^\x1b\[\d+(?:;\d+)*t/,
224
- /^\x1b\][^\x07\x1b]{0,4096}(?:\x07|\x1b\\)/,
225
- ];
226
-
227
- /** Issue #189: the complete report sequence starting at `offset`, or null. */
228
- function parseReportPrefix(data, offset = 0) {
229
- const input = data.slice(offset);
230
- for (const pattern of REPORT_PREFIXES) {
231
- const match = pattern.exec(input);
232
- if (match) return match[0];
233
- }
234
- return null;
235
- }
236
-
237
- /** Issue #189: the maximal run of concatenated report sequences at `offset`, or null. */
238
- function parseReportRun(data, offset = 0) {
239
- let position = offset;
240
- for (;;) {
241
- const report = parseReportPrefix(data, position);
242
- if (!report) break;
243
- position += report.length;
244
- }
245
- return position > offset ? data.slice(offset, position) : null;
246
- }
247
-
248
- /** Merge a segment into the previous one when it has the same kind, else append it. */
249
- function pushSegment(segments, segment) {
250
- const last = segments[segments.length - 1];
251
- if (last && last.kind === segment.kind) {
252
- if (last.kind === "mouse") last.events.push(...segment.events);
253
- else last.data += segment.data;
254
- return;
255
- }
256
- segments.push(segment);
257
- }
258
-
259
- /**
260
- * Issue #189: split one input read into ordered segments so the attach surface can dispatch per
261
- * kind instead of all-or-nothing. Mouse reports, terminal reports and everything else are returned
262
- * in arrival order, consecutive segments of the same kind merged.
263
- *
264
- * A partial escape sequence (a sequence split across reads) is not recognised and becomes keyboard
265
- * text — the same bytes the all-or-nothing parser used to forward.
266
- *
267
- * @param {string} data
268
- * @returns {Array<{kind: "mouse", events: Array<{length: number, raw: string, mouse: {encoding: string, button: number, col: number, row: number, action: string}}>} | {kind: "report", data: string} | {kind: "key", data: string}>}
269
- */
270
- export function splitAttachInput(data) {
271
- if (!data) return [];
272
- const segments = [];
273
- let offset = 0;
274
- while (offset < data.length) {
275
- const mouse = parseMouseRun(data, offset);
276
- if (mouse) {
277
- pushSegment(segments, { kind: "mouse", events: mouse.events });
278
- offset += mouse.length;
279
- continue;
280
- }
281
- const report = parseReportRun(data, offset);
282
- if (report) {
283
- pushSegment(segments, { kind: "report", data: report });
284
- offset += report.length;
285
- continue;
286
- }
287
- // Neither a mouse report nor a terminal report: keyboard text up to the next escape.
288
- const next = data.indexOf("\x1b", offset + 1);
289
- const end = next === -1 ? data.length : next;
290
- pushSegment(segments, { kind: "key", data: data.slice(offset, end) });
291
- offset = end;
292
- }
293
- return segments;
294
- }
295
- ```
296
-
297
- - [ ] **Step 6: Run the tests**
298
-
299
- Run: `node --test test/pty-scroll.test.mjs`
300
- Expected: PASS — the two new tests plus every pre-existing case (including the three
301
- `parseMouseInputChunk` assertions) are green.
302
-
303
- - [ ] **Step 7: Typecheck**
304
-
305
- Run: `npm run typecheck`
306
- Expected: exit 0 (the `.mjs` inference must keep `splitAttachInput`'s union narrowable).
307
-
308
- - [ ] **Step 8: Commit**
309
-
310
- ```bash
311
- git add src/core/pty-scroll.mjs test/pty-scroll.test.mjs
312
- git commit -m "feat(attach): split legacy input reads into mouse/report/key segments (issue #189)"
313
- ```
314
-
315
- ---
316
-
317
- ### Task 2: Component dispatch + component-level scenario
318
-
319
- Spec acceptance: **A3** (both orders of the issue's repro), **A4** (report-only reads), **A5**
320
- (viewport/wheel on a scrollable buffer), **A6** (keyboard semantics + classifier boundary), **A9**
321
- (mid-read detach guard).
322
-
323
- **Files:**
324
- - Modify: `src/ui/pty-attach.ts` (import line 15; `handleInput` at lines 297-376)
325
- - Create: `test-support/pty-attach-mixed-input-scenario.ts`
326
- - Create: `test/pty-attach-mixed-input.test.mjs`
327
-
328
- **Interfaces:**
329
- - Consumes: `splitAttachInput(data)` from Task 1 with the exact return shape documented there.
330
- - Produces: `PtyAttachComponent.handleInput(data)` (public, unchanged signature) and a private
331
- `handleKeyInput(data: string): void` holding the former keyboard chain.
332
-
333
- - [ ] **Step 1: Write the failing component test**
334
-
335
- Create `test/pty-attach-mixed-input.test.mjs`:
336
-
337
- ```js
338
- import assert from "node:assert/strict";
339
- import test from "node:test";
340
- import { execFileSync } from "node:child_process";
341
- import { fileURLToPath } from "node:url";
342
- import { join } from "node:path";
343
-
344
- const ROOT_DIR = fileURLToPath(new URL("../", import.meta.url));
345
- const SCENARIO = join(ROOT_DIR, "test-support", "pty-attach-mixed-input-scenario.ts");
346
-
347
- // Component-level coverage (issue #189): the REAL PtyAttachComponent against a REAL runner socket
348
- // and a fake PTY. Asserts that a read mixing mouse reports with terminal reports is dispatched per
349
- // segment: the gesture still copies (OSC 52) and keeps its selection, no raw mouse bytes reach the
350
- // child, a spontaneous report clears neither selection nor viewport, and the mid-read detach guard
351
- // stops the rest of the read.
352
- test("attach mixed input: per-segment dispatch keeps copy/selection/viewport; reports pass through", () => {
353
- const out = execFileSync(process.execPath, ["--experimental-transform-types", SCENARIO], {
354
- encoding: "utf8",
355
- timeout: 120_000,
356
- });
357
- const parsed = JSON.parse(out.trim().split("\n").filter(Boolean).pop());
358
- assert.equal(parsed.error, null, `scenario error: ${parsed.error}`);
359
- for (const [name, passed] of Object.entries(parsed.checks)) {
360
- assert.equal(passed, true, `check failed: ${name}`);
361
- }
362
- });
363
- ```
364
-
365
- - [ ] **Step 2: Run it and watch it fail**
366
-
367
- Run: `node --test test/pty-attach-mixed-input.test.mjs`
368
- Expected: FAIL — the scenario file does not exist yet (`Cannot find module ... pty-attach-mixed-input-scenario.ts`).
369
-
370
- - [ ] **Step 3: Write the scenario harness**
371
-
372
- Create `test-support/pty-attach-mixed-input-scenario.ts`:
373
-
374
- ```ts
375
- // Component-level coverage (issue #189): one input read that mixes mouse reports with terminal
376
- // reports must be dispatched per segment — the gesture is still processed (OSC 52 copy + selection
377
- // kept), no raw mouse bytes reach the child, and a spontaneous report clears neither the selection
378
- // nor the viewport. Modelled on test-support/pty-attach-mouse-scenario.ts (real runner socket, fake
379
- // PTY from test-support/fake-pty-pi.mjs, fake TUI capturing terminal.write).
380
- // Run via `node --experimental-transform-types`; prints one JSON line: { error, checks }.
381
- import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
382
- import { Socket } from "node:net";
383
- import { tmpdir } from "node:os";
384
- import { join, resolve } from "node:path";
385
- import { spawn } from "node:child_process";
386
- import { atomicWriteJson } from "../src/core/atomic.mjs";
387
- import * as P from "../src/core/paths.mjs";
388
- import { createView, readHost } from "../src/core/store.mjs";
389
- import { PtyAttachComponent } from "../src/ui/pty-attach.ts";
390
-
391
- const root = mkdtempSync(join(tmpdir(), "agentview-attach-mixed-"));
392
- const viewId = "mixed-e2e";
393
- const SELECTED_TEXT = "fake pi ready";
394
- const OSC52_COPY = `\x1b]52;c;${Buffer.from(SELECTED_TEXT, "utf8").toString("base64")}\x07`;
395
- const OSC11_REPLY = "\x1b]11;rgb:0d0d/0d0d/0d0d\x07";
396
-
397
- // Hermetic xclip stub: the scenario must never touch the host clipboard.
398
- const binDir = join(root, "bin");
399
- mkdirSync(binDir);
400
- writeFileSync(join(binDir, "xclip"), "#!/bin/sh\nexit 0\n");
401
- chmodSync(join(binDir, "xclip"), 0o755);
402
- process.env.PATH = `${binDir}:${process.env.PATH}`;
403
-
404
- function isAlive(pid: number): boolean {
405
- try {
406
- process.kill(pid, 0);
407
- return true;
408
- } catch {
409
- return false;
410
- }
411
- }
412
-
413
- const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
414
-
415
- async function waitFor(predicate: () => boolean, timeoutMs: number): Promise<boolean> {
416
- const start = Date.now();
417
- while (Date.now() - start < timeoutMs) {
418
- if (predicate()) return true;
419
- await sleep(25);
420
- }
421
- return false;
422
- }
423
-
424
- const meta = createView(root, { id: viewId, name: "mixed attach e2e", cwd: root });
425
- const configPath = P.hostConfigPath(root, viewId);
426
- atomicWriteJson(configPath, {
427
- root,
428
- viewId,
429
- sessionFile: meta.sessionFile,
430
- cwd: process.cwd(),
431
- initialPrompt: null,
432
- piCommand: process.execPath,
433
- piArgsPrefix: [resolve("test-support/fake-pty-pi.mjs")],
434
- model: null,
435
- tools: null,
436
- env: { AGENT_BOARD_ALLOW_PIPE_FALLBACK: "1" },
437
- cols: 80,
438
- rows: 22,
439
- });
440
-
441
- const runner = spawn(process.execPath, [resolve("runner/pty-runner.mjs"), configPath], {
442
- stdio: ["ignore", "pipe", "pipe"],
443
- });
444
- runner.stderr.resume();
445
-
446
- const checks: Record<string, boolean> = {};
447
- const result = { error: null as string | null, checks };
448
-
449
- try {
450
- const ready = await waitFor(() => {
451
- const host = readHost(root, viewId);
452
- return !!host && host.state === "alive" && !!host.socketPath && !!host.childPid && isAlive(host.runnerPid) && isAlive(host.childPid);
453
- }, 10_000);
454
- if (!ready) throw new Error("host never became ready");
455
-
456
- // Observe component→runner frames so forwarded input is visible.
457
- const sent: string[] = [];
458
- const socketProto = Socket.prototype as any;
459
- const origWrite = socketProto.write;
460
- socketProto.write = function (data: any, ...rest: any[]) {
461
- if (typeof data === "string") sent.push(data);
462
- return origWrite.call(this, data, ...rest);
463
- };
464
- const forwardedInput = (): string[] =>
465
- sent
466
- .map((line) => {
467
- try {
468
- return JSON.parse(line);
469
- } catch {
470
- return null;
471
- }
472
- })
473
- .filter((msg) => !!msg && msg.type === "input" && typeof msg.data === "string")
474
- .map((msg) => msg.data as string);
475
-
476
- // Observe terminal writes so OSC 52 copies are visible.
477
- const writes: string[] = [];
478
- const tui = {
479
- terminal: { rows: 24, cols: 80, columns: 80, write: (data: string) => writes.push(data) },
480
- requestRender: () => {},
481
- };
482
- const theme = { fg: (_color: string, text: string) => text, bold: (text: string) => text };
483
- const component: any = new PtyAttachComponent(
484
- tui as never,
485
- theme,
486
- {} as never,
487
- () => {},
488
- {
489
- socketPath: readHost(root, viewId).socketPath,
490
- screenLogPath: P.screenLogPath(root, viewId),
491
- title: "mixed attach e2e",
492
- } as never,
493
- );
494
-
495
- const rendered = () => (component.render(80) ?? []).join("\n");
496
- const firstBurstLine = (): number | null => {
497
- const match = /burstline-(\d{4})/.exec(rendered());
498
- return match ? Number(match[1]) : null;
499
- };
500
- const copies = () => writes.filter((write) => write === OSC52_COPY).length;
501
- const selectionPresent = () => component.selection !== null;
502
- const drag = (fromCol: number, toCol: number) => {
503
- component.handleInput(`\x1b[<0;${fromCol};2M`);
504
- component.handleInput(`\x1b[<32;${toCol};2M`);
505
- };
506
-
507
- checks.sawContent = await waitFor(() => rendered().includes(SELECTED_TEXT), 8000);
508
- if (!checks.sawContent) throw new Error("session content never rendered");
509
-
510
- // A3 control: a canonical drag in separate reads still copies.
511
- const controlCopies = copies();
512
- drag(1, 16);
513
- component.handleInput("\x1b[<0;16;2m");
514
- await waitFor(() => copies() > controlCopies, 3000);
515
- checks.controlCopies = copies() === controlCopies + 1;
516
-
517
- // A3 case 1 (the issue's repro): release + focus report coalesced into ONE read.
518
- const mixedCopies = copies();
519
- const mixedFrames = forwardedInput().length;
520
- drag(1, 16);
521
- component.handleInput("\x1b[<0;16;2m\x1b[I");
522
- await waitFor(() => copies() > mixedCopies, 3000);
523
- checks.mixedCopies = copies() === mixedCopies + 1;
524
- checks.mixedForwardedOnlyReport = forwardedInput().slice(mixedFrames).every((frame) => frame === "\x1b[I");
525
- // The pending-click window (DOUBLE_CLICK_MS = 260) is what used to wipe the selection: nothing to
526
- // await here, so this wait is the assertion's own window.
527
- await sleep(400);
528
- checks.selectionSurvivesMixed = selectionPresent();
529
-
530
- // A3 case 2: report FIRST, release after, still ONE read.
531
- const reportFirstCopies = copies();
532
- const reportFirstFrames = forwardedInput().length;
533
- drag(1, 16);
534
- component.handleInput("\x1b[I\x1b[<0;16;2m");
535
- await waitFor(() => copies() > reportFirstCopies, 3000);
536
- checks.reportFirstCopies = copies() === reportFirstCopies + 1;
537
- checks.reportFirstForwardedOnlyReport = forwardedInput().slice(reportFirstFrames).every((frame) => frame === "\x1b[I");
538
-
539
- // A4: a report alone mid-drag keeps the selection and copies nothing.
540
- const reportOnlyCopies = copies();
541
- const reportOnlyFrames = forwardedInput().length;
542
- drag(1, 16);
543
- component.handleInput("\x1b[I");
544
- await sleep(150);
545
- checks.reportOnlyKeepsSelection = selectionPresent();
546
- checks.reportOnlyNoCopy = copies() === reportOnlyCopies;
547
- checks.reportOnlyForwarded = forwardedInput().slice(reportOnlyFrames).every((frame) => frame === "\x1b[I");
548
-
549
- // A4 variant: an OSC 11 reply (an answer to a forwarded child query) behaves the same.
550
- const oscCopies = copies();
551
- const oscFrames = forwardedInput().length;
552
- drag(1, 16);
553
- component.handleInput(OSC11_REPLY);
554
- await sleep(150);
555
- checks.oscReplyKeepsSelection = selectionPresent();
556
- checks.oscReplyNoCopy = copies() === oscCopies;
557
- checks.oscReplyForwarded = forwardedInput().slice(oscFrames).every((frame) => frame === OSC11_REPLY);
558
-
559
- // A5: viewport semantics on a scrollable buffer. "burst" makes the fixture emit 200 numbered lines.
560
- component.handleInput("burst");
561
- await waitFor(() => firstBurstLine() === 200, 10_000);
562
- const settle = async (): Promise<number | null> => {
563
- let last = firstBurstLine();
564
- for (;;) {
565
- await sleep(60);
566
- const now = firstBurstLine();
567
- if (now === last) return now;
568
- last = now;
569
- }
570
- };
571
- const atBottom = await settle();
572
- for (let i = 0; i < 3; i++) component.handleInput("\x1b[<64;10;3M");
573
- const scrolled = (await waitFor(() => firstBurstLine() !== null && firstBurstLine()! < (atBottom ?? 0), 3000)) ? firstBurstLine() : null;
574
- component.handleInput("\x1b[I");
575
- // Nothing to await: the assertion is that a spontaneous report moves nothing.
576
- await sleep(150);
577
- checks.viewportKeptOnReport = scrolled !== null && firstBurstLine() === scrolled;
578
-
579
- const wheelFrames = forwardedInput().length;
580
- const beforeWheel = firstBurstLine();
581
- component.handleInput("\x1b[<64;10;3M\x1b[I");
582
- checks.wheelScrolledInMixedRead = await waitFor(() => firstBurstLine() !== null && firstBurstLine()! < (beforeWheel ?? 0), 3000);
583
- checks.wheelMixedForwardedOnlyReport = forwardedInput().slice(wheelFrames).every((frame) => frame === "\x1b[I");
584
-
585
- // A6: a real key still clears the selection (typing semantics unchanged) …
586
- const keyCopies = copies();
587
- drag(1, 16);
588
- component.handleInput("x");
589
- await sleep(80);
590
- checks.keyStillClears = !selectionPresent() && copies() === keyCopies;
591
-
592
- // … and ambiguous escape shapes stay keys (the classifier boundary).
593
- drag(1, 16);
594
- component.handleInput("\x1b[1;2R");
595
- await sleep(80);
596
- const clearedByShiftF3 = !selectionPresent();
597
- drag(1, 16);
598
- component.handleInput("\x1b[97;5u");
599
- await sleep(80);
600
- checks.ambiguousEscapesStayKeys = clearedByShiftF3 && !selectionPresent() && copies() === keyCopies;
601
-
602
- // A9 (last: it closes the component): a detach chord matched in a mixed read must stop the rest
603
- // of that read. The trailing segment is a KEY, not a report: once detach() closes the socket a
604
- // trailing report's send() is a silent no-op, so only a key can prove the loop stopped (it would
605
- // otherwise clearSelection() and wipe the highlight). The read is three segments — detach chord /
606
- // focus report / trailing key — because adjacent key runs merge: a bare `"\x1b[1;5D" + "x"` is
607
- // ONE key segment `"\x1b[1;5Dx"` and the chord would never match.
608
- drag(1, 16);
609
- component.handleInput("\x1b[1;5D" + "\x1b[I" + "x");
610
- await sleep(150);
611
- checks.detachStopsRead = component.closed === true;
612
- checks.detachTrailingSegmentStopped = selectionPresent();
613
-
614
- try {
615
- component.dispose();
616
- } catch {}
617
- } catch (err) {
618
- result.error = err instanceof Error ? err.message : String(err);
619
- }
620
-
621
- try {
622
- const pid = readHost(root, viewId)?.childPid;
623
- if (pid) process.kill(pid, "SIGKILL");
624
- } catch {}
625
- try {
626
- runner.kill("SIGKILL");
627
- } catch {}
628
- await sleep(50);
629
- try {
630
- rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 30 });
631
- } catch {}
632
- process.stdout.write(JSON.stringify(result) + "\n");
633
- process.exit(0);
634
- ```
635
-
636
- - [ ] **Step 4: Run the scenario to confirm it fails on the current code**
637
-
638
- Run: `node --test test/pty-attach-mixed-input.test.mjs`
639
- Expected: FAIL with the failing checks (`mixedCopies`, `reportFirstCopies`, `selectionSurvivesMixed`,
640
- `reportOnlyKeepsSelection`, `oscReplyKeepsSelection`, `viewportKeptOnReport`,
641
- `wheelScrolledInMixedRead`) — that is the reproduced defect on `f81949d`.
642
-
643
- - [ ] **Step 5: Implement the dispatch loop**
644
-
645
- In `src/ui/pty-attach.ts`:
646
-
647
- (a) Line 15 — swap the import (`splitAttachInput` replaces `parseMouseInputChunk`):
648
-
649
- ```ts
650
- import { clampInt, resolveAttachMouseAction, resolvePasteSelectionSource, resolveWheelLines, scrollViewportTop, selectionDragScrollLines, shouldMirrorSelectionToPrimary, shouldOwnOuterMouseMode, splitAttachInput, wheelScrollLinesUp } from "../core/pty-scroll.mjs";
651
- ```
652
-
653
- (b) Replace the first two statements of `handleInput` (lines 297-298) with the dispatch loop, and
654
- open the extracted keyboard handler — the rest of the old body (every `matchesKey` branch, including
655
- the `detach()` early returns and the final default branch) stays **verbatim** inside it:
656
-
657
- ```ts
658
- handleInput(data: string): void {
659
- // Issue #189: dispatch per segment instead of all-or-nothing. A read that mixes mouse
660
- // reports with other bytes used to fall through to the keyboard branch as a whole: the
661
- // gesture was dropped, an in-progress selection cleared, the viewport reset, and the raw
662
- // mouse bytes forwarded into the hosted session as typed input.
663
- //
664
- // Issue #170/#194 composition (post-merge with main): the inbound reply guard runs per
665
- // non-mouse segment, so a recognized report can never absorb keys coalesced into the same
666
- // read (a still-fragmented head keeps the bounded #194 absorption: 64 bytes / 250ms).
667
- for (const segment of splitAttachInput(data)) {
668
- if (segment.kind === "mouse") {
669
- this.handleMouseInputChunk(segment.events);
670
- } else if (!this.consumeByReplyGuard(segment.data)) {
671
- if (segment.kind === "report") this.send({ type: "input", data: segment.data });
672
- else this.handleKeyInput(segment.data);
673
- }
674
- // A detach/close inside a segment must stop the rest of this read.
675
- if (this.closed) return;
676
- }
677
- }
678
-
679
- /** Issue #170/#194: run the inbound reply guard over one non-mouse segment. Returns true when
680
- * the guard took it (complete protocol reply dropped / colour reply forwarded whole). */
681
- private consumeByReplyGuard(data: string): boolean {
682
- const guard = this.replyGuard;
683
- if (!guard) return false;
684
- const decision = guard.feed(data, this.nowFn());
685
- if (guard.pendingSince() !== null) this.armReplyGuardTimer();
686
- if (decision.action === "forward") {
687
- this.send({ type: "input", data: decision.data });
688
- return true;
689
- }
690
- return decision.action === "swallow";
691
- }
692
-
693
- /** Keyboard branch of `handleInput` (issue #189: one segment of the read at a time). Terminal
694
- * *reports* never arrive here — `handleInput` forwards them without touching the selection or
695
- * the viewport, because they carry no user intent (focus events, query replies). */
696
- private handleKeyInput(data: string): void {
697
- if (matchesKey(data, Key.pageUp)) {
698
- ```
699
-
700
- The report path is deliberately just a guard pass-through plus `send(...)`: no `clearPendingClick()`,
701
- no `clearSelection()`, no `scrollToBottom()` — that omission is the fix for the second half of the
702
- issue (spec §3.2). Complete protocol replies stay dropped by the #194 guard and colour replies are
703
- forwarded whole by it; #189 only changes *what* the guard is fed (one segment at a time).
704
-
705
- - [ ] **Step 6: Run the component test and the unit tests**
706
-
707
- Run: `node --test test/pty-attach-mixed-input.test.mjs test/pty-scroll.test.mjs`
708
- Expected: PASS — every check in the scenario plus both new unit blocks.
709
-
710
- - [ ] **Step 7: Run the pre-existing attach suites (regression)**
711
-
712
- Run: `node --test test/pty-attach-mouse.test.mjs test/pty-attach-wheel.test.mjs test/pty-attach-selection-mirror.test.mjs test/pty-attach-mouse-mode.test.mjs`
713
- Expected: PASS (these cover #167 middle-click dispatch, the wheel, #174 PRIMARY mirroring and #173
714
- mouse-mode ownership through the same seams).
715
-
716
- - [ ] **Step 8: Typecheck**
717
-
718
- Run: `npm run typecheck`
719
- Expected: exit 0.
720
-
721
- - [ ] **Step 9: Commit**
722
-
723
- ```bash
724
- git add src/ui/pty-attach.ts test-support/pty-attach-mixed-input-scenario.ts test/pty-attach-mixed-input.test.mjs
725
- git commit -m "fix(attach): dispatch mixed input reads per segment and pass terminal reports through (issue #189)"
726
- ```
727
-
728
- ---
729
-
730
- ### Task 3: Full verification and traceability sweep
731
-
732
- Spec acceptance: **A7** (typecheck/build/pack), **A8** (no regression across the repo).
733
-
734
- **Files:** none (verification only).
735
-
736
- - [ ] **Step 1: Run the full repo verification**
737
-
738
- Run: `npm run verify`
739
- Expected: exit 0 — typecheck, perf gate, `node --test test/*.test.mjs`, coverage thresholds
740
- (lines 85 / funcs 80 / branches 70), `pack:dry`.
741
-
742
- - [ ] **Step 2: Confirm the coverage numbers**
743
-
744
- Run: `npm run test:coverage 2>&1 | tail -20`
745
- Expected: thresholds met; `src/core/pty-scroll.mjs` and `src/ui/pty-attach.ts` do not drop below the
746
- configured floors (if they do, extend the unit cases — never lower the thresholds).
747
-
748
- - [ ] **Step 3: Byte-level spec/plan traceability sweep**
749
-
750
- Run:
751
-
752
- ```bash
753
- grep -n "splitAttachInput\|handleKeyInput" src/core/pty-scroll.mjs src/ui/pty-attach.ts
754
- grep -n "is not recognised and becomes keyboard text\|all-or-nothing contract" src/core/pty-scroll.mjs
755
- ```
756
-
757
- Expected: `splitAttachInput` exported exactly once and imported once by the component;
758
- `parseMouseInputChunk` still present with its all-or-nothing doc comment; no other call site changed.
759
-
760
- - [ ] **Step 4: Re-read the spec's acceptance matrix and record the evidence**
761
-
762
- For every row A1–A9 write down the command that produced the evidence and its result in the issue
763
- comment prepared for step 9/10 (spec §6). `U1`/`U2` stay pending until Task 4 runs.
764
-
765
- - [ ] **Step 5: Commit (only if Step 1-3 forced changes)**
766
-
767
- ```bash
768
- git add -p # stage only the fixes made in this task
769
- git commit -m "test(attach): keep the segment coverage at the configured floors (issue #189)"
770
- ```
771
-
772
- ---
773
-
774
- ### Task 4: Manual verification on a real terminal (post-merge, operator-run)
775
-
776
- Spec acceptance: **U1** (byte injection in regular TUI mode), **U2** (optional daily-use check).
777
-
778
- This task runs **after** the PR is merged (the board loads from a git package checkout, so a
779
- pre-merge worktree would not be the loaded code). It is the operator's step; the agent prepares the
780
- commands and records the result — it must be reported as `pending` until actually executed.
781
-
782
- - [ ] **Step 1: Deploy the merged code into the loaded package**
783
-
784
- ```bash
785
- git -C ~/.pi/agent/git/github.com/zhuxixi/pi-agent-board stash # local package-lock.json change
786
- git -C ~/.pi/agent/git/github.com/zhuxixi/pi-agent-board pull
787
- git -C ~/.pi/agent/git/github.com/zhuxixi/pi-agent-board log --oneline -1
788
- ```
789
-
790
- Then restart `pi --tui-mode regular` (the legacy path only runs in regular TUI mode).
791
-
792
- - [ ] **Step 2: Inject the exact mixed byte stream inside tmux**
793
-
794
- ```bash
795
- tmux send-keys -H 1b 5b 3c 30 3b 31 3b 32 4d # press (col 1, row 2)
796
- tmux send-keys -H 1b 5b 3c 33 32 3b 38 3b 32 4d # move (col 8, row 2)
797
- tmux send-keys -H 1b 5b 3c 30 3b 31 36 3b 32 6d 1b 5b 49 # release + focus-in, ONE frame
798
- xclip -o -selection primary
799
- ```
800
-
801
- Expected: the highlight from the release survives until the next click (#172 contract), the selected
802
- text (`fake pi ready`) is in the clipboard/PRIMARY, and the hosted session shows no echoed mouse
803
- bytes. If tmux splits the third injection into several reads, record that honestly and mark the row
804
- `pending` — A3 (component level) remains the primary evidence.
805
-
806
- - [ ] **Step 3: Record U1/U2 in the issue**
807
-
808
- Comment the observed result (pass/fail/pending with the exact commands and outputs). U2 (daily use:
809
- a selection is no longer wiped by a spontaneous report) is optional and non-blocking; report it as
810
- `pending` if not exercised.
811
-
812
- ---
813
-
814
- ## Self-review checklist (run before handing the plan to implementers)
815
-
816
- - **Spec coverage:** A1/A2 → Task 1; A3–A6/A9 → Task 2; A7/A8 → Task 3; U1/U2 → Task 4. Spec §3.1
817
- (pure layer) → Task 1 Steps 1-8; §3.2 (component wiring) → Task 2 Steps 5-9; §7 testability split →
818
- Global Constraints + both test files; §8 boundaries → Global Constraints (no `parseMouseEventPrefix`
819
- / `handleLocalMouseEvent` edits).
820
- - **Placeholder scan:** every step carries real code or a real command with an expected result.
821
- - **Type consistency:** `splitAttachInput`'s `{kind, events|data}` discriminated union is used
822
- identically in the JSDoc (Task 1), the dispatch loop (Task 2 Step 5) and the unit tests
823
- (Task 1 Step 1).