@huseynsnmz/pi-board 0.15.3 → 0.17.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 (216) hide show
  1. package/CHANGELOG.md +24 -4
  2. package/CONTRIBUTING.md +7 -0
  3. package/README.md +55 -419
  4. package/SECURITY.md +19 -0
  5. package/docs/how-it-works.md +31 -0
  6. package/docs/reference.md +277 -0
  7. package/package.json +5 -11
  8. package/src/commands/agent-board.ts +26 -2
  9. package/src/commands/attach-flow.ts +4 -0
  10. package/src/core/double-press.mjs +6 -2
  11. package/src/core/slash-commands.mjs +52 -0
  12. package/src/index.ts +1 -1
  13. package/src/runtime/service.mjs +12 -0
  14. package/src/ui/dashboard.ts +59 -25
  15. package/src/ui/pty-attach.ts +8 -3
  16. package/IMPLEMENTATION_PLAN.md +0 -920
  17. package/PRD.md +0 -484
  18. package/PROGRESS.md +0 -142
  19. package/VERIFY.md +0 -115
  20. package/docs/BATCH_SELECTION_READ_FLOW.md +0 -277
  21. package/docs/EXPLORATION.md +0 -187
  22. package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +0 -581
  23. package/docs/RELEASE.md +0 -148
  24. package/docs/superpowers/plans/2026-08-15-screenlog-gc.md +0 -704
  25. package/docs/superpowers/plans/2026-08-16-attach-double-cursor-jiggle-retry.md +0 -499
  26. package/docs/superpowers/plans/2026-08-21-attach-coldstart-jiggle-rearm.md +0 -797
  27. package/docs/superpowers/plans/2026-08-21-dashboard-keypress-lag.md +0 -366
  28. package/docs/superpowers/plans/2026-08-21-issue-14-needs-input-no-auto-done.md +0 -576
  29. package/docs/superpowers/plans/2026-08-22-jiggle-shrink-and-hold.md +0 -337
  30. package/docs/superpowers/plans/2026-08-22-launch-cwd-favorites.md +0 -916
  31. package/docs/superpowers/plans/2026-08-22-question-tool-grouping.md +0 -211
  32. package/docs/superpowers/plans/2026-08-22-rename-state-labels.md +0 -182
  33. package/docs/superpowers/plans/2026-08-22-stable-list-order.md +0 -51
  34. package/docs/superpowers/plans/2026-08-27-locks-acquirelock-spin.md +0 -614
  35. package/docs/superpowers/plans/2026-08-27-pty-runner-test-flaky.md +0 -32
  36. package/docs/superpowers/plans/2026-08-29-code-refs-badges.md +0 -223
  37. package/docs/superpowers/plans/2026-08-30-circular-navigation.md +0 -308
  38. package/docs/superpowers/plans/2026-08-30-post-exit-timing-fix.md +0 -37
  39. package/docs/superpowers/plans/2026-08-30-pty-attach-quality-debt.md +0 -311
  40. package/docs/superpowers/plans/2026-08-30-readme-v2.md +0 -294
  41. package/docs/superpowers/plans/2026-09-01-attach-detach-gate-cursor-anchor.md +0 -284
  42. package/docs/superpowers/plans/2026-09-02-attach-detach-editor-state.md +0 -722
  43. package/docs/superpowers/plans/2026-09-03-code-refs-pr-backlink-narrow.md +0 -551
  44. package/docs/superpowers/plans/2026-09-03-detach-gate-glyph-fallback.md +0 -146
  45. package/docs/superpowers/plans/2026-09-04-evidence-outputpreview.md +0 -209
  46. package/docs/superpowers/plans/2026-09-04-warm-host-reclaim.md +0 -796
  47. package/docs/superpowers/plans/2026-09-05-issue-13-drainnextfollowup-pty-probe.md +0 -114
  48. package/docs/superpowers/plans/2026-09-05-issue-38-windows-wezterm-ime-cursor.md +0 -73
  49. package/docs/superpowers/plans/2026-09-05-issue-39-truncate-codepoint-boundary.md +0 -143
  50. package/docs/superpowers/plans/2026-09-05-issue-61-mention-fallback-guards.md +0 -226
  51. package/docs/superpowers/plans/2026-09-05-issue-63-flaky-manual-completion.md +0 -87
  52. package/docs/superpowers/plans/2026-09-05-issue-64-changelog-release-helper.md +0 -53
  53. package/docs/superpowers/plans/2026-09-05-pty-host-stacking-sock-race.md +0 -731
  54. package/docs/superpowers/plans/2026-09-08-attach-ctrl-left-detach.md +0 -30
  55. package/docs/superpowers/plans/2026-09-08-dashboard-shrink-repaint.md +0 -68
  56. package/docs/superpowers/plans/2026-09-08-issue-11-attach-runtime-desync-heal.md +0 -917
  57. package/docs/superpowers/plans/2026-09-08-legacy-stale-host-recovery.md +0 -125
  58. package/docs/superpowers/plans/2026-09-08-spawn-async-error-swallow.md +0 -56
  59. package/docs/superpowers/plans/2026-09-08-stale-model-attach-guard.md +0 -96
  60. package/docs/superpowers/plans/2026-09-09-claimpid-blocks-replace.md +0 -267
  61. package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +0 -603
  62. package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +0 -252
  63. package/docs/superpowers/plans/2026-09-10-reader-consistency.md +0 -115
  64. package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +0 -469
  65. package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +0 -92
  66. package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +0 -771
  67. package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +0 -299
  68. package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +0 -609
  69. package/docs/superpowers/plans/2026-09-14-terminal-model.md +0 -145
  70. package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +0 -224
  71. package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +0 -341
  72. package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +0 -875
  73. package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +0 -116
  74. package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +0 -517
  75. package/docs/superpowers/plans/2026-09-23-a5-mid-stream-assertion.md +0 -25
  76. package/docs/superpowers/plans/2026-09-23-cwd-browse-tab-drill.md +0 -393
  77. package/docs/superpowers/plans/2026-09-23-terminal-query-forwarding.md +0 -51
  78. package/docs/superpowers/plans/2026-09-23-unconditional-detach-key.md +0 -58
  79. package/docs/superpowers/plans/2026-09-24-control-reconcile-flake.md +0 -698
  80. package/docs/superpowers/plans/2026-09-24-probe-attached-clients.md +0 -605
  81. package/docs/superpowers/plans/2026-09-29-issue-145-holding-semantic-state.md +0 -1183
  82. package/docs/superpowers/plans/2026-09-29-issue-145-user-verification.md +0 -66
  83. package/docs/superpowers/plans/2026-09-30-board-delete-key-x-y-confirm.md +0 -649
  84. package/docs/superpowers/plans/2026-09-30-issue-153-runner-stop-boot-window.md +0 -618
  85. package/docs/superpowers/plans/2026-09-30-issue-95-test-discipline.md +0 -555
  86. package/docs/superpowers/plans/2026-09-30-publish-dispatch-tag-check.md +0 -259
  87. package/docs/superpowers/plans/2026-10-06-attach-settle-scheme-delivery.md +0 -751
  88. package/docs/superpowers/plans/2026-10-09-issue-167-attach-middle-paste.md +0 -509
  89. package/docs/superpowers/plans/2026-10-09-issue-169-fullscreen-mouse-ownership.md +0 -346
  90. package/docs/superpowers/plans/2026-10-10-attach-delete-in-place.md +0 -832
  91. package/docs/superpowers/plans/2026-10-10-issue-174-selection-mirror-paste-source.md +0 -510
  92. package/docs/superpowers/plans/2026-10-14-issue-170-attach-reply-guard.md +0 -771
  93. package/docs/superpowers/plans/2026-10-14-issue-178-accept-registration-race.md +0 -1257
  94. package/docs/superpowers/plans/2026-10-14-issue-184-snapshot-window-contract.md +0 -657
  95. package/docs/superpowers/plans/2026-10-14-issue-185-attach-wheel-alt-screen.md +0 -47
  96. package/docs/superpowers/plans/2026-10-14-issue-187-motionless-drag.md +0 -931
  97. package/docs/superpowers/plans/2026-10-14-issue-189-mixed-input-segments.md +0 -823
  98. package/docs/superpowers/plans/2026-10-15-issue-188-x10-mouse-release.md +0 -526
  99. package/docs/superpowers/plans/2026-10-16-issue-198-attach-wheel-forward-fallback.md +0 -32
  100. package/docs/superpowers/plans/2026-10-18-attach-wheel-replay-pseudo-scrollback.md +0 -437
  101. package/docs/superpowers/plans/2026-10-19-issue-213-reply-guard-csi-key.md +0 -257
  102. package/docs/superpowers/plans/2026-10-19-issue-214-attach-forward-alt-modifier.md +0 -202
  103. package/docs/superpowers/plans/2026-10-21-issue-221-ask-user-question.md +0 -198
  104. package/docs/superpowers/plans/2026-10-23-issue-222-attach-ready.md +0 -290
  105. package/docs/superpowers/plans/2026-10-23-issue-225-monotonic-foreground-state.md +0 -450
  106. package/docs/superpowers/plans/2026-10-23-issue-229-vim-jkl.md +0 -449
  107. package/docs/superpowers/plans/2026-10-23-issue-232-attach-bg-only-cells.md +0 -153
  108. package/docs/superpowers/plans/2026-10-23-lease-heartbeat-staleness.md +0 -537
  109. package/docs/superpowers/plans/2026-10-23-selected-row-highlight.md +0 -405
  110. package/docs/superpowers/plans/2026-10-24-issue-217-windows-pid-reuse-stopping.md +0 -367
  111. package/docs/superpowers/plans/2026-10-24-issue-230-warm-host-background-runs.md +0 -806
  112. package/docs/superpowers/plans/2026-10-24-issue-238-windows-process-identity-token.md +0 -490
  113. package/docs/superpowers/plans/2026-10-24-issue-241-coordinator-root-exit.md +0 -461
  114. package/docs/superpowers/plans/2026-10-25-issue-239-coordinator-replacement-identity.md +0 -504
  115. package/docs/superpowers/plans/2026-10-26-issue-245-key-hints-doc-sync.md +0 -244
  116. package/docs/superpowers/plans/2026-10-27-issue-172-fullscreen-selection-clear.md +0 -344
  117. package/docs/superpowers/plans/2026-10-27-issue-252-select-key-hints.md +0 -209
  118. package/docs/superpowers/plans/2026-10-28-issue-255-coordinator-endpoint-zombie.md +0 -442
  119. package/docs/superpowers/plans/2026-10-28-issue-257-legacy-pty-runner-endpoint-cleanup.md +0 -256
  120. package/docs/superpowers/plans/2026-11-03-cwd-favorites-full-pool.md +0 -266
  121. package/docs/superpowers/specs/2026-08-15-screenlog-gc-design.md +0 -105
  122. package/docs/superpowers/specs/2026-08-16-attach-double-cursor-jiggle-retry-design.md +0 -142
  123. package/docs/superpowers/specs/2026-08-21-attach-coldstart-jiggle-rearm-design.md +0 -110
  124. package/docs/superpowers/specs/2026-08-21-dashboard-keypress-lag-design.md +0 -59
  125. package/docs/superpowers/specs/2026-08-21-issue-14-needs-input-no-auto-done-design.md +0 -109
  126. package/docs/superpowers/specs/2026-08-22-cwd-favorites-design.md +0 -131
  127. package/docs/superpowers/specs/2026-08-22-jiggle-shrink-and-hold-design.md +0 -111
  128. package/docs/superpowers/specs/2026-08-22-question-tool-grouping-design.md +0 -114
  129. package/docs/superpowers/specs/2026-08-22-rename-state-labels-design.md +0 -80
  130. package/docs/superpowers/specs/2026-08-27-locks-acquirelock-spin-design.md +0 -97
  131. package/docs/superpowers/specs/2026-08-27-pty-runner-test-flaky-design.md +0 -35
  132. package/docs/superpowers/specs/2026-08-29-code-refs-badges-design.md +0 -128
  133. package/docs/superpowers/specs/2026-08-30-circular-navigation-design.md +0 -47
  134. package/docs/superpowers/specs/2026-08-30-eprm-atomicwrite-race-design.md +0 -77
  135. package/docs/superpowers/specs/2026-08-30-post-exit-timing-fix-design.md +0 -80
  136. package/docs/superpowers/specs/2026-08-30-pty-attach-quality-debt-design.md +0 -105
  137. package/docs/superpowers/specs/2026-08-30-readme-v2-design.md +0 -115
  138. package/docs/superpowers/specs/2026-09-01-attach-detach-gate-cursor-anchor-design.md +0 -78
  139. package/docs/superpowers/specs/2026-09-02-attach-detach-editor-state-design.md +0 -120
  140. package/docs/superpowers/specs/2026-09-03-code-refs-pr-backlink-narrow-design.md +0 -92
  141. package/docs/superpowers/specs/2026-09-03-detach-gate-glyph-fallback-design.md +0 -99
  142. package/docs/superpowers/specs/2026-09-04-evidence-outputpreview-design.md +0 -50
  143. package/docs/superpowers/specs/2026-09-04-warm-host-reclaim-design.md +0 -106
  144. package/docs/superpowers/specs/2026-09-05-issue-13-drainnextfollowup-pty-probe-design.md +0 -64
  145. package/docs/superpowers/specs/2026-09-05-issue-38-windows-wezterm-ime-design.md +0 -48
  146. package/docs/superpowers/specs/2026-09-05-issue-39-truncate-codepoint-boundary-design.md +0 -64
  147. package/docs/superpowers/specs/2026-09-05-issue-61-mention-fallback-design.md +0 -71
  148. package/docs/superpowers/specs/2026-09-05-issue-63-flaky-manual-completion-design.md +0 -49
  149. package/docs/superpowers/specs/2026-09-05-issue-64-changelog-helper-design.md +0 -76
  150. package/docs/superpowers/specs/2026-09-05-pty-host-stacking-sock-race-design.md +0 -510
  151. package/docs/superpowers/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md +0 -130
  152. package/docs/superpowers/specs/2026-09-08-attach-ctrl-left-detach-design.md +0 -58
  153. package/docs/superpowers/specs/2026-09-08-dashboard-shrink-repaint-design.md +0 -52
  154. package/docs/superpowers/specs/2026-09-08-legacy-stale-host-recovery-design.md +0 -87
  155. package/docs/superpowers/specs/2026-09-08-spawn-async-error-swallow-design.md +0 -56
  156. package/docs/superpowers/specs/2026-09-08-stale-model-attach-guard-design.md +0 -79
  157. package/docs/superpowers/specs/2026-09-09-claimpid-blocks-replace-design.md +0 -147
  158. package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +0 -304
  159. package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +0 -114
  160. package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +0 -120
  161. package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +0 -146
  162. package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +0 -116
  163. package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +0 -84
  164. package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +0 -92
  165. package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +0 -123
  166. package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +0 -204
  167. package/docs/superpowers/specs/2026-09-23-a5-mid-stream-assertion-design.md +0 -43
  168. package/docs/superpowers/specs/2026-09-23-cwd-browse-tab-drill-design.md +0 -95
  169. package/docs/superpowers/specs/2026-09-23-terminal-query-forwarding-design.md +0 -63
  170. package/docs/superpowers/specs/2026-09-23-unconditional-detach-key-design.md +0 -57
  171. package/docs/superpowers/specs/2026-09-24-control-reconcile-flake-design.md +0 -98
  172. package/docs/superpowers/specs/2026-09-24-probe-attached-clients-design.md +0 -190
  173. package/docs/superpowers/specs/2026-09-29-issue-145-holding-semantic-state-design.md +0 -394
  174. package/docs/superpowers/specs/2026-09-30-board-delete-key-x-y-confirm-design.md +0 -86
  175. package/docs/superpowers/specs/2026-09-30-issue-153-runner-stop-boot-window-design.md +0 -75
  176. package/docs/superpowers/specs/2026-09-30-issue-95-test-discipline-design.md +0 -226
  177. package/docs/superpowers/specs/2026-09-30-publish-dispatch-tag-check-design.md +0 -129
  178. package/docs/superpowers/specs/2026-10-06-attach-settle-scheme-delivery-design.md +0 -196
  179. package/docs/superpowers/specs/2026-10-09-issue-167-attach-middle-paste-design.md +0 -125
  180. package/docs/superpowers/specs/2026-10-09-issue-169-fullscreen-mouse-ownership-design.md +0 -130
  181. package/docs/superpowers/specs/2026-10-10-attach-delete-in-place-design.md +0 -224
  182. package/docs/superpowers/specs/2026-10-10-issue-174-selection-mirror-paste-source-design.md +0 -154
  183. package/docs/superpowers/specs/2026-10-14-issue-170-attach-reply-guard-design.md +0 -195
  184. package/docs/superpowers/specs/2026-10-14-issue-178-accept-registration-race-design.md +0 -287
  185. package/docs/superpowers/specs/2026-10-14-issue-184-snapshot-window-contract-design.md +0 -288
  186. package/docs/superpowers/specs/2026-10-14-issue-185-attach-wheel-alt-screen-design.md +0 -126
  187. package/docs/superpowers/specs/2026-10-14-issue-187-motionless-drag-design.md +0 -269
  188. package/docs/superpowers/specs/2026-10-14-issue-189-mixed-input-segments-design.md +0 -369
  189. package/docs/superpowers/specs/2026-10-15-issue-188-x10-mouse-release-design.md +0 -252
  190. package/docs/superpowers/specs/2026-10-16-issue-198-attach-wheel-forward-fallback-design.md +0 -67
  191. package/docs/superpowers/specs/2026-10-18-attach-wheel-replay-pseudo-scrollback-design.md +0 -250
  192. package/docs/superpowers/specs/2026-10-19-issue-213-reply-guard-csi-key-design.md +0 -165
  193. package/docs/superpowers/specs/2026-10-19-issue-214-attach-forward-alt-modifier-design.md +0 -131
  194. package/docs/superpowers/specs/2026-10-20-issue-221-ask-user-question-design.md +0 -270
  195. package/docs/superpowers/specs/2026-10-22-issue-225-monotonic-foreground-state-design.md +0 -225
  196. package/docs/superpowers/specs/2026-10-23-issue-222-attach-ready-design.md +0 -204
  197. package/docs/superpowers/specs/2026-10-23-issue-229-vim-jkl-design.md +0 -90
  198. package/docs/superpowers/specs/2026-10-23-issue-232-attach-bg-only-cells-design.md +0 -146
  199. package/docs/superpowers/specs/2026-10-23-lease-heartbeat-staleness-design.md +0 -271
  200. package/docs/superpowers/specs/2026-10-23-selected-row-highlight-design.md +0 -191
  201. package/docs/superpowers/specs/2026-10-24-issue-217-windows-pid-reuse-stopping-design.md +0 -334
  202. package/docs/superpowers/specs/2026-10-24-issue-230-warm-host-background-runs-design.md +0 -283
  203. package/docs/superpowers/specs/2026-10-24-issue-238-windows-process-identity-token-design.md +0 -286
  204. package/docs/superpowers/specs/2026-10-24-issue-241-coordinator-root-exit-design.md +0 -381
  205. package/docs/superpowers/specs/2026-10-25-issue-239-coordinator-replacement-identity-design.md +0 -331
  206. package/docs/superpowers/specs/2026-10-26-issue-245-key-hints-doc-sync-design.md +0 -72
  207. package/docs/superpowers/specs/2026-10-27-issue-172-fullscreen-selection-clear-design.md +0 -248
  208. package/docs/superpowers/specs/2026-10-27-issue-252-select-key-hints-design.md +0 -95
  209. package/docs/superpowers/specs/2026-10-28-issue-255-coordinator-endpoint-zombie-design.md +0 -380
  210. package/docs/superpowers/specs/2026-10-28-issue-257-legacy-pty-runner-endpoint-cleanup-design.md +0 -197
  211. package/docs/superpowers/specs/2026-11-03-cwd-favorites-full-pool-design.md +0 -106
  212. package/scripts/budget-audit.mjs +0 -135
  213. package/scripts/release.mjs +0 -446
  214. package/scripts/release_helper.mjs +0 -309
  215. package/scripts/run-perf-gate.mjs +0 -40
  216. 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).