@zhuxixi/pi-agent-board 0.4.1 → 0.4.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/README.md CHANGED
@@ -86,6 +86,7 @@ Useful environment variables:
86
86
  | `AGENT_BOARD_TITLE_MODEL=<model>` | Model for generated session titles. Defaults to `openai-codex/gpt-5.5`; use `off` to disable. |
87
87
  | `AGENT_BOARD_TITLE_THINKING_LEVEL=<level>` | Thinking level for title generation. Defaults to `low`; use `off` to omit it. |
88
88
  | `AGENT_BOARD_DISABLE_PTY=1` | Disable PTY attach mode. |
89
+ | `AGENT_BOARD_IME_FIX=0` | Disable the IME cursor-rect coalescer (issue #28). The coalescer folds pi-tui's per-frame cursor-park writes into the frame's synchronized-output block so terminals report one stable IME cursor position per frame instead of two (candidate-window flicker in busy sessions). |
89
90
  | `AGENT_BOARD_FORCE_PTY=1` | Force PTY attach mode. |
90
91
  | `AGENT_BOARD_ATTACH_MOUSE=0` | Disable attach-view mouse handling and use terminal-native selection. |
91
92
  | `AGENT_BOARD_WHEEL_LINES=<1-50>` | Lines scrolled per mouse-wheel event in attach view. Defaults to `1`. |
@@ -0,0 +1,337 @@
1
+ # Jiggle shrink-and-hold 协议改造 Implementation Plan(issue #25)
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:** 把 attach 的 jiggle 从"±1 脉冲对"改成"shrink-and-hold"协议——缩住直到见到全清,冷启动自愈 ≤3s,同时以 G1-G5 守卫保证不破坏任何现有功能。
6
+
7
+ **Architecture:** 协议主体在 `pty-attach-jiggle-controller.mjs`(armed→held→restored 状态 + G1/G2 计时);`pty-attach-jiggle-retry.mjs` 状态机不动(退避表仅作 G2 计时);`pty-attach.ts` 替换 forceChildRedraw 胶水并接入 G3/G4/G5;E2E stub 改 hold 语义并新增 shell 兜底用例。
8
+
9
+ **Tech Stack:** Node 24(node:test)、TypeScript(tsc --noEmit)、node-pty、@xterm/headless。
10
+
11
+ ## Global Constraints
12
+
13
+ - 工作目录:`/home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-25-jiggle-shrink-and-hold`(**禁碰主 checkout**)。
14
+ - hold 尺寸 = (cols−1)×(rows−1);restore = 原始尺寸;restore **幂等(只发一次)**。
15
+ - G1 = start 后 6s 无 `\x1b[?2026h` → restore;G2 = 退避表走完(56.12s)无全清 → restore。
16
+ - re-arm(首个 TUI 帧)→ **restore 原尺寸且不重排链**(无脉冲)。
17
+ - G3:close 时 `restoreAndStop()`;G5:`start()` 前若 held 先 restore;G4:`notifyExternalResize(c,r)` 取消 hold/计时器并把 (c,r) 记为新原尺寸。
18
+ - 删除 `JIGGLE_RESTORE_MS`(无脉冲对)。
19
+ - 代码风格:tab 缩进、双引号、JSDoc、core 模块 .mjs;commit 用 conventional commits;`git add` 按文件。
20
+ - Spec:`docs/superpowers/specs/2026-08-22-jiggle-shrink-and-hold-design.md`(已提交 b9b3aab)。
21
+
22
+ ---
23
+
24
+ ### Task 1: 控制器协议改造(hold 状态机 + G1/G2/G3/G4/G5)
25
+
26
+ **Files:**
27
+ - Modify: `src/core/pty-attach-jiggle-controller.mjs`
28
+ - Test: `test/pty-attach-jiggle-controller.test.mjs`
29
+
30
+ **Interfaces:**
31
+ - Consumes: `pty-attach-jiggle-retry.mjs` 的 `createJiggleRetryState/nextRetryDelay/advanceRetry/stopRetry` 与 `feedOutput`(含 frameStartFound/clearFound)——**全部原样使用,不改**。
32
+ - Produces(Task 2/3 依赖):
33
+ - deps: `{ sendResize(cols, rows), setTimeoutFn, clearTimeoutFn }`(`sendJiggle`/`shouldFire` **删除**)
34
+ - 返回 `{ start(cols, rows), feed(data), restoreAndStop(), notifyExternalResize(cols, rows), getState() }`
35
+ - `getState()` → `{ held, clearDetected, stopped, tuiFrameSeen, originalCols, originalRows }`(retryIndex 可保留内部)
36
+
37
+ **行为规格(精确):**
38
+
39
+ ```js
40
+ start(cols, rows):
41
+ 1. 清全部计时器;若 held → sendResize(prevOriginalCols, prevOriginalRows)(G5,幂等标记复位)
42
+ 2. originalCols/Rows = (cols, rows)
43
+ 3. sendResize(cols, rows); sendResize(cols-1, rows-1); held = true; restored = false
44
+ 4. tuiFrameSeen = false; clearDetected = false; stopped = false; state = createJiggleRetryState()
45
+ 5. G1 计时器 = 6s:fire 时若 !tuiFrameSeen → restoreIfHeld()
46
+ 6. 预算链照常排(nextRetryDelay/advanceRetry 循环),但回调 fire 时 **no-op**(不发脉冲、不 sendResize;仅推进计数);delay 走完(null)→ stopRetry + restoreIfHeld()(G2)
47
+
48
+ feed(data):
49
+ 1. 若 clearDetected → return(沿用)
50
+ 2. feedOutput 扫描(沿用)
51
+ 3. clearFound → restoreIfHeld(); 停链(stopRetry+clearTimer); clearDetected=true; 取消 G1
52
+ 4. frameStartFound 且 !tuiFrameSeen → tuiFrameSeen=true; 取消 G1; **restoreIfHeld()**; 不重排链、不碰预算
53
+
54
+ restoreIfHeld(): if (held && !restored) { sendResize(originalCols, originalRows); restored = true; held = false; }
55
+
56
+ restoreAndStop(): 清全部计时器; restoreIfHeld(); state = stopRetry(state)
57
+
58
+ notifyExternalResize(cols, rows):
59
+ 清全部计时器; held = false; restored = false(hold 作废,G4)
60
+ originalCols/Rows = (cols, rows)
61
+ state = stopRetry(state)(链终止;本次 attach 语义已让位于真实 resize)
62
+ ```
63
+
64
+ - [ ] **Step 1: 重写单测**(全量替换 test/pty-attach-jiggle-controller.test.mjs,fake scheduler 记录 sendResize 调用序列):
65
+
66
+ ```js
67
+ import assert from "node:assert/strict";
68
+ import test from "node:test";
69
+ import { createJiggleRetryController } from "../src/core/pty-attach-jiggle-controller.mjs";
70
+
71
+ function fakeScheduler() {
72
+ const timers = new Map();
73
+ let nextId = 1;
74
+ return {
75
+ timers,
76
+ setTimeoutFn(fn, ms) {
77
+ const id = nextId++;
78
+ timers.set(id, { fn, ms });
79
+ return id;
80
+ },
81
+ clearTimeoutFn(id) { timers.delete(id); },
82
+ fire(id) { const t = timers.get(id); assert.ok(t, "no such timer"); timers.delete(id); t.fn(); },
83
+ fireNext() { const first = timers.entries().next().value; assert.ok(first, "no pending timer"); this.fire(first[0]); },
84
+ delays() { return [...timers.values()].map((t) => t.ms); },
85
+ findByDelay(ms) { for (const [id, t] of timers) if (t.ms === ms) return id; return null; },
86
+ };
87
+ }
88
+
89
+ function makeController() {
90
+ const scheduler = fakeScheduler();
91
+ const resizes = [];
92
+ const controller = createJiggleRetryController({
93
+ sendResize: (c, r) => resizes.push([c, r]),
94
+ setTimeoutFn: scheduler.setTimeoutFn,
95
+ clearTimeoutFn: scheduler.clearTimeoutFn,
96
+ });
97
+ return { controller, scheduler, resizes };
98
+ }
99
+
100
+ test("start() arms hold: sends original then shrunk size, holds", () => {
101
+ const { controller, scheduler, resizes } = makeController();
102
+ controller.start(170, 36);
103
+ assert.deepEqual(resizes, [[170, 36], [169, 35]]);
104
+ assert.equal(controller.getState().held, true);
105
+ assert.notEqual(scheduler.findByDelay(6000), null, "G1 timer armed"); // 6000 in delays
106
+ assert.ok(scheduler.delays().includes(6000), "G1 timer present");
107
+ });
108
+
109
+ test("feed with clear restores original size exactly once and stops", () => {
110
+ const { controller, scheduler, resizes } = makeController();
111
+ controller.start(170, 36);
112
+ controller.feed("x\x1b[2J\x1b[Hy");
113
+ assert.deepEqual(resizes.slice(-1), [[170, 36]]);
114
+ assert.equal(controller.getState().clearDetected, true);
115
+ assert.equal(controller.getState().stopped, true);
116
+ assert.equal(controller.getState().held, false);
117
+ // 幂等:后续 feed 不再发
118
+ controller.feed("more\x1b[2J");
119
+ assert.equal(resizes.filter(([c]) => c === 170).length, 2); // start 的 + restore 的,无第三发
120
+ });
121
+
122
+ test("re-arm (first TUI frame) restores size and does NOT reschedule chain", () => {
123
+ const { controller, scheduler, resizes } = makeController();
124
+ controller.start(170, 36);
125
+ const budgetBefore = scheduler.delays().length;
126
+ controller.feed("\x1b[?2026h first frame");
127
+ assert.deepEqual(resizes.slice(-1), [[170, 36]]);
128
+ assert.equal(controller.getState().tuiFrameSeen, true);
129
+ assert.equal(scheduler.delays().length, budgetBefore, "no new timers scheduled");
130
+ // G1 已取消
131
+ assert.equal(scheduler.findByDelay(6000), null);
132
+ });
133
+
134
+ test("G1: no TUI frame within 6s restores original size", () => {
135
+ const { controller, scheduler, resizes } = makeController();
136
+ controller.start(170, 36);
137
+ const g1 = scheduler.findByDelay(6000);
138
+ assert.ok(g1);
139
+ scheduler.fire(g1);
140
+ assert.deepEqual(resizes.slice(-1), [[170, 36]]);
141
+ assert.equal(controller.getState().held, false);
142
+ });
143
+
144
+ test("G1 cancelled once a frame arrives (no double restore)", () => {
145
+ const { controller, scheduler, resizes } = makeController();
146
+ controller.start(170, 36);
147
+ controller.feed("\x1b[?2026h frame");
148
+ const g1 = scheduler.findByDelay(6000);
149
+ assert.equal(g1, null);
150
+ });
151
+
152
+ test("G2: backoff budget exhausted without clear restores original size", () => {
153
+ const { controller, scheduler, resizes } = makeController();
154
+ controller.start(170, 36);
155
+ controller.feed("\x1b[?2026h frame"); // re-arm restores, cancels G1
156
+ const before = resizes.length;
157
+ // 排干全部退避计时器(无脉冲 fire 均为推进)
158
+ for (let i = 0; i < 8; i++) scheduler.fireNext();
159
+ assert.equal(controller.getState().stopped, true);
160
+ // hold 已在 re-arm 时恢复,G2 无需再发(幂等)
161
+ assert.equal(resizes.length, before);
162
+ });
163
+
164
+ test("G2 without any frame: budget exhausted restores", () => {
165
+ const { controller, scheduler, resizes } = makeController();
166
+ controller.start(170, 36);
167
+ const g1 = scheduler.findByDelay(6000);
168
+ scheduler.fireNext(); // 第一个退避计时器(120ms)推进,no-op
169
+ for (let i = 0; i < 7; i++) scheduler.fireNext();
170
+ assert.equal(controller.getState().stopped, true);
171
+ assert.deepEqual(resizes.slice(-1), [[170, 36]], "G2 restore fired");
172
+ });
173
+
174
+ test("restoreAndStop() restores then stops (G3)", () => {
175
+ const { controller, scheduler, resizes } = makeController();
176
+ controller.start(170, 36);
177
+ controller.restoreAndStop();
178
+ assert.deepEqual(resizes.slice(-1), [[170, 36]]);
179
+ assert.equal(controller.getState().stopped, true);
180
+ assert.equal(scheduler.timers.size, 0);
181
+ });
182
+
183
+ test("start() after a held start restores previous hold first (G5)", () => {
184
+ const { controller, scheduler, resizes } = makeController();
185
+ controller.start(170, 36);
186
+ controller.start(196, 39); // 新连接
187
+ assert.deepEqual(resizes.slice(0, 4), [[170, 36], [169, 35], [170, 36], [196, 39]]);
188
+ assert.deepEqual(resizes.slice(-1), [[195, 38]]);
189
+ });
190
+
191
+ test("notifyExternalResize cancels hold, adopts new size, stops chain (G4)", () => {
192
+ const { controller, scheduler, resizes } = makeController();
193
+ controller.start(170, 36);
194
+ resizes.length = 0;
195
+ controller.notifyExternalResize(200, 50);
196
+ assert.equal(scheduler.timers.size, 0, "all timers cleared");
197
+ assert.equal(controller.getState().held, false);
198
+ assert.equal(controller.getState().stopped, true);
199
+ assert.equal(controller.getState().originalCols, 200);
200
+ // 再 feed clear 不再触发任何 restore
201
+ controller.feed("\x1b[2J");
202
+ assert.equal(resizes.length, 0);
203
+ });
204
+
205
+ test("full G1 window passes while frames keep arriving → held restored by G2 only", () => {
206
+ const { controller, scheduler, resizes } = makeController();
207
+ controller.start(170, 36);
208
+ controller.feed("\x1b[?2026h frame"); // restores once
209
+ const before = resizes.length;
210
+ // 无全清的持续输出
211
+ for (let i = 0; i < 8; i++) scheduler.fireNext();
212
+ assert.equal(resizes.length, before, "no extra resizes");
213
+ assert.equal(controller.getState().stopped, true);
214
+ });
215
+ ```
216
+
217
+ - [ ] **Step 2: 跑测试确认失败**(接口不匹配即失败)
218
+ Run: `node --test test/pty-attach-jiggle-controller.test.mjs` → FAIL
219
+
220
+ - [ ] **Step 3: 实现控制器改造**(按上方行为规格重写 `src/core/pty-attach-jiggle-controller.mjs`;保留模块头注释并更新为 hold 协议描述;G1 常量 `NO_FRAME_RESTORE_MS = 6000`)
221
+
222
+ - [ ] **Step 4: 跑测试确认通过** → PASS(此时 pty-attach.ts 尚未接线,typecheck 可能因 deps 变化报错——本任务只保证控制器单测绿;组件接线在 Task 2)
223
+
224
+ - [ ] **Step 5: Commit**
225
+ ```bash
226
+ git add src/core/pty-attach-jiggle-controller.mjs test/pty-attach-jiggle-controller.test.mjs
227
+ git commit -m "feat: shrink-and-hold jiggle protocol with G1-G5 guards (issue #25)"
228
+ ```
229
+
230
+ ---
231
+
232
+ ### Task 2: 组件接线(forceChildRedraw → start;G3/G4/G5 接入)
233
+
234
+ **Files:**
235
+ - Modify: `src/ui/pty-attach.ts`
236
+
237
+ **Interfaces:**
238
+ - Consumes: Task 1 的 `{ start(cols, rows), feed, restoreAndStop, notifyExternalResize, getState }`;deps `{ sendResize, setTimeoutFn, clearTimeoutFn }`。
239
+
240
+ **精确改动:**
241
+ 1. 控制器字段初始化改为:
242
+ ```ts
243
+ private readonly jiggleRetry = createJiggleRetryController({
244
+ sendResize: (cols, rows) => this.sendResize(cols, rows),
245
+ setTimeoutFn: (fn, ms) => {
246
+ const t = setTimeout(fn, ms);
247
+ t.unref?.();
248
+ return t;
249
+ },
250
+ clearTimeoutFn: (t) => clearTimeout(t as ReturnType<typeof setTimeout>),
251
+ });
252
+ ```
253
+ 2. connect 处理器:删除 `this.forceChildRedraw()` 与其前独立 `this.sendResize()`(协议已在 start 内含 sendResize 原尺寸);改为 `this.jiggleRetry.start(this.cols, this.rows);`
254
+ 3. `forceChildRedraw()` 方法整个删除;`JIGGLE_RESTORE_MS` 常量及其注释删除;`clearRedrawTimer()` 若仅被 forceChildRedraw/close 使用则一并删除(先 grep 确认无他引用;close() 中 `this.clearRedrawTimer()` 调用改为无操作或删除该行)。
255
+ 4. `resizeIfNeeded`:在 `this.term.resize(...)` 前插入 `this.jiggleRetry.notifyExternalResize(size.cols, size.rows);`(G4)——注意它必须在确认尺寸真的变化后调用(函数内 size 更新之后、sendResize 之前)。
256
+ 5. `close()`:`this.jiggleRetry.stop()` 改为 `this.jiggleRetry.restoreAndStop()`(G3)。
257
+ 6. `checkClearSequence` → `this.jiggleRetry.feed(data)` 不变。
258
+
259
+ - [ ] **Step 1: 应用上述 6 处改动**
260
+ - [ ] **Step 2: 验证**
261
+ Run: `npm run typecheck`(必须干净);`node --test test/*.test.mjs`(除 E2E 外全绿;E2E 在 Task 3 重写)
262
+ - [ ] **Step 3: Commit**
263
+ ```bash
264
+ git add src/ui/pty-attach.ts
265
+ git commit -m "feat: wire shrink-and-hold controller into attach component (issue #25)"
266
+ ```
267
+
268
+ ---
269
+
270
+ ### Task 3: E2E 重写(hold 语义)+ shell 兜底用例
271
+
272
+ **Files:**
273
+ - Modify: `test-support/fake-coldstart-tui-pi.mjs`
274
+ - Test: `test/pty-attach-cold-start-e2e.test.mjs`(重写)
275
+
276
+ **stub 新语义(精确):**
277
+
278
+ ```js
279
+ #!/usr/bin/env node
280
+ // Fake cold-booting pi for the attach shrink-and-hold E2E (issue #25).
281
+ // Boot noise first; after STUB_TUI_DELAY_MS "starts the TUI": installs the
282
+ // resize listener and emits \x1b[?2026h-wrapped frames. Holds the protocol:
283
+ // whenever the PTY size becomes (cols, rows) where cols/rows differ from the
284
+ // size at TUI start (the hold's promised width delta), the running TUI
285
+ // responds with a fullRender containing \x1b[2J — because pi-tui's
286
+ // widthChanged path fires for ANY width change from its baseline.
287
+ const delay = Number(process.env.STUB_TUI_DELAY_MS ?? 8000);
288
+ process.stdout.write("boot: loading extensions...\n");
289
+ let started = false;
290
+ let baseline = null;
291
+ function startTui() {
292
+ if (started) return;
293
+ started = true;
294
+ process.stdout.on("resize", () => {
295
+ const c = process.stdout.columns;
296
+ const r = process.stdout.rows;
297
+ if (baseline === null) baseline = [c, r]; // 首个见到的尺寸=基线
298
+ if (c === baseline[0] && r === baseline[1]) return; // 无变化不全清
299
+ baseline = [c, r]; // 全清后新基线
300
+ process.stdout.write(
301
+ `\x1b[?2026h\x1b[2J\x1b[H\x1b[3Jframe@${c}x${r}\x1b[?2026l`,
302
+ );
303
+ });
304
+ process.stdout.write("\x1b[?2004h\x1b[?25l");
305
+ frame();
306
+ }
307
+ function frame() { process.stdout.write("\x1b[?2026hstub: working...\x1b[?2026l"); }
308
+ setTimeout(startTui, delay);
309
+ setInterval(() => { if (started) frame(); }, 100);
310
+ process.on("SIGTERM", () => process.exit(0));
311
+ process.on("SIGINT", () => process.exit(0));
312
+ ```
313
+
314
+ **E2E 用例 1(冷启动 ≤3s 自愈 + 终态原尺寸)**:runner + stub(delay 8000)+ 真控制器。connect 时仅 `controller.start(196, 39)`(不再手动 sendResize/jiggle——协议在 start 内)。喂全部 output 给 feed。断言:
315
+ - 首个 `\x1b[2J` 到达时刻 − 首帧(tuiFrameSeen 翻转)时刻 ≤ **3000ms**(frameAt/clearAt 都取自 controller.getState() 翻转的采样点,沿用 #10 的采样法)
316
+ - `getState().held === false` 且收到过 resize(196,39)(终态原尺寸;从 runner 侧回读 host.json cols/rows == 196/39 亦可作证)
317
+
318
+ **E2E 用例 2(shell 型子进程 G1 兜底)**:piArgsPrefix 指向 `test-support/fake-pty-pi.mjs`(既有,无 2026h、无 resize 响应)。connect 后 `controller.start(120, 36)`;喂 output;**手动 fire G1**不可行(真计时器)——改为等真 6s:断言 8s 内收到 sendResize(120, 36)(G1 restore)。测试时长 ~8s 可接受。
319
+
320
+ **E2E 用例 3(可选,若前两个已覆盖 G4 则略)**:略——G4 由单测覆盖,不在 E2E 层重复。
321
+
322
+ - [ ] **Step 1: 重写 stub 与 E2E**(保留 node-pty skip 守卫、freshRoot/waitFor/send 约定)
323
+ - [ ] **Step 2: 跑 E2E**
324
+ Run: `node --test test/pty-attach-cold-start-e2e.test.mjs` → 两用例 PASS(用例 1 ~10-12s;用例 2 ~8s)
325
+ - [ ] **Step 3: Commit**
326
+ ```bash
327
+ git add test-support/fake-coldstart-tui-pi.mjs test/pty-attach-cold-start-e2e.test.mjs
328
+ git commit -m "test: hold-protocol E2E cold-start and shell-fallback cases (issue #25)"
329
+ ```
330
+
331
+ ---
332
+
333
+ ### Task 4: 全量验证 + spec 对照
334
+
335
+ - [ ] **Step 1**: `npm run typecheck` 干净;`npm test` 全绿(含新 E2E)
336
+ - [ ] **Step 2**: 对照 spec 验收 1/2/3/4:E2E ≤3s ✓;shell G1 兜底 ✓;8 风险面守卫测试映射清单(写进 commit 后的验证评论)✓
337
+ - [ ] **Step 3**: `git status --short` 空;`git log --oneline main..HEAD` = 5 commits(spec+plan+3 任务)
@@ -0,0 +1,107 @@
1
+ # Spec:attach jiggle 协议改造 — shrink-and-hold(issue #25)
2
+
3
+ ## 日期
4
+ 2026-08-22
5
+
6
+ ## 问题
7
+
8
+ #10 修复后冷启动 attach 自愈延迟 10-25s(re-arm 生效但后续 ±1/200ms 脉冲对被启动风暴合并成净零尺寸变化,子端渲染时看不到宽度变化 → 不触发 fullRender 全清)。目标:冷启动 ≤3s 自愈,热 attach ≤300ms 不劣化,且不破坏任何现有功能。
9
+
10
+ ## 协议设计(核心状态机)
11
+
12
+ **旧协议**:connect → shrink(−1) → [200ms] → restore → 每次重试重复脉冲对 → 见全清停。
13
+ **新协议**:
14
+
15
+ ```
16
+ connect:
17
+ 1. sendResize(W×H) # 原始尺寸(保持现有语义)
18
+ 2. shrink 到 (W−1)×(H−1) 并 HOLD # "armed":与子端基线之间存在待兑现的宽度差
19
+ 3. 启动安全计时器(见"守卫 G1/G2")
20
+
21
+ 子端输出处理(沿用 feed):
22
+ - 见 \x1b[2J(全清)→ restore(W×H),链停 # 子端渲染时读到窄宽 → fullRender → 我们恢复
23
+ - 见首个 \x1b[?2026h(re-arm)→ restore(W×H) 并停在那里
24
+ # 冷启动死锁解法:TUI 若在 shrink 后才启动,首帧把窄宽当基线;
25
+ # restore 让"正在渲染的子端"下一帧看到宽 1 列 → 必然 fullRender → 走上面的全清分支
26
+
27
+ 守卫:
28
+ G1 无帧兜底: connect 后 6s 内无任何 TUI 帧 → restore(W×H)(没有渲染器可触发,继续缩无意义)
29
+ G2 预算兜底: 全部退避预算(56.12s)走完无全清 → restore(W×H)(非 pi 子进程/死 session)
30
+ G3 close/detach: close() 时若 hold 生效 → 先 restore 再断(socket 尚可用)
31
+ G4 真实 resize: resizeIfNeeded(新宽) 进入时若 hold 生效 → 取消 hold 状态、按新尺寸 sendResize、
32
+ 之后由下一次 attach 语义重启链(hold 的"原尺寸"取新值)
33
+ G5 reconnect: start() 重置前若 hold 生效 → 先 restore(socket 刚连上)
34
+ ```
35
+
36
+ **为什么合并不再重要**:旧协议的净零来自"缩"与"恢复"互相抵消;新协议在见到全清前根本不存在"恢复",任何一侧(外层 timer / 子端 SIGWINCH / 渲染节流)的合并最多推迟信号到达,不能消除"宽度与基线不同"这个事实。子端只要渲染任何一帧,全清必然发生。
37
+
38
+ ## 模块改动
39
+
40
+ ### 1. `src/core/pty-attach-jiggle-retry.mjs`(纯逻辑,微调)
41
+ - 不变:退避表、`hasTuiFrameStart`/`hasFullClearSequence`、carry=7。
42
+ - `JiggleRetryState` 增加 `held: boolean`(是否正缩着)与 `originalCols/originalRows`。由控制器维护,状态机保持零副作用。
43
+
44
+ ### 2. `src/core/pty-attach-jiggle-controller.mjs`(协议主体)
45
+ - deps 增加:`sendResize(cols, rows)`(发送任意尺寸;sendJiggle 语义被 hold 协议取代,删除或保留为内部组合)。
46
+ - `start()`:若前次 hold 生效 → 先 `sendResize(original)`(G5);随后 `sendResize(W,H)`、`sendResize(W−1,H−1)` 置 `held=true`(armed);启动 G1 计时器;预算链照常排(作 G2 计时用,重试回调在 hold 下为 no-op——不发脉冲)。
47
+ - `feed()`:见全清 → `restoreIfHeld()` + 停链 + 置 clearDetected;见首帧(re-arm)→ `restoreIfHeld()`(G-rearm),**预算链不重置不重排**(hold 协议下 re-arm 后无需再脉冲;若全清一直不来,由 G1/G2 兜底)。G1 计时器见帧后取消。
48
+ - `stop()`:restoreIfHeld 不在此做(组件 close 语义不同——见 G3,由组件在 socket 可用时显式调用 `restoreAndStop()`)。
49
+ - 新增 API:`restoreAndStop()`(G3)、`notifyExternalResize(cols, rows)`(G4:取消 hold/计时器、更新 original、可选重启链)。
50
+ - 计时器仍全部走注入的 setTimeoutFn/clearTimeoutFn,unref。
51
+
52
+ ### 3. `src/ui/pty-attach.ts`(薄胶水)
53
+ - `forceChildRedraw` 语义替换:connect 处理器改调 `jiggleRetry.start()`(内含 armed shrink);`JIGGLE_RESTORE_MS` 删除(无脉冲)。
54
+ - `resizeIfNeeded`:尺寸变化时调 `jiggleRetry.notifyExternalResize(newCols, newRows)`,再照常 `term.resize + sendResize`。
55
+ - `close()`:`jiggleRetry.restoreAndStop()`。
56
+ - `checkClearSequence` → `jiggleRetry.feed(data)` 不变。
57
+
58
+ ### 4. 测试
59
+ - 单测(controller):armed 后见全清→restore+停;见首帧→restore 且不重排;G1 6s 无帧 restore;G2 预算耗尽 restore;close restoreAndStop;notifyExternalResize 取消 hold 并更新原尺寸;重复 start 先 restore 旧 hold;所有 restore 只发一次(幂等)。
60
+ - E2E 重写(stub 语义改为 hold 协议):
61
+ - 冷启动:stub 延迟 8s 启动 TUI;收到 shrink 不动作;**收到 restore(原尺寸) 且 TUI 已启动** → 发全清。断言全清 ≤3s 内到达(re-arm 后 ~一帧间隔),且 PTY 终态=原尺寸。
62
+ - shell 型子进程(永不发全清):断言 G1 兜底 6s 后收到 restore(原尺寸)。
63
+ - (可选)hold 中途外部 resize:模拟 notifyExternalResize,断言不发旧尺寸。
64
+ - 既有 208 测试适配(controller 单测大改、retry 状态机测试微调)。
65
+
66
+ ## 决策表
67
+
68
+ | 决策点 | 选择 | 理由 |
69
+ |---|---|---|
70
+ | hold 的宽度 | cols−1 且 rows−1 | 沿用现有 jiggle 尺寸语义;宽高都变确保 heightChanged 路径也可触发 |
71
+ | re-arm 后行为 | 只 restore,不再脉冲 | 子端已在渲染,restore 即待兑现差值;脉冲回归旧脆弱性 |
72
+ | G1 时长 | 6s | > 最慢正常 boot 出首帧(实测 ~5.2s)+ 余量;< G2 预算 |
73
+ | G2 | 退避表走完(56.12s) | 与 #10 验收窗口一致;hold 下重试回调 no-op,表仅作计时 |
74
+ | restore 幂等 | 只发一次 | 防止 close/reconnect/兜底叠加多发 |
75
+ | `JIGGLE_RESTORE_MS` | 删除 | 无脉冲对 |
76
+
77
+ ## 风险面 → 守卫映射(用户重点确认区)
78
+
79
+ | # | 风险面 | 守卫 | 验证 |
80
+ |---|---|---|---|
81
+ | R1 | 非 pi 子进程/死 session | G1(6s)+G2(56s) restore | E2E shell stub |
82
+ | R2 | detach/close 中途 | G3 close 先 restore | 单测 |
83
+ | R3 | 真实 resize 打架 | G4 中断+取新值 | 单测 |
84
+ | R4 | 子端窄 1 列渲染 | 瞬态,restore 全清重绘 | 人工+E2E 终态尺寸断言 |
85
+ | R5 | 本地投影 1 列差 | 流内自洽,无影响 | 人工 |
86
+ | R6 | reconnect 残留 | G5 start 先 restore | 单测 |
87
+ | R7 | settle 后 UX | 无脉冲(频次低于现状) | 人工 |
88
+ | R8 | 测试有效性 | stub 改 hold 语义+兜底用例 | E2E |
89
+
90
+ ## 降级
91
+
92
+ - 若 pi-tui 改掉 `\x1b[?2026h`:re-arm 不触发,退化为 G1(6s) restore——**比 #10 修复前更好**(6s 内恢复正确尺寸,画面可能仍脏但不卡窄宽)。
93
+ - 若全清信号消失:同上走 G1/G2,终态尺寸正确。
94
+
95
+ ## 非目标
96
+
97
+ - 失同步渲染检测(#11 范围,若 hold 后仍有可见脏窗再评估)。
98
+ - runner / pi-tui 侧改动。
99
+ - screen.log 重放锚点问题。
100
+
101
+ ## 验收标准
102
+
103
+ 1. 冷启动 E2E:全清 ≤3s 到达且终态=原尺寸(旧协议同场景 >10s,反证有效)。
104
+ 2. shell stub E2E:G1 兜底 6s 后 restore 原尺寸。
105
+ 3. 8 个风险面守卫全部有单测/E2E 覆盖。
106
+ 4. 全套测试通过、typecheck 干净、CR 收敛。
107
+ 5. 人工实机:home 目录冷启动 attach ≤3s 自愈;热 attach 无可感知劣化。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhuxixi/pi-agent-board",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
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",
@@ -0,0 +1,193 @@
1
+ /**
2
+ * IME cursor-rect flicker fix (issue #28).
3
+ *
4
+ * pi-tui's doRender() emits each frame as separate terminal.write() calls:
5
+ *
6
+ * 1. ESC[?2026h ...content... ESC[?2026l (differential/full frame, sync block)
7
+ * 2. ESC[<n>A/B ESC[<col>G (positionHardwareCursor park write)
8
+ * 3. ESC[?25l (hideCursor - bypasses write())
9
+ *
10
+ * The park sequences sit OUTSIDE the synchronized-output block. Terminals that
11
+ * honor ?2026 (WezTerm et al.) present a frame and report the IME cursor
12
+ * rectangle at each ?2026l boundary, so every frame produces two cursor-rect
13
+ * reports at different positions (diff-write end vs parked input line) and the
14
+ * IME candidate window bounces at frame rate. E2E measured on WezTerm + fcitx5:
15
+ * ~20 position changes / 4s with the split writes, 0 with the park inside the
16
+ * block (see issue #28 for the full experiment).
17
+ *
18
+ * This module wraps a Terminal instance's write/hideCursor/showCursor at runtime
19
+ * and folds the out-of-block park/hide sequences back INSIDE the frame's sync
20
+ * block, re-emitting the frame as a single write. Content is byte-identical up
21
+ * to reordering of the trailing ?2026l; nothing is dropped or added.
22
+ *
23
+ * Safety properties (issue #28):
24
+ * - No match -> passthrough: any write that isn't a pure cursor-park/hide
25
+ * sequence flushes the held frame unchanged first, preserving byte order.
26
+ * - The three writes happen inside one synchronous doRender() stack, so a
27
+ * process.nextTick flush is enough to see them all; the added latency is
28
+ * sub-millisecond.
29
+ * - Uninstall restores the original methods; a WeakMap makes overlapping
30
+ * installs on the same terminal refcounted and idempotent.
31
+ * - Kill switch: AGENT_BOARD_IME_FIX=0 disables installation entirely.
32
+ *
33
+ * If pi-tui ever folds positionHardwareCursor into the sync block upstream,
34
+ * the "pure park sequence following a sync-end write" pattern stops matching
35
+ * and this wrapper degrades to a passthrough (one buffered write per frame,
36
+ * same bytes) - no behavior change.
37
+ */
38
+
39
+ const ESC = String.fromCharCode(27);
40
+ /** ESC[?2026l - end of a synchronized-output block. */
41
+ const SYNC_END = ESC + "[?2026l";
42
+
43
+ /**
44
+ * Cursor sequences positionHardwareCursor() emits after a frame: relative row
45
+ * moves (ESC[<n>A / ESC[<n>B, n optional) and an absolute column set
46
+ * (ESC[<col>G), plus the cursor visibility toggles (ESC[?25l/h). These are
47
+ * the ONLY sequences safe to fold into the block - anything else (line clears,
48
+ * absolute positioning, OSC/DCS queries, content) flushes the held frame.
49
+ */
50
+ const PARK_SEQUENCE = new RegExp(
51
+ "^(?:" + ESC + "\\[\\d*[AB]|" + ESC + "\\[\\d+G|" + ESC + "\\[\\?25[hl])*$",
52
+ );
53
+
54
+ /** True when a write ends a synchronized-output block (pi-tui frame writes do). */
55
+ export function endsWithSyncEnd(data) {
56
+ return typeof data === "string" && data.endsWith(SYNC_END);
57
+ }
58
+
59
+ /** True when data is exclusively cursor park/visibility sequences (see above). */
60
+ export function isPureCursorParking(data) {
61
+ return typeof data === "string" && data.length > 0 && PARK_SEQUENCE.test(data);
62
+ }
63
+
64
+ /** Insert seq just before the trailing SYNC_END of a held frame write. */
65
+ export function mergeIntoSyncBlock(held, seq) {
66
+ return held.slice(0, held.length - SYNC_END.length) + seq + SYNC_END;
67
+ }
68
+
69
+ /** terminal -> active wrapper, so overlapping installs share one wrapper. */
70
+ const activeWrappers = new WeakMap();
71
+
72
+ /**
73
+ * Each wrapTerminalWrites() call returns its own guarded handle: idempotent
74
+ * per handle, refcounted across handles — the patch is torn down only when
75
+ * the LAST caller uninstalls (CR round 1, issue-1).
76
+ */
77
+ function refcountedUninstall(entry) {
78
+ let done = false;
79
+ return () => {
80
+ if (done) return;
81
+ done = true;
82
+ entry.refs -= 1;
83
+ if (entry.refs > 0) return;
84
+ entry.teardown();
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Patch write/hideCursor/showCursor on a terminal instance so each frame's
90
+ * out-of-block park/hide sequences are folded into the frame's sync block and
91
+ * emitted as one write. Returns an uninstall function (idempotent, drops the
92
+ * patch when the last caller uninstalls).
93
+ */
94
+ export function wrapTerminalWrites(terminal) {
95
+ if (!terminal || typeof terminal.write !== "function" || typeof terminal.hideCursor !== "function" || typeof terminal.showCursor !== "function") {
96
+ throw new Error("terminal must expose write/hideCursor/showCursor");
97
+ }
98
+ const existing = activeWrappers.get(terminal);
99
+ if (existing) {
100
+ existing.refs += 1;
101
+ return refcountedUninstall(existing);
102
+ }
103
+
104
+ const original = {
105
+ write: terminal.write.bind(terminal),
106
+ hideCursor: terminal.hideCursor.bind(terminal),
107
+ showCursor: terminal.showCursor.bind(terminal),
108
+ };
109
+ /** Frame write ending in SYNC_END, accumulating park/hide merges. */
110
+ let held = null;
111
+ let flushScheduled = false;
112
+
113
+ const flush = () => {
114
+ if (held === null) return;
115
+ const out = held;
116
+ held = null;
117
+ original.write(out);
118
+ };
119
+
120
+ const scheduleFlush = () => {
121
+ if (flushScheduled) return;
122
+ flushScheduled = true;
123
+ process.nextTick(() => {
124
+ flushScheduled = false;
125
+ flush();
126
+ });
127
+ };
128
+
129
+ /** Fold a park/hide seq into the held frame, or emit it standalone. */
130
+ const mergeOrEmit = (seq, directEmit) => {
131
+ if (held !== null) {
132
+ held = mergeIntoSyncBlock(held, seq);
133
+ return;
134
+ }
135
+ directEmit();
136
+ };
137
+
138
+ terminal.write = (data) => {
139
+ if (typeof data !== "string" || data.length === 0) {
140
+ flush();
141
+ original.write(data);
142
+ return;
143
+ }
144
+ if (held !== null) {
145
+ // Only a pure park/hide burst may join the held frame; anything
146
+ // else means this isn't a doRender park tail - flush unchanged.
147
+ if (isPureCursorParking(data)) {
148
+ held = mergeIntoSyncBlock(held, data);
149
+ return;
150
+ }
151
+ flush();
152
+ }
153
+ if (endsWithSyncEnd(data)) {
154
+ held = data;
155
+ scheduleFlush();
156
+ return;
157
+ }
158
+ original.write(data);
159
+ };
160
+
161
+ terminal.hideCursor = () => mergeOrEmit(ESC + "[?25l", original.hideCursor);
162
+ terminal.showCursor = () => mergeOrEmit(ESC + "[?25h", original.showCursor);
163
+
164
+ const entry = { refs: 1, teardown: null };
165
+ entry.teardown = () => {
166
+ flush();
167
+ terminal.write = original.write;
168
+ terminal.hideCursor = original.hideCursor;
169
+ terminal.showCursor = original.showCursor;
170
+ activeWrappers.delete(terminal);
171
+ };
172
+
173
+ activeWrappers.set(terminal, entry);
174
+ return refcountedUninstall(entry);
175
+ }
176
+
177
+ /**
178
+ * Install the coalescer on a TUI's terminal. Never throws: on any surprise
179
+ * (shape change upstream, kill switch) it returns null and behavior stays
180
+ * exactly as today.
181
+ */
182
+ export function installImeCursorCoalesce(tui) {
183
+ if (process.env.AGENT_BOARD_IME_FIX === "0") return null;
184
+ try {
185
+ const terminal = tui && tui.terminal;
186
+ if (!terminal || typeof terminal.write !== "function" || typeof terminal.hideCursor !== "function" || typeof terminal.showCursor !== "function") {
187
+ return null;
188
+ }
189
+ return wrapTerminalWrites(terminal);
190
+ } catch {
191
+ return null;
192
+ }
193
+ }
@@ -1,17 +1,35 @@
1
1
  /**
2
- * Injectable orchestration for the attach jiggle-retry chain.
2
+ * Injectable orchestration for the attach shrink-and-hold jiggle protocol.
3
3
  *
4
- * Owns the retry state machine, backoff timers, cross-chunk scanning, and the
5
- * one-shot re-arm on the child TUI's first frame (\x1b[?2026h). The attach
6
- * component (and the cold-start E2E) drive it through injected callbacks, so
7
- * the whole choreography is testable without a TUI or socket.
4
+ * Replaces the pulse-pair jiggle (shrink 200ms restore) with a
5
+ * shrink-and-hold protocol: on connect we resize the child PTY down one
6
+ * column/row and KEEP it there until the child's own rendering proves it
7
+ * observed the width change (a full clear, \x1b[2J, which pi-tui emits from
8
+ * fullRender(true) whenever widthChanged fires). Because there is no
9
+ * "restore" that can cancel the shrink before a clear is seen, event-loop
10
+ * coalescing on either side (outer dashboard timers or the child's
11
+ * SIGWINCH/render throttle) can no longer collapse a jiggle into a
12
+ * net-zero size change.
8
13
  *
9
- * Cold-start design (issue #10): the chain starts at socket connect, but a cold
10
- * child pi-tui installs its SIGWINCH listener only ~5s in every early jiggle
11
- * is lost. When the first TUI frame arrives we re-arm once with a fresh budget,
12
- * so the next jiggle lands on a live TUI and its fullRender emits \x1b[2J,
13
- * which stops the chain. If pi-tui ever drops the 2026h sequence, this degrades
14
- * to the plain connect-time chain (still better than the old one-shot).
14
+ * Cold-start (issue #25): if the child TUI starts rendering AFTER we shrink,
15
+ * its first frame baselines at the shrunk size and never clears. The
16
+ * re-arm the first \x1b[?2026h frame therefore restores the original
17
+ * size: the now-rendering child sees a width different from its baseline
18
+ * and fullRenders. Guards:
19
+ * G1 NO_FRAME_RESTORE_MS no TUI frame within 6s restore (no renderer
20
+ * to trigger; continuing to hold has no purpose). If the TUI boots even
21
+ * later, its first frame re-arms a fresh hold (F1 slow-boot probe), so
22
+ * healing still fires the moment the child actually starts rendering.
23
+ * G2 backoff budget exhausted without a clear → restore (non-pi children,
24
+ * dead sessions).
25
+ * G3 restoreAndStop() — component close/detach restores while the socket
26
+ * is still usable.
27
+ * G4 notifyExternalResize() — a real user resize cancels the hold and
28
+ * adopts the new size.
29
+ * G5 start() restores any previous hold before arming a new one (reconnect).
30
+ *
31
+ * If pi-tui ever drops the 2026h sequence, re-arm degrades to G1: the PTY
32
+ * is restored within 6s — still better than the pre-#10 behavior.
15
33
  */
16
34
  import {
17
35
  advanceRetry,
@@ -21,92 +39,187 @@ import {
21
39
  stopRetry,
22
40
  } from "./pty-attach-jiggle-retry.mjs";
23
41
 
42
+ /** Restore the held (shrunk) child PTY when no TUI frame arrives this long. */
43
+ const NO_FRAME_RESTORE_MS = 6000;
44
+
24
45
  /**
25
46
  * @typedef {Object} JiggleRetryControllerDeps
26
- * @property {() => void} sendJiggle - Fire one resize jiggle at the child.
47
+ * @property {(cols: number, rows: number) => void} sendResize - Resize the child PTY.
27
48
  * @property {(fn: () => void, ms: number) => unknown} setTimeoutFn - Timer factory.
28
49
  * @property {(timer: unknown) => void} clearTimeoutFn - Timer canceller.
29
- * @property {() => boolean} [shouldFire] - Guard on retry fire; false stops the chain without firing.
30
50
  */
31
51
 
32
52
  /**
33
53
  * @param {JiggleRetryControllerDeps} deps
34
54
  */
35
55
  export function createJiggleRetryController(deps) {
36
- const { sendJiggle, setTimeoutFn, clearTimeoutFn, shouldFire } = deps;
56
+ const { sendResize, setTimeoutFn, clearTimeoutFn } = deps;
37
57
  let state = createJiggleRetryState();
38
58
  let carry = "";
39
59
  let tuiFrameSeen = false;
60
+ /** True while the child PTY is parked at the shrunk size. */
61
+ let held = false;
62
+ /** True once the original size has been sent back (restore is one-shot). */
63
+ let restored = false;
64
+ /** @type {[number, number]} */
65
+ let originalCols = 0;
66
+ let originalRows = 0;
40
67
  /** @type {unknown | null} */
41
- let timer = null;
68
+ let chainTimer = null;
69
+ /** @type {unknown | null} */
70
+ let g1Timer = null;
71
+
72
+ function clearChainTimer() {
73
+ if (chainTimer === null) return;
74
+ clearTimeoutFn(chainTimer);
75
+ chainTimer = null;
76
+ }
77
+
78
+ function clearG1Timer() {
79
+ if (g1Timer === null) return;
80
+ clearTimeoutFn(g1Timer);
81
+ g1Timer = null;
82
+ }
42
83
 
43
- function clearTimer() {
44
- if (timer === null) return;
45
- clearTimeoutFn(timer);
46
- timer = null;
84
+ function clearAllTimers() {
85
+ clearChainTimer();
86
+ clearG1Timer();
47
87
  }
48
88
 
49
- function scheduleNext() {
89
+ /**
90
+ * Restore the child PTY to the original size exactly once. No-op while
91
+ * not held or after the restore already happened.
92
+ */
93
+ function restoreIfHeld() {
94
+ if (!held || restored) return;
95
+ sendResize(originalCols, originalRows);
96
+ restored = true;
97
+ held = false;
98
+ }
99
+
100
+ /**
101
+ * Backoff chain is a pure countdown under the hold protocol: firing a
102
+ * retry schedules the next backoff (no pulse is sent — the shrink is
103
+ * already held). When the budget runs out, G2 restores the size.
104
+ */
105
+ function scheduleNextRetry() {
50
106
  const delay = nextRetryDelay(state);
51
107
  if (delay === null) {
52
108
  state = stopRetry(state);
109
+ restoreIfHeld(); // G2
53
110
  return;
54
111
  }
55
- timer = setTimeoutFn(() => {
56
- timer = null;
57
- if (shouldFire && !shouldFire()) {
58
- state = stopRetry(state);
59
- return;
60
- }
61
- sendJiggle();
112
+ chainTimer = setTimeoutFn(() => {
113
+ chainTimer = null;
62
114
  state = advanceRetry(state);
63
- scheduleNext();
115
+ scheduleNextRetry();
64
116
  }, delay);
65
117
  }
66
118
 
67
- /** Reset everything (fresh connection) and schedule the first retry. */
68
- function start() {
69
- clearTimer();
119
+ function armG1() {
120
+ clearG1Timer();
121
+ g1Timer = setTimeoutFn(() => {
122
+ g1Timer = null;
123
+ if (!tuiFrameSeen) restoreIfHeld(); // G1
124
+ }, NO_FRAME_RESTORE_MS);
125
+ }
126
+
127
+ /**
128
+ * Arm a fresh hold for a (re)connected attach at the given PTY size.
129
+ * Restores any previous hold first (G5), then shrinks and holds.
130
+ * @param {number} cols
131
+ * @param {number} rows
132
+ */
133
+ function start(cols, rows) {
134
+ clearAllTimers();
135
+ if (held) {
136
+ sendResize(originalCols, originalRows); // G5: unwind previous hold
137
+ restored = true;
138
+ held = false;
139
+ }
70
140
  state = createJiggleRetryState();
71
141
  carry = "";
72
142
  tuiFrameSeen = false;
73
- scheduleNext();
143
+ originalCols = cols;
144
+ originalRows = rows;
145
+ sendResize(cols, rows);
146
+ sendResize(cols - 1, rows - 1);
147
+ held = true;
148
+ restored = false;
149
+ armG1();
150
+ scheduleNextRetry();
74
151
  }
75
152
 
76
153
  /**
77
- * Feed one socket output chunk. Clear detection wins over re-arm when both
78
- * sequences appear in one chunk (a hot attach's first frame is often the
79
- * fullRender we were waiting for). Re-arm fires at most once per start().
154
+ * Feed one socket output chunk. A clear wins over the re-arm when both
155
+ * appear in one chunk. The first TUI frame restores the held size (the
156
+ * child is now rendering and will fullRender on the width delta) and
157
+ * does NOT reschedule the chain; if G1 already released the hold before
158
+ * the TUI booted, the frame instead re-arms a fresh hold so the running
159
+ * child still sees a width delta (F1 slow-boot probe).
80
160
  * @param {string} data
81
161
  */
82
162
  function feed(data) {
83
163
  if (state.clearDetected) return; // chain done; nothing left to detect
164
+ if (state.stopped) return; // chain ended (G2/G3/G4); output is inert
84
165
  const result = feedOutput(state, data, carry);
85
166
  state = result.state;
86
167
  carry = result.carry;
87
168
  if (result.clearFound) {
88
- clearTimer();
169
+ clearAllTimers();
170
+ restoreIfHeld();
89
171
  state = stopRetry({ ...state, clearDetected: true });
90
172
  return;
91
173
  }
92
- if (result.frameStartFound && !tuiFrameSeen && !state.clearDetected) {
174
+ if (result.frameStartFound && !tuiFrameSeen) {
93
175
  tuiFrameSeen = true;
94
- clearTimer();
95
- state = createJiggleRetryState();
96
- scheduleNext();
176
+ clearG1Timer();
177
+ if (held) {
178
+ restoreIfHeld(); // fast path: child is rendering, width delta now lands
179
+ } else {
180
+ // Slow boot: G1 released the hold before the TUI came up, so the
181
+ // child baselined at the original size. Re-arm a fresh hold — its
182
+ // next frame then sees a width delta and fullRenders. Guards: the
183
+ // clear path (primary) and G2 budget exhaustion (chain still
184
+ // ticking). G1 is NOT re-armed: frames are now flowing.
185
+ sendResize(originalCols - 1, originalRows - 1);
186
+ held = true;
187
+ restored = false;
188
+ }
97
189
  }
98
190
  }
99
191
 
100
- /** Stop the chain (component closed, etc.). */
101
- function stop() {
102
- clearTimer();
192
+ /**
193
+ * Restore the held size (if any) and stop all chain activity. Used by the
194
+ * component on close/detach while the socket is still usable (G3).
195
+ */
196
+ function restoreAndStop() {
197
+ clearAllTimers();
198
+ restoreIfHeld();
199
+ state = stopRetry(state);
200
+ }
201
+
202
+ /**
203
+ * A real user resize supersedes the hold protocol: cancel all timers,
204
+ * mark the hold void, adopt the new size as the new original, and stop
205
+ * the chain (G4).
206
+ * @param {number} cols
207
+ * @param {number} rows
208
+ */
209
+ function notifyExternalResize(cols, rows) {
210
+ clearAllTimers();
211
+ held = false;
212
+ restored = false;
213
+ originalCols = cols;
214
+ originalRows = rows;
103
215
  state = stopRetry(state);
104
216
  }
105
217
 
106
218
  return {
107
219
  start,
108
220
  feed,
109
- stop,
110
- getState: () => ({ ...state, tuiFrameSeen }),
221
+ restoreAndStop,
222
+ notifyExternalResize,
223
+ getState: () => ({ ...state, held, tuiFrameSeen, originalCols, originalRows }),
111
224
  };
112
225
  }
@@ -8,6 +8,7 @@ import { CURSOR_MARKER, Key, matchesKey, truncateToWidth, visibleWidth } from "@
8
8
  import { isProbablyEmptyPiInputLine } from "../core/pty-input.mjs";
9
9
  import { findHttpUrlAtCells, findWordRangeAtCells } from "../core/pty-links.mjs";
10
10
  import { createAttachOutputRenderScheduler, nextAttachRender, projectPtyCursor, shouldScheduleAttachRenderForMessage } from "../core/pty-attach-render.mjs";
11
+ import { installImeCursorCoalesce } from "../core/ime-cursor-coalesce.mjs";
11
12
  import { createJiggleRetryController } from "../core/pty-attach-jiggle-controller.mjs";
12
13
  import { clampInt, parseMouseInputChunk, resolveWheelLines, scrollViewportTop, selectionDragScrollLines } from "../core/pty-scroll.mjs";
13
14
 
@@ -47,11 +48,6 @@ const OSC52_MAX_BYTES = 1_000_000;
47
48
  const OSC52_CARRY_MAX_BYTES = OSC52_MAX_BYTES + 4096;
48
49
  const TERMINAL_PASSTHROUGH_MAX_BYTES = 5_000_000;
49
50
  const TERMINAL_PASSTHROUGH_CARRY_MAX_BYTES = TERMINAL_PASSTHROUGH_MAX_BYTES + 4096;
50
- /** Delay between the shrink and restore halves of a resize jiggle. 200ms keeps
51
- * the two SIGWINCHs apart well beyond pi-tui's 16ms render throttle, so a busy
52
- * (booting) child processes them as two separate renders instead of coalescing
53
- * the pair into a net-zero size change. */
54
- const JIGGLE_RESTORE_MS = 200;
55
51
  const KITTY_IMAGE_PREFIX = "\x1b_G";
56
52
  const ITERM2_FILE_PREFIX = "\x1b]1337;File=";
57
53
 
@@ -117,16 +113,16 @@ export class PtyAttachComponent implements Component {
117
113
  private parserBuffer = "";
118
114
  private retryTimer: ReturnType<typeof setTimeout> | null = null;
119
115
  private loadingTimer: ReturnType<typeof setInterval> | null = null;
120
- private redrawTimer: ReturnType<typeof setTimeout> | null = null;
121
116
  private mouseRefreshTimers: Array<ReturnType<typeof setTimeout>> = [];
122
- // Jiggle retry chain: re-send resize jiggle until we see a full-clear sequence
123
- // in the PTY output, proving the child pi-tui did a fullRender and the replay
124
- // garbage has been flushed. The controller re-arms once on the child TUI's
125
- // first frame so cold-start attaches get a fresh budget exactly when the
126
- // child can finally observe a resize (issue #10).
117
+ // Shrink-and-hold jiggle protocol (issue #25): on attach we resize the child to
118
+ // (cols-1, rows-1) and hold it there until the child emits a full clear
119
+ // (\x1b[2J) any render the child makes while held sees a width delta and
120
+ // must fullRender, so boot-storm coalescing of ±1 pulses can no longer
121
+ // produce a net-zero change. The controller restores the original size on
122
+ // clear / first TUI frame (re-arm) / no-frame 6s guard / budget exhaustion /
123
+ // close / external resize (guards G1-G5, see controller module).
127
124
  private readonly jiggleRetry = createJiggleRetryController({
128
- sendJiggle: () => this.forceChildRedraw(),
129
- shouldFire: () => !this.closed && this.connected,
125
+ sendResize: (cols, rows) => this.sendResize(cols, rows),
130
126
  setTimeoutFn: (fn, ms) => {
131
127
  const t = setTimeout(fn, ms);
132
128
  t.unref?.();
@@ -170,6 +166,10 @@ export class PtyAttachComponent implements Component {
170
166
  // into many small chunks; painting after each chunk would drive the outer TUI to its
171
167
  // frame cap and expose intermediate frames (visible as flicker on a busy session).
172
168
  private readonly outputRenderScheduler = createAttachOutputRenderScheduler(() => this.scheduleRender());
169
+ // Fold pi-tui's post-frame cursor-park writes back into the frame's sync block so the
170
+ // terminal reports one stable IME cursor rect per frame instead of two (issue #28).
171
+ // Never active when AGENT_BOARD_IME_FIX=0; no-op passthrough if pi-tui changes shape.
172
+ private readonly imeCoalesceUninstall: (() => void) | null;
173
173
 
174
174
  constructor(
175
175
  private readonly tui: TUI,
@@ -181,6 +181,9 @@ export class PtyAttachComponent implements Component {
181
181
  const size = this.currentSize();
182
182
  this.cols = size.cols;
183
183
  this.rows = size.rows;
184
+ // Assigned in the body (not as a field initializer) so it runs after the `tui`
185
+ // parameter property is set regardless of the TS loader's field-init semantics.
186
+ this.imeCoalesceUninstall = installImeCursorCoalesce(this.tui);
184
187
  this.term = new Terminal({ cols: this.cols, rows: this.rows, scrollback: 2000, allowProposedApi: true });
185
188
  // Keep mouse reporting enabled by default so wheel scrolling and local drag-to-copy
186
189
  // selection can coexist inside the attach surface. Set AGENT_BOARD_ATTACH_MOUSE=0
@@ -324,9 +327,7 @@ export class PtyAttachComponent implements Component {
324
327
  this.connected = true;
325
328
  this.status = "attached";
326
329
  this.send({ type: "hello", clientId: `ui-${Date.now()}`, wantOutput: true });
327
- this.sendResize();
328
- this.forceChildRedraw();
329
- this.jiggleRetry.start();
330
+ this.jiggleRetry.start(this.cols, this.rows);
330
331
  this.enableMouseScroll();
331
332
  this.scheduleRender();
332
333
  this.startAttachSettle();
@@ -390,7 +391,7 @@ export class PtyAttachComponent implements Component {
390
391
  /**
391
392
  * Attach transition lifecycle. Keep the loading banner up while the screen-log replay
392
393
  * and the initial resize-jiggle redraws settle, so the buffer doesn't visibly scroll or
393
- * flash through the viewport on attach. Each `forceChildRedraw` defers the settle
394
+ * flash through the viewport on attach. Each output chunk defers the settle
394
395
  * window; a hard timeout guards against a session that never produces output.
395
396
  */
396
397
  private startAttachSettle(): void {
@@ -435,13 +436,6 @@ export class PtyAttachComponent implements Component {
435
436
  this.scheduleRender(true);
436
437
  }
437
438
 
438
- private clearRedrawTimer(): void {
439
- if (this.redrawTimer) {
440
- clearTimeout(this.redrawTimer);
441
- this.redrawTimer = null;
442
- }
443
- }
444
-
445
439
  private clearMouseRefreshTimers(): void {
446
440
  for (const timer of this.mouseRefreshTimers) clearTimeout(timer);
447
441
  this.mouseRefreshTimers = [];
@@ -784,6 +778,9 @@ export class PtyAttachComponent implements Component {
784
778
  if (size.cols === this.cols && size.rows === this.rows) return;
785
779
  this.cols = size.cols;
786
780
  this.rows = size.rows;
781
+ // A real terminal resize supersedes the hold protocol: cancel any armed
782
+ // hold and adopt the new size as the baseline (guard G4, issue #25).
783
+ this.jiggleRetry.notifyExternalResize(size.cols, size.rows);
787
784
  this.term.resize(this.cols, this.rows);
788
785
  this.sendResize();
789
786
  this.enableMouseScroll();
@@ -794,28 +791,6 @@ export class PtyAttachComponent implements Component {
794
791
  this.clampViewportTop(this.bodyHeight());
795
792
  }
796
793
 
797
- private forceChildRedraw(): void {
798
- this.clearRedrawTimer();
799
- if (!this.connected) return;
800
- const cols = this.cols;
801
- const rows = this.rows;
802
- const jiggle = localResizeJiggleSize(cols, rows);
803
- if (!jiggle) return;
804
- // A completed-session reattach often starts from an old screen.log recorded at
805
- // a different terminal size. Real terminal zoom fixes that by causing SIGWINCH;
806
- // do the same proactively so the child Pi redraws for the attach viewport.
807
- this.sendResize(jiggle.cols, jiggle.rows);
808
- this.deferAttachSettle();
809
- this.redrawTimer = setTimeout(() => {
810
- this.redrawTimer = null;
811
- if (!this.closed && this.connected) {
812
- this.sendResize(cols, rows);
813
- this.deferAttachSettle();
814
- }
815
- }, JIGGLE_RESTORE_MS);
816
- this.redrawTimer.unref?.();
817
- }
818
-
819
794
  /** Feed socket output into the jiggle retry controller (clear/frame detection). */
820
795
  private checkClearSequence(data: string): void {
821
796
  this.jiggleRetry.feed(data);
@@ -984,13 +959,13 @@ export class PtyAttachComponent implements Component {
984
959
 
985
960
  private close(): void {
986
961
  this.closed = true;
987
- this.jiggleRetry.stop();
962
+ this.imeCoalesceUninstall?.();
963
+ this.jiggleRetry.restoreAndStop();
988
964
  this.disableMouseScroll();
989
965
  this.clearMouseRefreshTimers();
990
966
  this.clearPendingClick();
991
967
  this.clearSelectionAutoScroll();
992
968
  this.clearRetry();
993
- this.clearRedrawTimer();
994
969
  this.stopLoadingTicker();
995
970
  this.outputRenderScheduler.dispose();
996
971
  if (this.attachSettleTimer) {
@@ -1009,13 +984,6 @@ export class PtyAttachComponent implements Component {
1009
984
  }
1010
985
  }
1011
986
 
1012
- function localResizeJiggleSize(cols: number, rows: number): { cols: number; rows: number } | null {
1013
- if (cols > 21 && rows > 6) return { cols: cols - 1, rows: rows - 1 };
1014
- if (rows > 6) return { cols, rows: rows - 1 };
1015
- if (cols > 21) return { cols: cols - 1, rows };
1016
- return null;
1017
- }
1018
-
1019
987
  function sameMousePoint(a: MousePoint, b: MousePoint): boolean {
1020
988
  return a.line === b.line && Math.abs(a.col - b.col) <= 1;
1021
989
  }