@zhuxixi/pi-agent-board 0.5.1 → 0.5.2
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/docs/superpowers/plans/2026-09-01-attach-detach-gate-cursor-anchor.md +284 -0
- package/docs/superpowers/plans/2026-09-02-attach-detach-editor-state.md +722 -0
- package/docs/superpowers/plans/2026-09-03-detach-gate-glyph-fallback.md +146 -0
- package/docs/superpowers/specs/2026-09-01-attach-detach-gate-cursor-anchor-design.md +78 -0
- package/docs/superpowers/specs/2026-09-02-attach-detach-editor-state-design.md +120 -0
- package/docs/superpowers/specs/2026-09-03-detach-gate-glyph-fallback-design.md +99 -0
- package/package.json +1 -1
- package/runner/pty-runner.mjs +12 -2
- package/src/core/editor-state-reporter.mjs +102 -0
- package/src/core/pty-input.mjs +32 -0
- package/src/index.ts +12 -1
- package/src/ui/pty-attach.ts +55 -7
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# ← Detach Gate Cursor-Anchor Fix 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:** Fix issue #66 — `←` must detach when the Pi editor line is empty even while the terminal cursor rests on working/output lines (attach / streaming).
|
|
6
|
+
|
|
7
|
+
**Architecture:** Replace the cursor-line anchor in `PtyAttachComponent.childInputLooksEmpty()` with a bottom-up scan for Pi's inverse-video fake-cursor cell (`ESC[7m`, persists in the xterm buffer across differential frames), with a prompt-glyph fallback and a "treat as empty" escape fallback. Add a pure helper `isProbablyPiInputLine` for glyph detection.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript (pty-attach.ts, run via `node --experimental-transform-types` in tests), plain ESM (pty-input.mjs), `node:test` runner, @xterm/headless.
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-09-01-attach-detach-gate-cursor-anchor-design.md`
|
|
12
|
+
|
|
13
|
+
## Global Constraints
|
|
14
|
+
|
|
15
|
+
- Coverage gates: lines 85 / funcs 80 / branches 70 (c8, `npm run test:coverage`); Node 22/24 both green (CI).
|
|
16
|
+
- `isProbablyPiInputLine` glyph set must stay identical to `isProbablyEmptyPiInputLine`'s trim charset: `›>┃│|┆╎╏:`.
|
|
17
|
+
- Do not change `ctrl+]` semantics (passes through to Pi since v0.5.1) and do not make `←` unconditionally detach (keeps the edit-protection gate — spec §3).
|
|
18
|
+
- Do not modify `src/core/pty-input.mjs`'s existing `isProbablyEmptyPiInputLine` behavior.
|
|
19
|
+
- All edits inside worktree `WT=.pi/worktrees/issue-66-attach-detach-gate-cursor-anchor`; git ops via `git -C $WT`.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
### Task 1: Add `isProbablyPiInputLine` pure helper + unit tests
|
|
24
|
+
|
|
25
|
+
**Files:**
|
|
26
|
+
- Modify: `src/core/pty-input.mjs` (append glyph const + function after existing helper)
|
|
27
|
+
- Test: `test/pty-input.test.mjs`
|
|
28
|
+
|
|
29
|
+
**Interfaces:**
|
|
30
|
+
- Produces: `export function isProbablyPiInputLine(line: string): boolean` — true iff the line, after trimming leading whitespace, starts with a prompt/continuation glyph.
|
|
31
|
+
|
|
32
|
+
- [ ] **Step 1: Write the failing test**
|
|
33
|
+
|
|
34
|
+
Append to `test/pty-input.test.mjs` (keep existing tests untouched):
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
import { isProbablyEmptyPiInputLine, isProbablyPiInputLine } from "../src/core/pty-input.mjs";
|
|
38
|
+
|
|
39
|
+
test("isProbablyPiInputLine recognizes Pi prompt / continuation lines", () => {
|
|
40
|
+
assert.equal(isProbablyPiInputLine("> "), true);
|
|
41
|
+
assert.equal(isProbablyPiInputLine(" ┃ edit me"), true);
|
|
42
|
+
assert.equal(isProbablyPiInputLine(" │ second line"), true);
|
|
43
|
+
assert.equal(isProbablyPiInputLine("› draft"), true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("isProbablyPiInputLine rejects content lines and empty lines", () => {
|
|
47
|
+
assert.equal(isProbablyPiInputLine("chat content"), false);
|
|
48
|
+
assert.equal(isProbablyPiInputLine("────── ◊◊ ──────"), false);
|
|
49
|
+
assert.equal(isProbablyPiInputLine(""), false);
|
|
50
|
+
assert.equal(isProbablyPiInputLine(" "), false);
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
55
|
+
|
|
56
|
+
Run: `node --test test/pty-input.test.mjs`
|
|
57
|
+
Expected: FAIL — `isProbablyPiInputLine is not a function` (import error).
|
|
58
|
+
|
|
59
|
+
- [ ] **Step 3: Write minimal implementation**
|
|
60
|
+
|
|
61
|
+
Append to `src/core/pty-input.mjs` (before `isProbablyEmptyPiInputLine` or after — any top-level position):
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
/** Glyphs Pi uses to render editor prompt / continuation lines (`>` main prompt,
|
|
65
|
+
* `›`/`┃`/`│` and variants in older releases). Must stay in sync with the
|
|
66
|
+
* trim charset of isProbablyEmptyPiInputLine below. */
|
|
67
|
+
const PROMPT_GLYPHS = "›>┃│|┆╎╏:";
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Whether the given terminal line looks like a Pi editor input line: leading
|
|
71
|
+
* whitespace followed by a prompt/continuation glyph. The attach surface uses
|
|
72
|
+
* this to locate the editor line inside the buffer instead of trusting the
|
|
73
|
+
* terminal cursor, which wanders onto output/working lines while Pi streams
|
|
74
|
+
* (issue #66).
|
|
75
|
+
* @param {string} line
|
|
76
|
+
* @returns {boolean}
|
|
77
|
+
*/
|
|
78
|
+
export function isProbablyPiInputLine(line) {
|
|
79
|
+
const withoutLeftPadding = String(line || "").replace(/^[\s\u00a0]+/u, "");
|
|
80
|
+
return withoutLeftPadding.length > 0 && PROMPT_GLYPHS.includes(withoutLeftPadding[0]);
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
85
|
+
|
|
86
|
+
Run: `node --test test/pty-input.test.mjs`
|
|
87
|
+
Expected: PASS (4 tests: 2 existing + 2 new).
|
|
88
|
+
|
|
89
|
+
- [ ] **Step 5: Commit**
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
git add test/pty-input.test.mjs src/core/pty-input.mjs
|
|
93
|
+
git commit -m "feat: add isProbablyPiInputLine helper (issue #66)"
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
### Task 2: Rework `childInputLooksEmpty()` anchor + smoke scenarios
|
|
99
|
+
|
|
100
|
+
**Files:**
|
|
101
|
+
- Modify: `src/ui/pty-attach.ts` (import line ~8; `childInputLooksEmpty()` ~line 319; add private helper near it)
|
|
102
|
+
- Modify: `test-support/detach-gate-smoke.ts` (scenarios B/B1/B2/B3 + new E)
|
|
103
|
+
- Modify: `test/pty-attach-detach-gate.test.mjs` (assertions)
|
|
104
|
+
|
|
105
|
+
**Interfaces:**
|
|
106
|
+
- Consumes: `isProbablyPiInputLine` from Task 1; existing `isProbablyEmptyPiInputLine`.
|
|
107
|
+
- Produces: private `findLastInverseCellLine(active): number | null` — bottom-most line index containing an `isInverse()` cell; `null` when none.
|
|
108
|
+
|
|
109
|
+
- [ ] **Step 1: Write the failing smoke scenarios**
|
|
110
|
+
|
|
111
|
+
In `test-support/detach-gate-smoke.ts`:
|
|
112
|
+
|
|
113
|
+
1. Change scenario **B** (line ~76) so the draft line carries a fake cursor, then add B3 (cursor off the empty input line) and E (empty input line without fake cursor). Replace the current B block and append:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
// B. ← must NOT detach while attached with a draft in the editor line (child
|
|
117
|
+
// is mid-draft and ← is also the editor's cursor-left key). The draft line
|
|
118
|
+
// carries Pi's inverse-video fake cursor (ESC[7m).
|
|
119
|
+
{
|
|
120
|
+
const { attach, sent, didDetach } = makeAttach();
|
|
121
|
+
await writeToTerm(attach, "chat content\r\n> \x1b[7m草\x1b[27m稿");
|
|
122
|
+
(attach as unknown as { connected: boolean }).connected = true;
|
|
123
|
+
attach.handleInput("\x1b[D");
|
|
124
|
+
out.leftStaysGatedOnNonEmptyLine = !didDetach() && sent.length === 1 && sent[0].type === "input" && sent[0].data === "\x1b[D";
|
|
125
|
+
attach.dispose();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// B3. The streaming case from issue #66: the editor line is empty (bottom of
|
|
129
|
+
// the buffer, with its fake cursor) but the terminal cursor rests on the
|
|
130
|
+
// working line because Pi's differential frames only repaint the changed
|
|
131
|
+
// line. The gate must be judged from the fake-cursor line, not the cursor.
|
|
132
|
+
{
|
|
133
|
+
const { attach, sent, didDetach } = makeAttach();
|
|
134
|
+
await writeToTerm(attach, "chat content\r\n> \x1b[7m \x1b[27m");
|
|
135
|
+
await writeToTerm(attach, "\x1b[2;1H⠙ Working...");
|
|
136
|
+
(attach as unknown as { connected: boolean }).connected = true;
|
|
137
|
+
attach.handleInput("\x1b[D");
|
|
138
|
+
out.leftDetachesWhenCursorOffEmptyInputLine = didDetach() && sent.length === 0;
|
|
139
|
+
attach.dispose();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// E. Empty input line rendered WITHOUT a fake cursor: no inverse cell and no
|
|
143
|
+
// glyph anywhere, and the terminal cursor sits on a non-empty output line.
|
|
144
|
+
// Falls through to the escape fallback — treat as empty, detach.
|
|
145
|
+
{
|
|
146
|
+
const { attach, sent, didDetach } = makeAttach();
|
|
147
|
+
await writeToTerm(attach, "chat content\r\n");
|
|
148
|
+
await writeToTerm(attach, "\x1b[1;1H"); // park the cursor on the non-empty line
|
|
149
|
+
(attach as unknown as { connected: boolean }).connected = true;
|
|
150
|
+
attach.handleInput("\x1b[D");
|
|
151
|
+
out.leftDetachesOnEmptyInputWithoutFakeCursor = didDetach() && sent.length === 0;
|
|
152
|
+
attach.dispose();
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
2. Update the B1 header comment (scenario text already fits) and ensure B1 (`────── ◊◊ ──────` garbled buffer) stays as-is — it now asserts escape-on-garbled-buffer.
|
|
157
|
+
|
|
158
|
+
- [ ] **Step 2: Run smoke to verify new scenarios fail**
|
|
159
|
+
|
|
160
|
+
Run: `node --experimental-transform-types test-support/detach-gate-smoke.ts`
|
|
161
|
+
Expected: `leftDetachesWhenCursorOffEmptyInputLine` is `false` (old cursor-line anchor reads the Working line) and `leftDetachesOnEmptyInputWithoutFakeCursor` is `false` (old anchor reads last non-empty line `chat content`? — verify output); `leftStaysGatedOnNonEmptyLine` true.
|
|
162
|
+
|
|
163
|
+
- [ ] **Step 3: Write the implementation**
|
|
164
|
+
|
|
165
|
+
In `src/ui/pty-attach.ts`:
|
|
166
|
+
|
|
167
|
+
1. Update import (line ~8):
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
import { isProbablyEmptyPiInputLine, isProbablyPiInputLine } from "../core/pty-input.mjs";
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
2. Replace the `childInputLooksEmpty()` method (currently ~line 319) and add the helper above it:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
/** Bottom-most line whose cells include an inverse-video cell — Pi renders
|
|
177
|
+
* its editor cursor as an inverse "fake cursor" (`ESC[7m`), and the cell
|
|
178
|
+
* persists in the buffer even while streaming differential frames skip
|
|
179
|
+
* repainting the editor line. */
|
|
180
|
+
private findLastInverseCellLine(active: {
|
|
181
|
+
baseY: number;
|
|
182
|
+
length: number;
|
|
183
|
+
getLine(index: number): BufferLineLike | undefined;
|
|
184
|
+
}): number | null {
|
|
185
|
+
for (let y = active.baseY + active.length - 1; y >= active.baseY; y--) {
|
|
186
|
+
const line = active.getLine(y);
|
|
187
|
+
if (!line) continue;
|
|
188
|
+
for (let x = 0; x < line.length; x++) {
|
|
189
|
+
if (line.getCell(x)?.isInverse()) return y;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private childInputLooksEmpty(): boolean {
|
|
196
|
+
if (!this.receivedOutput) return true;
|
|
197
|
+
const active = this.term.buffer.active;
|
|
198
|
+
// The terminal cursor is not a reliable anchor for the editor line:
|
|
199
|
+
// while Pi streams output (or right after attach) the cursor rests on
|
|
200
|
+
// working/output lines, never the input line, so a genuinely empty
|
|
201
|
+
// editor was misread as non-empty and ← stopped detaching (issue #66).
|
|
202
|
+
// Pi's editor line always carries an inverse-video fake-cursor cell,
|
|
203
|
+
// so anchor on that instead.
|
|
204
|
+
const fakeCursorLine = this.findLastInverseCellLine(active);
|
|
205
|
+
if (fakeCursorLine !== null) {
|
|
206
|
+
const line = active.getLine(fakeCursorLine)?.translateToString(true) ?? "";
|
|
207
|
+
return isProbablyEmptyPiInputLine(line);
|
|
208
|
+
}
|
|
209
|
+
// Fallback: Pi variants that render no fake cursor — look for a
|
|
210
|
+
// prompt-glyph line.
|
|
211
|
+
for (let y = active.baseY + active.length - 1; y >= active.baseY; y--) {
|
|
212
|
+
const line = active.getLine(y)?.translateToString(true) ?? "";
|
|
213
|
+
if (isProbablyPiInputLine(line)) return isProbablyEmptyPiInputLine(line);
|
|
214
|
+
}
|
|
215
|
+
// No editor line recoverable (e.g. a garbled replay buffer): treat the
|
|
216
|
+
// input as empty — ← is the only detach key left on the attach surface,
|
|
217
|
+
// so it must always escape rather than trap the user.
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
- [ ] **Step 4: Update gate assertions**
|
|
223
|
+
|
|
224
|
+
In `test/pty-attach-detach-gate.test.mjs`, add to the existing test (after `leftDetachesOnEmptyInput`):
|
|
225
|
+
|
|
226
|
+
```js
|
|
227
|
+
assert.equal(parsed.leftDetachesWhenCursorOffEmptyInputLine, true, "← must detach when the editor line is empty even if the cursor sits on a working line");
|
|
228
|
+
assert.equal(parsed.leftDetachesOnEmptyInputWithoutFakeCursor, true, "← must detach when an empty editor line renders no fake cursor");
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
- [ ] **Step 5: Run tests to verify pass**
|
|
232
|
+
|
|
233
|
+
Run: `node --test test/pty-attach-detach-gate.test.mjs test/pty-input.test.mjs`
|
|
234
|
+
Expected: PASS — all gate assertions true, including the three new ones.
|
|
235
|
+
|
|
236
|
+
- [ ] **Step 6: Commit**
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
git add src/ui/pty-attach.ts test-support/detach-gate-smoke.ts test/pty-attach-detach-gate.test.mjs
|
|
240
|
+
git commit -m "fix: anchor ← detach gate on Pi's fake-cursor line, not terminal cursor (issue #66)"
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
---
|
|
244
|
+
|
|
245
|
+
### Task 3: Full regression (A6)
|
|
246
|
+
|
|
247
|
+
**Files:** none (verification only).
|
|
248
|
+
|
|
249
|
+
- [ ] **Step 1: Run verify**
|
|
250
|
+
|
|
251
|
+
Run (in worktree): `npm run verify`
|
|
252
|
+
Expected: typecheck clean; all `node --test test/*.test.mjs` pass (325+ tests, none of the pre-existing ones changed semantics); c8 coverage above gates (lines ≥85 / funcs ≥80 / branches ≥70); `npm pack --dry-run` succeeds.
|
|
253
|
+
|
|
254
|
+
- [ ] **Step 2: Commit any incidental fixes**
|
|
255
|
+
|
|
256
|
+
If verify surfaced issues, fix and commit with a message referencing issue #66. Otherwise nothing to commit.
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
### Task 4: Post-implementation manual verification (U1, U2)
|
|
261
|
+
|
|
262
|
+
**Files:** none (user verification; report back into the PR).
|
|
263
|
+
|
|
264
|
+
- [ ] **Step 1: U1 — attach-then-←**
|
|
265
|
+
|
|
266
|
+
1. Start agent-board, attach into an existing pi session (or create a new one).
|
|
267
|
+
2. As soon as the attach surface paints, press `←`.
|
|
268
|
+
Expected: returns to the dashboard immediately, no ↑/↓ or type-then-delete needed.
|
|
269
|
+
|
|
270
|
+
- [ ] **Step 2: U2 — ← while Pi is thinking**
|
|
271
|
+
|
|
272
|
+
1. Attach into a session and send a prompt that triggers Pi streaming (`⠹ Working...` animation visible).
|
|
273
|
+
2. While the animation is running, press `←`.
|
|
274
|
+
Expected: returns to the dashboard. (Fails before this fix — the exact reported symptom.)
|
|
275
|
+
|
|
276
|
+
- [ ] **Step 3: Sanity — ← does not steal the editor's cursor-left**
|
|
277
|
+
|
|
278
|
+
1. Attach, type a draft in the input box.
|
|
279
|
+
2. Press `←`.
|
|
280
|
+
Expected: cursor moves left inside the draft (key forwarded), NOT detach.
|
|
281
|
+
|
|
282
|
+
- [ ] **Step 4: Record results**
|
|
283
|
+
|
|
284
|
+
Write the U1/U2/U3 outcomes into the PR description (or a PR comment) before merge.
|