@zhuxixi/pi-agent-board 0.11.0 → 0.12.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 (33) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +4 -3
  3. package/docs/superpowers/plans/2026-10-10-attach-delete-in-place.md +832 -0
  4. package/docs/superpowers/plans/2026-10-14-issue-170-attach-reply-guard.md +771 -0
  5. package/docs/superpowers/plans/2026-10-14-issue-178-accept-registration-race.md +1257 -0
  6. package/docs/superpowers/plans/2026-10-14-issue-184-snapshot-window-contract.md +657 -0
  7. package/docs/superpowers/plans/2026-10-14-issue-185-attach-wheel-alt-screen.md +47 -0
  8. package/docs/superpowers/plans/2026-10-14-issue-187-motionless-drag.md +931 -0
  9. package/docs/superpowers/plans/2026-10-14-issue-189-mixed-input-segments.md +823 -0
  10. package/docs/superpowers/plans/2026-10-15-issue-188-x10-mouse-release.md +526 -0
  11. package/docs/superpowers/plans/2026-10-16-issue-198-attach-wheel-forward-fallback.md +32 -0
  12. package/docs/superpowers/plans/2026-10-18-attach-wheel-replay-pseudo-scrollback.md +437 -0
  13. package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +6 -0
  14. package/docs/superpowers/specs/2026-10-10-attach-delete-in-place-design.md +224 -0
  15. package/docs/superpowers/specs/2026-10-14-issue-170-attach-reply-guard-design.md +190 -0
  16. package/docs/superpowers/specs/2026-10-14-issue-178-accept-registration-race-design.md +287 -0
  17. package/docs/superpowers/specs/2026-10-14-issue-184-snapshot-window-contract-design.md +288 -0
  18. package/docs/superpowers/specs/2026-10-14-issue-185-attach-wheel-alt-screen-design.md +126 -0
  19. package/docs/superpowers/specs/2026-10-14-issue-187-motionless-drag-design.md +269 -0
  20. package/docs/superpowers/specs/2026-10-14-issue-189-mixed-input-segments-design.md +369 -0
  21. package/docs/superpowers/specs/2026-10-15-issue-188-x10-mouse-release-design.md +252 -0
  22. package/docs/superpowers/specs/2026-10-16-issue-198-attach-wheel-forward-fallback-design.md +67 -0
  23. package/docs/superpowers/specs/2026-10-18-attach-wheel-replay-pseudo-scrollback-design.md +250 -0
  24. package/package.json +1 -1
  25. package/src/commands/agent-board.ts +27 -3
  26. package/src/core/attach-delete.mjs +24 -0
  27. package/src/core/pty-scroll.mjs +323 -12
  28. package/src/core/rows.mjs +19 -0
  29. package/src/core/terminal-attach-client.mjs +9 -4
  30. package/src/core/terminal-attach-protocol.mjs +5 -2
  31. package/src/core/terminal-reply-guard.mjs +200 -0
  32. package/src/ui/dashboard.ts +4 -0
  33. package/src/ui/pty-attach.ts +334 -38
