@zhuxixi/pi-agent-board 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/README.md +4 -3
- package/docs/superpowers/plans/2026-10-10-attach-delete-in-place.md +832 -0
- package/docs/superpowers/plans/2026-10-14-issue-170-attach-reply-guard.md +771 -0
- package/docs/superpowers/plans/2026-10-14-issue-178-accept-registration-race.md +1257 -0
- package/docs/superpowers/plans/2026-10-14-issue-184-snapshot-window-contract.md +657 -0
- package/docs/superpowers/plans/2026-10-14-issue-185-attach-wheel-alt-screen.md +47 -0
- package/docs/superpowers/plans/2026-10-14-issue-187-motionless-drag.md +931 -0
- package/docs/superpowers/plans/2026-10-14-issue-189-mixed-input-segments.md +823 -0
- package/docs/superpowers/plans/2026-10-15-issue-188-x10-mouse-release.md +526 -0
- package/docs/superpowers/plans/2026-10-16-issue-198-attach-wheel-forward-fallback.md +32 -0
- package/docs/superpowers/plans/2026-10-18-attach-wheel-replay-pseudo-scrollback.md +437 -0
- package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +6 -0
- package/docs/superpowers/specs/2026-10-10-attach-delete-in-place-design.md +224 -0
- package/docs/superpowers/specs/2026-10-14-issue-170-attach-reply-guard-design.md +190 -0
- package/docs/superpowers/specs/2026-10-14-issue-178-accept-registration-race-design.md +287 -0
- package/docs/superpowers/specs/2026-10-14-issue-184-snapshot-window-contract-design.md +288 -0
- package/docs/superpowers/specs/2026-10-14-issue-185-attach-wheel-alt-screen-design.md +126 -0
- package/docs/superpowers/specs/2026-10-14-issue-187-motionless-drag-design.md +269 -0
- package/docs/superpowers/specs/2026-10-14-issue-189-mixed-input-segments-design.md +369 -0
- package/docs/superpowers/specs/2026-10-15-issue-188-x10-mouse-release-design.md +252 -0
- package/docs/superpowers/specs/2026-10-16-issue-198-attach-wheel-forward-fallback-design.md +67 -0
- package/docs/superpowers/specs/2026-10-18-attach-wheel-replay-pseudo-scrollback-design.md +250 -0
- package/package.json +1 -1
- package/src/commands/agent-board.ts +27 -3
- package/src/core/attach-delete.mjs +24 -0
- package/src/core/pty-scroll.mjs +323 -12
- package/src/core/rows.mjs +19 -0
- package/src/core/terminal-attach-client.mjs +9 -4
- package/src/core/terminal-attach-protocol.mjs +5 -2
- package/src/core/terminal-reply-guard.mjs +200 -0
- package/src/ui/dashboard.ts +4 -0
- package/src/ui/pty-attach.ts +334 -38
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
# Attach In-Place Delete Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** In the PTY attach view, `Ctrl+X` opens an in-place delete confirm; `y` archives the row (same `service.archive` semantics as the board), returns to the board with the neighbor selected and a `Deleted "name"` notice.
|
|
6
|
+
|
|
7
|
+
**Architecture:** A pure key-resolution function (`resolveAttachDeleteKey`) gates a `confirmArmed` state inside `PtyAttachComponent`; confirming performs the same local teardown as `detach()` minus the control frame, then resolves the custom-UI result as `{action:"delete"}`. The command layer (`attach()` in agent-board.ts) consumes that state, computes the neighbor row with a second pure function (`nextVisibleIdAfterRemoval`, same ordering source as the dashboard), archives, and hands `afterDelete` to `dashboardAttachLoop`, which reopens the board with `initialSelectedId` + a new `initialNotice` dep.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript (pi extension, tabs, English comments), pi-tui `matchesKey`/`Key`/`truncateToWidth`, `node:test` + TS probes via `node --experimental-transform-types`.
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-10-10-attach-delete-in-place-design.md` (v4, sha256 `cc51282e…3765`; line-number baseline `884040f` / 0.10.2). Every task cites the spec acceptance IDs it implements (A1–A8, U1–U4).
|
|
12
|
+
|
|
13
|
+
**Work from:** `/home/elling/work/git-repo/pi-agent-board/.pi/worktrees/issue-147-attach-delete-in-place` (all paths below are repo-relative; run every command from that worktree root).
|
|
14
|
+
|
|
15
|
+
## Global Constraints
|
|
16
|
+
|
|
17
|
+
- Spec §1.2 non-goals are hard: do NOT touch board keys (`x`→`y`, `Ctrl+X` ×2), `service.archive`/`archiveView`/coordinator, the `ctx.switchSession` path, or the orphan `src/commands/attach-flow.ts`.
|
|
18
|
+
- First `Ctrl+X` press and the armed-state `y`/`Y`/Enter confirm must NEVER be forwarded to the child PTY; second `Ctrl+X` is swallowed (`cancel-swallow`, user adjudication 2026-10-10); every other key cancels and falls through the existing chain.
|
|
19
|
+
- Confirm teardown = `this.close(true)` then `this.done({action:"delete"})` — no `detach` control frame, no archive from the component (spec §3.4.3).
|
|
20
|
+
- Header row count must not change when armed (no PTY resize); `NAME` truncated to ≤24 columns via `truncateToWidth(name, 24, "")` so `y confirms` stays visible (spec §3.2).
|
|
21
|
+
- Tabs for indentation; English comments and commit messages; conventional commits (`feat`/`test`/`docs`).
|
|
22
|
+
- Line numbers cited from the spec's baseline `884040f`; if the file drifted, locate by symbol name, not blindly by number.
|
|
23
|
+
|
|
24
|
+
## Review Focus
|
|
25
|
+
|
|
26
|
+
Five input classes the spec implies but individual tasks might not exercise — each is pinned by the listed test, in the task that owns the code:
|
|
27
|
+
|
|
28
|
+
1. **Armed state swallows input or hangs armed** (a `y` typed while composing gets eaten, or armed survives a mouse event and a later `y` mis-deletes) → pinned by Q3/Q4 (cancel forwards, second `Ctrl+X` swallowed) and Q5/Q6 (both mouse channels clear) in Task 3.
|
|
29
|
+
2. **Confirm path skips teardown** (timer leak, held-PTY size not restored, component still consuming socket events after exit) → pinned by Q8 (wire order: `resize` → `END`, no `DESTROY`, no `detach` frame) in Task 3.
|
|
30
|
+
3. **Gesture unavailable while disconnected / host exited** → the resolver never reads connection state; pinned by Q1 running against the never-connected default harness plus A1's pure-function contract in Task 1.
|
|
31
|
+
4. **Long session name clips the confirm hint** → pinned by Q2 (`y confirms` present with a 40-char title) in Task 3.
|
|
32
|
+
5. **Command layer orders archive after component exit, and archive failure is silent** → pinned by A5 (deps assertions after the second board opens) and A6 (`ctx.ui.notify` receives the error) in Task 4.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
### Task 1: `resolveAttachDeleteKey` pure function (spec A1)
|
|
37
|
+
|
|
38
|
+
**Files:**
|
|
39
|
+
- Create: `src/core/attach-delete.mjs`
|
|
40
|
+
- Test: `test/attach-delete-key.test.mjs` (new)
|
|
41
|
+
|
|
42
|
+
**Interfaces:**
|
|
43
|
+
- Consumes: pi-tui `matchesKey`, `Key` (devDeps pins `@earendil-works/pi-tui@1.1.0`, resolves for plain `node --test`).
|
|
44
|
+
- Produces: `resolveAttachDeleteKey(state: { confirmArmed: boolean }, data: string): { action: "arm" | "confirm" | "cancel" | "cancel-swallow" | "forward"; confirmArmed: boolean }` — Task 3 imports it.
|
|
45
|
+
|
|
46
|
+
- [ ] **Step 1: Write the failing test**
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
// test/attach-delete-key.test.mjs
|
|
50
|
+
import assert from "node:assert/strict";
|
|
51
|
+
import test from "node:test";
|
|
52
|
+
import { resolveAttachDeleteKey } from "../src/core/attach-delete.mjs";
|
|
53
|
+
|
|
54
|
+
test("resolveAttachDeleteKey: full branch matrix (issue #147 A1)", () => {
|
|
55
|
+
// arm: not armed + ctrl+x (raw 0x18)
|
|
56
|
+
assert.deepEqual(resolveAttachDeleteKey({ confirmArmed: false }, "\x18"), { action: "arm", confirmArmed: true });
|
|
57
|
+
// confirm: armed + y / Y / enter (\r)
|
|
58
|
+
assert.deepEqual(resolveAttachDeleteKey({ confirmArmed: true }, "y"), { action: "confirm", confirmArmed: false });
|
|
59
|
+
assert.deepEqual(resolveAttachDeleteKey({ confirmArmed: true }, "Y"), { action: "confirm", confirmArmed: false });
|
|
60
|
+
assert.deepEqual(resolveAttachDeleteKey({ confirmArmed: true }, "\r"), { action: "confirm", confirmArmed: false });
|
|
61
|
+
// cancel: armed + other keys (forwarded)
|
|
62
|
+
for (const key of ["n", "\x1b", "a", "\x1b[D", "\x1b[5~", " "]) {
|
|
63
|
+
assert.deepEqual(resolveAttachDeleteKey({ confirmArmed: true }, key), { action: "cancel", confirmArmed: false }, `key=${JSON.stringify(key)}`);
|
|
64
|
+
}
|
|
65
|
+
// cancel-swallow: armed + second ctrl+x (user adjudication 2026-10-10)
|
|
66
|
+
assert.deepEqual(resolveAttachDeleteKey({ confirmArmed: true }, "\x18"), { action: "cancel-swallow", confirmArmed: false });
|
|
67
|
+
// forward: not armed + anything else
|
|
68
|
+
assert.deepEqual(resolveAttachDeleteKey({ confirmArmed: false }, "x"), { action: "forward", confirmArmed: false });
|
|
69
|
+
assert.deepEqual(resolveAttachDeleteKey({ confirmArmed: false }, "\r"), { action: "forward", confirmArmed: false });
|
|
70
|
+
});
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
74
|
+
|
|
75
|
+
Run: `node --test test/attach-delete-key.test.mjs`
|
|
76
|
+
Expected: FAIL — `Cannot find module '../src/core/attach-delete.mjs'`.
|
|
77
|
+
|
|
78
|
+
- [ ] **Step 3: Write the implementation**
|
|
79
|
+
|
|
80
|
+
```js
|
|
81
|
+
// src/core/attach-delete.mjs
|
|
82
|
+
import { Key, matchesKey } from "@earendil-works/pi-tui";
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Issue #147: key resolution for the in-attach delete gesture. Pure — never
|
|
86
|
+
* reads socket/connection/editor state, so the gesture works even while the
|
|
87
|
+
* host is down. Second ctrl+x is swallowed (cancel-swallow, user adjudication
|
|
88
|
+
* 2026-10-10): the chord is a confirm toggle here, and forwarding it would
|
|
89
|
+
* inject one message.copy into the child Pi.
|
|
90
|
+
* @param {{ confirmArmed: boolean }} state
|
|
91
|
+
* @param {string} data raw key chunk
|
|
92
|
+
* @returns {{ action: "arm" | "confirm" | "cancel" | "cancel-swallow" | "forward", confirmArmed: boolean }}
|
|
93
|
+
*/
|
|
94
|
+
export function resolveAttachDeleteKey(state, data) {
|
|
95
|
+
if (matchesKey(data, Key.ctrl("x"))) {
|
|
96
|
+
return state.confirmArmed ? { action: "cancel-swallow", confirmArmed: false } : { action: "arm", confirmArmed: true };
|
|
97
|
+
}
|
|
98
|
+
if (state.confirmArmed) {
|
|
99
|
+
if (data === "y" || data === "Y" || matchesKey(data, Key.enter)) {
|
|
100
|
+
return { action: "confirm", confirmArmed: false };
|
|
101
|
+
}
|
|
102
|
+
return { action: "cancel", confirmArmed: false };
|
|
103
|
+
}
|
|
104
|
+
return { action: "forward", confirmArmed: false };
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
109
|
+
|
|
110
|
+
Run: `node --test test/attach-delete-key.test.mjs`
|
|
111
|
+
Expected: PASS (1 test).
|
|
112
|
+
|
|
113
|
+
- [ ] **Step 5: Commit**
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
git add src/core/attach-delete.mjs test/attach-delete-key.test.mjs
|
|
117
|
+
git commit -m "feat(core): pure key resolver for the in-attach delete gesture"
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Task 2: `nextVisibleIdAfterRemoval` (spec A2)
|
|
121
|
+
|
|
122
|
+
**Files:**
|
|
123
|
+
- Modify: `src/core/rows.mjs` (append after `groupRowsByFolder`, ~line 260)
|
|
124
|
+
- Test: `test/rows.test.mjs` (existing file — append a test)
|
|
125
|
+
|
|
126
|
+
**Interfaces:**
|
|
127
|
+
- Consumes: `groupRowsByFolder(rows, now)` from the same file — the SAME ordering source the dashboard uses for `orderedIds` (`src/ui/dashboard.ts:192`), so the two can never drift.
|
|
128
|
+
- Produces: `nextVisibleIdAfterRemoval(rows, id, now): string | null` — Task 4 imports it.
|
|
129
|
+
|
|
130
|
+
- [ ] **Step 1: Write the failing test** (append to `test/rows.test.mjs`; reuse its existing temp-root helpers if present, else this self-contained block)
|
|
131
|
+
|
|
132
|
+
```js
|
|
133
|
+
// appended to test/rows.test.mjs
|
|
134
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
135
|
+
import { tmpdir } from "node:os";
|
|
136
|
+
import { join } from "node:path";
|
|
137
|
+
import { createView, listRows } from "../src/core/store.mjs";
|
|
138
|
+
import { nextVisibleIdAfterRemoval } from "../src/core/rows.mjs";
|
|
139
|
+
|
|
140
|
+
test("nextVisibleIdAfterRemoval: neighbor semantics (issue #147 A2)", () => {
|
|
141
|
+
const root = mkdtempSync(join(tmpdir(), "agentview-neighbor-"));
|
|
142
|
+
try {
|
|
143
|
+
for (const id of ["v1", "v2", "v3"]) createView(root, { id, name: id, cwd: root });
|
|
144
|
+
const now = Date.now();
|
|
145
|
+
const rows = listRows(root);
|
|
146
|
+
assert.equal(nextVisibleIdAfterRemoval(rows, "v2", now), "v3", "middle row -> next visible row");
|
|
147
|
+
assert.equal(nextVisibleIdAfterRemoval(rows, "v3", now), "v2", "last row -> previous row");
|
|
148
|
+
const single = mkdtempSync(join(tmpdir(), "agentview-neighbor-single-"));
|
|
149
|
+
try {
|
|
150
|
+
createView(single, { id: "only", name: "only", cwd: single });
|
|
151
|
+
assert.equal(nextVisibleIdAfterRemoval(listRows(single), "only", Date.now()), null, "single row -> null (board falls back to orderedIds[0])");
|
|
152
|
+
assert.equal(nextVisibleIdAfterRemoval([], "only", Date.now()), null, "empty list -> null");
|
|
153
|
+
assert.equal(nextVisibleIdAfterRemoval(rows, "nope", now), null, "unknown id -> null");
|
|
154
|
+
} finally {
|
|
155
|
+
rmSync(single, { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
} finally {
|
|
158
|
+
rmSync(root, { recursive: true, force: true });
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
164
|
+
|
|
165
|
+
Run: `node --test test/rows.test.mjs`
|
|
166
|
+
Expected: FAIL — `nextVisibleIdAfterRemoval` is not exported.
|
|
167
|
+
|
|
168
|
+
- [ ] **Step 3: Write the implementation** (append to `src/core/rows.mjs`)
|
|
169
|
+
|
|
170
|
+
```js
|
|
171
|
+
/**
|
|
172
|
+
* Issue #147: the row the board should select after `id` is removed — the
|
|
173
|
+
* next visible row in dashboard order, else the previous one, else null
|
|
174
|
+
* (caller falls back to the existing orderedIds[0] clamp). Uses the same
|
|
175
|
+
* groupRowsByFolder ordering as the dashboard's orderedIds.
|
|
176
|
+
* @param {Row[]} rows
|
|
177
|
+
* @param {string} id
|
|
178
|
+
* @param {number} now
|
|
179
|
+
* @returns {string | null}
|
|
180
|
+
*/
|
|
181
|
+
export function nextVisibleIdAfterRemoval(rows, id, now) {
|
|
182
|
+
const ordered = groupRowsByFolder(rows, now).flatMap((g) => g.folders.flatMap((f) => f.rows.map((r) => r.id)));
|
|
183
|
+
const idx = ordered.indexOf(id);
|
|
184
|
+
if (idx === -1) return null;
|
|
185
|
+
if (idx + 1 < ordered.length) return ordered[idx + 1];
|
|
186
|
+
if (idx - 1 >= 0) return ordered[idx - 1];
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
192
|
+
|
|
193
|
+
Run: `node --test test/rows.test.mjs`
|
|
194
|
+
Expected: PASS (all tests in the file, including the new one).
|
|
195
|
+
|
|
196
|
+
- [ ] **Step 5: Commit**
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
git add src/core/rows.mjs test/rows.test.mjs
|
|
200
|
+
git commit -m "feat(core): neighbor id helper for post-delete board selection"
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### Task 3: `PtyAttachComponent` delete gesture + smoke scenarios (spec A3, A4)
|
|
204
|
+
|
|
205
|
+
**Files:**
|
|
206
|
+
- Modify: `src/ui/pty-attach.ts` — type `:17`, private fields ~`:156`, `handleInput` `:289`, `handleMouse` `:376-385`, `render` header `:399-401`, plus a new private helper near `detach()` (`:435`)
|
|
207
|
+
- Modify: `test-support/detach-gate-smoke.ts` — extend `makeAttach` (title option + `resultAction`), add scenarios Q1–Q8
|
|
208
|
+
- Test: `test/pty-attach-delete-gate.test.mjs` (new)
|
|
209
|
+
|
|
210
|
+
**Interfaces:**
|
|
211
|
+
- Consumes: `resolveAttachDeleteKey` from Task 1; `truncateToWidth` (already imported `:7`); `resolveAttachMouseAction` (already used in `handleMouse`).
|
|
212
|
+
- Produces: `PtyAttachResult` third state `{ action: "delete" }` — Task 4's `attach()` switches on it.
|
|
213
|
+
|
|
214
|
+
- [ ] **Step 1: Write the failing smoke scenarios + test file**
|
|
215
|
+
|
|
216
|
+
In `test-support/detach-gate-smoke.ts`:
|
|
217
|
+
|
|
218
|
+
(a) `makeAttach` gains a title option and exposes the captured result action — change the signature line and the constructor call, and extend the returned object:
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
function makeAttach(options: { backgroundReplies?: Array<{ r: number; g: number; b: number } | undefined>; queryVia?: "background" | "colors"; title?: string } = {}) {
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
const attach = new PtyAttachComponent(
|
|
226
|
+
scopedTui as never,
|
|
227
|
+
theme,
|
|
228
|
+
keybindings,
|
|
229
|
+
(r) => { result = r; },
|
|
230
|
+
{ socketPath: "/no/such/socket", title: options.title ?? "gate" },
|
|
231
|
+
);
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
return {
|
|
236
|
+
attach,
|
|
237
|
+
sent,
|
|
238
|
+
terminalWrites,
|
|
239
|
+
scopedTui,
|
|
240
|
+
backgroundProbeCalls,
|
|
241
|
+
didDetach: () => (result as { action?: string } | null)?.action === "detached",
|
|
242
|
+
resultAction: () => (result as { action?: string } | null)?.action ?? null,
|
|
243
|
+
};
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
(b) Append scenarios Q1–Q8 after the P13 block (keep the file's `out` record style; reuse `plainTui`/`theme`/`waitFor`):
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
// Q1. Issue #147: ctrl+x arms the in-place delete confirm — header row is
|
|
250
|
+
// replaced (same row count), the chord is NOT forwarded, and y confirms by
|
|
251
|
+
// resolving {action:"delete"} with no detach/terminate frame. The default
|
|
252
|
+
// harness socket never connected, so this also pins "works while disconnected".
|
|
253
|
+
{
|
|
254
|
+
const { attach, sent, resultAction } = makeAttach({ title: "session-a" });
|
|
255
|
+
attach.handleInput("\x18");
|
|
256
|
+
const header = attach.render(80)[0];
|
|
257
|
+
out.deleteArmShowsPrompt = header.includes("y confirms") && header.includes("session-a");
|
|
258
|
+
out.deleteArmHoldsKey = !sent.some((m) => m.type === "input" && m.data === "\x18");
|
|
259
|
+
attach.handleInput("y");
|
|
260
|
+
out.deleteConfirmResolves = resultAction() === "delete";
|
|
261
|
+
out.deleteConfirmSendsNoControlFrame = !sent.some((m) => m.type === "detach" || m.type === "terminate");
|
|
262
|
+
attach.dispose();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Q2. Long names truncate (<=24 cols) so the confirm hint stays visible.
|
|
266
|
+
{
|
|
267
|
+
const { attach } = makeAttach({ title: "x".repeat(40) });
|
|
268
|
+
attach.handleInput("\x18");
|
|
269
|
+
out.deleteLongNameKeepsHint = attach.render(80)[0].includes("y confirms");
|
|
270
|
+
attach.dispose();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Q3. Any other key cancels and is forwarded — input is never swallowed.
|
|
274
|
+
{
|
|
275
|
+
const { attach, sent, resultAction } = makeAttach();
|
|
276
|
+
attach.handleInput("\x18");
|
|
277
|
+
attach.handleInput("n");
|
|
278
|
+
out.deleteCancelForwards = sent.some((m) => m.type === "input" && m.data === "n") && resultAction() === null;
|
|
279
|
+
attach.dispose();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Q4. Second ctrl+x is swallowed (cancel-swallow): no byte reaches the child,
|
|
283
|
+
// the prompt clears, and a following y is ordinary input.
|
|
284
|
+
{
|
|
285
|
+
const { attach, sent, resultAction } = makeAttach();
|
|
286
|
+
attach.handleInput("\x18");
|
|
287
|
+
attach.handleInput("\x18");
|
|
288
|
+
out.deleteSecondSwallowed = !sent.some((m) => m.type === "input" && m.data === "\x18") && !attach.render(80)[0].includes("y confirms");
|
|
289
|
+
attach.handleInput("y");
|
|
290
|
+
out.deleteYAfterSwallowForwards = sent.some((m) => m.type === "input" && m.data === "y") && resultAction() === null;
|
|
291
|
+
attach.dispose();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Q5. A mouse chunk on the handleInput channel clears the armed state.
|
|
295
|
+
{
|
|
296
|
+
const { attach, sent, resultAction } = makeAttach();
|
|
297
|
+
attach.handleInput("\x18");
|
|
298
|
+
attach.handleInput("\x1b[<64;10;10M");
|
|
299
|
+
attach.handleInput("y");
|
|
300
|
+
out.deleteWheelChunkClears = sent.some((m) => m.type === "input" && m.data === "y") && resultAction() === null;
|
|
301
|
+
attach.dispose();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Q6. A TuiMouseEvent on the handleMouse channel (fullscreen mode, #167/#169)
|
|
305
|
+
// clears the armed state too — native paste is off so the event returns
|
|
306
|
+
// undefined, exercising the not-consumed path.
|
|
307
|
+
{
|
|
308
|
+
const { attach, sent, resultAction } = makeAttach();
|
|
309
|
+
attach.handleInput("\x18");
|
|
310
|
+
process.env.AGENT_BOARD_ATTACH_NATIVE_PASTE = "0";
|
|
311
|
+
(attach as unknown as { handleMouse: (e: unknown) => unknown }).handleMouse({
|
|
312
|
+
type: "press", button: "middle", x: 3, y: 3, screenX: 3, screenY: 3, width: 80, height: 22, shift: false,
|
|
313
|
+
});
|
|
314
|
+
delete process.env.AGENT_BOARD_ATTACH_NATIVE_PASTE;
|
|
315
|
+
attach.handleInput("y");
|
|
316
|
+
out.deleteHandleMouseClears = sent.some((m) => m.type === "input" && m.data === "y") && resultAction() === null;
|
|
317
|
+
attach.dispose();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Q7. Encoding forms arm too (kitty CSI-u, modifyOtherKeys).
|
|
321
|
+
{
|
|
322
|
+
const kitty = makeAttach();
|
|
323
|
+
kitty.attach.handleInput("\x1b[120;5u");
|
|
324
|
+
const mok = makeAttach();
|
|
325
|
+
mok.attach.handleInput("\x1b[27;5;120~");
|
|
326
|
+
out.deleteEncodingsArm = kitty.attach.render(80)[0].includes("y confirms") && mok.attach.render(80)[0].includes("y confirms");
|
|
327
|
+
kitty.attach.dispose();
|
|
328
|
+
mok.attach.dispose();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Q8. Confirm teardown mirrors detach()'s G3 ordering minus the control
|
|
332
|
+
// frame: restore the held PTY size, then a graceful socket end — never
|
|
333
|
+
// destroy, never a detach packet (clone of scenario D with \x18 + y).
|
|
334
|
+
{
|
|
335
|
+
const attach = new PtyAttachComponent(plainTui as never, theme, keybindings, () => {}, { socketPath: "/no/such/socket", title: "wire" });
|
|
336
|
+
const wire: string[] = [];
|
|
337
|
+
const socket = {
|
|
338
|
+
write(data: string) { wire.push(data); return true; },
|
|
339
|
+
end() { wire.push("END"); },
|
|
340
|
+
destroy() { wire.push("DESTROY"); },
|
|
341
|
+
once(_event: string, _listener: () => void) { return this; },
|
|
342
|
+
};
|
|
343
|
+
const internals = attach as unknown as {
|
|
344
|
+
socket: typeof socket | null;
|
|
345
|
+
connected: boolean;
|
|
346
|
+
jiggleRetry: { start: (cols: number, rows: number) => void };
|
|
347
|
+
};
|
|
348
|
+
internals.socket = socket;
|
|
349
|
+
internals.connected = true;
|
|
350
|
+
internals.jiggleRetry.start(80, 22);
|
|
351
|
+
wire.length = 0;
|
|
352
|
+
attach.handleInput("\x18");
|
|
353
|
+
attach.handleInput("y");
|
|
354
|
+
const packets = wire.filter((entry) => entry !== "END" && entry !== "DESTROY").map((entry) => JSON.parse(entry));
|
|
355
|
+
out.deleteTeardownRestoresBeforeEnd =
|
|
356
|
+
packets.length === 1 &&
|
|
357
|
+
packets[0].type === "resize" && packets[0].cols === 80 && packets[0].rows === 22 &&
|
|
358
|
+
wire.at(-1) === "END" && !wire.includes("DESTROY");
|
|
359
|
+
attach.dispose();
|
|
360
|
+
}
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
(c) The assertions file `test/pty-attach-delete-gate.test.mjs` (mirrors `pty-attach-detach-gate.test.mjs`):
|
|
364
|
+
|
|
365
|
+
```js
|
|
366
|
+
// test/pty-attach-delete-gate.test.mjs
|
|
367
|
+
import assert from "node:assert/strict";
|
|
368
|
+
import { execFileSync } from "node:child_process";
|
|
369
|
+
import { fileURLToPath } from "node:url";
|
|
370
|
+
import { join } from "node:path";
|
|
371
|
+
import test from "node:test";
|
|
372
|
+
|
|
373
|
+
const ROOT_DIR = fileURLToPath(new URL("../", import.meta.url));
|
|
374
|
+
const GATE_SCRIPT = join(ROOT_DIR, "test-support", "detach-gate-smoke.ts");
|
|
375
|
+
|
|
376
|
+
// Issue #147: the in-attach delete gesture — arm/confirm/cancel/cancel-swallow
|
|
377
|
+
// branches, both mouse channels, encoding forms, and the teardown ordering.
|
|
378
|
+
test("attach delete gate: arm/confirm/cancel/teardown semantics", () => {
|
|
379
|
+
const out = execFileSync(process.execPath, ["--experimental-transform-types", GATE_SCRIPT], {
|
|
380
|
+
encoding: "utf8",
|
|
381
|
+
timeout: 30_000,
|
|
382
|
+
});
|
|
383
|
+
const parsed = JSON.parse(out);
|
|
384
|
+
for (const key of [
|
|
385
|
+
"deleteArmShowsPrompt",
|
|
386
|
+
"deleteArmHoldsKey",
|
|
387
|
+
"deleteConfirmResolves",
|
|
388
|
+
"deleteConfirmSendsNoControlFrame",
|
|
389
|
+
"deleteLongNameKeepsHint",
|
|
390
|
+
"deleteCancelForwards",
|
|
391
|
+
"deleteSecondSwallowed",
|
|
392
|
+
"deleteYAfterSwallowForwards",
|
|
393
|
+
"deleteWheelChunkClears",
|
|
394
|
+
"deleteHandleMouseClears",
|
|
395
|
+
"deleteEncodingsArm",
|
|
396
|
+
"deleteTeardownRestoresBeforeEnd",
|
|
397
|
+
]) {
|
|
398
|
+
assert.equal(parsed[key], true, `${key} must hold (issue #147 A3/A4)`);
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
404
|
+
|
|
405
|
+
Run: `node --test test/pty-attach-delete-gate.test.mjs`
|
|
406
|
+
Expected: FAIL — every `delete*` key is `undefined` (component has no gesture yet). The pre-existing keys parsed by other tests must stay green.
|
|
407
|
+
|
|
408
|
+
- [ ] **Step 3: Implement the component gesture** (in `src/ui/pty-attach.ts`)
|
|
409
|
+
|
|
410
|
+
(a) Type (`:17`):
|
|
411
|
+
|
|
412
|
+
```ts
|
|
413
|
+
export type PtyAttachResult = { action: "detached" } | { action: "closed"; exitCode?: number | null } | { action: "delete" };
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
(b) Import + field (import block top of file; field near `private status = "connecting";`):
|
|
417
|
+
|
|
418
|
+
```ts
|
|
419
|
+
import { resolveAttachDeleteKey } from "../core/attach-delete.mjs";
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
```ts
|
|
423
|
+
/** Issue #147: in-place delete confirm is armed (first ctrl+x). */
|
|
424
|
+
private confirmArmed = false;
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
(c) Helper (next to `detach()`):
|
|
428
|
+
|
|
429
|
+
```ts
|
|
430
|
+
private clearConfirmArmed(): void {
|
|
431
|
+
if (!this.confirmArmed) return;
|
|
432
|
+
this.confirmArmed = false;
|
|
433
|
+
this.scheduleRender();
|
|
434
|
+
}
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
(d) `handleInput` — after the mouse branch line, before the `pageUp` branch (spec §3.4.3 insertion point):
|
|
438
|
+
|
|
439
|
+
```ts
|
|
440
|
+
handleInput(data: string): void {
|
|
441
|
+
const mouseInput = parseMouseInputChunk(data);
|
|
442
|
+
if (mouseInput) this.clearConfirmArmed();
|
|
443
|
+
if (mouseInput && this.handleMouseInputChunk(mouseInput)) return;
|
|
444
|
+
// Issue #147: in-place delete gesture. The resolver is pure and never
|
|
445
|
+
// reads connection state, so the gesture works even while the host is
|
|
446
|
+
// down. cancel falls through so input is never swallowed.
|
|
447
|
+
const deleteKey = resolveAttachDeleteKey({ confirmArmed: this.confirmArmed }, data);
|
|
448
|
+
this.confirmArmed = deleteKey.confirmArmed;
|
|
449
|
+
if (deleteKey.action === "arm") {
|
|
450
|
+
this.scheduleRender();
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if (deleteKey.action === "confirm") {
|
|
454
|
+
// Teardown first — same shape as detach() minus the control frame:
|
|
455
|
+
// close(true) restores a held PTY (G3, issue #42) and gracefully
|
|
456
|
+
// ends the socket; the consumer archives and the host is terminated,
|
|
457
|
+
// so no detach packet is sent. done() before archive so the host's
|
|
458
|
+
// death is never rendered here.
|
|
459
|
+
this.close(true);
|
|
460
|
+
this.done({ action: "delete" });
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
if (deleteKey.action === "cancel-swallow") {
|
|
464
|
+
this.scheduleRender();
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (deleteKey.action === "cancel") this.scheduleRender();
|
|
468
|
+
if (matchesKey(data, Key.pageUp)) {
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
(e) `handleMouse` — clear at entry (before `resolveAttachMouseAction`), covering consumed and not-consumed paths:
|
|
472
|
+
|
|
473
|
+
```ts
|
|
474
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
475
|
+
this.clearConfirmArmed();
|
|
476
|
+
const action = resolveAttachMouseAction(event, {
|
|
477
|
+
nativePasteEnabled: process.env.AGENT_BOARD_ATTACH_NATIVE_PASTE !== "0",
|
|
478
|
+
});
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
(f) `render` header (`:399-401`) — armed row replaces the header line, same row count, `NAME` truncated:
|
|
482
|
+
|
|
483
|
+
```ts
|
|
484
|
+
const header = this.confirmArmed
|
|
485
|
+
? this.theme.fg("accent", this.theme.bold(` Delete "${truncateToWidth(this.opts.title, 24, "")}"? `)) +
|
|
486
|
+
this.theme.fg("muted", "y confirms · any other key cancels")
|
|
487
|
+
: this.theme.fg("accent", this.theme.bold(` ${this.opts.title} `)) +
|
|
488
|
+
this.theme.fg("muted", `${this.status} · opens links · drag selects+copies · Ctrl+←/Ctrl+\\ detach`);
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
492
|
+
|
|
493
|
+
Run: `node --test test/pty-attach-delete-gate.test.mjs test/pty-attach-detach-gate.test.mjs`
|
|
494
|
+
Expected: PASS both files — the new gate keys all true, and the pre-existing detach-gate scenarios stay green (no key routing regressions).
|
|
495
|
+
|
|
496
|
+
- [ ] **Step 5: Commit**
|
|
497
|
+
|
|
498
|
+
```bash
|
|
499
|
+
git add src/ui/pty-attach.ts test-support/detach-gate-smoke.ts test/pty-attach-delete-gate.test.mjs
|
|
500
|
+
git commit -m "feat(attach): ctrl+x in-place delete confirm with teardown-first exit"
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
### Task 4: Command layer + board reopen plumbing (spec A5, A6)
|
|
504
|
+
|
|
505
|
+
**Files:**
|
|
506
|
+
- Modify: `src/commands/agent-board.ts` — `AttachOutcome` `:137`, `dashboardAttachLoop` `:139-161`, `openDashboard` options `:74-80` + deps pass `:104`, `attach()` open-pty branch `:187-192`
|
|
507
|
+
- Modify: `src/ui/dashboard.ts` — `DashboardDeps` `:116-124`, ctor `:175`
|
|
508
|
+
- Create: `test-support/attach-delete-flow-probe.ts`
|
|
509
|
+
- Test: `test/attach-delete-flow.test.mjs` (new)
|
|
510
|
+
|
|
511
|
+
**Interfaces:**
|
|
512
|
+
- Consumes: `nextVisibleIdAfterRemoval` (Task 2), `PtyAttachResult` `{action:"delete"}` (Task 3), `DashboardDeps.initialNotice` (this task).
|
|
513
|
+
- Produces: `AttachOutcome.afterDelete?: { nextSelectedId: string | null; notice: string }`; `openDashboard` option `initialNotice?: string`.
|
|
514
|
+
|
|
515
|
+
- [ ] **Step 1: Write the failing flow probe + test**
|
|
516
|
+
|
|
517
|
+
`test-support/attach-delete-flow-probe.ts` (pattern: `navigation-wrap.ts` service construction + scripted `ctx.ui.custom`):
|
|
518
|
+
|
|
519
|
+
```ts
|
|
520
|
+
// A5/A6 probe (issue #147): drive the REAL dashboardAttachLoop with a stub
|
|
521
|
+
// ctx and a real temp-root service. ui.custom is scripted two-stage: the
|
|
522
|
+
// board stage runs the factory (constructing a real DashboardComponent whose
|
|
523
|
+
// deps we capture) and resolves the attach action; the PTY stage resolves the
|
|
524
|
+
// component result {action:"delete"} without a factory run. service.resolveAttachTarget
|
|
525
|
+
// is stubbed to {kind:"pty"} — the stub custom never connects a socket (spec §5
|
|
526
|
+
// exception). DEL_FLOW_MODE=archive-fail stubs service.archive for A6.
|
|
527
|
+
// Run via `node --experimental-transform-types`.
|
|
528
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
529
|
+
import { tmpdir } from "node:os";
|
|
530
|
+
import { join } from "node:path";
|
|
531
|
+
import { createService } from "../src/runtime/service.mjs";
|
|
532
|
+
import { createView } from "../src/core/store.mjs";
|
|
533
|
+
import { dashboardAttachLoop } from "../src/commands/agent-board.ts";
|
|
534
|
+
|
|
535
|
+
const root = mkdtempSync(join(tmpdir(), "agentview-del-flow-"));
|
|
536
|
+
createView(root, { id: "v1", name: "one", cwd: root });
|
|
537
|
+
createView(root, { id: "v2", name: "two", cwd: root });
|
|
538
|
+
createView(root, { id: "v3", name: "three", cwd: root });
|
|
539
|
+
|
|
540
|
+
const service = createService({
|
|
541
|
+
root,
|
|
542
|
+
runnerScript: "/no/runner.mjs",
|
|
543
|
+
piCommand: "pi",
|
|
544
|
+
piArgsPrefix: [],
|
|
545
|
+
defaultCwd: root,
|
|
546
|
+
launch: () => ({ pid: null, configPath: "/no/config.json" }),
|
|
547
|
+
launchHost: () => ({ pid: null, configPath: "/no/host-config.json" }),
|
|
548
|
+
launchTitle: () => ({ pid: null, configPath: "/no/title-config.json" }),
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
// Session files must exist so "archive preserves the session file" is a real assertion.
|
|
552
|
+
for (const id of ["v1", "v2", "v3"]) {
|
|
553
|
+
const meta = JSON.parse(readFileSync(join(root, "views", id, "meta.json"), "utf8"));
|
|
554
|
+
if (meta.sessionFile && !existsSync(meta.sessionFile)) writeFileSync(meta.sessionFile, "");
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const stub = service as unknown as { resolveAttachTarget: (id: string) => Promise<unknown>; archive: (id: string) => Promise<{ ok: boolean; error?: string }> };
|
|
558
|
+
stub.resolveAttachTarget = async () => ({ kind: "pty", socketPath: join(root, "fake.sock") });
|
|
559
|
+
const archiveCalls: string[] = [];
|
|
560
|
+
const realArchive = service.archive.bind(service);
|
|
561
|
+
if (process.env.DEL_FLOW_MODE === "archive-fail") {
|
|
562
|
+
stub.archive = async (id: string) => { archiveCalls.push(id); return { ok: false, error: "boom" }; };
|
|
563
|
+
} else {
|
|
564
|
+
stub.archive = async (id: string) => { archiveCalls.push(id); return realArchive(id); };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const notifications: Array<{ text: string; level: string }> = [];
|
|
568
|
+
const boardDeps: Array<{ initialSelectedId: string | null; initialNotice?: string }> = [];
|
|
569
|
+
let stage = 0;
|
|
570
|
+
const fakeTui = { terminal: { rows: 24, cols: 80, columns: 80, write() {} }, requestRender() {} };
|
|
571
|
+
const theme = { fg: (_c: string, t: string) => t, bold: (t: string) => t };
|
|
572
|
+
|
|
573
|
+
const ctx = {
|
|
574
|
+
cwd: root,
|
|
575
|
+
model: null,
|
|
576
|
+
modelRegistry: { getAvailable: () => [] },
|
|
577
|
+
sessionManager: { getSessionFile: () => null },
|
|
578
|
+
ui: {
|
|
579
|
+
setWorkingVisible() {},
|
|
580
|
+
setHeader() {},
|
|
581
|
+
setFooter() {},
|
|
582
|
+
setTitle() {},
|
|
583
|
+
notify: (text: string, level: string) => notifications.push({ text, level }),
|
|
584
|
+
custom: (factory: (t: unknown, th: unknown, kb: unknown, done: (r: unknown) => void) => unknown) =>
|
|
585
|
+
new Promise((resolve) => {
|
|
586
|
+
stage += 1;
|
|
587
|
+
if (stage === 2) {
|
|
588
|
+
resolve({ action: "delete" });
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
const comp = factory(fakeTui, theme, {}, (result: unknown) => resolve(result)) as unknown as {
|
|
592
|
+
deps: { initialSelectedId: string | null; initialNotice?: string };
|
|
593
|
+
done: (r: unknown) => void;
|
|
594
|
+
dispose?: () => void;
|
|
595
|
+
};
|
|
596
|
+
boardDeps.push({ initialSelectedId: comp.deps.initialSelectedId, initialNotice: comp.deps.initialNotice });
|
|
597
|
+
comp.done(stage === 1 ? { action: "attach", viewId: "v2", stopFirst: false } : { action: "exit" });
|
|
598
|
+
comp.dispose?.();
|
|
599
|
+
}),
|
|
600
|
+
},
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
await dashboardAttachLoop(ctx as never, service, root, null);
|
|
604
|
+
|
|
605
|
+
const v2Meta = JSON.parse(readFileSync(join(root, "views", "v2", "meta.json"), "utf8"));
|
|
606
|
+
const sessionExists = existsSync(String(v2Meta.sessionFile));
|
|
607
|
+
const rowsAfter = (await import("../src/core/store.mjs")).listRows(root).map((r) => r.meta.id);
|
|
608
|
+
const report = {
|
|
609
|
+
error: null as string | null,
|
|
610
|
+
archived: v2Meta.archived === true,
|
|
611
|
+
sessionExists,
|
|
612
|
+
rowsAfter,
|
|
613
|
+
archiveCalls,
|
|
614
|
+
notifications,
|
|
615
|
+
boardDeps,
|
|
616
|
+
stage,
|
|
617
|
+
};
|
|
618
|
+
try {
|
|
619
|
+
console.log(JSON.stringify(report));
|
|
620
|
+
} finally {
|
|
621
|
+
rmSync(root, { recursive: true, force: true });
|
|
622
|
+
}
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
`test/attach-delete-flow.test.mjs`:
|
|
626
|
+
|
|
627
|
+
```js
|
|
628
|
+
// test/attach-delete-flow.test.mjs
|
|
629
|
+
import assert from "node:assert/strict";
|
|
630
|
+
import { execFileSync } from "node:child_process";
|
|
631
|
+
import { fileURLToPath } from "node:url";
|
|
632
|
+
import { join } from "node:path";
|
|
633
|
+
import test from "node:test";
|
|
634
|
+
|
|
635
|
+
const ROOT_DIR = fileURLToPath(new URL("../", import.meta.url));
|
|
636
|
+
const PROBE = join(ROOT_DIR, "test-support", "attach-delete-flow-probe.ts");
|
|
637
|
+
|
|
638
|
+
function runProbe(mode) {
|
|
639
|
+
const out = execFileSync(process.execPath, ["--experimental-transform-types", PROBE], {
|
|
640
|
+
encoding: "utf8",
|
|
641
|
+
timeout: 60_000,
|
|
642
|
+
env: { ...process.env, ...(mode ? { DEL_FLOW_MODE: mode } : {}) },
|
|
643
|
+
});
|
|
644
|
+
return JSON.parse(out.trim().split("\n").filter(Boolean).at(-1));
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// A5: the loop archives the deleted row, preserves its session file, and the
|
|
648
|
+
// reopened board receives the neighbor id + the Deleted notice.
|
|
649
|
+
test("attach delete flow: archive + board reopen params (issue #147 A5)", () => {
|
|
650
|
+
const r = runProbe();
|
|
651
|
+
assert.equal(r.error, null);
|
|
652
|
+
assert.equal(r.archived, true, "v2 must be archived");
|
|
653
|
+
assert.equal(r.sessionExists, true, "session file must be preserved");
|
|
654
|
+
assert.deepEqual(r.archiveCalls, ["v2"], "exactly one archive call for the deleted view");
|
|
655
|
+
assert.ok(!r.rowsAfter.includes("v2"), "archived row leaves the board list");
|
|
656
|
+
assert.equal(r.boardDeps.length, 2, "two board opens (attach pick + post-delete reopen)");
|
|
657
|
+
assert.equal(r.boardDeps[1].initialSelectedId, "v3", "neighbor id lands on the reopened board");
|
|
658
|
+
assert.equal(r.boardDeps[1].initialNotice, 'Deleted "two"', "the notice lands on the reopened board");
|
|
659
|
+
assert.deepEqual(r.notifications, [], "no notifications on the happy path");
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
// A6: archive failure is not silent — an error notify fires, the loop still
|
|
663
|
+
// reopens the board, and nothing throws.
|
|
664
|
+
test("attach delete flow: archive failure notifies (issue #147 A6)", () => {
|
|
665
|
+
const r = runProbe("archive-fail");
|
|
666
|
+
assert.equal(r.error, null);
|
|
667
|
+
assert.deepEqual(r.archiveCalls, ["v2"]);
|
|
668
|
+
assert.ok(r.notifications.some((n) => n.level === "error" && n.text.includes("boom")), "error notify fires");
|
|
669
|
+
assert.equal(r.boardDeps.length, 2, "board still reopens after the failure");
|
|
670
|
+
assert.equal(r.archived, false, "row stays (archive stubbed to fail)");
|
|
671
|
+
});
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
675
|
+
|
|
676
|
+
Run: `node --test test/attach-delete-flow.test.mjs`
|
|
677
|
+
Expected: FAIL — `boardDeps.length` is 1 (no delete branch: the PTY stage's `{action:"delete"}` maps to `detached`, no `afterDelete`, no `initialNotice` dep) or `archived` is false.
|
|
678
|
+
|
|
679
|
+
- [ ] **Step 3: Implement the command layer + dashboard dep**
|
|
680
|
+
|
|
681
|
+
(a) `src/ui/dashboard.ts` — deps interface (`:116-124`) gains one field, ctor consumes it right after `this.selectedId = deps.initialSelectedId ?? null;` (`:175`):
|
|
682
|
+
|
|
683
|
+
```ts
|
|
684
|
+
initialSelectedId?: string | null;
|
|
685
|
+
initialNotice?: string;
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
```ts
|
|
689
|
+
this.selectedId = deps.initialSelectedId ?? null;
|
|
690
|
+
// Issue #147: the post-delete reopen carries the Deleted notice here —
|
|
691
|
+
// the component is new each open, so an inline notice cannot travel.
|
|
692
|
+
if (deps.initialNotice) this.notice(deps.initialNotice, "info");
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
(b) `src/commands/agent-board.ts` — import the Task 2 helper:
|
|
696
|
+
|
|
697
|
+
```ts
|
|
698
|
+
import { nextVisibleIdAfterRemoval } from "../core/rows.mjs";
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
`AttachOutcome` (`:137`):
|
|
702
|
+
|
|
703
|
+
```ts
|
|
704
|
+
type AttachOutcome = { action: "detached" | "closed" | "switched" | "none"; afterDelete?: { nextSelectedId: string | null; notice: string } };
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
`dashboardAttachLoop` (`:139-161`) — thread the notice and neighbor:
|
|
708
|
+
|
|
709
|
+
```ts
|
|
710
|
+
let selectedId = initialSelectedId;
|
|
711
|
+
let again = true;
|
|
712
|
+
let pendingNotice: string | undefined;
|
|
713
|
+
while (again) {
|
|
714
|
+
const currentId = currentViewId(ctx, service);
|
|
715
|
+
if (currentId) void service.markVisited?.(currentId)?.catch(() => {});
|
|
716
|
+
void service.reconcile().catch(() => {});
|
|
717
|
+
const result = await openDashboard(ctx, service, {
|
|
718
|
+
initialSelectedId: selectedId,
|
|
719
|
+
currentThinkingLevel: getThinkingLevel?.(),
|
|
720
|
+
initialNotice: pendingNotice,
|
|
721
|
+
});
|
|
722
|
+
pendingNotice = undefined;
|
|
723
|
+
if (result.action !== "attach") return;
|
|
724
|
+
selectedId = result.viewId;
|
|
725
|
+
const outcome = await attach(ctx, service, root, result.viewId, result.stopFirst);
|
|
726
|
+
if (outcome.afterDelete) {
|
|
727
|
+
// Issue #147: land on the neighbor (null -> deleted id -> existing
|
|
728
|
+
// orderedIds[0] clamp) and show the Deleted notice once.
|
|
729
|
+
selectedId = outcome.afterDelete.nextSelectedId ?? selectedId;
|
|
730
|
+
pendingNotice = outcome.afterDelete.notice;
|
|
731
|
+
}
|
|
732
|
+
again = outcome.action === "detached" || outcome.action === "closed" || outcome.action === "none";
|
|
733
|
+
}
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
`openDashboard` options (`:74-80`) + deps pass (`:104`):
|
|
737
|
+
|
|
738
|
+
```ts
|
|
739
|
+
initialSelectedId?: string | null;
|
|
740
|
+
initialNotice?: string;
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
```ts
|
|
744
|
+
initialSelectedId: options.initialSelectedId,
|
|
745
|
+
initialNotice: options.initialNotice,
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
`attach()` open-pty branch (`:187-192`):
|
|
749
|
+
|
|
750
|
+
```ts
|
|
751
|
+
if (plan.plan === "open-pty") {
|
|
752
|
+
void service.markVisited?.(viewId)?.catch(() => {});
|
|
753
|
+
const result = await openPtyAttach(ctx, root, row.meta.id, row.meta.name, plan.socketPath);
|
|
754
|
+
void service.markVisited?.(viewId)?.catch(() => {});
|
|
755
|
+
if (result.action === "delete") {
|
|
756
|
+
// Issue #147: the component already tore down and exited — archive
|
|
757
|
+
// here so the host's death is never rendered inside the attach view.
|
|
758
|
+
// Neighbor and name are computed BEFORE archiving (rows change).
|
|
759
|
+
const latest = service.row(viewId) ?? row;
|
|
760
|
+
const name = latest.meta.name;
|
|
761
|
+
const nextSelectedId = nextVisibleIdAfterRemoval(service.rows(), viewId, Date.now());
|
|
762
|
+
const res = await service.archive(viewId);
|
|
763
|
+
if (!res.ok) ctx.ui.notify(res.error ?? "Delete failed", "error");
|
|
764
|
+
else return { action: "detached", afterDelete: { nextSelectedId, notice: `Deleted "${name}"` } };
|
|
765
|
+
return { action: "detached" };
|
|
766
|
+
}
|
|
767
|
+
return { action: result.action === "closed" ? "closed" : "detached" };
|
|
768
|
+
}
|
|
769
|
+
```
|
|
770
|
+
|
|
771
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
772
|
+
|
|
773
|
+
Run: `node --test test/attach-delete-flow.test.mjs`
|
|
774
|
+
Expected: PASS (both A5 and A6 tests).
|
|
775
|
+
|
|
776
|
+
- [ ] **Step 5: Commit**
|
|
777
|
+
|
|
778
|
+
```bash
|
|
779
|
+
git add src/commands/agent-board.ts src/ui/dashboard.ts test-support/attach-delete-flow-probe.ts test/attach-delete-flow.test.mjs
|
|
780
|
+
git commit -m "feat(board): archive from the attach view and reopen on the neighbor row"
|
|
781
|
+
```
|
|
782
|
+
|
|
783
|
+
### Task 5: Docs sync + full verification + manual checklist (spec A7, A8, U1–U4)
|
|
784
|
+
|
|
785
|
+
**Files:**
|
|
786
|
+
- Modify: `README.md:96` (quick-start attach paragraph) and `README.md:201` (PTY attach section paragraph)
|
|
787
|
+
|
|
788
|
+
**Interfaces:**
|
|
789
|
+
- Consumes: everything above; this task changes no code.
|
|
790
|
+
|
|
791
|
+
- [ ] **Step 1: README copy (both paragraphs get the same two sentences appended)**
|
|
792
|
+
|
|
793
|
+
Append to the end of both the `:96` quick-start paragraph and the `:201` attach-section paragraph:
|
|
794
|
+
|
|
795
|
+
```markdown
|
|
796
|
+
`Ctrl+X` opens an in-place delete confirm: press `y` (or Enter) to archive the row and return to the board with the next row selected — any other key cancels and is passed through to the session. While attached, the child Pi's own `Ctrl+X` bindings (copy in `/tree` and fullscreen, clear-all in the models dialog) are captured by this gesture.
|
|
797
|
+
```
|
|
798
|
+
|
|
799
|
+
- [ ] **Step 2: Static checks (A7)**
|
|
800
|
+
|
|
801
|
+
Run: `npm run typecheck && rg -n "Ctrl\+X.*opens an in-place delete confirm" README.md`
|
|
802
|
+
Expected: typecheck 0 errors; exactly 2 README hits (both attach paragraphs); board copy untouched (`rg -n "x., then .y. to confirm" README.md` still matches `:118`).
|
|
803
|
+
|
|
804
|
+
- [ ] **Step 3: Full verification (A8)**
|
|
805
|
+
|
|
806
|
+
Run: `npm run verify`
|
|
807
|
+
Expected: typecheck + perf gate + full test suite + coverage thresholds (lines 85 / funcs 80 / branches 70) + pack:dry all green, no new failures vs `884040f`.
|
|
808
|
+
|
|
809
|
+
- [ ] **Step 4: Commit**
|
|
810
|
+
|
|
811
|
+
```bash
|
|
812
|
+
git add README.md
|
|
813
|
+
git commit -m "docs: document the attach in-place delete gesture"
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
- [ ] **Step 5: Post-implementation manual verification (U1–U4) — user-executed, spec §4**
|
|
817
|
+
|
|
818
|
+
Record results in the PR description (these gate the merge, not the implementation):
|
|
819
|
+
|
|
820
|
+
- **U1**: on a checkout installed via `pi install "$(pwd)"` (or dev auto-discovery), restart pi, open the board, attach an idle session, press `Ctrl+X` (prompt appears), press `y` → row gone, `Deleted "name"` visible, selection on the neighbor row, session file still present, host stopped (no leftover pid).
|
|
821
|
+
- **U2**: attach → `Ctrl+X` → `n` → keep typing → session not deleted, no characters lost. Also: `Ctrl+X` twice → prompt clears, nothing forwarded, then typing `y` is ordinary input.
|
|
822
|
+
- **U3**: attach a Working session → `Ctrl+X` → `y` → run stops then archives; back at the board with no `host exited` / reconnect banner flash.
|
|
823
|
+
- **U4**: kill the host process (or wait for exited) → `Ctrl+X` → `y` → still deletes and returns to the board.
|
|
824
|
+
|
|
825
|
+
---
|
|
826
|
+
|
|
827
|
+
## Self-Review (done at plan time)
|
|
828
|
+
|
|
829
|
+
1. **Spec coverage:** A1→Task 1, A2→Task 2, A3+A4→Task 3 (Q1–Q8 + encodings), A5+A6→Task 4, A7+A8→Task 5 steps 2-3, U1–U4→Task 5 step 5. Spec §3.4.1-3.4.6 all map to Tasks 1-5; §3.5 boundary behaviors are pinned by Q3/Q4 (no-swallow/no-hang), Q8 (teardown), A6 (failure). Non-goals: no task touches board keys, archive internals, session-switch, or attach-flow.ts. ✔
|
|
830
|
+
2. **Placeholder scan:** no TBD/TODO; every code step carries real code; run commands and expected outcomes are explicit. ✔
|
|
831
|
+
3. **Type consistency:** `resolveAttachDeleteKey` five-action union used identically in Task 1 (impl) and Task 3 (component); `afterDelete` shape identical in Task 4's AttachOutcome and loop; `initialNotice` identical in dashboard deps and openDashboard options. ✔
|
|
832
|
+
4. **Review Focus:** all five lines pinned (Q3-Q8, A1-pure, Q2, A5/A6). ✔
|