@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.
- package/CHANGELOG.md +34 -0
- package/README.md +4 -3
- package/docs/superpowers/plans/2026-10-10-attach-delete-in-place.md +832 -0
- package/docs/superpowers/plans/2026-10-14-issue-170-attach-reply-guard.md +771 -0
- package/docs/superpowers/plans/2026-10-14-issue-178-accept-registration-race.md +1257 -0
- package/docs/superpowers/plans/2026-10-14-issue-184-snapshot-window-contract.md +657 -0
- package/docs/superpowers/plans/2026-10-14-issue-185-attach-wheel-alt-screen.md +47 -0
- package/docs/superpowers/plans/2026-10-14-issue-187-motionless-drag.md +931 -0
- package/docs/superpowers/plans/2026-10-14-issue-189-mixed-input-segments.md +823 -0
- package/docs/superpowers/plans/2026-10-15-issue-188-x10-mouse-release.md +526 -0
- package/docs/superpowers/plans/2026-10-16-issue-198-attach-wheel-forward-fallback.md +32 -0
- package/docs/superpowers/plans/2026-10-18-attach-wheel-replay-pseudo-scrollback.md +437 -0
- package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +6 -0
- package/docs/superpowers/specs/2026-10-10-attach-delete-in-place-design.md +224 -0
- package/docs/superpowers/specs/2026-10-14-issue-170-attach-reply-guard-design.md +190 -0
- package/docs/superpowers/specs/2026-10-14-issue-178-accept-registration-race-design.md +287 -0
- package/docs/superpowers/specs/2026-10-14-issue-184-snapshot-window-contract-design.md +288 -0
- package/docs/superpowers/specs/2026-10-14-issue-185-attach-wheel-alt-screen-design.md +126 -0
- package/docs/superpowers/specs/2026-10-14-issue-187-motionless-drag-design.md +269 -0
- package/docs/superpowers/specs/2026-10-14-issue-189-mixed-input-segments-design.md +369 -0
- package/docs/superpowers/specs/2026-10-15-issue-188-x10-mouse-release-design.md +252 -0
- package/docs/superpowers/specs/2026-10-16-issue-198-attach-wheel-forward-fallback-design.md +67 -0
- package/docs/superpowers/specs/2026-10-18-attach-wheel-replay-pseudo-scrollback-design.md +250 -0
- package/package.json +1 -1
- package/src/commands/agent-board.ts +27 -3
- package/src/core/attach-delete.mjs +24 -0
- package/src/core/pty-scroll.mjs +323 -12
- package/src/core/rows.mjs +19 -0
- package/src/core/terminal-attach-client.mjs +9 -4
- package/src/core/terminal-attach-protocol.mjs +5 -2
- package/src/core/terminal-reply-guard.mjs +200 -0
- package/src/ui/dashboard.ts +4 -0
- 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).
|