@zhuxixi/pi-agent-board 0.10.2 → 0.11.1
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 +16 -0
- package/docs/superpowers/plans/2026-10-10-issue-174-selection-mirror-paste-source.md +510 -0
- package/docs/superpowers/specs/2026-10-10-issue-174-selection-mirror-paste-source-design.md +154 -0
- package/package.json +1 -1
- package/src/core/pty-scroll.mjs +23 -0
- package/src/ui/pty-attach.ts +94 -21
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ conventional commits by `scripts/release_helper.mjs`. Entries are
|
|
|
5
5
|
forward-only: they begin with the first release after this file landed —
|
|
6
6
|
for earlier history, see the git log and the pull-request list.
|
|
7
7
|
|
|
8
|
+
## [0.11.1] - 2026-10-13
|
|
9
|
+
|
|
10
|
+
### Fixes
|
|
11
|
+
|
|
12
|
+
- **attach**: defer color-scheme reports until child input is ready (#180)
|
|
13
|
+
|
|
14
|
+
[0.11.1]: https://github.com/zhuxixi/pi-agent-board/compare/v0.11.0...v0.11.1
|
|
15
|
+
|
|
16
|
+
## [0.11.0] - 2026-10-12
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
- **attach**: mirror pi's fullscreen selection into X11 PRIMARY + paste-source override (#177)
|
|
21
|
+
|
|
22
|
+
[0.11.0]: https://github.com/zhuxixi/pi-agent-board/compare/v0.10.2...v0.11.0
|
|
23
|
+
|
|
8
24
|
## [0.10.2] - 2026-10-10
|
|
9
25
|
|
|
10
26
|
### Fixes
|
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
# Issue #174 Selection Mirror + Paste Source 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:** Close the fullscreen select→middle-click-paste loop: mirror pi's (final) selection text into X11 PRIMARY when a selection gesture ends inside the attach view (B2), and add an opt-in `AGENT_BOARD_ATTACH_PASTE_SOURCE=primary|clipboard` paste-source override (C).
|
|
6
|
+
|
|
7
|
+
**Architecture:** Two pure decision helpers in `src/core/pty-scroll.mjs` (`resolvePasteSelectionSource`, `shouldMirrorSelectionToPrimary`); `PtyAttachComponent.handleMouse` observes left release/click (returns `undefined`, consumes nothing) and schedules a `process.nextTick` mirror that reads `TuiAltScreen.getActiveSelectionText()` and reuses `writePrimarySelection`; `pastePrimarySelection` parameterizes its `-selection` argument from the env knob. pi's own selection behavior is untouched.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript (tabs), node:test + assert/strict, node --experimental-transform-types scenario with a PATH-stubbed bidirectional `xclip`.
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-10-10-issue-174-selection-mirror-paste-source-design.md` (acceptance IDs A1–A5, U1). Cross-review waived by user decision (recorded in the issue research dir).
|
|
12
|
+
|
|
13
|
+
## Global Constraints
|
|
14
|
+
|
|
15
|
+
- Work ONLY inside the worktree `<WT>` (branch `issue-174-selection-mirror-paste-source`); never touch main.
|
|
16
|
+
- Tabs for indentation; English comments and commit messages; conventional commit subjects.
|
|
17
|
+
- Pure-function split is a hard spec constraint: both helpers take resolved values (no `process.env` reads inside); must not be re-inlined into the component.
|
|
18
|
+
- `handleMouse` must remain the ONLY mirror scheduling point; `writePrimarySelection`/`pastePrimarySelection` remain the only xclip touch points.
|
|
19
|
+
- The mirror branch must return `undefined` (observe only) — pi's fullscreen selection keeps every left-button event. No changes to #169's ownership model, #171's paste decision behavior, or regular-mode behavior.
|
|
20
|
+
- The kill switch `AGENT_BOARD_ATTACH_NATIVE_PASTE=0` must disable BOTH the mirror and the paste.
|
|
21
|
+
- Tests use `node:test` with self-contained fixtures under temp dirs; never the host repository.
|
|
22
|
+
- `test/` and `test-support/` are EXCLUDED from tsc — scenario files are runtime-transformed only; `src/` must stay `npm run typecheck`-clean.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
### Task 1: Pure decision helpers (A1)
|
|
27
|
+
|
|
28
|
+
**Files:**
|
|
29
|
+
- Modify: `src/core/pty-scroll.mjs` (add exports next to `resolveAttachMouseAction` / `shouldOwnOuterMouseMode`)
|
|
30
|
+
- Test: `test/pty-scroll.test.mjs` (extend the existing import block and append tests)
|
|
31
|
+
|
|
32
|
+
**Interfaces:**
|
|
33
|
+
- Consumes: none.
|
|
34
|
+
- Produces: `resolvePasteSelectionSource(value) → "primary" | "clipboard"` (value = the raw `AGENT_BOARD_ATTACH_PASTE_SOURCE` env string or undefined; unknown → `"primary"`) and `shouldMirrorSelectionToPrimary(event, { nativePasteEnabled }) → "mirror-primary" | null`. Tasks 2–3 import both from `../core/pty-scroll.mjs`.
|
|
35
|
+
|
|
36
|
+
- [ ] **Step 1: Write the failing tests**
|
|
37
|
+
|
|
38
|
+
In `test/pty-scroll.test.mjs`, extend the existing import from `../src/core/pty-scroll.mjs` with `resolvePasteSelectionSource` and `shouldMirrorSelectionToPrimary`, then append:
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
test("resolvePasteSelectionSource: clipboard opt-in, everything else primary", () => {
|
|
42
|
+
assert.equal(resolvePasteSelectionSource(undefined), "primary");
|
|
43
|
+
assert.equal(resolvePasteSelectionSource(""), "primary");
|
|
44
|
+
assert.equal(resolvePasteSelectionSource("primary"), "primary");
|
|
45
|
+
assert.equal(resolvePasteSelectionSource("clipboard"), "clipboard");
|
|
46
|
+
assert.equal(resolvePasteSelectionSource(" CLIPBOARD "), "clipboard");
|
|
47
|
+
assert.equal(resolvePasteSelectionSource("garbage"), "primary");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("shouldMirrorSelectionToPrimary: only selection endings of the left button with paste enabled", () => {
|
|
51
|
+
assert.equal(shouldMirrorSelectionToPrimary(ev("release", "left"), { nativePasteEnabled: true }), "mirror-primary");
|
|
52
|
+
assert.equal(shouldMirrorSelectionToPrimary(ev("click", "left"), { nativePasteEnabled: true }), "mirror-primary");
|
|
53
|
+
assert.equal(shouldMirrorSelectionToPrimary(ev("release", "left"), { nativePasteEnabled: false }), null);
|
|
54
|
+
assert.equal(shouldMirrorSelectionToPrimary(ev("click", "left"), { nativePasteEnabled: false }), null);
|
|
55
|
+
const cases = [
|
|
56
|
+
["press", "left"],
|
|
57
|
+
["move", "left"],
|
|
58
|
+
["drag", "left"],
|
|
59
|
+
["wheel", "left"],
|
|
60
|
+
["release", "middle"],
|
|
61
|
+
["release", "right"],
|
|
62
|
+
["release", "none"],
|
|
63
|
+
["click", "middle"],
|
|
64
|
+
["click", "none"],
|
|
65
|
+
];
|
|
66
|
+
for (const [type, button] of cases) {
|
|
67
|
+
assert.equal(shouldMirrorSelectionToPrimary(ev(type, button), { nativePasteEnabled: true }), null, `${type}/${button}`);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
(`ev` helper already exists in this file from #171 — reuse it, do not redefine.)
|
|
73
|
+
|
|
74
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
75
|
+
|
|
76
|
+
Run: `node --test test/pty-scroll.test.mjs`
|
|
77
|
+
Expected: FAIL — the two helpers are not exported.
|
|
78
|
+
|
|
79
|
+
- [ ] **Step 3: Implement**
|
|
80
|
+
|
|
81
|
+
In `src/core/pty-scroll.mjs`, next to `resolveAttachMouseAction`:
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
/**
|
|
85
|
+
* Issue #174 (C): which X11 selection buffer middle-click paste reads.
|
|
86
|
+
* "primary" = classic X11 middle-click semantics (default); "clipboard" =
|
|
87
|
+
* escape hatch for environments where PRIMARY never updates (e.g. WSLg).
|
|
88
|
+
* Unknown or unset values fall back to "primary". Pure: the raw env value is
|
|
89
|
+
* read at the call site.
|
|
90
|
+
*/
|
|
91
|
+
export function resolvePasteSelectionSource(value) {
|
|
92
|
+
return typeof value === "string" && value.trim().toLowerCase() === "clipboard" ? "clipboard" : "primary";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Issue #174 (B2): whether a normalized mouse event ends a selection gesture
|
|
97
|
+
* we should mirror into the X11 PRIMARY selection. Only release/click of the
|
|
98
|
+
* left button with native paste enabled (pi's fullscreen selection owns the
|
|
99
|
+
* gesture; we observe without consuming). Pure by contract.
|
|
100
|
+
*/
|
|
101
|
+
export function shouldMirrorSelectionToPrimary(event, { nativePasteEnabled }) {
|
|
102
|
+
if (!nativePasteEnabled) return null;
|
|
103
|
+
if ((event.type === "release" || event.type === "click") && event.button === "left") return "mirror-primary";
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
109
|
+
|
|
110
|
+
Run: `node --test test/pty-scroll.test.mjs`
|
|
111
|
+
Expected: PASS (new + all pre-existing cases).
|
|
112
|
+
|
|
113
|
+
- [ ] **Step 5: Commit**
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
git add src/core/pty-scroll.mjs test/pty-scroll.test.mjs
|
|
117
|
+
git commit -m "feat(attach): pure decision helpers for selection mirror and paste source"
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
### Task 2: B2 — mirror wiring + runner-free scenario (A2)
|
|
123
|
+
|
|
124
|
+
**Files:**
|
|
125
|
+
- Modify: `src/ui/pty-attach.ts` (import line ~13; `handleMouse` :376; new private method near `writePrimarySelection` ~:1150)
|
|
126
|
+
- Create: `test-support/pty-attach-selection-mirror-scenario.ts`
|
|
127
|
+
- Test: `test/pty-attach-selection-mirror.test.mjs`
|
|
128
|
+
|
|
129
|
+
**Interfaces:**
|
|
130
|
+
- Consumes: `shouldMirrorSelectionToPrimary(event, { nativePasteEnabled })` from Task 1; `this.writePrimarySelection(text)` (existing private); `this.tui` cast `as { getActiveSelectionText?: () => string | undefined }`.
|
|
131
|
+
- Produces: private `mirrorFullscreenSelectionToPrimary(): void`; the observe-branch in `handleMouse`.
|
|
132
|
+
|
|
133
|
+
- [ ] **Step 1: Write the failing test + scenario**
|
|
134
|
+
|
|
135
|
+
Create `test/pty-attach-selection-mirror.test.mjs`:
|
|
136
|
+
|
|
137
|
+
```js
|
|
138
|
+
import assert from "node:assert/strict";
|
|
139
|
+
import test from "node:test";
|
|
140
|
+
import { execFileSync } from "node:child_process";
|
|
141
|
+
import { fileURLToPath } from "node:url";
|
|
142
|
+
import { join } from "node:path";
|
|
143
|
+
|
|
144
|
+
const ROOT_DIR = fileURLToPath(new URL("../", import.meta.url));
|
|
145
|
+
const SCENARIO = join(ROOT_DIR, "test-support", "pty-attach-selection-mirror-scenario.ts");
|
|
146
|
+
|
|
147
|
+
// Component-level coverage (issue #174 B2): the REAL PtyAttachComponent with a
|
|
148
|
+
// fake fullscreen TUI (getActiveSelectionText fixture) and a PATH-stubbed
|
|
149
|
+
// bidirectional xclip that records reads/writes to a log. Asserts the mirror
|
|
150
|
+
// fires on left release/click (deferred one tick), never consumes the event,
|
|
151
|
+
// skips empty selections, honors the kill switch, and ignores non-endings.
|
|
152
|
+
test("attach selection mirror: left release/click mirrors pi's selection into PRIMARY", () => {
|
|
153
|
+
const out = execFileSync(process.execPath, ["--experimental-transform-types", SCENARIO], {
|
|
154
|
+
encoding: "utf8",
|
|
155
|
+
timeout: 60_000,
|
|
156
|
+
});
|
|
157
|
+
const parsed = JSON.parse(out.trim().split("\n").filter(Boolean).pop());
|
|
158
|
+
assert.equal(parsed.error, null, `scenario error: ${parsed.error}`);
|
|
159
|
+
assert.equal(parsed.releaseResultUndefined, true, "release(left) must return undefined (observe only)");
|
|
160
|
+
assert.equal(parsed.mirrorWrites, 1, "exactly one PRIMARY write for the drag-style release");
|
|
161
|
+
assert.equal(parsed.mirrorText, "mirror-fixture-text", "the mirrored text is getActiveSelectionText()'s value");
|
|
162
|
+
assert.equal(parsed.clickResultUndefined, true, "click(left) must return undefined");
|
|
163
|
+
assert.equal(parsed.clickMirrorWrites, 1, "the click case also mirrors (selection non-empty)");
|
|
164
|
+
assert.equal(parsed.emptyWrites, 0, "empty selection must not write");
|
|
165
|
+
assert.equal(parsed.killSwitchWrites, 0, "NATIVE_PASTE=0 must disable the mirror");
|
|
166
|
+
assert.equal(parsed.nonEndingWrites, 0, "press/move must not trigger the mirror");
|
|
167
|
+
});
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Create `test-support/pty-attach-selection-mirror-scenario.ts`:
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
// Component-level selection-mirror coverage (issue #174 B2): the REAL
|
|
174
|
+
// PtyAttachComponent with a fake fullscreen TUI whose getActiveSelectionText
|
|
175
|
+
// returns a controllable fixture, run against a nonexistent control socket,
|
|
176
|
+
// with a PATH-stubbed bidirectional xclip that appends every invocation
|
|
177
|
+
// (args + stdin for writes) to a log file and prints a read fixture for -o.
|
|
178
|
+
// Run via `node --experimental-transform-types`.
|
|
179
|
+
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
180
|
+
import { tmpdir } from "node:os";
|
|
181
|
+
import { join } from "node:path";
|
|
182
|
+
import { PtyAttachComponent } from "../src/ui/pty-attach.ts";
|
|
183
|
+
|
|
184
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
185
|
+
const READ_FIXTURE = "read-fixture-text";
|
|
186
|
+
|
|
187
|
+
const root = mkdtempSync(join(tmpdir(), "agentview-sel-mirror-"));
|
|
188
|
+
const binDir = join(root, "bin");
|
|
189
|
+
mkdirSync(binDir);
|
|
190
|
+
const logPath = join(root, "xclip.log");
|
|
191
|
+
writeFileSync(logPath, "");
|
|
192
|
+
writeFileSync(
|
|
193
|
+
join(binDir, "xclip"),
|
|
194
|
+
[
|
|
195
|
+
"#!/bin/sh",
|
|
196
|
+
`if [ "$1" = "-o" ]; then`,
|
|
197
|
+
` echo "read $*" >> "${logPath}"`,
|
|
198
|
+
` printf '%s' "${READ_FIXTURE}"`,
|
|
199
|
+
"else",
|
|
200
|
+
' text="$(cat)"',
|
|
201
|
+
` echo "write $* text=$text" >> "${logPath}"`,
|
|
202
|
+
"fi",
|
|
203
|
+
].join("\n"),
|
|
204
|
+
);
|
|
205
|
+
chmodSync(join(binDir, "xclip"), 0o755);
|
|
206
|
+
process.env.PATH = `${binDir}:${process.env.PATH}`;
|
|
207
|
+
|
|
208
|
+
// The selection pi would have finalized by the time the deferred mirror runs.
|
|
209
|
+
let selectionText: string | undefined = "mirror-fixture-text";
|
|
210
|
+
|
|
211
|
+
const writes = () =>
|
|
212
|
+
readFileSync(logPath, "utf8")
|
|
213
|
+
.split("\n")
|
|
214
|
+
.filter((l) => l.startsWith("write "));
|
|
215
|
+
|
|
216
|
+
const leftEvent = (type: "release" | "click" | "press" | "move") => ({
|
|
217
|
+
type,
|
|
218
|
+
button: "left",
|
|
219
|
+
x: 3,
|
|
220
|
+
y: 3,
|
|
221
|
+
screenX: 3,
|
|
222
|
+
screenY: 3,
|
|
223
|
+
width: 80,
|
|
224
|
+
height: 22,
|
|
225
|
+
shift: false,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
async function withComponent(env: Record<string, string> | undefined, fn: (c: PtyAttachComponent) => Promise<void>): Promise<void> {
|
|
229
|
+
const prevEnv: Record<string, string | undefined> = {};
|
|
230
|
+
for (const [k, v] of Object.entries(env ?? {})) {
|
|
231
|
+
prevEnv[k] = process.env[k];
|
|
232
|
+
process.env[k] = v;
|
|
233
|
+
}
|
|
234
|
+
const tui = {
|
|
235
|
+
mode: "fullscreen",
|
|
236
|
+
getActiveSelectionText: () => selectionText,
|
|
237
|
+
terminal: { rows: 24, cols: 80, columns: 80, write: () => {} },
|
|
238
|
+
requestRender: () => {},
|
|
239
|
+
};
|
|
240
|
+
const theme = { fg: (_c: string, t: string) => t, bold: (t: string) => t };
|
|
241
|
+
let component: PtyAttachComponent | null = null;
|
|
242
|
+
try {
|
|
243
|
+
component = new PtyAttachComponent(
|
|
244
|
+
tui as never,
|
|
245
|
+
theme as never,
|
|
246
|
+
{} as never,
|
|
247
|
+
() => {},
|
|
248
|
+
{ socketPath: join(root, "nonexistent.sock"), title: "sel mirror e2e" } as never,
|
|
249
|
+
);
|
|
250
|
+
await fn(component);
|
|
251
|
+
} finally {
|
|
252
|
+
try { component?.dispose(); } catch {}
|
|
253
|
+
await sleep(50);
|
|
254
|
+
for (const [k, v] of Object.entries(prevEnv)) {
|
|
255
|
+
if (v === undefined) delete process.env[k];
|
|
256
|
+
else process.env[k] = v;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const result = {
|
|
262
|
+
error: null as string | null,
|
|
263
|
+
releaseResultUndefined: false,
|
|
264
|
+
mirrorWrites: -1,
|
|
265
|
+
mirrorText: null as string | null,
|
|
266
|
+
clickResultUndefined: false,
|
|
267
|
+
clickMirrorWrites: -1,
|
|
268
|
+
emptyWrites: -1,
|
|
269
|
+
killSwitchWrites: -1,
|
|
270
|
+
nonEndingWrites: -1,
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
// 1. Drag-style release mirrors the selection exactly once, unconsumed.
|
|
275
|
+
await withComponent(undefined, async (c) => {
|
|
276
|
+
const before = writes().length;
|
|
277
|
+
const r = (c as any).handleMouse(leftEvent("release"));
|
|
278
|
+
result.releaseResultUndefined = r === undefined;
|
|
279
|
+
await sleep(80); // nextTick + xclip spawn
|
|
280
|
+
const ws = writes();
|
|
281
|
+
result.mirrorWrites = ws.length - before;
|
|
282
|
+
result.mirrorText = ws.length > before ? ws[ws.length - 1].replace(/^write .*?text=/, "") : null;
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// 2. Click ending (non-empty selection) mirrors too, unconsumed.
|
|
286
|
+
await withComponent(undefined, async (c) => {
|
|
287
|
+
const before = writes().length;
|
|
288
|
+
const r = (c as any).handleMouse(leftEvent("click"));
|
|
289
|
+
result.clickResultUndefined = r === undefined;
|
|
290
|
+
await sleep(80);
|
|
291
|
+
result.clickMirrorWrites = writes().length - before;
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// 3. Empty selection: no write.
|
|
295
|
+
await withComponent(undefined, async (c) => {
|
|
296
|
+
const prev = selectionText;
|
|
297
|
+
selectionText = undefined;
|
|
298
|
+
const before = writes().length;
|
|
299
|
+
(c as any).handleMouse(leftEvent("release"));
|
|
300
|
+
await sleep(80);
|
|
301
|
+
result.emptyWrites = writes().length - before;
|
|
302
|
+
selectionText = prev;
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// 4. Kill switch: no write.
|
|
306
|
+
await withComponent({ AGENT_BOARD_ATTACH_NATIVE_PASTE: "0" }, async (c) => {
|
|
307
|
+
const before = writes().length;
|
|
308
|
+
(c as any).handleMouse(leftEvent("release"));
|
|
309
|
+
await sleep(80);
|
|
310
|
+
result.killSwitchWrites = writes().length - before;
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// 5. Non-endings (press/move): no write.
|
|
314
|
+
await withComponent(undefined, async (c) => {
|
|
315
|
+
const before = writes().length;
|
|
316
|
+
(c as any).handleMouse(leftEvent("press"));
|
|
317
|
+
(c as any).handleMouse(leftEvent("move"));
|
|
318
|
+
await sleep(80);
|
|
319
|
+
result.nonEndingWrites = writes().length - before;
|
|
320
|
+
});
|
|
321
|
+
} catch (err) {
|
|
322
|
+
result.error = err instanceof Error ? err.message : String(err);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 30 });
|
|
327
|
+
} catch {}
|
|
328
|
+
console.log(JSON.stringify(result));
|
|
329
|
+
process.exit(0);
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
- [ ] **Step 2: Run the test to verify it fails**
|
|
333
|
+
|
|
334
|
+
Run: `node --test test/pty-attach-selection-mirror.test.mjs`
|
|
335
|
+
Expected: FAIL — `mirrorWrites` 0 (≠1), `releaseResultUndefined` still true (returns undefined today for everything), so the failing assertion is `mirrorWrites === 1` / `mirrorText`. The kill-switch/empty/non-ending cases pass trivially; the failure must be on the mirror fields.
|
|
336
|
+
|
|
337
|
+
- [ ] **Step 3: Implement the mirror**
|
|
338
|
+
|
|
339
|
+
In `src/ui/pty-attach.ts`:
|
|
340
|
+
|
|
341
|
+
Import line (~13) — extend the core import (alphabetical):
|
|
342
|
+
|
|
343
|
+
```ts
|
|
344
|
+
import { clampInt, parseMouseInputChunk, resolveAttachMouseAction, resolveWheelLines, scrollViewportTop, selectionDragScrollLines, shouldMirrorSelectionToPrimary, shouldOwnOuterMouseMode } from "../core/pty-scroll.mjs";
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
`handleMouse` (:376) — hoist the flag, add the observe-branch before the paste logic:
|
|
348
|
+
|
|
349
|
+
```ts
|
|
350
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
351
|
+
const nativePasteEnabled = process.env.AGENT_BOARD_ATTACH_NATIVE_PASTE !== "0";
|
|
352
|
+
if (shouldMirrorSelectionToPrimary(event, { nativePasteEnabled })) {
|
|
353
|
+
// Observe only — pi's fullscreen selection keeps the event. Mirroring must
|
|
354
|
+
// run after TuiAltScreen finalizes the selection for this event (overlays
|
|
355
|
+
// are dispatched before the selection logic), hence the deferred tick.
|
|
356
|
+
process.nextTick(() => this.mirrorFullscreenSelectionToPrimary());
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
const action = resolveAttachMouseAction(event, { nativePasteEnabled });
|
|
360
|
+
if (action !== "paste-primary") return undefined;
|
|
361
|
+
this.clearPendingClick();
|
|
362
|
+
this.clearSelection();
|
|
363
|
+
this.pastePrimarySelection();
|
|
364
|
+
return { handled: true, render: false };
|
|
365
|
+
}
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
Near `writePrimarySelection` (~:1150), add:
|
|
369
|
+
|
|
370
|
+
```ts
|
|
371
|
+
/** Issue #174 (B2): after a selection gesture ends inside this overlay, copy
|
|
372
|
+
* pi's final selection text into the X11 PRIMARY selection so the classic
|
|
373
|
+
* select-then-middle-click-paste loop works in fullscreen. Runs on nextTick
|
|
374
|
+
* because overlays are dispatched before TuiAltScreen finalizes the
|
|
375
|
+
* selection for the same event. No-op in regular mode (no
|
|
376
|
+
* getActiveSelectionText) and under the native-paste kill switch. */
|
|
377
|
+
private mirrorFullscreenSelectionToPrimary(): void {
|
|
378
|
+
if (this.closed) return;
|
|
379
|
+
const alt = this.tui as { getActiveSelectionText?: () => string | undefined };
|
|
380
|
+
const text = alt.getActiveSelectionText?.();
|
|
381
|
+
if (typeof text === "string" && text.length > 0) this.writePrimarySelection(text);
|
|
382
|
+
}
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
Do NOT touch anything else (paste decision, ownership gating, legacy paths).
|
|
386
|
+
|
|
387
|
+
- [ ] **Step 4: Run the test to verify it passes**
|
|
388
|
+
|
|
389
|
+
Run: `node --test test/pty-attach-selection-mirror.test.mjs`
|
|
390
|
+
Expected: PASS.
|
|
391
|
+
|
|
392
|
+
- [ ] **Step 5: Run the unit tests + typecheck**
|
|
393
|
+
|
|
394
|
+
Run: `node --test test/pty-scroll.test.mjs && npm run typecheck`
|
|
395
|
+
Expected: PASS both.
|
|
396
|
+
|
|
397
|
+
- [ ] **Step 6: Commit**
|
|
398
|
+
|
|
399
|
+
```bash
|
|
400
|
+
git add src/ui/pty-attach.ts test-support/pty-attach-selection-mirror-scenario.ts test/pty-attach-selection-mirror.test.mjs
|
|
401
|
+
git commit -m "feat(attach): mirror pi's fullscreen selection into X11 PRIMARY (issue #174)"
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
---
|
|
405
|
+
|
|
406
|
+
### Task 3: C — paste-source override + scenario case (A3)
|
|
407
|
+
|
|
408
|
+
**Files:**
|
|
409
|
+
- Modify: `src/ui/pty-attach.ts` (`pastePrimarySelection` ~:1120, one line)
|
|
410
|
+
- Modify: `test-support/pty-attach-selection-mirror-scenario.ts` (one more case)
|
|
411
|
+
- Modify: `test/pty-attach-selection-mirror.test.mjs` (assertions for the new case)
|
|
412
|
+
|
|
413
|
+
**Interfaces:**
|
|
414
|
+
- Consumes: `resolvePasteSelectionSource(value)` from Task 1.
|
|
415
|
+
- Produces: paste reads `-selection primary|clipboard` per `AGENT_BOARD_ATTACH_PASTE_SOURCE`.
|
|
416
|
+
|
|
417
|
+
- [ ] **Step 1: Extend the scenario + test first**
|
|
418
|
+
|
|
419
|
+
In `test-support/pty-attach-selection-mirror-scenario.ts`, add a reads-counter helper next to `writes` and one more case before the catch (after case 5):
|
|
420
|
+
|
|
421
|
+
```ts
|
|
422
|
+
const reads = () =>
|
|
423
|
+
readFileSync(logPath, "utf8")
|
|
424
|
+
.split("\n")
|
|
425
|
+
.filter((l) => l.startsWith("read "));
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
and inside the `result` object add `clipboardReads: -1, clipboardReadSource: null as string | null`. Then after case 5:
|
|
429
|
+
|
|
430
|
+
```ts
|
|
431
|
+
// 6. Paste source override: middle paste reads CLIPBOARD when configured.
|
|
432
|
+
await withComponent({ AGENT_BOARD_ATTACH_PASTE_SOURCE: "clipboard" }, async (c) => {
|
|
433
|
+
const before = reads().length;
|
|
434
|
+
const r = (c as any).handleMouse({ type: "press", button: "middle", x: 3, y: 3, screenX: 3, screenY: 3, width: 80, height: 22, shift: false });
|
|
435
|
+
result.middleHandled = r?.handled === true;
|
|
436
|
+
await sleep(200); // xclip spawn + close
|
|
437
|
+
const rs = reads();
|
|
438
|
+
result.clipboardReads = rs.length - before;
|
|
439
|
+
result.clipboardReadSource = rs.length > before ? rs[rs.length - 1] : null;
|
|
440
|
+
});
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
(declare `middleHandled: false` in `result` too). In `test/pty-attach-selection-mirror.test.mjs`, append:
|
|
444
|
+
|
|
445
|
+
```js
|
|
446
|
+
assert.equal(parsed.middleHandled, true, "middle press still pastes (handled)");
|
|
447
|
+
assert.equal(parsed.clipboardReads, 1, "PASTE_SOURCE=clipboard: exactly one clipboard read");
|
|
448
|
+
assert.equal(parsed.clipboardReadSource, "read -o -selection clipboard", "the read targets the clipboard selection");
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
- [ ] **Step 2: Run to verify the new assertions fail**
|
|
452
|
+
|
|
453
|
+
Run: `node --test test/pty-attach-selection-mirror.test.mjs`
|
|
454
|
+
Expected: FAIL — `clipboardReadSource` is `read -o -selection primary` (the hardcoded value).
|
|
455
|
+
|
|
456
|
+
- [ ] **Step 3: Implement**
|
|
457
|
+
|
|
458
|
+
In `pastePrimarySelection()` (~:1120), change the spawn line:
|
|
459
|
+
|
|
460
|
+
```ts
|
|
461
|
+
const child = spawn("xclip", ["-o", "-selection", resolvePasteSelectionSource(process.env.AGENT_BOARD_ATTACH_PASTE_SOURCE)], { stdio: ["ignore", "pipe", "ignore"] });
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
(extend the existing `../core/pty-scroll.mjs` import with `resolvePasteSelectionSource` — alphabetical placement).
|
|
465
|
+
|
|
466
|
+
- [ ] **Step 4: Run to verify it passes**
|
|
467
|
+
|
|
468
|
+
Run: `node --test test/pty-attach-selection-mirror.test.mjs && node --test test/pty-attach-mouse.test.mjs`
|
|
469
|
+
Expected: PASS (the #171 scenario pins the default primary source — still green).
|
|
470
|
+
|
|
471
|
+
- [ ] **Step 5: Commit**
|
|
472
|
+
|
|
473
|
+
```bash
|
|
474
|
+
git add src/ui/pty-attach.ts test-support/pty-attach-selection-mirror-scenario.ts test/pty-attach-selection-mirror.test.mjs
|
|
475
|
+
git commit -m "feat(attach): AGENT_BOARD_ATTACH_PASTE_SOURCE paste-source override (issue #174)"
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
---
|
|
479
|
+
|
|
480
|
+
### Task 4: Full verify + coverage gate (A4, A5)
|
|
481
|
+
|
|
482
|
+
**Files:**
|
|
483
|
+
- Modify: whatever Tasks 1–3 fallout requires (expected: none).
|
|
484
|
+
|
|
485
|
+
- [ ] **Step 1: Run the full verify pipeline**
|
|
486
|
+
|
|
487
|
+
Run: `npm run verify`
|
|
488
|
+
Expected: PASS end-to-end (typecheck + perf gate + tests + coverage lines 85 / funcs 80 / branches 70 + pack:dry).
|
|
489
|
+
|
|
490
|
+
- [ ] **Step 2: Fix any fallout, then re-run** (known pre-existing flake: `pty-runner.integration.test.mjs`, issue #95 — re-run once; still red → STOP and report BLOCKED)
|
|
491
|
+
|
|
492
|
+
- [ ] **Step 3: Commit (only if fixes were needed)**
|
|
493
|
+
|
|
494
|
+
```bash
|
|
495
|
+
git add <changed files>
|
|
496
|
+
git commit -m "test: verify fallout fixes for issue #174"
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
---
|
|
500
|
+
|
|
501
|
+
## Acceptance traceability
|
|
502
|
+
|
|
503
|
+
| Spec ID | Covered by |
|
|
504
|
+
| --- | --- |
|
|
505
|
+
| A1 (unit decision tables) | Task 1 |
|
|
506
|
+
| A2 (B2 mirror wiring, integration) | Task 2 |
|
|
507
|
+
| A3 (C paste source, integration) | Task 3 |
|
|
508
|
+
| A4 (static/build) | Task 4 (+ typecheck in Tasks 2–3) |
|
|
509
|
+
| A5 (existing suite regression) | Task 4 |
|
|
510
|
+
| U1 (user manual: 5-step real-terminal checklist) | Post-implementation checklist for the user — executed after merge or from the worktree install |
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Drafted: zai-coding-cn/glm-5.3 (selected) · zai-coding-cn/glm-5.3 (physical) @ 2026-10-10T00:31:00Z
|
|
2
|
+
|
|
3
|
+
# Spec: #174 — middle-click paste in fullscreen reads a stale X11 PRIMARY: mirror pi's selection to PRIMARY (B2) + paste-source override (C)
|
|
4
|
+
|
|
5
|
+
Status: DRAFT (awaiting spec-cross-review or user waiver, then user approval — github-issue-driven step 4 gates)
|
|
6
|
+
Issue: https://github.com/zhuxixi/pi-agent-board/issues/174
|
|
7
|
+
Repo state baseline: main @ `0fd1dc5` (post-#173)
|
|
8
|
+
|
|
9
|
+
## Problem (root cause, verified live + from source)
|
|
10
|
+
|
|
11
|
+
In fullscreen, drag-select belongs to `TuiAltScreen` and its copy-on-select writes
|
|
12
|
+
**CLIPBOARD** only; nothing in a pi session ever writes X11 **PRIMARY**. Middle-click
|
|
13
|
+
paste (#171) reads **PRIMARY** → serves a fossil (live evidence 2026-10-10: PRIMARY held
|
|
14
|
+
`assert_contains(doc, contracts)` while the user's fresh selection sat in CLIPBOARD).
|
|
15
|
+
Pre-1.0 the loop closed because the component's own selection mirrored to PRIMARY
|
|
16
|
+
(`writePrimarySelection`, a1c5f08) — dead in fullscreen since pi owns left-drag.
|
|
17
|
+
|
|
18
|
+
Design-critical ordering fact (pi-tui 1.1.0 `handleMouseEvent`): overlays are dispatched
|
|
19
|
+
**before** pi's selection finalization for the same event, so the mirror must read the
|
|
20
|
+
selection **deferred** (`process.nextTick` after the release/click).
|
|
21
|
+
|
|
22
|
+
## Goal / non-goals
|
|
23
|
+
|
|
24
|
+
**Goal**: (B2) when a selection gesture ends inside the attach view in fullscreen, mirror
|
|
25
|
+
pi's final selection text into X11 PRIMARY so the classic select → middle-click-paste
|
|
26
|
+
loop works again; (C) opt-in `AGENT_BOARD_ATTACH_PASTE_SOURCE=primary|clipboard` for
|
|
27
|
+
environments where PRIMARY never updates. Both governed by the existing
|
|
28
|
+
`AGENT_BOARD_ATTACH_NATIVE_PASTE` kill switch. pi's own selection behavior (visuals,
|
|
29
|
+
CLIPBOARD copy) is untouched — B2 observes, never consumes.
|
|
30
|
+
|
|
31
|
+
**Non-goals**:
|
|
32
|
+
- Selections released outside the attach overlay (dashboard / other pi UI) — outside the
|
|
33
|
+
component's event window; pi-wide coverage belongs upstream (earendil-works/pi).
|
|
34
|
+
- Any change to #169's ownership model, #171's paste decision table, or regular-mode
|
|
35
|
+
behavior (mirror is a no-op there: `getActiveSelectionText` doesn't exist on
|
|
36
|
+
regular-mode TUIs; paste source defaults to primary as today).
|
|
37
|
+
|
|
38
|
+
## Design
|
|
39
|
+
|
|
40
|
+
### 1. Pure decision helpers (`src/core/pty-scroll.mjs`)
|
|
41
|
+
|
|
42
|
+
```js
|
|
43
|
+
/** Issue #174 (C): which X11 selection buffer middle-click paste reads.
|
|
44
|
+
* "primary" (default, classic X11 semantics) | "clipboard" (escape hatch for
|
|
45
|
+
* environments where PRIMARY never updates, e.g. WSLg). Unknown values fall
|
|
46
|
+
* back to "primary". Pure: the env value is read at the call site. */
|
|
47
|
+
resolvePasteSelectionSource(value) → "primary" | "clipboard"
|
|
48
|
+
|
|
49
|
+
/** Issue #174 (B2): whether a normalized mouse event should trigger mirroring
|
|
50
|
+
* pi's (final) selection text into X11 PRIMARY. Only selection-gesture endings
|
|
51
|
+
* (release/click of the left button) with native paste enabled. Pure. */
|
|
52
|
+
shouldMirrorSelectionToPrimary(event, { nativePasteEnabled }) → "mirror-primary" | null
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Decision table for `shouldMirrorSelectionToPrimary`:
|
|
56
|
+
|
|
57
|
+
| type | button | nativePasteEnabled | result |
|
|
58
|
+
| --- | --- | --- | --- |
|
|
59
|
+
| release / click | left | true | `"mirror-primary"` |
|
|
60
|
+
| release / click | left | false | `null` |
|
|
61
|
+
| press/move/drag/wheel | any | * | `null` |
|
|
62
|
+
| release / click | middle/right/none | * | `null` |
|
|
63
|
+
|
|
64
|
+
### 2. Component wiring (`src/ui/pty-attach.ts`)
|
|
65
|
+
|
|
66
|
+
- `handleMouse(event)` gains one branch BEFORE the paste branch (order irrelevant; events
|
|
67
|
+
are disjoint): if `shouldMirrorSelectionToPrimary(event, {nativePasteEnabled})` →
|
|
68
|
+
schedule `process.nextTick(() => this.mirrorFullscreenSelectionToPrimary())` and
|
|
69
|
+
**return `undefined`** (observe only — pi keeps the event). The paste branch is
|
|
70
|
+
unchanged (`press + middle` → paste, `{handled:true, render:false}`).
|
|
71
|
+
- New private method:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
/** Issue #174 (B2): after a selection gesture ends inside this overlay, copy
|
|
75
|
+
* pi's final selection text into the X11 PRIMARY selection so the classic
|
|
76
|
+
* select-then-middle-click-paste loop works in fullscreen. Runs on nextTick
|
|
77
|
+
* because overlays are dispatched before TuiAltScreen finalizes the selection
|
|
78
|
+
* for the same event. No-op in regular mode (no getActiveSelectionText) and
|
|
79
|
+
* under the native-paste kill switch. */
|
|
80
|
+
private mirrorFullscreenSelectionToPrimary(): void {
|
|
81
|
+
if (this.closed) return;
|
|
82
|
+
const alt = this.tui as { getActiveSelectionText?: () => string | undefined };
|
|
83
|
+
const text = alt.getActiveSelectionText?.();
|
|
84
|
+
if (typeof text === "string" && text.length > 0) this.writePrimarySelection(text);
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
(`writePrimarySelection` already guards `AGENT_BOARD_ATTACH_NATIVE_PASTE === "0"`;
|
|
89
|
+
the decision helper guards the scheduling side — belt and suspenders, no behavior
|
|
90
|
+
difference.)
|
|
91
|
+
- `pastePrimarySelection()`: the spawn args become
|
|
92
|
+
`xclip -o -selection ${resolvePasteSelectionSource(process.env.AGENT_BOARD_ATTACH_PASTE_SOURCE)}`.
|
|
93
|
+
Everything else (800 ms kill timer, forward-as-input, silent no-op) unchanged.
|
|
94
|
+
|
|
95
|
+
### Resulting behavior matrix
|
|
96
|
+
|
|
97
|
+
| Scenario | Behavior |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| fullscreen: drag-select inside attach → middle-click in attach | selection lands in PRIMARY (B2) AND CLIPBOARD (pi); middle paste = the selection ✅ |
|
|
100
|
+
| fullscreen: select in another X11 app → middle-click in attach | pastes that app's PRIMARY (unchanged, correct) |
|
|
101
|
+
| fullscreen: select elsewhere in pi (dashboard) → middle-click in attach | NOT mirrored (no event window) — known limitation; middle paste reads whatever PRIMARY holds |
|
|
102
|
+
| `AGENT_BOARD_ATTACH_PASTE_SOURCE=clipboard` | middle paste reads CLIPBOARD regardless of PRIMARY |
|
|
103
|
+
| `AGENT_BOARD_ATTACH_NATIVE_PASTE=0` | no mirror, no paste (kill switch governs both sides) |
|
|
104
|
+
| regular mode | mirror no-ops (no `getActiveSelectionText`), paste reads primary (default) — unchanged |
|
|
105
|
+
|
|
106
|
+
## Acceptance matrix
|
|
107
|
+
|
|
108
|
+
| ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
|
|
109
|
+
|----|--------|----------|----------|----------|
|
|
110
|
+
| A1 | 两个纯决策表(mirror 触发条件 × 开关;paste source 解析) | 自动化验证(unit) | `node --test test/pty-scroll.test.mjs`(新增用例直接调两个 helper:mirror 表全部行 + source 的 unset/"primary"/"clipboard"/garbage) | 新增用例全绿,既有用例不回归 |
|
|
111
|
+
| A2 | B2 接线:release/click(left) → nextTick 后 PRIMARY 收到 pi 的选中文本;observe 不消费;空选择不写;kill switch 不写 | 自动化验证(integration,组件级场景) | 新增 `test-support/pty-attach-selection-mirror-scenario.ts`(fake tui `mode:"fullscreen"` + `getActiveSelectionText` fixture + PATH-stub `xclip` 把 args/stdin 记日志),由 `test/pty-attach-selection-mirror.test.mjs` 运行断言 | ① `handleMouse({release,left})` 返回 `undefined` 且 nextTick 后 stub 日志含 `-selection primary` 写、stdin=fixture;② `getActiveSelectionText→undefined` 时无写;③ NATIVE_PASTE=0 无写;④ `press+left`/`move` 不触发 |
|
|
112
|
+
| A3 | C 接线:paste source 覆盖 | 自动化验证(integration,同一场景文件加 case) | 同场景:`AGENT_BOARD_ATTACH_PASTE_SOURCE=clipboard` + `handleMouse({press,middle})` | stub 日志显示 `-o -selection clipboard`;fixture 文本经 socket 以 `{type:"input"}` 发出(复用 #171 场景缝) |
|
|
113
|
+
| A4 | 类型与构建 | 自动化验证(static/build) | `npm run typecheck`、`npm run verify` | 全部通过 |
|
|
114
|
+
| A5 | 既有行为不回归(#171 中键粘贴、#169 ownership、regular 路径) | 自动化验证(unit+integration,既有套件) | `npm test` / `npm run test:coverage` | 既有测试全绿,覆盖阈值不降 |
|
|
115
|
+
| U1 | 真机:pi 内选中 → 中键粘贴闭环 | 用户实测 | ① fullscreen attach 内拖选一段文字;② 中键 → 应粘贴刚选的内容;③ 在别的 X11 应用选中 → 中键 → 粘贴该内容;④ `AGENT_BOARD_ATTACH_PASTE_SOURCE=clipboard` 后中键粘贴读剪贴板;⑤ `AGENT_BOARD_ATTACH_NATIVE_PASTE=0` 后中键无反应、选中不再镜像 PRIMARY | ①–⑤ 全部符合 |
|
|
116
|
+
|
|
117
|
+
## Testability split design (hard constraint for implementation)
|
|
118
|
+
|
|
119
|
+
- **Pure core**: `resolvePasteSelectionSource(value)` and
|
|
120
|
+
`shouldMirrorSelectionToPrimary(event, {nativePasteEnabled})` — plain functions, no env
|
|
121
|
+
reads inside, no side effects. Unit decision tables (A1).
|
|
122
|
+
- **I/O shell**: `PtyAttachComponent.handleMouse` maps decisions to side effects
|
|
123
|
+
(`process.nextTick` scheduling → `mirrorFullscreenSelectionToPrimary()` → existing
|
|
124
|
+
`writePrimarySelection`; paste branch spawns xclip with the resolved source).
|
|
125
|
+
Component-level tests (A2/A3) drive only public seams (`handleMouse`, ticks,
|
|
126
|
+
`dispose()`) and observe the PATH-stubbed `xclip` log + attach-socket messages; the
|
|
127
|
+
stub handles both directions (`-o` prints a fixture; write mode records stdin/args).
|
|
128
|
+
- The pure helpers must not be re-inlined into the component; `handleMouse` must remain
|
|
129
|
+
the only mirror scheduling point; `writePrimarySelection`/`pastePrimarySelection`
|
|
130
|
+
remain the only xclip touch points.
|
|
131
|
+
|
|
132
|
+
## Risks / notes
|
|
133
|
+
|
|
134
|
+
- nextTick timing assumes pi's release handling is synchronous in the same tick (verified
|
|
135
|
+
in tui-alt-screen.js: overlay dispatch → `handleSelectionMouseEvent` → copy, no awaits
|
|
136
|
+
before selection state is final; `copySelectionToClipboard` is async but only writes
|
|
137
|
+
CLIPBOARD — irrelevant to our read). If a future pi-tui defers selection state, the
|
|
138
|
+
mirror lags one gesture — acceptable degradation, not corruption.
|
|
139
|
+
- `getActiveSelectionText` typing: structural cast in the component (declared private in
|
|
140
|
+
pi-tui 1.1.0's `tui-alt-screen.d.ts` but runtime-reachable, hence the cast; absent on
|
|
141
|
+
regular TUIs and old runtimes → optional access → no-op).
|
|
142
|
+
- Double-click word / triple-click line selections: press path sets the full range
|
|
143
|
+
immediately; release/click mirror picks up the same text — covered by the same code
|
|
144
|
+
path, no special casing.
|
|
145
|
+
- The stub-xclip scenario must restore PATH and clean temp dirs (self-contained).
|
|
146
|
+
|
|
147
|
+
## Deliverables
|
|
148
|
+
|
|
149
|
+
- `src/core/pty-scroll.mjs`: + `resolvePasteSelectionSource`, `shouldMirrorSelectionToPrimary`
|
|
150
|
+
- `src/ui/pty-attach.ts`: mirror branch in `handleMouse`, `mirrorFullscreenSelectionToPrimary()`,
|
|
151
|
+
paste source in `pastePrimarySelection`
|
|
152
|
+
- `test/pty-scroll.test.mjs`: decision-table cases
|
|
153
|
+
- `test-support/pty-attach-selection-mirror-scenario.ts` + `test/pty-attach-selection-mirror.test.mjs`
|
|
154
|
+
- CHANGELOG entry via release tooling at release time (not in this PR)
|
package/package.json
CHANGED
package/src/core/pty-scroll.mjs
CHANGED
|
@@ -107,6 +107,29 @@ export function resolveAttachMouseAction(event, { nativePasteEnabled }) {
|
|
|
107
107
|
return null;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Issue #174 (C): which X11 selection buffer middle-click paste reads.
|
|
112
|
+
* "primary" = classic X11 middle-click semantics (default); "clipboard" =
|
|
113
|
+
* escape hatch for environments where PRIMARY never updates (e.g. WSLg).
|
|
114
|
+
* Unknown or unset values fall back to "primary". Pure: the raw env value is
|
|
115
|
+
* read at the call site.
|
|
116
|
+
*/
|
|
117
|
+
export function resolvePasteSelectionSource(value) {
|
|
118
|
+
return typeof value === "string" && value.trim().toLowerCase() === "clipboard" ? "clipboard" : "primary";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Issue #174 (B2): whether a normalized mouse event ends a selection gesture
|
|
123
|
+
* we should mirror into the X11 PRIMARY selection. Only release/click of the
|
|
124
|
+
* left button with native paste enabled (pi's fullscreen selection owns the
|
|
125
|
+
* gesture; we observe without consuming). Pure by contract.
|
|
126
|
+
*/
|
|
127
|
+
export function shouldMirrorSelectionToPrimary(event, { nativePasteEnabled }) {
|
|
128
|
+
if (!nativePasteEnabled) return null;
|
|
129
|
+
if ((event.type === "release" || event.type === "click") && event.button === "left") return "mirror-primary";
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
110
133
|
/**
|
|
111
134
|
* Issue #169: whether the attach surface should manage the OUTER terminal's
|
|
112
135
|
* mouse mode. Only in regular TUI mode — in fullscreen, TuiAltScreen owns
|
package/src/ui/pty-attach.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { installImeCursorCoalesce } from "../core/ime-cursor-coalesce.mjs";
|
|
|
12
12
|
import { createJiggleRetryController } from "../core/pty-attach-jiggle-controller.mjs";
|
|
13
13
|
import { createTerminalAttachClient } from "../core/terminal-attach-client.mjs";
|
|
14
14
|
import { backgroundRgbFromTerminalColors, buildSettleSchemePatch, colorSchemeForBackgroundRgb, extractOscQuerySequences, resolveProbeApi, toColorSchemeReport } from "../core/terminal-query-sequences.mjs";
|
|
15
|
-
import { clampInt, parseMouseInputChunk, resolveAttachMouseAction, resolveWheelLines, scrollViewportTop, selectionDragScrollLines, shouldOwnOuterMouseMode } from "../core/pty-scroll.mjs";
|
|
15
|
+
import { clampInt, parseMouseInputChunk, resolveAttachMouseAction, resolvePasteSelectionSource, resolveWheelLines, scrollViewportTop, selectionDragScrollLines, shouldMirrorSelectionToPrimary, shouldOwnOuterMouseMode } from "../core/pty-scroll.mjs";
|
|
16
16
|
|
|
17
17
|
export type PtyAttachResult = { action: "detached" } | { action: "closed"; exitCode?: number | null };
|
|
18
18
|
|
|
@@ -240,6 +240,12 @@ export class PtyAttachComponent implements Component {
|
|
|
240
240
|
// null never matches the duplicate check, so a timeout-then-late sequence
|
|
241
241
|
// still delivers. Single-shot per attach — settle runs at most once.
|
|
242
242
|
private sentScheme: TerminalColorScheme | null = null;
|
|
243
|
+
// Pi enables bracketed paste AFTER entering raw mode and disables it at
|
|
244
|
+
// shutdown. Only current runner output/snapshots may establish readiness;
|
|
245
|
+
// the screen.log warm-start can belong to a previous child incarnation.
|
|
246
|
+
private childInputReady = false;
|
|
247
|
+
private childInputModeCarry = "";
|
|
248
|
+
private pendingSchemeReport: { scheme: TerminalColorScheme; api?: "colors" | "background" | "none"; late?: boolean } | null = null;
|
|
243
249
|
// Issue #128 D2: unsubscriber for the color-scheme bridge registered at
|
|
244
250
|
// attach settle; nulled on every terminal path so the TUI never keeps a
|
|
245
251
|
// listener pointing at a dead attach surface.
|
|
@@ -374,9 +380,15 @@ export class PtyAttachComponent implements Component {
|
|
|
374
380
|
* middle-click paste is ours; every other event returns undefined so pi's
|
|
375
381
|
* own fullscreen selection and the wheel defer path keep working. */
|
|
376
382
|
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
383
|
+
const nativePasteEnabled = process.env.AGENT_BOARD_ATTACH_NATIVE_PASTE !== "0";
|
|
384
|
+
if (shouldMirrorSelectionToPrimary(event, { nativePasteEnabled })) {
|
|
385
|
+
// Observe only — pi's fullscreen selection keeps the event. Mirroring must
|
|
386
|
+
// run after TuiAltScreen finalizes the selection for this event (overlays
|
|
387
|
+
// are dispatched before the selection logic), hence the deferred tick.
|
|
388
|
+
process.nextTick(() => this.mirrorFullscreenSelectionToPrimary());
|
|
389
|
+
return undefined;
|
|
390
|
+
}
|
|
391
|
+
const action = resolveAttachMouseAction(event, { nativePasteEnabled });
|
|
380
392
|
if (action !== "paste-primary") return undefined;
|
|
381
393
|
this.clearPendingClick();
|
|
382
394
|
this.clearSelection();
|
|
@@ -463,7 +475,7 @@ export class PtyAttachComponent implements Component {
|
|
|
463
475
|
this.parserBuffer = "";
|
|
464
476
|
socket.on("connect", () => {
|
|
465
477
|
if (this.socket !== socket) {
|
|
466
|
-
try { socket.destroy(); } catch {}
|
|
478
|
+
try { socket.destroy(); } catch { /* best-effort stale-socket cleanup */ }
|
|
467
479
|
return;
|
|
468
480
|
}
|
|
469
481
|
this.clearRetry();
|
|
@@ -514,7 +526,7 @@ export class PtyAttachComponent implements Component {
|
|
|
514
526
|
// The error may belong to an old socket after reconnect. Dispose that
|
|
515
527
|
// socket, but do not alter the state of the current connection.
|
|
516
528
|
if (this.socket !== socket) {
|
|
517
|
-
try { socket.destroy(); } catch {}
|
|
529
|
+
try { socket.destroy(); } catch { /* best-effort stale-socket cleanup */ }
|
|
518
530
|
return;
|
|
519
531
|
}
|
|
520
532
|
this.socket = null;
|
|
@@ -523,7 +535,7 @@ export class PtyAttachComponent implements Component {
|
|
|
523
535
|
// stale-socket guard (this.socket no longer matches) and must not be
|
|
524
536
|
// the only path that cancels the probe deadline (CR R1 advisory).
|
|
525
537
|
this.attachClient.onDisconnect();
|
|
526
|
-
try { socket.destroy(); } catch {}
|
|
538
|
+
try { socket.destroy(); } catch { /* already handling socket failure */ }
|
|
527
539
|
this.disconnectedAt ??= Date.now();
|
|
528
540
|
if (this.closed) return;
|
|
529
541
|
this.status = `waiting for host… ${err.message}`;
|
|
@@ -647,8 +659,9 @@ export class PtyAttachComponent implements Component {
|
|
|
647
659
|
if (this.colorSchemeBridgeUnsubscribe) return;
|
|
648
660
|
try {
|
|
649
661
|
this.colorSchemeBridgeUnsubscribe = this.tui.onTerminalColorSchemeChange((scheme) => {
|
|
650
|
-
|
|
651
|
-
|
|
662
|
+
if (!toColorSchemeReport(scheme)) return;
|
|
663
|
+
this.pendingSchemeReport = { scheme };
|
|
664
|
+
this.flushPendingSchemeReport();
|
|
652
665
|
});
|
|
653
666
|
} catch {
|
|
654
667
|
/* best-effort: an unbridgeable TUI keeps pre-#128 behavior */
|
|
@@ -658,6 +671,8 @@ export class PtyAttachComponent implements Component {
|
|
|
658
671
|
/** Called on every terminal path (close() covers detach/dispose; the exit
|
|
659
672
|
* branch calls done() without close(), so it unsubscribes explicitly). */
|
|
660
673
|
private detachColorSchemeBridge(): void {
|
|
674
|
+
this.pendingSchemeReport = null;
|
|
675
|
+
this.childInputReady = false;
|
|
661
676
|
const unsubscribe = this.colorSchemeBridgeUnsubscribe;
|
|
662
677
|
this.colorSchemeBridgeUnsubscribe = null;
|
|
663
678
|
if (!unsubscribe) return;
|
|
@@ -722,7 +737,8 @@ export class PtyAttachComponent implements Component {
|
|
|
722
737
|
const rgb = backgroundRgbFromTerminalColors(colors as never);
|
|
723
738
|
const scheme = rgb ? colorSchemeForBackgroundRgb(rgb) ?? undefined : undefined;
|
|
724
739
|
if (!scheme) return;
|
|
725
|
-
if (this.sentScheme === scheme) {
|
|
740
|
+
if (this.sentScheme === scheme && this.childInputReady) {
|
|
741
|
+
this.pendingSchemeReport = null;
|
|
726
742
|
this.opts.onDiagnostic?.(buildSettleSchemePatch({ probeApi: api, outcome: "duplicate_skipped" }));
|
|
727
743
|
return;
|
|
728
744
|
}
|
|
@@ -742,6 +758,12 @@ export class PtyAttachComponent implements Component {
|
|
|
742
758
|
this.opts.onDiagnostic?.(buildSettleSchemePatch({ probeApi: api, outcome: "dropped_disconnected" }));
|
|
743
759
|
return;
|
|
744
760
|
}
|
|
761
|
+
if (!this.childInputReady) {
|
|
762
|
+
// Settle/probe completion is not a child-readiness handshake. Keep
|
|
763
|
+
// only the latest scheme, then send when the child enters raw mode.
|
|
764
|
+
this.pendingSchemeReport = { scheme, api, late };
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
745
767
|
this.sentScheme = scheme;
|
|
746
768
|
this.send({ type: "input", data });
|
|
747
769
|
const details: { probeApi: "colors" | "background" | "none"; outcome: "reported"; report: string; late?: boolean } = {
|
|
@@ -753,6 +775,26 @@ export class PtyAttachComponent implements Component {
|
|
|
753
775
|
this.opts.onDiagnostic?.(buildSettleSchemePatch(details));
|
|
754
776
|
}
|
|
755
777
|
|
|
778
|
+
private updateChildInputReadiness(data: string): void {
|
|
779
|
+
// Scan only LIVE bytes, not warm-start history. A reused xterm buffer
|
|
780
|
+
// can still have the old child's paste mode enabled during cold boot.
|
|
781
|
+
const input = this.childInputModeCarry + data;
|
|
782
|
+
for (const match of input.matchAll(/\x1b\[\?2004([hl])/g)) {
|
|
783
|
+
this.childInputReady = match[1] === "h";
|
|
784
|
+
}
|
|
785
|
+
// Keep at most an incomplete eight-byte mode sequence across chunks.
|
|
786
|
+
this.childInputModeCarry = input.slice(-7);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
private flushPendingSchemeReport(): void {
|
|
790
|
+
if (this.closed || !this.connected || !this.childInputReady) return;
|
|
791
|
+
const pending = this.pendingSchemeReport;
|
|
792
|
+
this.pendingSchemeReport = null;
|
|
793
|
+
if (!pending) return;
|
|
794
|
+
if (pending.api) this.deliverSettleScheme(pending.scheme, pending.api, pending.late ?? false);
|
|
795
|
+
else this.send({ type: "input", data: toColorSchemeReport(pending.scheme) });
|
|
796
|
+
}
|
|
797
|
+
|
|
756
798
|
private startDesyncProbe(): void {
|
|
757
799
|
this.stopDesyncProbe();
|
|
758
800
|
// The probe is deliberately NOT hooked into the render path: rendering is
|
|
@@ -1118,15 +1160,16 @@ export class PtyAttachComponent implements Component {
|
|
|
1118
1160
|
}
|
|
1119
1161
|
|
|
1120
1162
|
/**
|
|
1121
|
-
* Middle-click paste. Reads the X11
|
|
1122
|
-
*
|
|
1123
|
-
*
|
|
1124
|
-
*
|
|
1163
|
+
* Middle-click paste. Reads the X11 selection (PRIMARY by default, or CLIPBOARD via
|
|
1164
|
+
* AGENT_BOARD_ATTACH_PASTE_SOURCE=clipboard) via xclip and forwards it to the hosted
|
|
1165
|
+
* session as input, mimicking the terminal-native middle-click paste that mouse
|
|
1166
|
+
* reporting (kept on for wheel scrolling) would otherwise swallow. Silent no-op when
|
|
1167
|
+
* xclip is absent or AGENT_BOARD_ATTACH_NATIVE_PASTE=0.
|
|
1125
1168
|
*/
|
|
1126
1169
|
private pastePrimarySelection(): void {
|
|
1127
1170
|
if (process.env.AGENT_BOARD_ATTACH_NATIVE_PASTE === "0") return;
|
|
1128
1171
|
try {
|
|
1129
|
-
const child = spawn("xclip", ["-o", "-selection",
|
|
1172
|
+
const child = spawn("xclip", ["-o", "-selection", resolvePasteSelectionSource(process.env.AGENT_BOARD_ATTACH_PASTE_SOURCE)], { stdio: ["ignore", "pipe", "ignore"] });
|
|
1130
1173
|
let out = "";
|
|
1131
1174
|
const timer = setTimeout(() => {
|
|
1132
1175
|
try {
|
|
@@ -1148,6 +1191,19 @@ export class PtyAttachComponent implements Component {
|
|
|
1148
1191
|
}
|
|
1149
1192
|
}
|
|
1150
1193
|
|
|
1194
|
+
/** Issue #174 (B2): after a selection gesture ends inside this overlay, copy
|
|
1195
|
+
* pi's final selection text into the X11 PRIMARY selection so the classic
|
|
1196
|
+
* select-then-middle-click-paste loop works in fullscreen. Runs on nextTick
|
|
1197
|
+
* because overlays are dispatched before TuiAltScreen finalizes the
|
|
1198
|
+
* selection for the same event. No-op in regular mode (no
|
|
1199
|
+
* getActiveSelectionText) and under the native-paste kill switch. */
|
|
1200
|
+
private mirrorFullscreenSelectionToPrimary(): void {
|
|
1201
|
+
if (this.closed) return;
|
|
1202
|
+
const alt = this.tui as { getActiveSelectionText?: () => string | undefined };
|
|
1203
|
+
const text = alt.getActiveSelectionText?.();
|
|
1204
|
+
if (typeof text === "string" && text.length > 0) this.writePrimarySelection(text);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1151
1207
|
/** Write `text` to the X11 PRIMARY selection so other apps can middle-click-paste it. */
|
|
1152
1208
|
private writePrimarySelection(text: string): void {
|
|
1153
1209
|
if (process.env.AGENT_BOARD_ATTACH_NATIVE_PASTE === "0") return;
|
|
@@ -1391,8 +1447,12 @@ export class PtyAttachComponent implements Component {
|
|
|
1391
1447
|
// constructor's screen.log warm-start must not outlive the protocol
|
|
1392
1448
|
// answer — a gated reset let the dead session's tail render as a
|
|
1393
1449
|
// cold-start ghost once the banner lifted (final-review F1).
|
|
1394
|
-
if (typeof snap.frame === "string") this.pushOutput(snap.frame);
|
|
1395
|
-
else if (snap.empty || snap.resnapshot)
|
|
1450
|
+
if (typeof snap.frame === "string") this.pushOutput(snap.frame, { authoritative: true });
|
|
1451
|
+
else if (snap.empty || snap.resnapshot) {
|
|
1452
|
+
this.childInputReady = false;
|
|
1453
|
+
this.childInputModeCarry = "";
|
|
1454
|
+
this.term.reset();
|
|
1455
|
+
}
|
|
1396
1456
|
return;
|
|
1397
1457
|
}
|
|
1398
1458
|
if (event === "output") {
|
|
@@ -1402,6 +1462,10 @@ export class PtyAttachComponent implements Component {
|
|
|
1402
1462
|
if (typeof payload === "string") this.pushOutput(payload, { forwardProtocols: true });
|
|
1403
1463
|
return;
|
|
1404
1464
|
}
|
|
1465
|
+
if (event === "epochReset") {
|
|
1466
|
+
this.childInputReady = false;
|
|
1467
|
+
this.childInputModeCarry = "";
|
|
1468
|
+
}
|
|
1405
1469
|
// "resubscribing": recovery is client-internal (informational only).
|
|
1406
1470
|
// "protocolError": observability only — recovery is automatic until the
|
|
1407
1471
|
// legacy fallback, whose mode event the UI acts on above.
|
|
@@ -1480,11 +1544,19 @@ export class PtyAttachComponent implements Component {
|
|
|
1480
1544
|
}
|
|
1481
1545
|
}
|
|
1482
1546
|
|
|
1483
|
-
private pushOutput(data: string, opts: { forwardProtocols?: boolean } = {}): void {
|
|
1547
|
+
private pushOutput(data: string, opts: { forwardProtocols?: boolean; authoritative?: boolean } = {}): void {
|
|
1484
1548
|
if (data.length === 0) return;
|
|
1485
1549
|
// Recorded synchronously BEFORE term.write (whose callback is async): the
|
|
1486
1550
|
// desync quiet-window must measure when output ARRIVED, not when parsing finished.
|
|
1487
1551
|
this.lastOutputAt = this.nowFn();
|
|
1552
|
+
// Current snapshots explicitly restore all modes, including paste mode.
|
|
1553
|
+
// Read their readiness synchronously so an older parse callback cannot
|
|
1554
|
+
// overwrite a newer live shutdown/reload signal.
|
|
1555
|
+
if (opts.authoritative) {
|
|
1556
|
+
this.childInputReady = false;
|
|
1557
|
+
this.childInputModeCarry = "";
|
|
1558
|
+
}
|
|
1559
|
+
if (opts.forwardProtocols || opts.authoritative) this.updateChildInputReadiness(data);
|
|
1488
1560
|
if (opts.forwardProtocols) this.forwardTerminalProtocols(data);
|
|
1489
1561
|
// @xterm/headless parses asynchronously; the buffer is only populated once this
|
|
1490
1562
|
// callback fires. Mark the buffer as ready (so the project path can paint it), but
|
|
@@ -1493,6 +1565,7 @@ export class PtyAttachComponent implements Component {
|
|
|
1493
1565
|
// chunk also defers the settle window, so the banner holds until output actually
|
|
1494
1566
|
// stops arriving (i.e. the redraw finished), not just until the resize jiggle ends.
|
|
1495
1567
|
this.term.write(data, () => {
|
|
1568
|
+
if (opts.forwardProtocols || opts.authoritative) this.flushPendingSchemeReport();
|
|
1496
1569
|
this.receivedOutput = true;
|
|
1497
1570
|
this.deferAttachSettle();
|
|
1498
1571
|
this.outputRenderScheduler.request();
|
|
@@ -1553,16 +1626,16 @@ export class PtyAttachComponent implements Component {
|
|
|
1553
1626
|
this.connected = false;
|
|
1554
1627
|
if (!socket) return;
|
|
1555
1628
|
if (!gracefulSocket) {
|
|
1556
|
-
try { socket.destroy(); } catch {}
|
|
1629
|
+
try { socket.destroy(); } catch { /* best-effort teardown */ }
|
|
1557
1630
|
return;
|
|
1558
1631
|
}
|
|
1559
1632
|
// socket.end() preserves the ordering of the already-buffered restore and
|
|
1560
1633
|
// detach packets. destroy() would discard buffered writes under backpressure.
|
|
1561
1634
|
socket.once("close", () => this.clearGracefulSocketCloseTimer());
|
|
1562
|
-
try { socket.end(); } catch {}
|
|
1635
|
+
try { socket.end(); } catch { /* fallback timer below forces cleanup */ }
|
|
1563
1636
|
this.gracefulSocketCloseTimer = setTimeout(() => {
|
|
1564
1637
|
this.gracefulSocketCloseTimer = null;
|
|
1565
|
-
try { socket.destroy(); } catch {}
|
|
1638
|
+
try { socket.destroy(); } catch { /* best-effort teardown */ }
|
|
1566
1639
|
}, GRACEFUL_SOCKET_CLOSE_MS);
|
|
1567
1640
|
this.gracefulSocketCloseTimer.unref?.();
|
|
1568
1641
|
}
|