@@ -0,0 +1,931 @@
1
+ # Issue #187 — motion-less drag classification 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-mode attach path, classify a mouse release as a drag from the gesture's
6
+ *screen* geometry (or a motion event) instead of only from the "a move event arrived" flag, so a Warp
7
+ fast flick (press + release, zero motion) copies the selection instead of falling into the click path.
8
+
9
+ **Architecture:** The decision becomes a pure helper in `src/core/pty-scroll.mjs`
10
+ (`selectionReleaseIsDrag`, with `sameScreenCell`); the component records the raw reported screen cell
11
+ at press (`selection.screenAnchor`) and feeds both cells to the helper on release. Buffer-space
12
+ points stay in charge of the selection range only — they must never be compared across two events,
13
+ because the viewport base drifts while a session streams. The copy branch itself is unchanged, so
14
+ #172's "highlight stays until the next click" contract is preserved.
15
+
16
+ **Tech Stack:** TypeScript (`src/ui/pty-attach.ts`), ESM JavaScript (`src/core/pty-scroll.mjs`),
17
+ `node:test` + `assert/strict`, component-level scenarios run through
18
+ `node --experimental-transform-types`, `@xterm/headless`, real runner socket (`runner/pty-runner.mjs`)
19
+ with the fake PTY child (`test-support/fake-pty-pi.mjs`).
20
+
21
+ **Spec:** `docs/superpowers/specs/2026-10-14-issue-187-motionless-drag-design.md` (reviewed v4,
22
+ sha256 `2092b5d83bcf3da0109a2d8020a636aa3b0459af68e420a75f83533a5ede947e`).
23
+
24
+ ## Global Constraints
25
+
26
+ - Work in this worktree only: `/home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-187-attach-legacy-motionless-drag`
27
+ (all paths below are relative to it). Never touch the main checkout.
28
+ - Baseline: `origin/main` @ `3624007`. The reviewed spec cites `f81949d` line numbers; `#190` added 25
29
+ lines above `handleLocalMouseEvent`, so **use the line numbers in this plan**, which are current.
30
+ - `node_modules` is a symlink to the main checkout — do not run `npm install` in the worktree.
31
+ - Tabs for indentation. English comments and commit messages. Conventional commit subjects
32
+ (`feat`/`fix`/`test`/`docs`), no `(#N)` suffix until the PR exists.
33
+ - Stage files individually (`git add <file>`); never `git add -A`.
34
+ - Definition of done: `npm run verify` (typecheck + perf gate + tests + coverage + pack:dry) passes
35
+ from the worktree root.
36
+ - The two existing harness patterns to copy from (do not invent a third):
37
+ `test-support/pty-attach-mouse-scenario.ts` (+ `test/pty-attach-mouse.test.mjs`) and
38
+ `test-support/pty-attach-alt-wheel-scenario.ts` (+ `test/pty-attach-alt-wheel.test.mjs`).
39
+ - Out of scope (unchanged, do not "fix while you are here"): the selection range storage, the
40
+ press/move branches, autoscroll, middle-click paste, the PRIMARY mirror semantics, wheel handling,
41
+ the fullscreen `handleMouse` path, and the double-click comparison (still buffer-space by design —
42
+ see the spec's guarantee boundaries). #188/#189 stay broken; do not build on their fix.
43
+
44
+ ## File structure
45
+
46
+ | Path | Responsibility | Action |
47
+ | --- | --- | --- |
48
+ | `src/core/pty-scroll.mjs` | pure mouse/geometry decision helpers | modify: move `sameMousePoint` in, add `sameScreenCell`, `selectionReleaseIsDrag` |
49
+ | `src/ui/pty-attach.ts` | attach component; owns selection state and the copy branch | modify: import the helpers, drop the local `sameMousePoint`, record `screenAnchor`, use the decision |
50
+ | `test/pty-scroll.test.mjs` | unit decision tables | modify: A1 cases |
51
+ | `test-support/pty-attach-legacy-drag-scenario.ts` | component-level legacy gesture harness (default-mode PTY) | create |
52
+ | `test/pty-attach-legacy-drag.test.mjs` | asserts the four static-PTY cases (A2/A3/A4/A6) | create |
53
+ | `test-support/pty-attach-legacy-drag-drift-scenario.ts` | component-level drift harness (`steady` PTY) | create |
54
+ | `test/pty-attach-legacy-drag-drift.test.mjs` | asserts the drift negative control (A5) | create |
55
+
56
+ Acceptance traceability (spec ID → task/step):
57
+
58
+ | Spec ID | Covered by |
59
+ | --- | --- |
60
+ | A1 | Task 1 (steps 1–4) |
61
+ | A2 | Task 2 (steps 1–5, case 1) |
62
+ | A3 | Task 2 (step 6, case 2) |
63
+ | A4 | Task 2 (step 6, case 3) |
64
+ | A6 | Task 2 (step 6, case 4) |
65
+ | A5 | Task 3 (steps 1–5) |
66
+ | A7 / A8 | Task 4 (steps 1–3) |
67
+ | U1 / U2 / U3 | Task 4 (step 4 — manual runbook handed to the operator) |
68
+
69
+ ---
70
+
71
+ ### Task 1: pure gesture-decision helpers (A1)
72
+
73
+ **Files:**
74
+ - Modify: `src/core/pty-scroll.mjs` (append at end of file)
75
+ - Modify: `src/ui/pty-attach.ts` (import line 15; delete local `sameMousePoint` at lines 1700-1702)
76
+ - Test: `test/pty-scroll.test.mjs` (append at end of file)
77
+
78
+ **Interfaces:**
79
+ - Consumes: nothing.
80
+ - Produces: `sameMousePoint(a, b) -> boolean` (buffer points `{line, col}`, 1-cell col slack),
81
+ `sameScreenCell(a, b) -> boolean` (screen cells `{row, col}`, 1-cell col slack),
82
+ `selectionReleaseIsDrag({dragging, pressScreen, releaseScreen}) -> boolean`.
83
+
84
+ - [ ] **Step 1: Write the failing tests**
85
+
86
+ Append to `test/pty-scroll.test.mjs` (and add `sameMousePoint`, `sameScreenCell`,
87
+ `selectionReleaseIsDrag` to the existing import block at the top — keep the list alphabetical):
88
+
89
+ ```js
90
+ test("sameMousePoint: same buffer line within the 1-cell slack", () => {
91
+ assert.equal(sameMousePoint({ line: 4, col: 10 }, { line: 4, col: 10 }), true);
92
+ assert.equal(sameMousePoint({ line: 4, col: 10 }, { line: 4, col: 11 }), true);
93
+ assert.equal(sameMousePoint({ line: 4, col: 10 }, { line: 4, col: 12 }), false);
94
+ assert.equal(sameMousePoint({ line: 4, col: 10 }, { line: 5, col: 10 }), false);
95
+ });
96
+
97
+ test("sameScreenCell: same screen row within the 1-cell slack", () => {
98
+ assert.equal(sameScreenCell({ row: 2, col: 1 }, { row: 2, col: 1 }), true);
99
+ assert.equal(sameScreenCell({ row: 2, col: 1 }, { row: 2, col: 2 }), true);
100
+ assert.equal(sameScreenCell({ row: 2, col: 1 }, { row: 2, col: 3 }), false);
101
+ assert.equal(sameScreenCell({ row: 2, col: 1 }, { row: 3, col: 1 }), false);
102
+ });
103
+
104
+ test("selectionReleaseIsDrag: geometry decides in screen cells; missing geometry degrades to a click", () => {
105
+ const press = { row: 2, col: 1 };
106
+ // a motion event always wins
107
+ assert.equal(selectionReleaseIsDrag({ dragging: true, pressScreen: press, releaseScreen: press }), true);
108
+ assert.equal(selectionReleaseIsDrag({ dragging: true, pressScreen: null, releaseScreen: null }), true);
109
+ // a stationary gesture is a click, including the 1-cell report slack
110
+ assert.equal(selectionReleaseIsDrag({ dragging: false, pressScreen: press, releaseScreen: { row: 2, col: 1 } }), false);
111
+ assert.equal(selectionReleaseIsDrag({ dragging: false, pressScreen: press, releaseScreen: { row: 2, col: 2 } }), false);
112
+ // motion-less drags: horizontally past the slack, or any vertical movement
113
+ assert.equal(selectionReleaseIsDrag({ dragging: false, pressScreen: press, releaseScreen: { row: 2, col: 16 } }), true);
114
+ assert.equal(selectionReleaseIsDrag({ dragging: false, pressScreen: press, releaseScreen: { row: 3, col: 1 } }), true);
115
+ // an out-of-body release compares RAW reported cells, never the clamped buffer point
116
+ assert.equal(selectionReleaseIsDrag({ dragging: false, pressScreen: press, releaseScreen: { row: 30, col: 16 } }), true);
117
+ // missing geometry degrades to the pre-fix answer
118
+ assert.equal(selectionReleaseIsDrag({ dragging: false, pressScreen: null, releaseScreen: press }), false);
119
+ assert.equal(selectionReleaseIsDrag({ dragging: false, pressScreen: press, releaseScreen: null }), false);
120
+ });
121
+ ```
122
+
123
+ - [ ] **Step 2: Run the tests to verify they fail**
124
+
125
+ Run: `node --test test/pty-scroll.test.mjs`
126
+ Expected: FAIL — `sameMousePoint is not defined` / `sameScreenCell is not defined` /
127
+ `selectionReleaseIsDrag is not defined` (import resolution error), existing tests still listed.
128
+
129
+ - [ ] **Step 3: Implement the helpers**
130
+
131
+ Append to `src/core/pty-scroll.mjs`:
132
+
133
+ ```js
134
+ /**
135
+ * Issue #187: two mouse points are "the same" within the 1-cell coordinate slack
136
+ * terminals report. Buffer-space comparison ({line, col}) — shared by double-click
137
+ * detection. Moved here from pty-attach.ts so both comparisons have one definition.
138
+ * Pure by contract.
139
+ */
140
+ export function sameMousePoint(a, b) {
141
+ return a.line === b.line && Math.abs(a.col - b.col) <= 1;
142
+ }
143
+
144
+ /**
145
+ * Issue #187: two SCREEN cells are "the same" within the same 1-cell slack. Screen
146
+ * space ({row, col}) is what the pointer actually did; buffer space drifts while a
147
+ * session streams (the viewport base moves), which is why the drag/click decision must
148
+ * not compare buffer points captured at two different moments. Pure by contract.
149
+ */
150
+ export function sameScreenCell(a, b) {
151
+ return a.row === b.row && Math.abs(a.col - b.col) <= 1;
152
+ }
153
+
154
+ /**
155
+ * Issue #187: whether a mouse release ends a real selection drag. Terminals such as
156
+ * Warp deliver a fast drag as press + release with zero motion events, so the move flag
157
+ * alone is not sufficient — the gesture geometry decides. Both points are SCREEN cells
158
+ * as reported by the terminal (never the clamped buffer point). Missing geometry (no
159
+ * screen anchor recorded) degrades to the pre-fix answer: not a drag. Pure by contract.
160
+ */
161
+ export function selectionReleaseIsDrag({ dragging, pressScreen, releaseScreen }) {
162
+ if (dragging) return true;
163
+ if (!pressScreen || !releaseScreen) return false;
164
+ return !sameScreenCell(pressScreen, releaseScreen);
165
+ }
166
+ ```
167
+
168
+ - [ ] **Step 4: Delete the local copy and import the moved helper**
169
+
170
+ In `src/ui/pty-attach.ts`:
171
+ - line 15 import: add `sameMousePoint` (alphabetical: after `resolveWheelLines`, before `scrollViewportTop`)
172
+ - delete the local definition at lines 1700-1702:
173
+
174
+ ```ts
175
+ function sameMousePoint(a: MousePoint, b: MousePoint): boolean {
176
+ return a.line === b.line && Math.abs(a.col - b.col) <= 1;
177
+ }
178
+ ```
179
+
180
+ `isDoubleClickCandidate` (line 1120) keeps calling `sameMousePoint` unchanged — same function, new home.
181
+
182
+ - [ ] **Step 5: Run the tests and the typecheck**
183
+
184
+ Run: `node --test test/pty-scroll.test.mjs && node --test test/pty-attach-mouse.test.mjs && npm run typecheck`
185
+ Expected: all PASS (the new cases green, the component scenarios unaffected by the move).
186
+
187
+ - [ ] **Step 6: Commit**
188
+
189
+ ```bash
190
+ git add src/core/pty-scroll.mjs src/ui/pty-attach.ts test/pty-scroll.test.mjs
191
+ git commit -m "refactor(attach): pure gesture-decision helpers in pty-scroll
192
+
193
+ Move sameMousePoint next to its peers and add sameScreenCell plus
194
+ selectionReleaseIsDrag so the drag/click decision has a testable pure
195
+ core. No behaviour change yet: the component still decides on the move
196
+ flag alone (issue #187)."
197
+ ```
198
+
199
+ ---
200
+
201
+ ### Task 2: screen-space wiring + component-level gesture coverage (A2, A3, A4, A6)
202
+
203
+ **Files:**
204
+ - Modify: `src/ui/pty-attach.ts` (type at line 153, selection field at line 223, press branch at
205
+ lines 1059-1060, release branch at line 1079, import line 15)
206
+ - Create: `test-support/pty-attach-legacy-drag-scenario.ts`
207
+ - Create: `test/pty-attach-legacy-drag.test.mjs`
208
+
209
+ **Interfaces:**
210
+ - Consumes: `selectionReleaseIsDrag` from Task 1.
211
+ - Produces: `MouseSelection` gains an optional `screenAnchor: { row: number; col: number }`;
212
+ the scenario prints one JSON line with the fields the test asserts
213
+ (`sawContent`, `motionlessOsc52`, `motionlessPrimary`, `motionlessSelectionSurvives`,
214
+ `motionlessForwardedInput`, `movefulOsc52`, `clickPressedSelection`, `clickOsc52`,
215
+ `clickSelectionCleared`, `doubleOsc52`, `doubleSelectionIsWord`).
216
+
217
+ - [ ] **Step 1: Write the failing component scenario**
218
+
219
+ Create `test-support/pty-attach-legacy-drag-scenario.ts`. It drives the REAL component against a REAL
220
+ runner socket (default fake-PTY mode: one stable `fake pi ready` line, no output during the gestures)
221
+ with a fake TUI capturing `terminal.write`, and a PATH-stubbed `xclip` that logs its writes:
222
+
223
+ ```ts
224
+ // Component-level legacy-path drag coverage (issue #187): the REAL
225
+ // PtyAttachComponent against a REAL runner socket (fake pty child in default
226
+ // mode, so the buffer holds one stable line) with a fake TUI capturing
227
+ // terminal.write and a PATH-stubbed xclip logging PRIMARY writes. Verifies:
228
+ // 1. press + release with ZERO motion events across 15 columns copies the line
229
+ // (OSC 52 payload + PRIMARY mirror) and the selection survives the 260 ms
230
+ // pending-click timer — the Warp fast-flick byte stream;
231
+ // 2. the same gesture with a motion event in between copies the SAME text;
232
+ // 3. a same-cell press+release still behaves as a click (no copy, cleared);
233
+ // 4. two rapid same-cell clicks still select the word under the pointer and
234
+ // copy it — the click path this change routes through.
235
+ // Run via `node --experimental-transform-types`.
236
+ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
237
+ import { Socket } from "node:net";
238
+ import { tmpdir } from "node:os";
239
+ import { join, resolve } from "node:path";
240
+ import { spawn } from "node:child_process";
241
+ import { atomicWriteJson } from "../src/core/atomic.mjs";
242
+ import * as P from "../src/core/paths.mjs";
243
+ import { createView, readHost } from "../src/core/store.mjs";
244
+ import { PtyAttachComponent } from "../src/ui/pty-attach.ts";
245
+
246
+ const root = mkdtempSync(join(tmpdir(), "agentview-legacy-drag-"));
247
+ const viewId = "legacy-drag-e2e";
248
+ const prevPath = process.env.PATH;
249
+
250
+ // Hermetic xclip stub: log every write (args + stdin) so the PRIMARY mirror is
251
+ // observable and the host clipboard is never touched.
252
+ const binDir = join(root, "bin");
253
+ mkdirSync(binDir);
254
+ const xclipLog = join(root, "xclip.log");
255
+ writeFileSync(xclipLog, "");
256
+ writeFileSync(
257
+ join(binDir, "xclip"),
258
+ ["#!/bin/sh", 'text="$(cat)"', `echo "write $* text=$text" >> "${xclipLog}"`].join("\n"),
259
+ );
260
+ chmodSync(join(binDir, "xclip"), 0o755);
261
+ process.env.PATH = `${binDir}:${process.env.PATH}`;
262
+
263
+ function isAlive(pid: number): boolean {
264
+ try {
265
+ process.kill(pid, 0);
266
+ return true;
267
+ } catch {
268
+ return false;
269
+ }
270
+ }
271
+
272
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
273
+
274
+ async function waitFor(predicate: () => boolean, timeoutMs: number): Promise<boolean> {
275
+ const start = Date.now();
276
+ while (Date.now() - start < timeoutMs) {
277
+ if (predicate()) return true;
278
+ await sleep(25);
279
+ }
280
+ return false;
281
+ }
282
+
283
+ const meta = createView(root, { id: viewId, name: "legacy drag e2e", cwd: root });
284
+ const configPath = P.hostConfigPath(root, viewId);
285
+ atomicWriteJson(configPath, {
286
+ root,
287
+ viewId,
288
+ sessionFile: meta.sessionFile,
289
+ cwd: process.cwd(),
290
+ initialPrompt: null,
291
+ piCommand: process.execPath,
292
+ piArgsPrefix: [resolve("test-support/fake-pty-pi.mjs")],
293
+ model: null,
294
+ tools: null,
295
+ env: { AGENT_BOARD_ALLOW_PIPE_FALLBACK: "1" },
296
+ cols: 80,
297
+ rows: 22,
298
+ });
299
+
300
+ const runner = spawn(process.execPath, [resolve("runner/pty-runner.mjs"), configPath], {
301
+ stdio: ["ignore", "pipe", "pipe"],
302
+ });
303
+ runner.stderr.resume();
304
+
305
+ const result = {
306
+ error: null as string | null,
307
+ sawContent: false,
308
+ motionlessOsc52: null as string | null,
309
+ motionlessPrimary: null as string | null,
310
+ motionlessSelectionSurvives: false,
311
+ motionlessForwardedInput: false,
312
+ movefulOsc52: null as string | null,
313
+ clickPressedSelection: false,
314
+ clickOsc52: null as string | null,
315
+ clickSelectionCleared: false,
316
+ doubleOsc52: null as string | null,
317
+ doubleSelectionIsWord: false,
318
+ };
319
+
320
+ try {
321
+ const ready = await waitFor(() => {
322
+ const host = readHost(root, viewId);
323
+ return !!host && host.state === "alive" && !!host.socketPath && !!host.childPid && isAlive(host.runnerPid) && isAlive(host.childPid);
324
+ }, 15_000);
325
+ if (!ready) throw new Error("host never became ready");
326
+
327
+ // Intercept component→runner messages: mouse events must never arrive as input.
328
+ const sent: string[] = [];
329
+ const socketProto = Socket.prototype as any;
330
+ const origWrite = socketProto.write;
331
+ socketProto.write = function (data: any, ...rest: any[]) {
332
+ if (typeof data === "string") sent.push(data);
333
+ return origWrite.call(this, data, ...rest);
334
+ };
335
+ const forwardedInputs = () =>
336
+ sent
337
+ .map((l) => {
338
+ try {
339
+ return JSON.parse(l);
340
+ } catch {
341
+ return null;
342
+ }
343
+ })
344
+ .filter((m) => !!m && m.type === "input" && typeof m.data === "string");
345
+
346
+ const writes: string[] = [];
347
+ const tui = {
348
+ terminal: { rows: 24, cols: 80, columns: 80, write: (s: string) => void writes.push(s) },
349
+ requestRender: () => {},
350
+ };
351
+ const theme = { fg: (_c: string, t: string) => t, bold: (t: string) => t };
352
+ const component: any = new PtyAttachComponent(
353
+ tui as never,
354
+ theme as never,
355
+ {} as never,
356
+ () => {},
357
+ {
358
+ socketPath: readHost(root, viewId).socketPath,
359
+ screenLogPath: P.screenLogPath(root, viewId),
360
+ title: "legacy drag e2e",
361
+ } as never,
362
+ );
363
+
364
+ // Wait for session content so the gesture lands on a settled attach (an empty
365
+ // buffer would make the copy a silent no-op and the case vacuous).
366
+ const deadline = Date.now() + 8000;
367
+ while (Date.now() < deadline && !result.sawContent) {
368
+ const lines = component.render(80) ?? [];
369
+ if (lines.join("\n").includes("fake pi ready")) result.sawContent = true;
370
+ else await sleep(40);
371
+ }
372
+ if (!result.sawContent) throw new Error("session content never rendered");
373
+
374
+ const osc52Since = (from: number) =>
375
+ writes
376
+ .slice(from)
377
+ .filter((s) => s.includes("\x1b]52;"))
378
+ .map((s) => {
379
+ const m = /\x1b\]52;c;([A-Za-z0-9+/=]*)\x07/.exec(s);
380
+ return m ? Buffer.from(m[1], "base64").toString("utf8") : `<unparsed:${JSON.stringify(s)}>`;
381
+ });
382
+ const mirroredWrites = () =>
383
+ readFileSync(xclipLog, "utf8")
384
+ .split("\n")
385
+ .filter((l) => l.startsWith("write ") && l.includes("-selection primary"));
386
+ const lastMirrored = () => {
387
+ const lines = mirroredWrites();
388
+ return lines.length ? (lines.at(-1) as string).replace(/^write .*?text=/, "") : null;
389
+ };
390
+ // A previous case's pending-click timer must never fire inside the next case.
391
+ const resetGestureState = () => {
392
+ component.clearPendingClick();
393
+ component.selection = null;
394
+ component.selectionDragging = false;
395
+ };
396
+
397
+ // Case 1 (A2): the reported bug — press + release, zero motion events, 15 columns.
398
+ resetGestureState();
399
+ const from1 = writes.length;
400
+ const mirroredBefore1 = mirroredWrites().length;
401
+ component.handleInput("\x1b[<0;1;2M");
402
+ await sleep(20);
403
+ component.handleInput("\x1b[<0;16;2m");
404
+ await sleep(400);
405
+ result.motionlessOsc52 = osc52Since(from1).at(-1) ?? null;
406
+ result.motionlessSelectionSurvives = !!component.selection;
407
+ result.motionlessForwardedInput = forwardedInputs().length > 0;
408
+ await waitFor(() => mirroredWrites().length > mirroredBefore1, 2000);
409
+ result.motionlessPrimary = lastMirrored();
410
+
411
+ // Case 2 (A3): the canonical gesture (a motion event in the middle) must agree.
412
+ resetGestureState();
413
+ const from2 = writes.length;
414
+ component.handleInput("\x1b[<0;1;2M");
415
+ component.handleInput("\x1b[<32;8;2M");
416
+ component.handleInput("\x1b[<0;16;2m");
417
+ await sleep(400);
418
+ result.movefulOsc52 = osc52Since(from2).at(-1) ?? null;
419
+
420
+ // Case 3 (A4): a same-cell press+release stays a click.
421
+ resetGestureState();
422
+ const from3 = writes.length;
423
+ component.handleInput("\x1b[<0;1;2M");
424
+ await sleep(20);
425
+ result.clickPressedSelection = !!component.selection;
426
+ component.handleInput("\x1b[<0;1;2m");
427
+ await sleep(400);
428
+ result.clickOsc52 = osc52Since(from3).at(-1) ?? null;
429
+ result.clickSelectionCleared = !component.selection;
430
+
431
+ // Case 4 (A6): two rapid same-cell clicks still select and copy the word.
432
+ resetGestureState();
433
+ const from4 = writes.length;
434
+ component.handleInput("\x1b[<0;1;2M");
435
+ component.handleInput("\x1b[<0;1;2m");
436
+ await sleep(60);
437
+ component.handleInput("\x1b[<0;1;2M");
438
+ component.handleInput("\x1b[<0;1;2m");
439
+ await sleep(400);
440
+ result.doubleOsc52 = osc52Since(from4).at(-1) ?? null;
441
+ result.doubleSelectionIsWord =
442
+ !!component.selection &&
443
+ component.selection.anchor.line === 0 &&
444
+ component.selection.anchor.col === 0 &&
445
+ component.selection.focus.col === 3;
446
+
447
+ try {
448
+ component.dispose();
449
+ } catch {}
450
+ } catch (err) {
451
+ result.error = err instanceof Error ? `${err.message}\n${err.stack}` : String(err);
452
+ }
453
+
454
+ try {
455
+ const host = readHost(root, viewId);
456
+ if (host?.childPid) process.kill(host.childPid, "SIGKILL");
457
+ } catch {}
458
+ try {
459
+ runner.kill("SIGKILL");
460
+ } catch {}
461
+ await sleep(80);
462
+ process.env.PATH = prevPath;
463
+ try {
464
+ rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 30 });
465
+ } catch {}
466
+ console.log(JSON.stringify(result));
467
+ ```
468
+
469
+ Create `test/pty-attach-legacy-drag.test.mjs`:
470
+
471
+ ```js
472
+ import assert from "node:assert/strict";
473
+ import test from "node:test";
474
+ import { execFileSync } from "node:child_process";
475
+ import { fileURLToPath } from "node:url";
476
+ import { join } from "node:path";
477
+
478
+ const ROOT_DIR = fileURLToPath(new URL("../", import.meta.url));
479
+ const SCENARIO = join(ROOT_DIR, "test-support", "pty-attach-legacy-drag-scenario.ts");
480
+
481
+ // Component-level legacy drag coverage (issue #187): a motion-less press+release
482
+ // (Warp fast flick) must copy; the canonical move-ful gesture must copy the same
483
+ // text; a same-cell click must stay a click; the double-click path the fix routes
484
+ // through must still select and copy the word.
485
+ test("attach legacy drag: a motion-less press→release copies; clicks still behave as clicks", () => {
486
+ const out = execFileSync(process.execPath, ["--experimental-transform-types", SCENARIO], {
487
+ encoding: "utf8",
488
+ timeout: 60_000,
489
+ });
490
+ const p = JSON.parse(out.trim().split("\n").filter(Boolean).pop());
491
+ assert.equal(p.error, null, `scenario error: ${p.error}`);
492
+ assert.equal(p.sawContent, true, "session content must render before the gesture (no empty-buffer race)");
493
+ // A2
494
+ assert.equal(p.motionlessOsc52, "fake pi ready", "a motion-less drag must copy the selected line via OSC 52");
495
+ assert.equal(p.motionlessPrimary, "fake pi ready", "the copy must also mirror into X11 PRIMARY");
496
+ assert.equal(p.motionlessSelectionSurvives, true, "the selection must survive the 260 ms pending-click timer");
497
+ assert.equal(p.motionlessForwardedInput, false, "mouse events must never be forwarded to the child as input");
498
+ // A3
499
+ assert.equal(p.movefulOsc52, "fake pi ready", "the move-ful drag must copy the same text");
500
+ assert.equal(p.movefulOsc52, p.motionlessOsc52, "both drag shapes must produce the same payload");
501
+ // A4
502
+ assert.equal(p.clickPressedSelection, true, "the press must create a selection (otherwise this case is vacuous)");
503
+ assert.equal(p.clickOsc52, null, "a stationary click must not copy");
504
+ assert.equal(p.clickSelectionCleared, true, "the pending-click timer must clear the selection");
505
+ // A6
506
+ assert.equal(p.doubleOsc52, "fake", "a double click must select and copy the word under the pointer");
507
+ assert.equal(p.doubleSelectionIsWord, true, "the word selection must stay past the pending-click window");
508
+ });
509
+ ```
510
+
511
+ - [ ] **Step 2: Run the scenario test to verify it fails on the real bug**
512
+
513
+ Run: `node --test test/pty-attach-legacy-drag.test.mjs`
514
+ Expected: FAIL on the first A2 assertion — `motionlessOsc52` is `null` (no copy happens today for a
515
+ zero-motion gesture). The scenario itself must not error (`p.error === null`), which proves the harness
516
+ and the empty-buffer precondition are sound.
517
+
518
+ - [ ] **Step 3: Record the screen anchor at press**
519
+
520
+ In `src/ui/pty-attach.ts`:
521
+
522
+ ```ts
523
+ // line 152-154
524
+ type MousePoint = { line: number; col: number };
525
+ type ScreenCell = { row: number; col: number };
526
+ /** Issue #187: `screenAnchor` is the RAW reported cell of the press; the drag/click
527
+ * decision compares it against the release cell, because buffer coordinates drift
528
+ * while the session streams. Absent on word selections (they are created on release). */
529
+ type MouseSelection = { anchor: MousePoint; focus: MousePoint; screenAnchor?: ScreenCell };
530
+ ```
531
+
532
+ and in the press branch (line 1059):
533
+
534
+ ```ts
535
+ this.selection = { anchor: point, focus: point, screenAnchor: { row: mouse.row, col: mouse.col } };
536
+ ```
537
+
538
+ - [ ] **Step 4: Use the decision on release**
539
+
540
+ In `src/ui/pty-attach.ts` line 1079, replace
541
+
542
+ ```ts
543
+ const shouldCopy = this.selectionDragging;
544
+ ```
545
+
546
+ with
547
+
548
+ ```ts
549
+ // Issue #187: a Warp fast flick arrives as press + release with no motion
550
+ // event, so the move flag alone cannot decide. Compare the RAW reported
551
+ // screen cells (never the clamped buffer points, whose base drifts as the
552
+ // session streams).
553
+ const shouldCopy = selectionReleaseIsDrag({
554
+ dragging: this.selectionDragging,
555
+ pressScreen: this.selection.screenAnchor,
556
+ releaseScreen: { row: mouse.row, col: mouse.col },
557
+ });
558
+ ```
559
+
560
+ and add `selectionReleaseIsDrag` to the `../core/pty-scroll.mjs` import on line 15 (alphabetical:
561
+ after `resolveWheelLines`, before `shouldMirrorSelectionToPrimary`).
562
+
563
+ - [ ] **Step 5: Run the scenario test to verify it passes**
564
+
565
+ Run: `node --test test/pty-attach-legacy-drag.test.mjs`
566
+ Expected: PASS — all eleven assertions, including the A2 reproduction, the A3 equivalence, the A4
567
+ negative control and the A6 double-click case.
568
+
569
+ - [ ] **Step 6: Commit**
570
+
571
+ ```bash
572
+ git add src/ui/pty-attach.ts test-support/pty-attach-legacy-drag-scenario.ts test/pty-attach-legacy-drag.test.mjs
573
+ git commit -m "fix(attach): classify the legacy gesture by screen geometry
574
+
575
+ A drag delivered as press + release with no motion event (Warp fast
576
+ flicks) took the click path: no copy, and the selection was wiped by the
577
+ 260 ms pending-click timer. Decide from the raw reported screen cells
578
+ recorded at press instead of the move flag alone, so the gesture lands on
579
+ the existing copy branch and #172's highlight-retention contract is
580
+ preserved."
581
+ ```
582
+
583
+ ---
584
+
585
+ ### Task 3: viewport-drift negative control (A5)
586
+
587
+ **Files:**
588
+ - Create: `test-support/pty-attach-legacy-drag-drift-scenario.ts`
589
+ - Create: `test/pty-attach-legacy-drag-drift.test.mjs`
590
+
591
+ **Interfaces:**
592
+ - Consumes: the wired component from Task 2.
593
+ - Produces: a scenario printing `{ error, sawContent, bufferGrew, pressCreatedSelection, osc52, selectionCleared }`.
594
+
595
+ - [ ] **Step 1: Write the failing-first scenario**
596
+
597
+ The drift case only becomes meaningful once the buffer really moves between press and release, so the
598
+ scenario asserts that movement itself. Create
599
+ `test-support/pty-attach-legacy-drag-drift-scenario.ts` (same harness, `FAKE_PTY_STREAM_MODE: "steady"`,
600
+ no xclip stub needed because this case must not copy — but keep the stub to stay hermetic):
601
+
602
+ ```ts
603
+ // Component-level drift coverage (issue #187, review finding R1-G1): the REAL
604
+ // PtyAttachComponent against a REAL runner whose fake pty child streams a line
605
+ // every 25 ms (FAKE_PTY_STREAM_MODE=steady). A stationary click — same screen
606
+ // cell for press and release — must stay a click even though the stream moves
607
+ // the viewport base between the two events, so the buffer-space line of the
608
+ // release differs from the press. xclip is PATH-stubbed so the host clipboard
609
+ // is never touched and any copy would still be visible in the log.
610
+ // Run via `node --experimental-transform-types`.
611
+ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
612
+ import { Socket } from "node:net";
613
+ import { tmpdir } from "node:os";
614
+ import { join, resolve } from "node:path";
615
+ import { spawn } from "node:child_process";
616
+ import { atomicWriteJson } from "../src/core/atomic.mjs";
617
+ import * as P from "../src/core/paths.mjs";
618
+ import { createView, readHost } from "../src/core/store.mjs";
619
+ import { PtyAttachComponent } from "../src/ui/pty-attach.ts";
620
+
621
+ const root = mkdtempSync(join(tmpdir(), "agentview-legacy-drag-drift-"));
622
+ const viewId = "legacy-drag-drift-e2e";
623
+ const prevPath = process.env.PATH;
624
+ const binDir = join(root, "bin");
625
+ mkdirSync(binDir);
626
+ const xclipLog = join(root, "xclip.log");
627
+ writeFileSync(xclipLog, "");
628
+ writeFileSync(join(binDir, "xclip"), ["#!/bin/sh", 'text="$(cat)"', `echo "write $* text=$text" >> "${xclipLog}"`].join("\n"));
629
+ chmodSync(join(binDir, "xclip"), 0o755);
630
+ process.env.PATH = `${binDir}:${process.env.PATH}`;
631
+
632
+ function isAlive(pid: number): boolean {
633
+ try {
634
+ process.kill(pid, 0);
635
+ return true;
636
+ } catch {
637
+ return false;
638
+ }
639
+ }
640
+
641
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
642
+
643
+ async function waitFor(predicate: () => boolean, timeoutMs: number): Promise<boolean> {
644
+ const start = Date.now();
645
+ while (Date.now() - start < timeoutMs) {
646
+ if (predicate()) return true;
647
+ await sleep(25);
648
+ }
649
+ return false;
650
+ }
651
+
652
+ const meta = createView(root, { id: viewId, name: "legacy drag drift e2e", cwd: root });
653
+ const configPath = P.hostConfigPath(root, viewId);
654
+ atomicWriteJson(configPath, {
655
+ root,
656
+ viewId,
657
+ sessionFile: meta.sessionFile,
658
+ cwd: process.cwd(),
659
+ initialPrompt: null,
660
+ piCommand: process.execPath,
661
+ piArgsPrefix: [resolve("test-support/fake-pty-pi.mjs")],
662
+ model: null,
663
+ tools: null,
664
+ env: { AGENT_BOARD_ALLOW_PIPE_FALLBACK: "1", FAKE_PTY_STREAM_MODE: "steady" },
665
+ cols: 80,
666
+ rows: 22,
667
+ });
668
+
669
+ const runner = spawn(process.execPath, [resolve("runner/pty-runner.mjs"), configPath], {
670
+ stdio: ["ignore", "pipe", "pipe"],
671
+ });
672
+ runner.stderr.resume();
673
+
674
+ const result = {
675
+ error: null as string | null,
676
+ sawContent: false,
677
+ bufferGrew: false,
678
+ bufferGrowth: 0,
679
+ pressCreatedSelection: false,
680
+ osc52: null as string | null,
681
+ selectionCleared: false,
682
+ mirroredWrites: 0,
683
+ };
684
+
685
+ try {
686
+ const ready = await waitFor(() => {
687
+ const host = readHost(root, viewId);
688
+ return !!host && host.state === "alive" && !!host.socketPath && !!host.childPid && isAlive(host.runnerPid) && isAlive(host.childPid);
689
+ }, 15_000);
690
+ if (!ready) throw new Error("host never became ready");
691
+
692
+ const sent: string[] = [];
693
+ const socketProto = Socket.prototype as any;
694
+ const origWrite = socketProto.write;
695
+ socketProto.write = function (data: any, ...rest: any[]) {
696
+ if (typeof data === "string") sent.push(data);
697
+ return origWrite.call(this, data, ...rest);
698
+ };
699
+
700
+ const writes: string[] = [];
701
+ const tui = {
702
+ terminal: { rows: 24, cols: 80, columns: 80, write: (s: string) => void writes.push(s) },
703
+ requestRender: () => {},
704
+ };
705
+ const theme = { fg: (_c: string, t: string) => t, bold: (t: string) => t };
706
+ const component: any = new PtyAttachComponent(
707
+ tui as never,
708
+ theme as never,
709
+ {} as never,
710
+ () => {},
711
+ {
712
+ socketPath: readHost(root, viewId).socketPath,
713
+ screenLogPath: P.screenLogPath(root, viewId),
714
+ title: "legacy drag drift e2e",
715
+ } as never,
716
+ );
717
+
718
+ const deadline = Date.now() + 8000;
719
+ while (Date.now() < deadline && !result.sawContent) {
720
+ const lines = component.render(80) ?? [];
721
+ if (lines.join("\n").includes("steady-")) result.sawContent = true;
722
+ else await sleep(40);
723
+ }
724
+ if (!result.sawContent) throw new Error("streaming session content never rendered");
725
+
726
+ component.clearPendingClick();
727
+ component.selection = null;
728
+ component.selectionDragging = false;
729
+ const from = writes.length;
730
+ const bufferBefore = component.term.buffer.active.length;
731
+
732
+ // Same SCREEN cell for press and release: the stream must be the only reason the
733
+ // two events map to different buffer lines.
734
+ component.handleInput("\x1b[<0;1;2M");
735
+ await sleep(20);
736
+ result.pressCreatedSelection = !!component.selection;
737
+ await sleep(180);
738
+ const bufferAtRelease = component.term.buffer.active.length;
739
+ component.handleInput("\x1b[<0;1;2m");
740
+ await sleep(400);
741
+
742
+ result.bufferGrowth = bufferAtRelease - bufferBefore;
743
+ result.bufferGrew = result.bufferGrowth >= 3;
744
+ result.osc52 = writes
745
+ .slice(from)
746
+ .filter((s) => s.includes("\x1b]52;"))
747
+ .at(-1) ?? null;
748
+ result.selectionCleared = !component.selection;
749
+ result.mirroredWrites = readFileSync(xclipLog, "utf8")
750
+ .split("\n")
751
+ .filter((l) => l.startsWith("write ") && l.includes("-selection primary")).length;
752
+
753
+ try {
754
+ component.dispose();
755
+ } catch {}
756
+ } catch (err) {
757
+ result.error = err instanceof Error ? `${err.message}\n${err.stack}` : String(err);
758
+ }
759
+
760
+ try {
761
+ const host = readHost(root, viewId);
762
+ if (host?.childPid) process.kill(host.childPid, "SIGKILL");
763
+ } catch {}
764
+ try {
765
+ runner.kill("SIGKILL");
766
+ } catch {}
767
+ await sleep(80);
768
+ process.env.PATH = prevPath;
769
+ try {
770
+ rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 30 });
771
+ } catch {}
772
+ console.log(JSON.stringify(result));
773
+ ```
774
+
775
+ Create `test/pty-attach-legacy-drag-drift.test.mjs`:
776
+
777
+ ```js
778
+ import assert from "node:assert/strict";
779
+ import test from "node:test";
780
+ import { execFileSync } from "node:child_process";
781
+ import { fileURLToPath } from "node:url";
782
+ import { join } from "node:path";
783
+
784
+ const ROOT_DIR = fileURLToPath(new URL("../", import.meta.url));
785
+ const SCENARIO = join(ROOT_DIR, "test-support", "pty-attach-legacy-drag-drift-scenario.ts");
786
+
787
+ // Drift negative control (issue #187, finding R1-G1): with the session streaming,
788
+ // a stationary click maps press and release to different BUFFER lines while the
789
+ // screen cell is identical. Classifying on screen cells keeps it a click; a
790
+ // buffer-space comparison would copy and never clear. The case asserts the buffer
791
+ // really moved, otherwise it proves nothing.
792
+ test("attach legacy drag drift: a stationary click stays a click while the viewport drifts", () => {
793
+ const out = execFileSync(process.execPath, ["--experimental-transform-types", SCENARIO], {
794
+ encoding: "utf8",
795
+ timeout: 60_000,
796
+ });
797
+ const p = JSON.parse(out.trim().split("\n").filter(Boolean).pop());
798
+ assert.equal(p.error, null, `scenario error: ${p.error}`);
799
+ assert.equal(p.sawContent, true, "streaming content must render before the gesture");
800
+ assert.equal(p.bufferGrew, true, `the buffer must have drifted during the gesture (grew ${p.bufferGrowth} lines)`);
801
+ assert.equal(p.pressCreatedSelection, true, "the press must create a selection (otherwise this case is vacuous)");
802
+ assert.equal(p.osc52, null, "a stationary click must not copy, however far the viewport drifted");
803
+ assert.equal(p.selectionCleared, true, "the pending-click timer must clear the selection");
804
+ assert.equal(p.mirroredWrites, 0, "no PRIMARY write may happen for a click");
805
+ });
806
+ ```
807
+
808
+ - [ ] **Step 2: Run it to verify the control is sound**
809
+
810
+ Run: `node --test test/pty-attach-legacy-drag-drift.test.mjs`
811
+ Expected: PASS with `bufferGrowth >= 3`. If `bufferGrew` is false the case is not exercising drift —
812
+ increase the `await sleep(180)` pause rather than loosening the assertion.
813
+
814
+ - [ ] **Step 3: Prove the case has discriminating power**
815
+
816
+ Temporarily (do not commit) restore the buffer-space comparison the review rejected, by feeding the
817
+ helper buffer lines as if they were screen rows in `src/ui/pty-attach.ts`:
818
+
819
+ ```ts
820
+ const shouldCopy = selectionReleaseIsDrag({
821
+ dragging: this.selectionDragging,
822
+ pressScreen: { row: this.selection.anchor.line, col: this.selection.anchor.col },
823
+ releaseScreen: point ? { row: point.line, col: point.col } : null,
824
+ });
825
+ ```
826
+
827
+ Run: `node --test test/pty-attach-legacy-drag-drift.test.mjs`
828
+ Expected: FAIL on the `osc52` assertion — the drifted buffer lines make the stationary click look like a
829
+ drag, which is exactly the R1-G1 regression this case exists to catch. Revert the temporary edit
830
+ (`git checkout -- src/ui/pty-attach.ts` is wrong here: it would drop Task 2's committed work — re-apply
831
+ the Step 4 text by hand), run the test again and confirm it passes, then confirm
832
+ `git diff origin/main...HEAD --stat` still shows only the intended files.
833
+
834
+ - [ ] **Step 4: Commit**
835
+
836
+ ```bash
837
+ git add test-support/pty-attach-legacy-drag-drift-scenario.ts test/pty-attach-legacy-drag-drift.test.mjs
838
+ git commit -m "test(attach): drift negative control for the legacy drag decision
839
+
840
+ Pin the review finding that killed the buffer-space comparison: with the
841
+ session streaming, a stationary click maps press and release to different
842
+ buffer lines, so only a screen-cell decision keeps it a click."
843
+ ```
844
+
845
+ ---
846
+
847
+ ### Task 4: full verification and the manual runbook (A7, A8, U1, U2, U3)
848
+
849
+ **Files:**
850
+ - No source changes expected. Any failure found here is fixed in the owning task's files, then this
851
+ task's steps are re-run.
852
+
853
+ **Interfaces:**
854
+ - Consumes: everything above.
855
+ - Produces: the verification record pasted into the PR / issue, plus the manual steps for the operator.
856
+
857
+ - [ ] **Step 1: Run the aggregate gate**
858
+
859
+ Run: `npm run verify`
860
+ Expected: PASS end to end — `tsc --noEmit`, perf gate, `node --test test/*.test.mjs` (including the two
861
+ new scenario tests), `c8` coverage above lines 85 / funcs 80 / branches 70, `npm pack --dry-run`.
862
+
863
+ - [ ] **Step 2: Record the acceptance evidence**
864
+
865
+ Run: `npm run test:coverage 2>&1 | tail -30`
866
+ Expected: every suite green; paste the coverage summary and the two new test names into the PR body as
867
+ the A1–A6/A8 evidence.
868
+
869
+ - [ ] **Step 3: Confirm the deliverables and the scope guard**
870
+
871
+ Run: `git diff --stat origin/main...HEAD` and `git status --short`
872
+ Expected: only the seven files from the file-structure table (plus the spec/plan docs) appear; nothing
873
+ staged but uncommitted; no stray fixture files. Confirm by reading the diff that the selection range
874
+ storage, the press/move branches, autoscroll, middle-click paste, PRIMARY mirroring and wheel handling
875
+ are untouched.
876
+
877
+ - [ ] **Step 4: Hand the manual runbook to the operator (U1/U2/U3)**
878
+
879
+ These run **after the branch is merged and the extension checkout is updated** — the operator's pi
880
+ loads the board from `~/.pi/agent/git/github.com/zhuxixi/pi-agent-board` (registered as
881
+ `git:github.com/zhuxixi/pi-agent-board` in `~/.pi/agent/settings.json`; entry `index.ts`, no build
882
+ step), so a worktree build is never what a real session loads.
883
+
884
+ ```bash
885
+ # 0. prerequisite: that checkout is currently on f81949d with a dirty package-lock.json
886
+ git -C ~/.pi/agent/git/github.com/zhuxixi/pi-agent-board status --short
887
+ git -C ~/.pi/agent/git/github.com/zhuxixi/pi-agent-board pull
888
+ # then restart pi.
889
+ ```
890
+
891
+ - **U1 (regression, real terminal)**: `pi --tui-mode regular` → `/agent-board` → attach any session →
892
+ left-drag over some text and release → middle-click paste. Pass: the pasted text equals the selection;
893
+ the highlight still stays until the next click (#172 behaviour).
894
+ - **U2 (the exact reported byte stream)**: inside tmux, `pi --tui-mode regular` → `/agent-board` →
895
+ attach, then inject the Warp press/release pair with no motion event:
896
+ ```bash
897
+ tmux send-keys -H 1b 5b 3c 30 3b 31 3b 32 4d # press (col 1, row 2)
898
+ tmux send-keys -H 1b 5b 3c 30 3b 31 36 3b 32 6d # release(col 16, row 2)
899
+ ```
900
+ Pass: the line under the gesture reaches the clipboard (OSC 52 / PRIMARY) and the highlight stays.
901
+ If the real regular-mode TUI does not hand those bytes to the attach overlay, record the observation
902
+ and mark U2 `pending` — A2 stays the evidence for the fix.
903
+ - **U3 (drift regression)**: same environment; make the attached session stream
904
+ (`while :; do date; sleep 0.2; done`), then click plain text several times while it scrolls, then
905
+ stop the stream and double-click a word as a control. Pass: clicks never change the clipboard and
906
+ never open a link; the post-stream double-click selects and copies the word. Double-click *during*
907
+ the stream missing is pre-existing behaviour (see the spec's guarantee boundaries) and is not a
908
+ failure of this change.
909
+
910
+ - [ ] **Step 5: Report**
911
+
912
+ Post the `npm run verify` result, the coverage summary, the A-item evidence and the U1/U2/U3 outcome
913
+ (including any `pending`) into issue #187 before opening the PR. Do not merge until the reviewer's
914
+ Zima CR converged (process step 9).
915
+
916
+ ---
917
+
918
+ ## Self-review
919
+
920
+ 1. **Spec coverage** — A1→Task 1; A2/A3/A4/A6→Task 2; A5→Task 3; A7/A8→Task 4 steps 1–3;
921
+ U1/U2/U3→Task 4 step 4. Every design decision in the spec has a task: the pure helpers (Task 1),
922
+ the `screenAnchor` recording and the release decision (Task 2 steps 3–4), the raw-not-clamped rule
923
+ (A1 case + Task 2 step 4 comment), the preserved #172 contract (copy branch untouched, asserted by
924
+ A2's "selection survives"), and the documented double-click boundary (asserted by A6 in the
925
+ no-drift fixture, and by U3's control step).
926
+ 2. **Placeholder scan** — no TBD/TODO; every code step ships the full text of the file or the exact
927
+ patch; every run step names the command and the expected outcome.
928
+ 3. **Type consistency** — `sameMousePoint`/`sameScreenCell` take `{line, col}`/`{row, col}`
929
+ respectively; `selectionReleaseIsDrag` receives `{dragging, pressScreen, releaseScreen}` in the
930
+ helper, the unit tests and the component call site; `MouseSelection.screenAnchor` is the only new
931
+ field and it is optional everywhere it is read (`selectWordAtPoint` does not set it).