@zhuxixi/pi-agent-board 0.12.0 → 0.12.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 CHANGED
@@ -5,6 +5,20 @@ 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.12.1] - 2026-10-20
9
+
10
+ ### Fixes
11
+
12
+ - **release**: collapse repeated trailing PR refs in CHANGELOG entries (#218)
13
+ - **attach**: keep the Alt modifier on the forwarded wheel report (#216)
14
+ - **attach**: stop the reply guard swallowing kitty/modifyOtherKeys PageDown (#215)
15
+
16
+ ### Changes
17
+
18
+ - **attach**: tidy the mouse-enable helper placement and comment indent (#209)
19
+
20
+ [0.12.1]: https://github.com/zhuxixi/pi-agent-board/compare/v0.12.0...v0.12.1
21
+
8
22
  ## [0.12.0] - 2026-10-19
9
23
 
10
24
  ### Features
@@ -0,0 +1,257 @@
1
+ # reply guard CSI-key collision — 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:** Stop the reply guard from swallowing kitty/modifyOtherKeys PageDown (`\x1b[6;<mods>u` / `\x1b[6;<mods>~`) while keeping every genuine cell-size reply fragment armed.
6
+
7
+ **Architecture:** One new pure predicate in `src/core/terminal-reply-guard.mjs`: a *complete* CSI key sequence is user input and must be classified before the `REPLY_HEADS` prefix loop. Every fragment shape the guard protects today has no `u`/`~` terminator, so the predicate cannot weaken #170's protection.
8
+
9
+ **Tech Stack:** ESM `.mjs` core module, `node:test` + `assert/strict`, TypeScript component (unchanged), `@earendil-works/pi-tui` (unchanged).
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-10-19-issue-213-reply-guard-csi-key-design.md`
12
+
13
+ ## Global Constraints
14
+
15
+ - Tabs for indentation; English comments and commit messages (repo AGENTS.md).
16
+ - No new dependencies.
17
+ - `npm run verify` must pass on CI (typecheck + perf gate + tests + coverage + pack dry-run). Locally only the targeted suites plus `typecheck`/`pack:dry` are authoritative — this Windows host has a pre-existing ~72-test baseline failure set that also fails on `main`.
18
+ - New test names carry the `#213-` prefix (spec §6 "验收 ID 作用域") to avoid colliding with the `A1:`/`A2:`/… names already in `test/terminal-reply-guard.test.mjs`.
19
+ - Do not change `REPLY_HEADS`, the absorption window (250 ms / 64 bytes), or the kill switch.
20
+
21
+ ## Review Focus
22
+
23
+ - **A genuine cell-size fragment that looks like a key** — `\x1b[6;` and `\x1b[6;24` (no terminator) must still `reply-head` and arm; only `u`/`~`-terminated sequences are keys.
24
+ - **A complete reply that looks like a key** — `\x1b[6;24;80t` must still be `reply-complete`/drop (DROP_SHAPES runs first).
25
+ - **Other kitty functional keys that share a head prefix** — `\x1b[6;1:2;65u` (alternate keys) must also be treated as a key, which is why the predicate is `(?:[;:]\d+)*` and not the narrower form in the issue text.
26
+ - **A key that arrives while the guard is armed** — an armed window must not swallow it silently; the state machine's `armed + other` path drops the stale buffer and passes the event.
27
+ - **Plain text right after a key** — forwarding must stay byte-exact (no duplication, no reordering).
28
+
29
+ ---
30
+
31
+ ### Task 1: Classify complete CSI keys before the head prefixes
32
+
33
+ **Files:**
34
+ - Modify: `src/core/terminal-reply-guard.mjs` (add `CSI_KEY_SEQUENCE` near the other shape constants, and one branch in `classifyReplyEvent`)
35
+ - Test: `test/terminal-reply-guard.test.mjs`
36
+
37
+ **Interfaces:**
38
+ - Produces: `classifyReplyEvent(data)` now returns `{ kind: "other" }` for `CSI <num>[;<mod>[:<event>][;<alt>]]{u,~}`. No exported symbol changes.
39
+
40
+ - [ ] **Step 1: Write the failing tests**
41
+
42
+ Append to `test/terminal-reply-guard.test.mjs`:
43
+
44
+ ```js
45
+ // Issue #213: kitty / modifyOtherKeys PageDown shares the cell-size head prefix.
46
+ test("#213-A1: a complete CSI key sequence is user input, never a reply head", () => {
47
+ for (const seq of ["\x1b[6;1u", "\x1b[6;5u", "\x1b[6;1:2u", "\x1b[6;1:2;65u", "\x1b[6;5~", "\x1b[6;2~"]) {
48
+ assert.deepEqual(classifyReplyEvent(seq), { kind: "other" }, `${JSON.stringify(seq)} must be a key`);
49
+ }
50
+ });
51
+
52
+ test("#213-A2: neighbouring keys are unaffected", () => {
53
+ for (const seq of ["\x1b[5;1u", "\x1b[5;5~", "\x1b[57422u", "\x1b[57421u", "\x1b[3;5~"]) {
54
+ assert.deepEqual(classifyReplyEvent(seq), { kind: "other" }, `${JSON.stringify(seq)} must stay a key`);
55
+ }
56
+ });
57
+
58
+ test("#213-A3: genuine cell-size fragments still arm", () => {
59
+ for (const seq of ["\x1b[6;", "\x1b[6;24"]) {
60
+ assert.deepEqual(classifyReplyEvent(seq), { kind: "reply-head", family: "cell-size" });
61
+ }
62
+ });
63
+
64
+ test("#213-A4: a complete cell-size reply still drops", () => {
65
+ assert.deepEqual(classifyReplyEvent("\x1b[6;24;80t"), { kind: "reply-complete", family: "cell-size", action: "drop" });
66
+ });
67
+ ```
68
+
69
+ - [ ] **Step 2: Run the tests to verify they fail**
70
+
71
+ Run: `node --test test/terminal-reply-guard.test.mjs`
72
+ Expected: FAIL — `#213-A1` reports `{ kind: "reply-head", family: "cell-size" }` for `\x1b[6;1u` / `\x1b[6;5~`.
73
+
74
+ - [ ] **Step 3: Implement the predicate**
75
+
76
+ In `src/core/terminal-reply-guard.mjs`, next to `REPLY_HEADS`:
77
+
78
+ ```js
79
+ // Issue #213: kitty keyboard protocol keys that carry a modifier use the legacy
80
+ // functional-key encoding `CSI <num>;<mods>u`, and PageDown's legacy number IS 6
81
+ // (`\x1b[6;1u` = no modifier, mods field is bitmask+1). modifyOtherKeys uses the same
82
+ // prefix with `~` (`\x1b[6;5~`). Both therefore share the cell-size head prefix
83
+ // (`\x1b[6;`), which used to arm the guard and swallow the key — asymmetric with
84
+ // PageUp (key number 5) and invisible to the previous tests.
85
+ //
86
+ // A COMPLETE CSI key is user input, never a reply fragment: check it before the head
87
+ // prefixes. Only the terminator decides — geniune cell-size fragments (`\x1b[6;`,
88
+ // `\x1b[6;24`) carry no `u`/`~` and still arm below. `(?:[;:]\d+)*` also covers the
89
+ // alternate-key form (`\x1b[6;1:2;65u`), which a narrower `(?:;\d+)?(?::\d+)?` misses.
90
+ const CSI_KEY_SEQUENCE = /^\x1b\[\d+(?:[;:]\d+)*[u~]$/;
91
+ ```
92
+
93
+ and in `classifyReplyEvent`, after the `FORWARD_SHAPES` loop and **before** the `REPLY_HEADS` loop:
94
+
95
+ ```js
96
+ // Issue #213: a complete CSI key wins over the head prefixes (see CSI_KEY_SEQUENCE).
97
+ if (CSI_KEY_SEQUENCE.test(data)) return { kind: "other" };
98
+ ```
99
+
100
+ - [ ] **Step 4: Run the tests to verify they pass**
101
+
102
+ Run: `node --test test/terminal-reply-guard.test.mjs test/pty-scroll.test.mjs`
103
+ Expected: PASS (the four new tests plus the existing 15).
104
+
105
+ - [ ] **Step 5: Commit**
106
+
107
+ ```bash
108
+ git add src/core/terminal-reply-guard.mjs test/terminal-reply-guard.test.mjs
109
+ git commit -m "fix(attach): classify complete CSI keys before the reply-guard head prefixes (issue #213)"
110
+ ```
111
+
112
+ ---
113
+
114
+ ### Task 2: Prove the key actually reaches the child
115
+
116
+ **Files:**
117
+ - Modify: `test-support/attach-reply-guard-smoke.ts` (new case + result fields)
118
+ - Modify: `test/pty-attach-reply-guard.test.mjs` (assertions)
119
+
120
+ **Interfaces:**
121
+ - Consumes: `classifyReplyEvent` behaviour from Task 1 through the real `PtyAttachComponent.handleInput`.
122
+ - Produces: `out.guardedPageDown: string[]`, `out.guardedPageDownThenText: string[]` in the smoke's JSON.
123
+
124
+ - [ ] **Step 1: Write the failing assertions**
125
+
126
+ In `test/pty-attach-reply-guard.test.mjs`:
127
+
128
+ ```js
129
+ assert.deepEqual(parsed.guardedPageDown, ["\x1b[6;1u", "\x1b[6;5~"], "kitty and modifyOtherKeys PageDown must be forwarded to the child, not swallowed");
130
+ assert.deepEqual(parsed.guardedPageDownThenText, ["\x1b[6;1u", "x"], "text after a key must still be forwarded verbatim");
131
+ ```
132
+
133
+ - [ ] **Step 2: Run to verify it fails**
134
+
135
+ Run: `node --test test/pty-attach-reply-guard.test.mjs`
136
+ Expected: FAIL — `parsed.guardedPageDown` is `undefined`.
137
+
138
+ - [ ] **Step 3: Implement the smoke case**
139
+
140
+ In `test-support/attach-reply-guard-smoke.ts`, next to the existing `guardedFragments` case:
141
+
142
+ ```ts
143
+ // Issue #213: a complete CSI key must reach the child, not the absorption window.
144
+ f.attach.handleInput("\x1b[6;1u");
145
+ f.attach.handleInput("\x1b[6;5~");
146
+ out.guardedPageDown = [...f.sent];
147
+
148
+ // …and the bytes that follow a key are still forwarded verbatim.
149
+ f.attach.handleInput("\x1b[6;1u");
150
+ f.attach.handleInput("x");
151
+ out.guardedPageDownThenText = [...f.sent];
152
+ ```
153
+
154
+ - [ ] **Step 4: Run to verify it passes**
155
+
156
+ Run: `node --test test/pty-attach-reply-guard.test.mjs`
157
+ Expected: PASS.
158
+
159
+ - [ ] **Step 5: Commit**
160
+
161
+ ```bash
162
+ git add test-support/attach-reply-guard-smoke.ts test/pty-attach-reply-guard.test.mjs
163
+ git commit -m "test(attach): pin that kitty PageDown reaches the child through the reply guard (issue #213)"
164
+ ```
165
+
166
+ ---
167
+
168
+ ### Task 3: Retire the pinned invariant in the docs
169
+
170
+ **Files:**
171
+ - Modify: `src/core/terminal-reply-guard.mjs` (module header comment)
172
+ - Modify: `docs/superpowers/specs/2026-10-14-issue-170-attach-reply-guard-design.md` (§3.3 design-points list)
173
+
174
+ **Interfaces:**
175
+ - Consumes: nothing at runtime; produces the text the A8 static check greps for.
176
+
177
+ - [ ] **Step 1: Update the module header**
178
+
179
+ In the `terminal-reply-guard.mjs` header, extend the paragraph that lists what arms a window with:
180
+
181
+ ```
182
+ * Issue #213: `\x1b[6;` is NOT a "can never be a key" prefix — kitty's functional-key
183
+ * encoding puts PageDown at `CSI 6;<mods>u` (and modifyOtherKeys at `\x1b[6;<mods>~`).
184
+ * The rule is therefore "a COMPLETE CSI key sequence (`u`/`~` terminated) beats the head
185
+ * prefixes", not "these prefixes are never keys". Fragments without a terminator still arm.
186
+ ```
187
+
188
+ - [ ] **Step 2: Append the correction to the #170 design doc**
189
+
190
+ In `docs/superpowers/specs/2026-10-14-issue-170-attach-reply-guard-design.md`, at the end of the §3.3 design-points list (do NOT rewrite L106 — append):
191
+
192
+ ```markdown
193
+ 7. **修正(issue #213)**:上一条「`ESC[6;` 永不可能是按键前缀」已被推翻 —— kitty 功能键编码
194
+ 把 PageDown 放在 `CSI 6;<mods>u`(modifyOtherKeys 为 `\x1b[6;<mods>~`),与 cell-size 的
195
+ `ESC[6;` 共享前缀。现行规则是「**完整 CSI 键序列(以 `u`/`~` 终结)优先于 head 前缀**」,
196
+ 不带终结符的片段仍然武装。实现见 `CSI_KEY_SEQUENCE`。
197
+ ```
198
+
199
+ - [ ] **Step 3: Verify the A8 greps**
200
+
201
+ Run:
202
+ ```bash
203
+ grep -n "complete CSI key" src/core/terminal-reply-guard.mjs
204
+ grep -n "完整 CSI 键序列优先.*#213" docs/superpowers/specs/2026-10-14-issue-170-attach-reply-guard-design.md
205
+ grep -c "can never be a key" src/core/terminal-reply-guard.mjs
206
+ ```
207
+ Expected: the first two each match at least once (the first inside the module header, not inside a function body); the third prints 0.
208
+
209
+ - [ ] **Step 4: Commit**
210
+
211
+ ```bash
212
+ git add src/core/terminal-reply-guard.mjs docs/superpowers/specs/2026-10-14-issue-170-attach-reply-guard-design.md
213
+ git commit -m "docs(attach): retire the 'ESC[6; is never a key' invariant (issue #213)"
214
+ ```
215
+
216
+ ---
217
+
218
+ ### Task 4: Full gate
219
+
220
+ **Files:** none (verification only).
221
+
222
+ - [ ] **Step 1: Type/static checks**
223
+
224
+ Run: `npm run typecheck && npm run pack:dry`
225
+ Expected: both clean.
226
+
227
+ - [ ] **Step 2: Targeted suites**
228
+
229
+ Run: `node --test test/terminal-reply-guard.test.mjs test/pty-attach-reply-guard.test.mjs test/pty-scroll.test.mjs test/pty-attach-wheel.test.mjs`
230
+ Expected: all green.
231
+
232
+ - [ ] **Step 3: Baseline comparison**
233
+
234
+ Run: `node --test test/pty-attach-*.test.mjs` and compare the failing set against the known Windows-host baseline (same set as on `main`; no new names).
235
+ Expected: no new failures.
236
+
237
+ - [ ] **Step 4: Commit**
238
+
239
+ Nothing to commit — this task is the gate the PR description cites.
240
+
241
+ ---
242
+
243
+ ### Task 5: Manual verification (U1)
244
+
245
+ **Files:** none.
246
+
247
+ - [ ] **Step 1: Load the fix into a real pi**
248
+
249
+ Per spec §6.1: `pi install "$(pwd)"` (README.md:54) or symlink the worktree to
250
+ `~/.pi/agent/extensions/agent-board`, **not** the `~/.pi/agent/git/...` cache clone.
251
+
252
+ - [ ] **Step 2: Restart pi completely** and open a new attach session.
253
+
254
+ - [ ] **Step 3: Press PageDown in the attach view** (WezTerm + pi fullscreen).
255
+ Expected: the panel scrolls one page backwards, mirroring PageUp; no swallowed key.
256
+
257
+ - [ ] **Step 4: Record the outcome** in the issue (pass/fail + observed behaviour).
@@ -0,0 +1,202 @@
1
+ # attach forward Alt modifier — 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:** Preserve the Alt modifier when the attach view forwards a wheel notch to a fullscreen child, so Alt+wheel keeps its 5× accelerator.
6
+
7
+ **Architecture:** `encodeWheelReport` gains an optional `alt` flag that sets SGR bit 3 (`+8`); `forwardWheelToChild` passes `event.alt === true` through. Nothing else changes — the local scroll path never rebuilds the report.
8
+
9
+ **Tech Stack:** ESM `.mjs` pure helper, TypeScript component shell, `node:test` + `assert/strict`.
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-10-19-issue-214-attach-forward-alt-modifier-design.md`
12
+
13
+ ## Global Constraints
14
+
15
+ - Tabs for indentation; English comments and commit messages (repo AGENTS.md).
16
+ - No new dependencies.
17
+ - Local `npm run verify` cannot go green on this Windows host (pre-existing ~72-test baseline that also fails on `main`); the authoritative local gates are the targeted suites plus `npm run typecheck` and `npm run pack:dry`. CI is the final gate.
18
+ - `alt` must be compared strictly (`=== true`), never by truthiness: a stray `1`/`"yes"` must not set bit 3.
19
+ - Do not touch the local scroll path, the reply guard, or the input classifier.
20
+
21
+ ## Review Focus
22
+
23
+ - **A truthy non-boolean `alt`** — `alt: 1` and `alt: "yes"` must NOT set bit 3, because the component passes `event.alt === true` (an older pi-tui could hand over something unexpected).
24
+ - **Plain wheel parity** — with no Alt, the encoded report must stay byte-identical to today's (`\x1b[<64;…M` / `\x1b[<65;…M`); the existing #198/#205 assertions must keep passing untouched.
25
+ - **The forward hop is the only seam** — the legacy regular-mode path forwards the raw SGR unchanged and already carries Alt; a change that also rewrote that path would be a scope violation.
26
+ - **Down direction** — `alt + down` is `73`, not `72`; an implementation that ORs the bit onto a fixed 64 would break down-scroll.
27
+ - **Child-side consumption** — the byte must decode as `button & 8 !== 0` under pi-tui's `parseWheelEvent`; `72`/`73` satisfy that and `64`/`65` do not.
28
+
29
+ ---
30
+
31
+ ### Task 1: Encode the Alt bit in `encodeWheelReport`
32
+
33
+ **Files:**
34
+ - Modify: `src/core/pty-scroll.mjs` (`encodeWheelReport`, ~L370)
35
+ - Test: `test/pty-scroll.test.mjs`
36
+
37
+ **Interfaces:**
38
+ - Produces: `encodeWheelReport(direction: "up"|"down", { col?: number, row?: number, alt?: boolean }) -> string`. Default `alt: false` keeps every existing call site identical.
39
+
40
+ - [ ] **Step 1: Write the failing tests**
41
+
42
+ Append to `test/pty-scroll.test.mjs`:
43
+
44
+ ```js
45
+ // Issue #214: Alt is SGR bit 3 (value 8); the child's pi-tui reads it to apply the
46
+ // 5x wheel multiplier, so the forward hop must not strip it.
47
+ test("#214-A1: Alt+wheel-up encodes button 72", () => {
48
+ assert.equal(encodeWheelReport("up", { col: 5, row: 3, alt: true }), "\x1b[<72;5;3M");
49
+ });
50
+
51
+ test("#214-A2: Alt+wheel-down encodes button 73", () => {
52
+ assert.equal(encodeWheelReport("down", { col: 5, row: 3, alt: true }), "\x1b[<73;5;3M");
53
+ });
54
+
55
+ test("#214-A3: without a strict boolean true, bit 3 stays clear", () => {
56
+ for (const alt of [false, undefined, null, 0, 1, "yes"]) {
57
+ assert.equal(encodeWheelReport("up", { col: 5, row: 3, alt }), "\x1b[<64;5;3M", `alt=${String(alt)} must not set bit 3`);
58
+ assert.equal(encodeWheelReport("down", { col: 5, row: 3, alt }), "\x1b[<65;5;3M", `alt=${String(alt)} must not set bit 3`);
59
+ }
60
+ });
61
+ ```
62
+
63
+ - [ ] **Step 2: Run the tests to verify they fail**
64
+
65
+ Run: `node --test test/pty-scroll.test.mjs`
66
+ Expected: FAIL — `#214-A1` gets `\x1b[<64;5;3M` instead of `72`.
67
+
68
+ - [ ] **Step 3: Implement**
69
+
70
+ ```js
71
+ export function encodeWheelReport(direction, { col, row, alt = false } = {}) {
72
+ const base = direction === "up" ? 64 : 65;
73
+ // Issue #214: SGR bit 3 (value 8) is Alt. pi-tui multiplies by 5 when it is set,
74
+ // so the forward hop must carry it. Strict comparison: a truthy non-boolean must
75
+ // not flip the bit.
76
+ const button = alt === true ? base + 8 : base;
77
+ const cell = (v) => (Number.isFinite(v) ? Math.max(1, Math.floor(v)) : 1);
78
+ return `\x1b[<${button};${cell(col)};${cell(row)}M`;
79
+ }
80
+ ```
81
+
82
+ Keep the existing JSDoc's description of the direction/coordinates; add one line about `alt`.
83
+
84
+ - [ ] **Step 4: Run the tests to verify they pass**
85
+
86
+ Run: `node --test test/pty-scroll.test.mjs`
87
+ Expected: PASS (three new tests plus the existing suite).
88
+
89
+ - [ ] **Step 5: Commit**
90
+
91
+ ```bash
92
+ git add src/core/pty-scroll.mjs test/pty-scroll.test.mjs
93
+ git commit -m "feat(attach): carry the Alt modifier in the forwarded wheel report (issue #214)"
94
+ ```
95
+
96
+ ---
97
+
98
+ ### Task 2: Pass `event.alt` through the forward hop
99
+
100
+ **Files:**
101
+ - Modify: `src/ui/pty-attach.ts` (`forwardWheelToChild`, ~L1477)
102
+ - Modify: `test-support/pty-attach-wheel-fallback-scenario.ts` (new alt round)
103
+ - Modify: `test/pty-attach-wheel-fallback.test.mjs` (new assertions)
104
+
105
+ **Interfaces:**
106
+ - Consumes: `encodeWheelReport(…, { alt })` from Task 1.
107
+ - Produces: scenario result fields `forwardedAltUp` / `forwardedAltDown`.
108
+
109
+ - [ ] **Step 1: Write the failing assertion**
110
+
111
+ In `test/pty-attach-wheel-fallback.test.mjs`, after the existing plain-wheel assertions (which must stay untouched):
112
+
113
+ ```js
114
+ assert.equal(parsed.forwardedAltUp, "\x1b[<72;4;3M", "Alt+wheel-up must reach the child as button 72");
115
+ assert.equal(parsed.forwardedAltDown, "\x1b[<73;4;3M", "Alt+wheel-down must reach the child as button 73");
116
+ ```
117
+
118
+ - [ ] **Step 2: Run to verify it fails**
119
+
120
+ Run: `node --test test/pty-attach-wheel-fallback.test.mjs`
121
+ Expected: FAIL — `parsed.forwardedAltUp` is `undefined`.
122
+
123
+ - [ ] **Step 3: Implement**
124
+
125
+ In `src/ui/pty-attach.ts` `forwardWheelToChild`, add the flag:
126
+
127
+ ```ts
128
+ data: encodeWheelReport(linesUp > 0 ? "up" : "down", {
129
+ col: clampInt(event.x + 1, 1, this.cols),
130
+ row: clampInt(event.y, 1, Math.max(1, this.bodyHeight())),
131
+ // Issue #214: the overlay already received the Alt bit from pi-tui; carry it
132
+ // into the synthesized report so the child keeps its 5x wheel accelerator.
133
+ alt: event.alt === true,
134
+ }),
135
+ ```
136
+
137
+ In `test-support/pty-attach-wheel-fallback-scenario.ts`, add one more round using the existing `mouseBase` (do not modify the existing rounds, whose assertions are #198's regression pins):
138
+
139
+ ```ts
140
+ // Issue #214: Alt must survive the forward hop.
141
+ let beforeAlt = forwardedInputs().length;
142
+ (component as any).handleMouse({ ...wheelEvent(-3), alt: true });
143
+ if (await waitFor(() => forwardedInputs().length > beforeAlt, 3000)) {
144
+ result.forwardedAltUp = forwardedInputs().at(-1) ?? null;
145
+ }
146
+ beforeAlt = forwardedInputs().length;
147
+ (component as any).handleMouse({ ...wheelEvent(2), alt: true });
148
+ if (await waitFor(() => forwardedInputs().length > beforeAlt, 3000)) {
149
+ result.forwardedAltDown = forwardedInputs().at(-1) ?? null;
150
+ }
151
+ ```
152
+
153
+ Declare both fields in the scenario's `result` object (`null as string | null`).
154
+
155
+ - [ ] **Step 4: Run to verify it passes**
156
+
157
+ Run: `node --test test/pty-attach-wheel-fallback.test.mjs`
158
+ Expected: PASS — the two new assertions and every existing #198 assertion.
159
+
160
+ - [ ] **Step 5: Commit**
161
+
162
+ ```bash
163
+ git add src/ui/pty-attach.ts test-support/pty-attach-wheel-fallback-scenario.ts test/pty-attach-wheel-fallback.test.mjs
164
+ git commit -m "fix(attach): forward the Alt modifier on the wheel hop (issue #214)"
165
+ ```
166
+
167
+ ---
168
+
169
+ ### Task 3: Regression gate
170
+
171
+ **Files:** none.
172
+
173
+ - [ ] **Step 1: Targeted suites**
174
+
175
+ Run: `node --test test/pty-scroll.test.mjs test/pty-attach-wheel-fallback.test.mjs test/pty-attach-wheel.test.mjs test/pty-attach-frame-overflow.test.mjs test/pty-attach-alt-wheel.test.mjs test/pty-attach-mouse-mode.test.mjs test/terminal-reply-guard.test.mjs`
176
+ Expected: all green.
177
+
178
+ - [ ] **Step 2: Static gates**
179
+
180
+ Run: `npm run typecheck && npm run pack:dry`
181
+ Expected: both clean.
182
+
183
+ - [ ] **Step 3: Baseline comparison**
184
+
185
+ Run: `node --test test/pty-attach-*.test.mjs` and compare the failing set against the known Windows-host baseline. Expected: no new names.
186
+
187
+ ---
188
+
189
+ ### Task 4: Manual verification (U1)
190
+
191
+ **Files:** none.
192
+
193
+ - [ ] **Step 1: Load this checkout into pi**
194
+
195
+ `pi install "$(pwd)"` (README.md:54) or symlink to `~/.pi/agent/extensions/agent-board` — **not** the `~/.pi/agent/git/...` cache clone.
196
+
197
+ - [ ] **Step 2: Restart pi completely**, open a new attach session on a fullscreen child.
198
+
199
+ - [ ] **Step 3: Hold Alt and roll one wheel notch** in the attach view.
200
+ Expected: the child scrolls 5 lines, matching the behaviour outside attach.
201
+
202
+ - [ ] **Step 4: Record the outcome** in the issue.
@@ -111,6 +111,11 @@ export function createReplyGuard({ windowMs = REPLY_GUARD_WINDOW_MS, maxAbsorbBy
111
111
 
112
112
  6. **近失形状的归属**(R2-D3):完整但参数为空的 DA(`ESC[?c`)按完成形状处理(drop);不完整前缀(`ESC[?1;`、`ESC[6;`、`ESC[6;24;80`、`ESC]11;`、`ESC P`)一律武装(swallow);未知第三值的 997 报告按 forward 家族整条转发。这三类由 A1/A4 的「近失形状」清单锁死,不得靠实现者自由发挥。
113
113
 
114
+ 7. **修正(issue #213)**:第 1 条「`ESC[6;` 永不可能是按键前缀」已被推翻 —— kitty 功能键编码
115
+ 把 PageDown 放在 `CSI 6;<mods>u`(modifyOtherKeys 为 `\x1b[6;<mods>~`),与 cell-size 的
116
+ `ESC[6;` 共享前缀。「完整 CSI 键序列优先」(#213)是现行规则:以 `u`/`~` 终结的完整键序列
117
+ 先于 head 前缀判定,不带终结符的片段仍然武装。实现见 `CSI_KEY_SEQUENCE`。
118
+
114
119
  ### 3.4 接线点
115
120
 
116
121
  `src/ui/pty-attach.ts` 的 `handleInput(data)` **最前面**(在既有 mouse/key 拦截之前):
@@ -0,0 +1,165 @@
1
+ Drafted: deepseek/deepseek-flash (selected) · deepseek/deepseek-flash (physical) @ 2026-10-19T12:40:00+08:00
2
+ Revised: deepseek/deepseek-flash (selected) · deepseek/deepseek-flash (physical) @ 2026-10-19T13:05:00+08:00 — 第 1 轮复核处置(R1-G1/G2/G3 + D1/D2/D3)
3
+ Revised: deepseek/deepseek-flash (selected) · deepseek/deepseek-flash (physical) @ 2026-10-19T13:15:00+08:00 — 第 2 轮复核处置(R2-G1 + D1/D2/D3)
4
+ Revised: deepseek/deepseek-flash (selected) · deepseek/deepseek-flash (physical) @ 2026-10-19T13:25:00+08:00 — 第 3 轮复核处置(收敛轮,R3-D1/D2/D3 细化)
5
+ Revised: deepseek/deepseek-flash (selected) · deepseek/deepseek-flash (physical) @ 2026-10-19T14:20:00+08:00 — 代码评审批次修复(A8③ 空转 grep、REPLY_HEADS 注释未改、armed 路径未覆盖)
6
+
7
+ # issue #213 根因报告与修复设计:reply guard 的 cell-size 前缀吞掉 kitty 编码的 PageDown
8
+
9
+ 仓库:zhuxixi/pi-agent-board · 基线:`origin/main` @ `ef4317f`
10
+
11
+ ## 1. 症状
12
+
13
+ `v0.12.0` + WezTerm 20240203 + pi fullscreen 1.3.0 → `/agent-board` → attach:
14
+
15
+ | 键 | 结果 |
16
+ | --- | --- |
17
+ | `PageUp` | 正常(本地滚动 / 转发) |
18
+ | `PageDown` | **完全无反应** |
19
+ | 鼠标滚轮 | 正常 |
20
+
21
+ 不对称本身就是线索:两个键在 `handleKeyInput` 里走**完全相同**的
22
+ `tryScrollBy(±pageSize)` → `send()` 兜底,所以差异只能出在更上游的输入分类层。
23
+
24
+ ## 2. 根因
25
+
26
+ `src/core/terminal-reply-guard.mjs` 的 `REPLY_HEADS` 含:
27
+
28
+ ```js
29
+ { family: "cell-size", prefix: "\x1b[6;" }
30
+ ```
31
+
32
+ 本意是吸收被拆碎的 XTWINOPS cell-size 回复(`CSI 6;<rows>;<cols>t`)。
33
+ 但 **PageDown 的 legacy 功能键编号正是 6**:kitty 键盘协议下带修饰键的 PageDown 编码为
34
+ `CSI 6;<mod>u`(`\x1b[6;1u`),modifyOtherKeys 回退下为 `CSI 6;<mod>~`(`\x1b[6;5~`)。
35
+ 两者都以 `\x1b[6;` 开头 → `classifyReplyEvent` 判成 `reply-head` → guard 进入 armed
36
+ → 250ms/64 字节吸收窗口把按键吞掉。PageUp 的编号是 5(`\x1b[5;1u`),永不命中,所以不受影响。
37
+
38
+ 附带发现的**文档与代码矛盾**:`REPLY_HEADS` 上方的注释写着
39
+ 「Deliberately absent: … `\x1b[5`/`\x1b[6` (PageUp/PageDown heads)」,
40
+ 但列表里实际存在 `\x1b[6;` —— 注释想表达的是"不吸收 `\x1b[6` 开头的裸键位",
41
+ 实现却用了一个更宽的前缀,正是这条矛盾掩盖了本次冲突。
42
+
43
+ 引入点:`2148360 fix(attach): stop terminal reply fragments leaking into the attached child's input (#194)`。
44
+
45
+ ## 3. 证据(对仓库当前代码实测)
46
+
47
+ ```
48
+ "\x1b[6;1u" → { kind: "reply-head", family: "cell-size" } ❌ PageDown 被吞
49
+ "\x1b[6;5~" → { kind: "reply-head", family: "cell-size" } ❌ modifyOtherKeys PageDown 被吞
50
+ "\x1b[5;1u" → { kind: "other" } ✓ PageUp
51
+ "\x1b[6~" → { kind: "other" } ✓ 无修饰的 legacy PageDown
52
+ "\x1b[57422u" → { kind: "other" } ✓ kitty codepoint 形式
53
+ "\x1b[6;" → { kind: "reply-head", family: "cell-size" } ✓ 真 fragment 仍 arm
54
+ "\x1b[6;24" → { kind: "reply-head", family: "cell-size" } ✓
55
+ "\x1b[6;24;80t" → { kind: "reply-complete", action: "drop" } ✓
56
+ ```
57
+
58
+ 现有测试(15 个 `test()` / 63 条 assert,全绿)**没有覆盖** `\x1b[6;<mod>u` / `\x1b[6;<mod>~` 形态 —— 这是回归缺口。
59
+ `test/terminal-reply-guard.test.mjs:58` 只测了裸 `\x1b[5~` / `\x1b[6~`,
60
+ `:96` 测了 `\x1b[6;` 仍需 arm。
61
+
62
+ ## 4. 修复
63
+
64
+ **在 head 前缀之前**识别"完整的 CSI 键序列":真 cell-size fragment 没有 `u`/`~` 终结符,
65
+ 所以完整键序列可以安全地判成用户输入。
66
+
67
+ ```js
68
+ // Kitty / modifyOtherKeys keys: `CSI <num>[;<mod>…]{u,~}`. PageDown's legacy number
69
+ // is 6, which shares the `\x1b[6;` prefix with the cell-size reply head.
70
+ const CSI_KEY_SEQUENCE = /^\x1b\[\d+(?:[;:]\d+)*[u~]$/;
71
+
72
+ export function classifyReplyEvent(data) {
73
+ …DROP/FORWARD 判定不变…
74
+ // A COMPLETE CSI key is user input, never a reply fragment.
75
+ if (CSI_KEY_SEQUENCE.test(data)) return { kind: "other" };
76
+ for (const head of REPLY_HEADS) { … }
77
+ …
78
+ }
79
+ ```
80
+
81
+ **正则比 issue 原文更宽一点**:issue 给的 `/^\x1b\[\d+(?:;\d+)?(?::\d+)?[u~]$/` 覆盖不了
82
+ kitty 的"交替键"编码 `\x1b[6;1:2;65u`(`;65` 在主修饰符之后),会重新落回 reply-head。
83
+ `(?:[;:]\d+)*` 覆盖全部 `CSI <num>;[<mod>[:<event>][;<alternate>]]u` 形态。
84
+
85
+ 保持不变的行为:
86
+
87
+ - `\x1b[6;`、`\x1b[6;24`(无终结符)→ 仍然 `reply-head`(arm)
88
+ - `\x1b[6;24;80t` → 仍然 `reply-complete` drop(DROP_SHAPES 命中在前)
89
+ - 其余 `REPLY_HEADS`(`\x1b[?`、`\x1b[>`、`\x1bP`、`\x1b]1`、`\x1b]4`)无键位冲突,不动
90
+
91
+ 顺带修掉 `REPLY_HEADS` 上方与新语义矛盾的注释(说明 `\x1b[6;` 只能靠"完整键序列优先"来消解冲突)。
92
+
93
+ ### 4.1 推翻既有设计文档的 pinned 不变式(R1-G3)
94
+
95
+ `docs/superpowers/specs/2026-10-14-issue-170-attach-reply-guard-design.md:106` 钉死了:
96
+
97
+ > 窗口只由 `reply-head` 武装,只有「**永不可能是按键/鼠标编码前缀**」的形状才武装:
98
+ > `ESC[?`、`ESC[>`、**`ESC[6;`**、`ESC P`、`ESC]1`、`ESC]4`。
99
+
100
+ 本次根因正是这条论断的反例:kitty 功能键编号形态 `ESC[6;1u`(PageDown)打破「永不可能是按键前缀」。
101
+ 因此本修复**显式推翻**该不变式,替换为:
102
+
103
+ > 完整 CSI 键序列(`CSI <num>[;<mod>[:<event>][;<alt>]]{u,~}`)优先于 head 前缀判定;
104
+ > 只有**不带** `u`/`~` 终结符的片段才按 head 武装。
105
+
106
+ 同步要求(实现阶段必做):
107
+
108
+ - **改掉 `REPLY_HEADS` 上方那段与新语义矛盾的注释**(源码 :76-79,「can never be a key/mouse/paste
109
+ encoding」「Deliberately absent: … `[5`/`[6`(PageUp/PageDown heads)」):不得再出现
110
+ 绝对断言,改为说明「完整键序列优先,只有不带终结符的片段才武装」。
111
+ 模块 header 用**英文**写同一条规则(`complete CSI key sequence … beats the head prefixes`),
112
+ 中文仅在 #170 文档里作为跨引用出现。
113
+ - 在 #170 设计文档 §3.3 的「设计要点」清单里补上「完整 CSI 键序列优先于 head 前缀」这一新类别,
114
+ 并把 L106 的论断标注为已被 #213 修正(追加而非改写历史)
115
+ - #170 文档 L112 与 A4 清单里「`ESC[6;` 必须武装」仍然成立(本修复只对**完整**键序列例外)
116
+
117
+ ## 5. 边界与风险
118
+
119
+ | 风险 | 说明 / 缓解 |
120
+ | --- | --- |
121
+ | 真 cell-size 回复被误判成键 | 不可能:cell-size 以 `t` 结尾,不带 `u`/`~` |
122
+ | kitty 其他功能键(`\x1b[5;1u` 等) | 本就未被 head 命中,行为不变 |
123
+ | modifyOtherKeys 其他键(`\x1b[3;5~`) | 不命中 `\x1b[6;`,行为不变 |
124
+ | 极少数终端把 PageDown 编码成 `\x1b[6;`(截断) | 仍会 arm 并吞掉;这是 issue #170 的保护方向,保留 |
125
+ | 安全方向 | 修复只是让**用户按键**更早地判定为非回复;不会让回复碎片更容易漏进子会话 |
126
+
127
+ ## 6. 验收矩阵
128
+
129
+ | ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
130
+ | --- | --- | --- | --- | --- |
131
+ | A1 | kitty/modifyOtherKeys PageDown 判为键 | 自动化(unit) | `test/terminal-reply-guard.test.mjs`:`\x1b[6;1u`、`\x1b[6;5u`、`\x1b[6;1:2u`、`\x1b[6;1:2;65u`、`\x1b[6;5~` | 全部 `{ kind: "other" }` |
132
+ | A2 | PageUp 与其他键不受影响 | 自动化(unit) | 同文件:`\x1b[5;1u`、`\x1b[5;5~`、`\x1b[57422u`、`\x1b[57421u`、`\x1b[3;5~` | 全部 `{ kind: "other" }` |
133
+ | A3 | 真 cell-size fragment 仍 arm | 自动化(unit) | 同文件:`\x1b[6;`、`\x1b[6;24` | 均 `{ kind: "reply-head", family: "cell-size" }` |
134
+ | A4 | 完整 cell-size 回复仍 drop | 自动化(unit) | 同文件:`\x1b[6;24;80t` | `{ kind: "reply-complete", family: "cell-size", action: "drop" }` |
135
+ | A5 | guard 状态机不回归 | 自动化(unit) | 现有 `terminal-reply-guard` 全部用例 | 全绿 |
136
+ | A6 | attach 按键真的转发到子会话(不被 guard 吞) | 自动化(integration) | `test-support/attach-reply-guard-smoke.ts` 新增:`handleInput("")` / `handleInput("[6;5~")` 后 `sent` 含该字节;断言归 `test/pty-attach-reply-guard.test.mjs` | `sent` 收到完整键序列;且随后的普通文本仍正常转发 |
137
+ | A7 | 语法/静态检查 | 自动化(static) | `npm run typecheck`(`tsconfig` 对 `.mjs` 是 `allowJs` + `checkJs:false`,只做语法解析)+ `npm run pack:dry` | 均通过 |
138
+ | A8 | §4.1 的文档同步真的落地 | 自动化(static) | 三条独立 grep,全部锚定**英文** marker(源码是英文注释,中文只出现在 #170 文档):① `grep -c "complete CSI key" src/core/terminal-reply-guard.mjs`(≥1,且位于模块顶部注释区);② `grep -n "完整 CSI 键序列优先.*#213" docs/superpowers/specs/2026-10-14-issue-170-attach-reply-guard-design.md`(≥1);③ `grep -c "can never be a key" src/core/terminal-reply-guard.mjs`(**必须为 0** —— 旧的绝对断言已删除) | ①② ≥1、③ ==0 |
139
+ | U1 | 真实终端 PageDown | 用户实测 | 见 §6.1 三步前置 + WezTerm + pi fullscreen → attach → 按 PageDown | 面板按页向后滚动(或转发给子会话),与 PageUp 对称 |
140
+
141
+ ### 6.1 U1 执行时机与部署前置
142
+
143
+ 仓库加载补丁 checkout 的方式见 `README.md:54`(`pi install "$(pwd)"`)或 `README.md:68`(symlink 到
144
+ `~/.pi/agent/extensions/agent-board`)。U1 必须在以下三步之后执行,否则测的是旧代码:
145
+
146
+ 1. 用上述任一方式把**当前 worktree/checkout** 加载到 pi(不是 `~/.pi/agent/git/...` 的缓存克隆)
147
+ 2. 完整重启 pi(模块缓存不会热更新),并新开一个 attach 会话
148
+ 3. 时机:本地 `npm run typecheck` + 目标套件全绿之后、合并之前
149
+
150
+ > **验收 ID 作用域**:#213 的 `A1–A8` 仅指本 issue。新增测试名一律带 `#213-` 前缀
151
+ > (如 `test("#213-A1: kitty PageDown is a key, not a reply head")`),
152
+ > 因为 `test/terminal-reply-guard.test.mjs` 里已有 #170 遗留的 `A1:`/`A2:`/… 测试名,
153
+ > 直接用 `--test-name-pattern="A1"` 会同时命中两套语义不同的用例。
154
+
155
+ ## 7. 可测性拆分设计
156
+
157
+ - **判定逻辑已经是纯函数**(`classifyReplyEvent`),零改动地单测;本次只新增一个正则常量。
158
+ - 测试边界:`test/terminal-reply-guard.test.mjs` 覆盖 A1–A5(纯函数层);`test-support/attach-reply-guard-smoke.ts` + `test/pty-attach-reply-guard.test.mjs` 覆盖 A6(真实 `PtyAttachComponent.handleInput` → `sent` 转发,只替换 socket)。`test/pty-scroll.test.mjs` 与本次改动无关,不作为护栏。
159
+ - 正则常量导出与否:**不导出**(内部实现细节),测试通过 `classifyReplyEvent` 的公开行为验证,避免把实现形状钉进测试。
160
+
161
+ ## 8. 非目标
162
+
163
+ - 不改变 guard 的吸收窗口(250ms / 64 字节)与 kill switch 语义
164
+ - 不改动其他 `REPLY_HEADS` 条目
165
+ - 不处理 `PageUp`/`PageDown` 在 legacy 路径的行为差异(本就正常)
@@ -0,0 +1,131 @@
1
+ Drafted: deepseek/deepseek-flash (selected) · deepseek/deepseek-flash (physical) @ 2026-10-19T13:58:00+08:00
2
+ Revised: deepseek/deepseek-flash (selected) · deepseek/deepseek-flash (physical) @ 2026-10-19T14:10:00+08:00 — 第 1 轮复核处置(收敛轮,R1-D1..D4 细化)
3
+
4
+ # issue #214 根因报告与修复设计:attach 转发丢 Alt 修饰位,Alt+滚轮退化为 1 行
5
+
6
+ 仓库:zhuxixi/pi-agent-board · 基线:`origin/main` @ `64bca89`
7
+
8
+ ## 1. 症状
9
+
10
+ pi fullscreen 1.3.0 + WezTerm,在 attach 一个 fullscreen 子会话时:
11
+
12
+ | 手势 | 正常 fullscreen | attach 内 |
13
+ | --- | --- | --- |
14
+ | 滚轮 | 1 notch = 加速后的行数 | 相同(转发给子会话) |
15
+ | **Alt + 滚轮** | **5×**(`ALT_WHEEL_SCROLL_MULTIPLIER = 5`) | **1×** —— Alt 被静默丢弃 |
16
+
17
+ 其余滚轮行为(普通滚轮、PageUp/PageDown、本地 scrollback)都正常。
18
+
19
+ ## 2. 根因
20
+
21
+ fullscreen 子会话的滚轮走**转发**路径(alternate buffer 无 scrollback,见 #185/#198/#199/#205)。该路径**从零重建** SGR 报告,只保留方向:
22
+
23
+ `src/ui/pty-attach.ts:1477`:
24
+
25
+ ```ts
26
+ private forwardWheelToChild(linesUp: number, event: TuiMouseEvent): void {
27
+ this.send({
28
+ type: "input",
29
+ data: encodeWheelReport(linesUp > 0 ? "up" : "down", {
30
+ col: clampInt(event.x + 1, 1, this.cols),
31
+ row: clampInt(event.y, 1, Math.max(1, this.bodyHeight())),
32
+ // ← event.alt 没有传
33
+ }),
34
+ });
35
+ }
36
+ ```
37
+
38
+ `src/core/pty-scroll.mjs:370`:
39
+
40
+ ```js
41
+ export function encodeWheelReport(direction, { col, row } = {}) {
42
+ const button = direction === "up" ? 64 : 65; // 永远不置 SGR bit 3 (值 8 = Alt)
43
+ ...
44
+ }
45
+ ```
46
+
47
+ 真实终端用 SGR **bit 3(值 8)** 表示 Alt:Alt+wheel-up = `72 = 64 + 8`,Alt+wheel-down = `73 = 65 + 8`。
48
+ 子会话的 pi-tui 正是读这一位来决定 5× 加速
49
+ (`@earendil-works/pi-tui/dist/tui-alt-screen.js` `handleViewportInput`):
50
+
51
+ ```js
52
+ const wheelDelta = wheelEvent.direction * ((wheelEvent.button & 8) !== 0 ? lines * ALT_WHEEL_SCROLL_MULTIPLIER : lines);
53
+ ```
54
+
55
+ attach 覆盖层**本来就拿到了** Alt:pi-tui 的 `TuiMouseEvent` 带 `alt: boolean`(`createMouseEvent` 里 `alt: (button & 8) !== 0`)。它只是没被传下去。
56
+
57
+ ## 3. 证据(对仓库当前代码实测)
58
+
59
+ ```
60
+ encodeWheelReport("up", {col:5,row:3}) → "\x1b[<64;5;3M"
61
+ encodeWheelReport("up", {col:5,row:3,alt:true}) → "\x1b[<64;5;3M" ❌ alt 被忽略(应为 72)
62
+ encodeWheelReport("down", {col:5,row:3,alt:true}) → "\x1b[<65;5;3M" ❌(应为 73)
63
+ ```
64
+
65
+ 子会话侧对照:
66
+
67
+ | 子会话收到的报告 | 结果 |
68
+ | --- | --- |
69
+ | `\x1b[<64;5;3M`(plain) | 1× |
70
+ | `\x1b[<72;5;3M`(Alt+up,**当前永远收不到**) | 5× |
71
+ | `\x1b[<65;5;3M`(plain) | 1× |
72
+ | `\x1b[<73;5;3M`(Alt+down,**当前永远收不到**) | 5× |
73
+
74
+ ## 4. 修复
75
+
76
+ 把覆盖层的 `alt` 透传进编码器,置 SGR bit 3:
77
+
78
+ `src/core/pty-scroll.mjs`:
79
+
80
+ ```js
81
+ export function encodeWheelReport(direction, { col, row, alt = false } = {}) {
82
+ const base = direction === "up" ? 64 : 65;
83
+ const button = alt === true ? base + 8 : base;
84
+ const cell = (v) => (Number.isFinite(v) ? Math.max(1, Math.floor(v)) : 1);
85
+ return `\x1b[<${button};${cell(col)};${cell(row)}M`;
86
+ }
87
+ ```
88
+
89
+ `src/ui/pty-attach.ts` `forwardWheelToChild`:在参数对象里补 `alt: event.alt === true`。
90
+
91
+ ## 5. 边界与风险
92
+
93
+ | 风险 | 说明 / 缓解 |
94
+ | --- | --- |
95
+ | 影响本地滚动 | 不影响:本地路径用 `wheelDelta`(外层 pi 已加速的值),不经 `encodeWheelReport` |
96
+ | 影响 legacy regular-mode 路径 | 不影响:该路径(`handleMouseInputChunk`,`src/ui/pty-attach.ts:1110`)把原始 SGR `raw` **原样**转发,从不重建报告,因此天然保留 alt 位,本次不动它 |
97
+ | `event.alt` 缺失(老 pi-tui) | `alt = false` 默认值兜底;`=== true` 显式比较,`undefined` 不会误开位 |
98
+ | Alt 被终端吞掉 | 不在本次范围:终端若不发 Alt 位,覆盖层也拿不到;本次只保证"拿到就转发" |
99
+ | shift / ctrl 修饰位 | **非目标**:pi-tui 只消费 Alt 位做加速,转发其他位不会改变行为却扩大改动面;若将来需要,同一参数可扩展 |
100
+ | 与 #170 reply guard 的交互 | 无关:本次只改出站编码,入站分类不动 |
101
+ | 与 #213 的交互 | 无关:`\x1b[<` 开头是 SGR mouse,不命中 `CSI_KEY_SEQUENCE` 也不命中任何 reply head |
102
+
103
+ ## 6. 验收矩阵
104
+
105
+ | ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
106
+ | --- | --- | --- | --- | --- |
107
+ | A1 | Alt+wheel-up 编码为 72 | 自动化(unit) | `test/pty-scroll.test.mjs`:`encodeWheelReport("up", { col: 5, row: 3, alt: true })` | `"\x1b[<72;5;3M"` |
108
+ | A2 | Alt+wheel-down 编码为 73 | 自动化(unit) | 同上 | `"\x1b[<73;5;3M"` |
109
+ | A3 | 无 Alt 时行为不变,且严格比较(`=== true`) | 自动化(unit) | 同上:`alt: false` / 缺省 / `alt: undefined` / **`alt: 1`** / **`alt: "yes"`**(truthy 非布尔) | 全部为 64 / 65,无一置 bit 3 —— 锁死 `event.alt === true` 的严格比较契约 |
110
+ | A4 | 转发链路真的把 `event.alt` 传下去 | 自动化(integration) | `test-support/pty-attach-wheel-fallback-scenario.ts` 增加 `alt: true` 的 wheel 事件,断言 runner 收到 `\x1b[<72;…M` | 收到的报告含 `72`(up)/ `73`(down) |
111
+ | A5 | 本地滚动路径不受影响 | 自动化(integration) | 既有 `pty-attach-wheel` / `pty-attach-frame-overflow` / `pty-attach-alt-wheel` 套件 | 全绿 |
112
+ | A6 | 静态检查 | 自动化(static) | `npm run typecheck` + `npm run pack:dry` | 通过 |
113
+ | U1 | 真实终端 Alt+滚轮 | 用户实测 | 部署前置见 §6.1;attach 一个 fullscreen 子会话,按住 Alt 滚一格 | 子会话滚动 5 行(与 attach 外一致) |
114
+
115
+ ### 6.1 U1 执行时机与部署前置
116
+
117
+ 1. 用 `pi install "$(pwd)"`(`README.md:54`)或 symlink 到 `~/.pi/agent/extensions/agent-board` 加载**当前 checkout**,而不是 `~/.pi/agent/git/...` 缓存
118
+ 2. 完整重启 pi,新开一个 attach 会话
119
+ 3. 时机:目标套件全绿之后、合并之前
120
+
121
+ ## 7. 可测性拆分设计
122
+
123
+ - **编码逻辑是纯函数**(`encodeWheelReport`),签名扩展一个可选参数即可单测;无 I/O。
124
+ - **接线在 `forwardWheelToChild`**(薄壳),只多传一个字段;集成测试落在既有的 `pty-attach-wheel-fallback-scenario.ts`(真实 `Socket.prototype.write` 拦截),与 #198/#205 的断言模式一致。
125
+ - **测试边界**:A1–A3 在 `test/pty-scroll.test.mjs`(纯函数);A4 在 `test/pty-attach-wheel-fallback.test.mjs` + 对应 scenario;A5 复用既有套件;不新增测试文件。
126
+
127
+ ## 8. 非目标
128
+
129
+ - 不转发 shift / ctrl 修饰位(见 §5)
130
+ - 不改本地滚动路径的任何行为
131
+ - 不改 reply guard / 输入分类
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhuxixi/pi-agent-board",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "description": "Agent-board dashboard for Pi: dispatch, monitor, peek/reply, and attach to background Pi sessions.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -36,6 +36,31 @@ const CONVENTIONAL_RE =
36
36
  const VERSION_SQUASH_RE = /^\d+\.\d+\.\d+(?:[-.][\w.]+)?\s*\(#\d+\)$/;
37
37
  /** Functional whitelist for verify (CHANGELOG-worthy types). */
38
38
  const FUNCTIONAL_TYPES = new Set(["feat", "fix", "refactor", "docs", "perf"]);
39
+ /** A PR ref sitting at the very end of a subject, e.g. the `(#208)` in `... (#208)`. */
40
+ const TRAILING_PR_RE = /\s*\(#(\d+)\)\s*$/;
41
+
42
+ /**
43
+ * Strip trailing PR refs from a subject, keeping the rightmost as the PR.
44
+ *
45
+ * GitHub merge/squash subjects can carry the number twice (`... (#208) (#208)`),
46
+ * and the conventional regex above captures only the last one, leaving the
47
+ * earlier copy inside the message where the renderer appends a ref again
48
+ * (issue #218). Refs in the middle of a subject (`... (issue #189) (#196)`) are
49
+ * not tail refs and stay verbatim.
50
+ * @param {string} subject
51
+ * @returns {{message: string, pr: number|null}}
52
+ */
53
+ export function stripTrailingPrRefs(subject) {
54
+ let text = String(subject ?? "").trim();
55
+ let pr = null;
56
+ for (let m = text.match(TRAILING_PR_RE); m; m = text.match(TRAILING_PR_RE)) {
57
+ // Rightmost ref wins: GitHub appends its own number after any that the
58
+ // author already wrote, so the first match is the authoritative one.
59
+ if (pr === null) pr = Number(m[1]);
60
+ text = text.slice(0, m.index).trimEnd();
61
+ }
62
+ return { message: text, pr };
63
+ }
39
64
 
40
65
  /**
41
66
  * Parse commit subject lines into changelog entries.
@@ -51,12 +76,14 @@ export function parseCommitLines(lines) {
51
76
  if (VERSION_SQUASH_RE.test(s)) continue;
52
77
  if (/bump\s+version/i.test(s)) continue;
53
78
  if (s.startsWith("Merge ")) continue;
54
- const m = s.match(CONVENTIONAL_RE);
55
- const prTail = s.match(/\(#(\d+)\)\s*$/);
79
+ const { message: normalized, pr: tailPr } = stripTrailingPrRefs(s);
80
+ const m = normalized.match(CONVENTIONAL_RE);
56
81
  const entry = m
57
- ? { type: m[1], scope: m[2] || "", message: m[3].trim(), pr: m[4] ? Number(m[4]) : null }
58
- : { type: "other", scope: "", message: s, pr: prTail ? Number(prTail[1]) : null };
59
- const key = `${entry.type}|${entry.scope}|${entry.message}`;
82
+ ? { type: m[1], scope: m[2] || "", message: m[3].trim(), pr: tailPr ?? (m[4] ? Number(m[4]) : null) }
83
+ : { type: "other", scope: "", message: normalized, pr: tailPr };
84
+ // PR-aware key: two commits with the same text merged under different
85
+ // PRs must both keep their number (issue #218).
86
+ const key = `${entry.type}|${entry.scope}|${entry.message}|${entry.pr ?? ""}`;
60
87
  if (seen.has(key)) continue;
61
88
  seen.add(key);
62
89
  entries.push(entry);
@@ -83,8 +110,13 @@ export function generateChangelog({ version, date, entries, prevTag = null }) {
83
110
  lines.push(`### ${title}`, "");
84
111
  for (const e of group) {
85
112
  const scope = e.scope ? `**${e.scope}**: ` : "";
86
- const pr = e.pr ? ` (#${e.pr})` : "";
87
- lines.push(`- ${scope}${e.message}${pr}`);
113
+ // Render defensively: an entry handed in with the ref already in its
114
+ // message (parse-time normalization missed, hand-authored fixtures)
115
+ // must still emit the number exactly once (issue #218).
116
+ const { message, pr: embeddedPr } = stripTrailingPrRefs(e.message);
117
+ const prNumber = e.pr ?? embeddedPr;
118
+ const pr = prNumber ? ` (#${prNumber})` : "";
119
+ lines.push(`- ${scope}${message}${pr}`);
88
120
  }
89
121
  lines.push("");
90
122
  }
@@ -9,17 +9,6 @@ export const MIN_WHEEL_LINES = 1;
9
9
  /** Upper bound for $AGENT_BOARD_WHEEL_LINES. */
10
10
  export const MAX_WHEEL_LINES = 50;
11
11
 
12
- /**
13
- * Resolve how many lines a single mouse-wheel event scrolls in the attach viewport.
14
- *
15
- * Reads `AGENT_BOARD_WHEEL_LINES`, clamps it to `[MIN_WHEEL_LINES, MAX_WHEEL_LINES]`,
16
- * and falls back to `DEFAULT_WHEEL_LINES` when unset, empty, or non-numeric. macOS
17
- * inertial/momentum scrolling fires a *burst* of wheel events per gesture, so a small
18
- * value here already scrolls quickly; the previous fixed `5` overshot badly.
19
- *
20
- * @param {Record<string, string|undefined>} [env]
21
- * @returns {number}
22
- */
23
12
  /**
24
13
  * Issue #207: the full-screen mouse enable sequence to re-assert, matching pi-tui's own
25
14
  * choice. Multiplexers get button-motion only (1000/1002/1004/1006) because any-motion
@@ -43,6 +32,18 @@ export function fullscreenMouseEnableSequence(env = process.env) {
43
32
  : "\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1004h\x1b[?1006h";
44
33
  }
45
34
 
35
+ /**
36
+ * Resolve how many lines a single mouse-wheel event scrolls in the attach viewport.
37
+ *
38
+ * Reads `AGENT_BOARD_WHEEL_LINES`, clamps it to `[MIN_WHEEL_LINES, MAX_WHEEL_LINES]`,
39
+ * and falls back to `DEFAULT_WHEEL_LINES` when unset, empty, or non-numeric. macOS
40
+ * inertial/momentum scrolling fires a *burst* of wheel events per gesture, so a small
41
+ * value here already scrolls quickly; the previous fixed `5` overshot badly.
42
+ *
43
+ * @param {Record<string, string|undefined>} [env]
44
+ * @returns {number}
45
+ */
46
+
46
47
  export function resolveWheelLines(env = process.env) {
47
48
  const raw = String(env.AGENT_BOARD_WHEEL_LINES ?? "").trim();
48
49
  if (!raw) return DEFAULT_WHEEL_LINES;
@@ -362,12 +363,18 @@ export function resolveAttachWheelRoute({ bufferType, wheelLinesUp, mouseEnabled
362
363
  * event: the child applies its own wheel acceleration, exactly as it would for
363
364
  * a real terminal.
364
365
  *
366
+ * `alt` sets SGR bit 3 (value 8) so the child keeps its Alt+wheel accelerator.
367
+ *
365
368
  * @param {"up"|"down"} direction
366
- * @param {{col?: number, row?: number}} coords
369
+ * @param {{col?: number, row?: number, alt?: boolean}} coords
367
370
  * @returns {string}
368
371
  */
369
- export function encodeWheelReport(direction, { col, row } = {}) {
370
- const button = direction === "up" ? 64 : 65;
372
+ export function encodeWheelReport(direction, { col, row, alt = false } = {}) {
373
+ const base = direction === "up" ? 64 : 65;
374
+ // Issue #214: SGR bit 3 (value 8) is Alt. pi-tui multiplies by 5 when it is set,
375
+ // so the forward hop must carry it. Strict comparison: a truthy non-boolean must
376
+ // not flip the bit.
377
+ const button = alt === true ? base + 8 : base;
371
378
  const cell = (v) => (Number.isFinite(v) ? Math.max(1, Math.floor(v)) : 1);
372
379
  return `\x1b[<${button};${cell(col)};${cell(row)}M`;
373
380
  }
@@ -17,6 +17,12 @@
17
17
  * DA/kitty reply is meaningless to it, and forwarding kitty flags would retune
18
18
  * the shared real-terminal keyboard stack).
19
19
  *
20
+ * Issue #213: `\x1b[6;` is not proof of a reply — kitty's functional-key encoding puts
21
+ * PageDown at `CSI 6;<mods>u` (and modifyOtherKeys at `\x1b[6;<mods>~`). The rule is
22
+ * therefore key-first: a complete CSI key sequence (`u`/`~` terminated) beats the head
23
+ * prefixes, so the head list is a heuristic rather than an invariant.
24
+ * Fragments without a terminator still arm.
25
+ *
20
26
  * Pure only: no I/O, no timers, no `process.env`, no pi-tui import. Time is
21
27
  * injected by the caller (see `armReplyGuardTimer` in src/ui/pty-attach.ts).
22
28
  *
@@ -67,10 +73,11 @@ const FORWARD_SHAPES = [
67
73
  { family: "osc-color", pattern: /^\x1b\](?:4;[0-9]+|10|11|12);[\s\S]*(?:\x07|\x1b\\)$/ },
68
74
  ];
69
75
 
70
- // Prefixes that can only start a reply and can never be a key/mouse/paste
71
- // encoding — only these arm an absorption window. Deliberately absent: bare
72
- // `\x1b`, `\x1b[`, `\x1b[5`/`\x1b[6` (PageUp/PageDown heads), `\x1b[<` (SGR
73
- // mouse), `\x1bO` (SS3).
76
+ // Prefixes that arm an absorption window for a possibly-fragmented reply. These are
77
+ // heuristics, not proofs: `\x1b[6;` also prefixes kitty's PageDown (`CSI 6;<mods>u`,
78
+ // see CSI_KEY_SEQUENCE) and modifyOtherKeys' `\x1b[6;<mods>~`, which is why a COMPLETE
79
+ // CSI key is classified before this list is consulted. Bare `\x1b`, `\x1b[`, `\x1b[<`
80
+ // (SGR mouse) and `\x1bO` (SS3) are deliberately absent.
74
81
  const REPLY_HEADS = [
75
82
  { family: "device-attributes", prefix: "\x1b[?" },
76
83
  { family: "device-attributes", prefix: "\x1b[>" },
@@ -80,6 +87,19 @@ const REPLY_HEADS = [
80
87
  { family: "osc-color", prefix: "\x1b]4" },
81
88
  ];
82
89
 
90
+ // Issue #213: kitty keyboard protocol keys that carry a modifier use the legacy
91
+ // functional-key encoding `CSI <num>;<mods>u`, and PageDown's legacy number IS 6
92
+ // (`\x1b[6;1u` = no modifier, the mods field is bitmask+1). modifyOtherKeys uses the
93
+ // same prefix with `~` (`\x1b[6;5~`). Both therefore share the cell-size head prefix
94
+ // (`\x1b[6;`), which used to arm the guard and swallow the key — asymmetric with
95
+ // PageUp (key number 5) and invisible to the previous tests.
96
+ //
97
+ // A COMPLETE CSI key is user input, never a reply fragment: check it before the head
98
+ // prefixes. Only the terminator decides — genuine cell-size fragments (`\x1b[6;`,
99
+ // `\x1b[6;24`) carry no `u`/`~` and still arm below. `(?:[;:]\d+)*` also covers the
100
+ // alternate-key form (`\x1b[6;1:2;65u`), which a narrower `(?:;\d+)?(?::\d+)?` misses.
101
+ const CSI_KEY_SEQUENCE = /^\x1b\[\d+(?:[;:]\d+)*[u~]$/;
102
+
83
103
  /**
84
104
  * Classify one inbound input event. Complete reply shapes win over head
85
105
  * prefixes (a complete kitty reply also starts with `\x1b[?`); escape-led
@@ -93,6 +113,8 @@ export function classifyReplyEvent(data) {
93
113
  for (const shape of FORWARD_SHAPES) {
94
114
  if (shape.pattern.test(data)) return { kind: "reply-complete", family: shape.family, action: "forward" };
95
115
  }
116
+ // Issue #213: a complete CSI key wins over the head prefixes (see CSI_KEY_SEQUENCE).
117
+ if (CSI_KEY_SEQUENCE.test(data)) return { kind: "other" };
96
118
  for (const head of REPLY_HEADS) {
97
119
  if (data.startsWith(head.prefix)) return { kind: "reply-head", family: head.family };
98
120
  }
@@ -1480,6 +1480,9 @@ export class PtyAttachComponent implements Component {
1480
1480
  data: encodeWheelReport(linesUp > 0 ? "up" : "down", {
1481
1481
  col: clampInt(event.x + 1, 1, this.cols),
1482
1482
  row: clampInt(event.y, 1, Math.max(1, this.bodyHeight())),
1483
+ // Issue #214: the overlay already received the Alt bit from pi-tui; carry it
1484
+ // into the synthesized report so the child keeps its 5x wheel accelerator.
1485
+ alt: event.alt === true,
1483
1486
  }),
1484
1487
  });
1485
1488
  }
@@ -1537,7 +1540,7 @@ export class PtyAttachComponent implements Component {
1537
1540
  return Math.max(0, bottom - height);
1538
1541
  }
1539
1542
 
1540
- /** Issue #205: lowest buffer line the local viewport may scroll to — the bottom
1543
+ /** Issue #205: lowest buffer line the local viewport may scroll to — the bottom
1541
1544
  * minus the rows the runner vouched for as real scrollback. */
1542
1545
  private scrollFloorStart(height: number): number {
1543
1546
  const bottom = this.bottomViewportTop(height);