From eef20ab967312aac4f0084dc823270e81303f8aa Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Mon, 22 Jun 2026 22:44:43 +0800 Subject: [PATCH 01/12] docs: add screenshot pre-resize (redraw-settle) design Restore the resize-before-screenshot mechanism that was lost during screenshot subsystem rewrites. Default renderer path (viewport + scrollback) now does fit + conn.resize + event-driven redraw settle (500ms timeout fallback) before capturing, fixing layout corruption in heavy TUI apps like claude. Co-Authored-By: Claude --- ...2026-06-22-screenshot-pre-resize-design.md | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-22-screenshot-pre-resize-design.md diff --git a/docs/superpowers/specs/2026-06-22-screenshot-pre-resize-design.md b/docs/superpowers/specs/2026-06-22-screenshot-pre-resize-design.md new file mode 100644 index 0000000..f2455c2 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-screenshot-pre-resize-design.md @@ -0,0 +1,188 @@ +# Screenshot Pre-Resize (Redraw-Settle) Design + +**Date:** 2026-06-22 +**Branch:** feat/screenshot-pre-resize +**Status:** Approved + +--- + +## Problem Statement + +claude 等 heavy TUI 应用的截图(默认渲染端路径,无 `--with-frame`)经常出现布局混乱——文字换行错位、TUI 元素位置不对、或画面是半重绘的旧帧。用户此前曾通过「每次截图前触发一次 resize」修复过同一问题,但该机制在后续截图子系统重写中丢失,导致回归。 + +--- + +## Root Cause Analysis + +### 默认截图链路 + +``` +cli-box screenshot → daemon screenshot_handler + → request_renderer_screenshot → WS capture_request + → Electron renderer captureToPng(scroll) → 返回 PNG +``` + +### 渲染端入口(现状)`electron-app/src/renderer/components/Terminal.tsx:34-51` + +```tsx +async captureToPng(scrollOffset: number = 0): Promise { + const term = xtermRef.current; + if (!term) throw new Error("Terminal not initialized"); + if (scrollOffset === 0) { + // 视口路径:直接读 canvas,没有任何 resize / relayout + const canvasEl = term.element?.querySelector("canvas"); + if (canvasEl) { + const dataUrl = canvasEl.toDataURL("image/png"); + return dataUrl.split(",")[1]; + } + } + // scrollback 路径:只有本地 fit(),没有 conn.resize(),TUI 收不到 SIGWINCH + const fitAddon = fitAddonRef.current; + if (fitAddon) fitAddon.fit(); + return renderBufferToPng(term, term.cols, term.rows, scrollOffset); +} +``` + +### 三个缺口 + +1. **视口截图(`scroll=0`,claude 全屏 TUI 常见场景)**:直接 `canvas.toDataURL()`,零 resize。xterm 的 cols/rows 与 PTY 实际尺寸漂移、或 TUI 处于半重绘状态时,canvas 上就是错乱布局。 +2. **scrollback 截图**:只 `fitAddon.fit()`(仅本地重算),无 `conn.resize()` → TUI 不 reflow。 +3. 真正会发 PTY resize 的 `doFit()`(`Terminal.tsx:115-118`)只在 mount / window resize / container ref 触发,不在截图路径上。 + +### 为何复发 + +截图子系统被多次重写(2026-06-05 frame、2026-06-06 size-fix、2026-06-17 start-shell-screenshot)。2026-06-18 reliability fix(commit `17517c2`)只处理超时 / 窗口识别 / 重连,未覆盖截图前 resize。「视口路径直接读 canvas」的写法在这些迭代中引入,丢掉了 resize 逻辑。 + +--- + +## Design + +### Approach: renderer-side fix in `captureToPng` + +| 方案 | 做法 | 取舍 | +|------|------|------| +| **1(采纳)渲染端 `captureToPng` 内修复** | 截图入口统一 fit + resize + settle | 改动最小、落在真实截图点、viewport + scrollback 双路一次性覆盖 | +| 2 daemon 截图前发 resize_request | 跨进程通知渲染端 resize 再 capture | 逻辑跨进程散开、要把 resize 编排进 WS 协议、复杂度高 | +| 3 后台周期 resync | 心搏定时 fit + resize | 不保证截图那一瞬间 canvas 是 fresh 的,治标不治本 | + +截图本质是渲染端行为,resize(xterm + PTY WS)也在渲染端,在最贴近截图点修最干净。 + +### Mechanism: fit → conn.resize → 等 redraw settle(事件驱动 + 超时兜底) + +**核心洞察**:无法直接观测 TUI 内部状态,但 TUI 收到 SIGWINCH 后重绘必然产生 PTY 输出回流渲染端。因此「重绘完成」≈「resize 之后那波输出安静下来」。 + +#### 实现(`electron-app/src/renderer/components/Terminal.tsx`) + +**Step 1 — 在现有 `conn.onOutput` 回调(Terminal.tsx:145)追加时间戳:** + +```tsx +conn.onOutput((data) => { + const term = xtermRef.current; + if (!term) return; + lastOutputAtRef.current = performance.now(); // ← 新增 + const writeData = typeof data === "string" ? data : decoder.decode(data as Uint8Array); + term.write(writeData); +}); +``` + +**Step 2 — 新增常量与 helper:** + +```tsx +const SYNC_RESIZE_TIMEOUT_MS = 500; // 超时兜底:到点直接截 +const OUTPUT_QUIESCENCE_MS = 30; // 输出停顿 ≥30ms 视为这波重绘完成 + +// fit 到当前 DOM 尺寸 + 同步给 PTY(触发 SIGWINCH)+ 等重绘稳定 +const syncResizeAndSettle = async () => { + const term = xtermRef.current; + const fitAddon = fitAddonRef.current; + const conn = connRef.current; + if (!term || !fitAddon || !conn) return; + fitAddon.fit(); + const baseline = performance.now(); // 记录发 resize 的时刻 + conn.resize(term.cols, term.rows); // → SIGWINCH → TUI 重绘 → 输出回流 + await waitForRedrawSettle(baseline, OUTPUT_QUIESCENCE_MS, SYNC_RESIZE_TIMEOUT_MS); + await nextFrame(); // 让 xterm 把最新 buffer 提交到 canvas(2×rAF) +}; + +// 阻塞:等到「resize 后有新输出 且 安静 30ms」,或 500ms 超时 +async function waitForRedrawSettle(baseline: number, quietMs: number, timeoutMs: number) { + const start = performance.now(); + while (true) { + await tick(10); // rAF 或 10ms 步进 + const now = performance.now(); + const sawNewOutput = lastOutputAtRef.current > baseline; // resize 后有新输出 + const quiet = now - lastOutputAtRef.current >= quietMs; // 这波输出停了 + if ((sawNewOutput && quiet) || now - start >= timeoutMs) break; + } +} +``` + +**Step 3 — `captureToPng` 入口统一调用(覆盖 viewport + scrollback):** + +```tsx +async captureToPng(scrollOffset: number = 0): Promise { + const term = xtermRef.current; + if (!term) throw new Error("Terminal not initialized"); + await syncResizeAndSettle(); // ← 新增:截图前 resize + 等稳定 + if (scrollOffset === 0) { + const canvasEl = term.element?.querySelector("canvas"); + if (canvasEl) { + const dataUrl = canvasEl.toDataURL("image/png"); + return dataUrl.split(",")[1]; + } + } + return renderBufferToPng(term, term.cols, term.rows, scrollOffset); +} +``` + +**Step 4 — `doFit`(Terminal.tsx:115-118)复用相同 fit+resize**(去掉 settle),消除重复,保持单一来源。 + +### 为什么「总是发 resize」而非「尺寸变了才发」 + +PTY 尺寸漂移时,xterm 的 cols 不变(DOM 没变),若只在 cols 变化时才 resize 会漏掉漂移场景。**总是发 `conn.resize(cols, rows)`**:daemon 侧 `resize_pty` 幂等,且同尺寸 SIGWINCH 也会触发全屏 TUI 重绘——顺带解决「canvas 是旧帧」的 stale 问题,等于免费做一次强制刷新。 + +### 行为对照(事件驱动 + 超时兜底) + +| 场景 | 表现 | +|------|------| +| heavy TUI 收到 SIGWINCH 重绘 | 输出回流 → 安静 30ms → **快路径,几十 ms 就截** | +| 同尺寸 SIGWINCH / TUI 无反应 | 无新输出 → **500ms 超时兜底,直接截** | +| TUI 持续输出(spinner) | 永不安静 → **500ms 超时兜底截** | + +--- + +## Scope + +- **In scope**:默认渲染端截图(viewport + scrollback,CLI sandbox 如 claude)。 +- **Out of scope(follow-up)**:`--with-frame`(ScreenCaptureKit 直捕窗口)绕过渲染端,要 resize 需跨进程编排,复杂度高;claude 问题在默认路径,本次不扩面。 + +--- + +## Testing Strategy + +### UT(vitest)— 回归看护(最重要) + +mock xterm + conn(参考 `electron-app/src/__tests__/connectPty.test.ts`): + +- **快路径**:`captureToPng(0)` 后用 fake timer 推进,resize 后触发一次 onOutput 再推进 30ms,断言 `toDataURL` 在 settle 之后才被调用。 +- **超时兜底**:mock conn 不回放任何输出,推进 500ms,断言仍完成截图(兜底生效)。 +- **顺序守卫**:断言 `conn.resize` 在 canvas/buffer 读取之前被调用——这正是当初被回退掉机制的回归守卫。 + +### E2E / release_test + +按 `tests/release_test.md` 流程:启动 sandbox → 跑全屏 TUI → 改窗口尺寸 → 截图 → 人工核对布局正确,截图存档到 `release_test/YYYY-MM-DD-HH-MM-SS/`。 + +--- + +## Risks + +- **延迟**:单次截图最多 +500ms(仅当 TUI 无输出回流时;正常快路径几十 ms)。daemon renderer 超时已为 10s(commit `fb1894f`),预算充足。 +- **quiescence 偏紧**:极重绘机器上 30ms 可能偏短,导致快路径提前触发;两个常量已命名(`SYNC_RESIZE_TIMEOUT_MS` / `OUTPUT_QUIESCENCE_MS`),后续可调。 +- **nextFrame**:用 2×rAF 保证 canvas 提交最新 buffer;scrollback 路径走 `renderBufferToPng` 不依赖 canvas,harmless。 + +--- + +## Out of Scope + +- `--with-frame` 路径的截图前 resize(记为 follow-up)。 +- 调整 daemon renderer 超时(已 10s,无需动)。 From cfd7024dca0750c38bebf757cdea09b65802815c Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Mon, 22 Jun 2026 22:53:26 +0800 Subject: [PATCH 02/12] docs: add screenshot pre-resize implementation plan Four-task TDD plan: extract waitForRedrawSettle + captureWithResizeSettle into a pure dependency-injected screenshotSync.ts module (testable without React/xterm/fake-timers), wire it into Terminal.tsx captureToPng, and add a release-test scenario for the layout-after-resize regression. Co-Authored-By: Claude --- .../plans/2026-06-22-screenshot-pre-resize.md | 562 ++++++++++++++++++ 1 file changed, 562 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-22-screenshot-pre-resize.md diff --git a/docs/superpowers/plans/2026-06-22-screenshot-pre-resize.md b/docs/superpowers/plans/2026-06-22-screenshot-pre-resize.md new file mode 100644 index 0000000..a10c3c7 --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-screenshot-pre-resize.md @@ -0,0 +1,562 @@ +# Screenshot Pre-Resize (Redraw-Settle) Implementation Plan + +> **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. + +**Goal:** Restore resize-before-screenshot so heavy TUI apps (claude) screenshot with correct layout — fit → PTY resize (SIGWINCH) → wait for redraw output to settle (500ms timeout fallback) → capture. + +**Architecture:** Extract the new capture orchestration into a pure, dependency-injected `renderer/screenshotSync.ts` module (matching the codebase precedent of `renderer/terminalBuffer.ts`, which was extracted from `Terminal.tsx` for testability). `Terminal.tsx`'s `captureToPng` becomes thin glue that builds the deps from its refs and delegates. All novel logic is unit-testable without React/xterm/DOM timers. + +**Tech Stack:** TypeScript · React (forwardRef + useImperativeHandle) · xterm.js · vitest (jsdom) · WebSocket → daemon `resize_pty` (SIGWINCH). + +## Global Constraints + +- **Renderer-side only.** No daemon changes. `--with-frame` (ScreenCaptureKit) path is out of scope. +- **TDD.** Every new function is born from a failing test first. +- **Constants:** `DEFAULT_QUIESCENCE_MS = 30`, `DEFAULT_RESIZE_TIMEOUT_MS = 500` — exported from `screenshotSync.ts`, never magic numbers inline. +- **Always resize.** `captureWithResizeSettle` always calls `resize()` before reading the canvas (same-size SIGWINCH is intentional — it forces a TUI redraw and doubles as a canvas refresh). +- **Test invocation pattern:** tests run via subshell `(cd electron-app && pnpm vitest run )`; typecheck via `(cd electron-app && pnpm typecheck)` — matches `test.sh`. +- **Follow existing test patterns:** vitest + jsdom, dependency-injected mocks (see `src/__tests__/connectPty.test.ts`, `src/__tests__/captureToPng.test.ts`, `src/__tests__/mocks/`). + +--- + +## File Structure + +| File | Responsibility | Action | +|------|----------------|--------| +| `electron-app/src/renderer/screenshotSync.ts` | Pure capture orchestration: `waitForRedrawSettle` (settle/timeout clock logic) + `captureWithResizeSettle` (fit→resize→settle→frame→capture). Exports constants. | Create | +| `electron-app/src/__tests__/screenshotSync.test.ts` | Unit tests for both functions with injected fake clocks. | Create | +| `electron-app/src/renderer/components/Terminal.tsx` | Wire `captureWithResizeSettle` into `captureToPng`; add `lastOutputAtRef`; timestamp in `onOutput`. | Modify | + +--- + +### Task 1: `waitForRedrawSettle` — redraw-settle clock logic + +**Files:** +- Create: `electron-app/src/renderer/screenshotSync.ts` +- Test: `electron-app/src/__tests__/screenshotSync.test.ts` + +**Interfaces:** +- Produces: `SettleClock` interface, `waitForRedrawSettle(clock, baseline, quietMs, timeoutMs, tickMs?)` → `Promise<"settled" | "timeout">`. Consumed by Task 2's `captureWithResizeSettle`. + +- [ ] **Step 1: Write the failing tests** + +Create `electron-app/src/__tests__/screenshotSync.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { waitForRedrawSettle, type SettleClock } from "../renderer/screenshotSync"; + +describe("waitForRedrawSettle", () => { + it("returns 'settled' once output arrives after baseline and goes quiet", async () => { + let t = 0; + let lastOut = 0; + // Simulate TUI redraw output landing at t≈25 (after the baseline of 0). + const clock: SettleClock = { + now: () => t, + getLastOutputAt: () => lastOut, + sleep: async (ms: number) => { + t += ms; + if (lastOut === 0 && t >= 25) lastOut = t; + }, + }; + + const result = await waitForRedrawSettle(clock, /*baseline*/ 0, /*quietMs*/ 30, /*timeoutMs*/ 500); + + expect(result).toBe("settled"); + // output at 25, quietMs 30 → settles once now - 25 >= 30 (i.e. now >= 55) + expect(t).toBeGreaterThanOrEqual(55); + expect(t).toBeLessThan(500); + }); + + it("returns 'timeout' when no new output ever arrives", async () => { + let t = 0; + const clock: SettleClock = { + now: () => t, + getLastOutputAt: () => 0, // never exceeds baseline of 0 + sleep: async (ms: number) => { + t += ms; + }, + }; + + const result = await waitForRedrawSettle(clock, 0, 30, 500); + + expect(result).toBe("timeout"); + expect(t).toBeGreaterThanOrEqual(500); + }); + + it("returns 'timeout' when only stale (pre-baseline) output exists", async () => { + let t = 100; // baseline will be 100 + const clock: SettleClock = { + now: () => t, + getLastOutputAt: () => 50, // output happened before baseline → not "new" + sleep: async (ms: number) => { + t += ms; + }, + }; + + const result = await waitForRedrawSettle(clock, 100, 30, 500); + + expect(result).toBe("timeout"); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `(cd electron-app && pnpm vitest run src/__tests__/screenshotSync.test.ts)` +Expected: FAIL — `Failed to resolve import "../renderer/screenshotSync"` (module does not exist yet). + +- [ ] **Step 3: Implement the module** + +Create `electron-app/src/renderer/screenshotSync.ts`: + +```ts +/** + * Screenshot pre-resize orchestration, extracted from Terminal.tsx for testing. + * + * Before capturing a terminal frame we re-sync the terminal's cols/rows to the + * DOM (fit), push that size to the PTY (resize → SIGWINCH), then wait for the + * TUI to finish redrawing. "Redraw done" is approximated by watching PTY output + * settle (no new output for `quietMs`), capped by `timeoutMs`. + * + * Time sources are injected (SettleClock) so the logic is fully deterministic + * under test without relying on vitest fake-timer mocking of performance.now(). + */ + +/** Default quiescence window: output quiet for this long ⇒ redraw finished. */ +export const DEFAULT_QUIESCENCE_MS = 30; +/** Default hard cap: give up waiting and capture whatever we have. */ +export const DEFAULT_RESIZE_TIMEOUT_MS = 500; + +/** Injectable monotonic clock + last-output-at probe + async sleep. */ +export interface SettleClock { + /** Monotonic milliseconds (e.g. performance.now()). */ + now(): number; + /** Monotonic ms timestamp of the most recent PTY output write. */ + getLastOutputAt(): number; + /** Async delay (e.g. setTimeout). */ + sleep(ms: number): Promise; +} + +/** + * Wait until PTY output has gone quiet for `quietMs` after `baseline` + * (evidence the TUI reacted to the SIGWINCH and finished its redraw burst), + * or until `timeoutMs` elapses — whichever comes first. + * + * @returns "settled" if output quieted within the timeout, else "timeout". + */ +export async function waitForRedrawSettle( + clock: SettleClock, + baseline: number, + quietMs: number, + timeoutMs: number, + tickMs: number = 10, +): Promise<"settled" | "timeout"> { + const start = clock.now(); + for (;;) { + await clock.sleep(tickMs); + const now = clock.now(); + const lastOutputAt = clock.getLastOutputAt(); + const sawNewOutput = lastOutputAt > baseline; + const quiet = now - lastOutputAt >= quietMs; + if (sawNewOutput && quiet) return "settled"; + if (now - start >= timeoutMs) return "timeout"; + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `(cd electron-app && pnpm vitest run src/__tests__/screenshotSync.test.ts)` +Expected: PASS — 3 tests. + +- [ ] **Step 5: Commit** + +```bash +git add electron-app/src/renderer/screenshotSync.ts electron-app/src/__tests__/screenshotSync.test.ts +git commit -m "feat(capture): add waitForRedrawSettle clock logic" +``` + +--- + +### Task 2: `captureWithResizeSettle` — fit→resize→settle→capture orchestration + +**Files:** +- Modify: `electron-app/src/renderer/screenshotSync.ts` +- Test: `electron-app/src/__tests__/screenshotSync.test.ts` + +**Interfaces:** +- Consumes: `waitForRedrawSettle`, `SettleClock`, `DEFAULT_QUIESCENCE_MS`, `DEFAULT_RESIZE_TIMEOUT_MS` (from Task 1). +- Produces: `CaptureDeps` interface (extends `SettleClock`), `CaptureOptions`, `captureWithResizeSettle(deps, scrollOffset, opts?)` → `Promise`. Consumed by Task 3's `Terminal.tsx`. + +- [ ] **Step 1: Write the failing tests** + +Append to `electron-app/src/__tests__/screenshotSync.test.ts` (add the import of `captureWithResizeSettle` and `CaptureDeps` to the existing import line, then add the describe block): + +Update the top import line to: + +```ts +import { + waitForRedrawSettle, + captureWithResizeSettle, + type SettleClock, + type CaptureDeps, +} from "../renderer/screenshotSync"; +``` + +Append this helper + describe block at the end of the file: + +```ts +/** Builds a CaptureDeps whose injected clock settles quickly (output at t≈5 then quiet). */ +function makeSettlingDeps(overrides: Partial = {}): { deps: CaptureDeps; calls: string[] } { + const calls: string[] = []; + let t = 0; + let lastOut = 0; + const deps: CaptureDeps = { + now: () => t, + getLastOutputAt: () => lastOut, + sleep: async (ms: number) => { + t += ms; + if (lastOut === 0 && t >= 5) lastOut = t; + }, + cols: () => 80, + rows: () => 24, + fit: () => { + calls.push("fit"); + }, + resize: (cols, rows) => { + calls.push(`resize:${cols}x${rows}`); + }, + awaitFrame: async () => { + calls.push("frame"); + }, + readViewportCanvas: () => { + calls.push("readCanvas"); + return "PNGDATA"; + }, + renderScrollback: (offset) => { + calls.push(`renderScrollback:${offset}`); + return `SCROLLBACK:${offset}`; + }, + ...overrides, + }; + return { deps, calls }; +} + +describe("captureWithResizeSettle", () => { + it("resizes and waits a frame BEFORE reading the viewport canvas (order guard)", async () => { + const { deps, calls } = makeSettlingDeps(); + + const result = await captureWithResizeSettle(deps, 0); + + expect(result).toBe("PNGDATA"); + expect(calls.indexOf("resize:80x24")).toBeLessThan(calls.indexOf("readCanvas")); + expect(calls.indexOf("frame")).toBeLessThan(calls.indexOf("readCanvas")); + expect(calls).toContain("fit"); + }); + + it("returns the viewport canvas PNG for scrollOffset 0", async () => { + const { deps, calls } = makeSettlingDeps(); + + const result = await captureWithResizeSettle(deps, 0); + + expect(result).toBe("PNGDATA"); + expect(calls).not.toContain("renderScrollback:0"); + }); + + it("renders scrollback (non-zero offset) and skips the canvas", async () => { + const { deps, calls } = makeSettlingDeps(); + + const result = await captureWithResizeSettle(deps, 5); + + expect(result).toBe("SCROLLBACK:5"); + expect(calls).not.toContain("readCanvas"); + expect(calls).toContain("renderScrollback:5"); + }); + + it("falls back to renderScrollback(0) when the viewport canvas is null", async () => { + const { deps, calls } = makeSettlingDeps({ + readViewportCanvas: () => { + calls.push("readCanvas"); + return null; + }, + }); + + const result = await captureWithResizeSettle(deps, 0); + + expect(result).toBe("SCROLLBACK:0"); + expect(calls).toContain("renderScrollback:0"); + }); + + it("uses default 30ms/500ms options when none given", async () => { + // Spy on waitForRedrawSettle indirectly: a deps whose sleep records total + // waited time proves the loop honoured the default timeout when no output. + let waited = 0; + const { deps } = makeSettlingDeps({ + getLastOutputAt: () => 0, // no new output → must hit 500ms timeout + sleep: async (ms: number) => { + waited += ms; + }, + }); + + await captureWithResizeSettle(deps, 0); + + // Default timeoutMs is 500; loop ticks in 10ms increments until >=500. + expect(waited).toBeGreaterThanOrEqual(500); + expect(waited).toBeLessThan(600); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `(cd electron-app && pnpm vitest run src/__tests__/screenshotSync.test.ts)` +Expected: FAIL — `captureWithResizeSettle` / `CaptureDeps` not exported (TS error / runtime undefined). + +- [ ] **Step 3: Implement `captureWithResizeSettle`** + +Append to `electron-app/src/renderer/screenshotSync.ts` (after the existing `waitForRedrawSettle`): + +```ts +/** Injectable terminal/PTY/canvas operations the orchestrator needs. */ +export interface CaptureDeps extends SettleClock { + cols(): number; + rows(): number; + /** Recompute cols/rows from the current DOM (xterm FitAddon.fit). */ + fit(): void; + /** Push new cols/rows to the PTY (WS resize → SIGWINCH). */ + resize(cols: number, rows: number): void; + /** Wait for the renderer to commit the latest buffer to the canvas (rAF). */ + awaitFrame(): Promise; + /** Read the viewport canvas as base64 PNG (no data: prefix), or null if absent. */ + readViewportCanvas(): string | null; + /** Render the scrollback at `scrollOffset` lines up as base64 PNG. */ + renderScrollback(scrollOffset: number): string; +} + +export interface CaptureOptions { + quietMs: number; + timeoutMs: number; +} + +/** + * Fit → resize → wait for redraw settle → wait one frame → capture. + * + * Guarantees `resize()` runs strictly before any canvas/buffer read, so the + * PTY has been told the current size (and the TUI given a chance to reflow) + * before we snapshot pixels. + * + * For `scrollOffset === 0` the live viewport canvas is preferred; if it is + * unavailable we fall back to buffer rendering. Non-zero offsets always render + * from the buffer. + */ +export async function captureWithResizeSettle( + deps: CaptureDeps, + scrollOffset: number, + opts: CaptureOptions = { quietMs: DEFAULT_QUIESCENCE_MS, timeoutMs: DEFAULT_RESIZE_TIMEOUT_MS }, +): Promise { + deps.fit(); + const baseline = deps.now(); + deps.resize(deps.cols(), deps.rows()); + await waitForRedrawSettle(deps, baseline, opts.quietMs, opts.timeoutMs); + await deps.awaitFrame(); + + if (scrollOffset === 0) { + const canvas = deps.readViewportCanvas(); + if (canvas !== null) return canvas; + } + return deps.renderScrollback(scrollOffset); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `(cd electron-app && pnpm vitest run src/__tests__/screenshotSync.test.ts)` +Expected: PASS — all 8 tests (3 from Task 1 + 5 from Task 2). + +- [ ] **Step 5: Commit** + +```bash +git add electron-app/src/renderer/screenshotSync.ts electron-app/src/__tests__/screenshotSync.test.ts +git commit -m "feat(capture): add captureWithResizeSettle orchestrator" +``` + +--- + +### Task 3: Wire `captureWithResizeSettle` into `Terminal.tsx` + +**Files:** +- Modify: `electron-app/src/renderer/components/Terminal.tsx` + +**Interfaces:** +- Consumes: `captureWithResizeSettle`, `DEFAULT_QUIESCENCE_MS`, `DEFAULT_RESIZE_TIMEOUT_MS` (from Tasks 1–2). +- Produces: unchanged `SandboxTerminalHandle.captureToPng(scrollOffset?)` signature; now internally resizes+settles before capture. + +- [ ] **Step 1: Add the import** + +In `electron-app/src/renderer/components/Terminal.tsx`, after the existing import of `renderBufferToPng` (line 6), add: + +```ts +import { captureWithResizeSettle } from "../screenshotSync"; +``` + +- [ ] **Step 2: Add the `lastOutputAtRef`** + +In the same file, after the line `const connRef = useRef | null>(null);` (line 28), add: + +```ts + const lastOutputAtRef = useRef(0); +``` + +- [ ] **Step 3: Replace the `captureToPng` body** + +Replace the entire `captureToPng` method inside `useImperativeHandle` (currently lines 34–51) with: + +```tsx + async captureToPng(scrollOffset: number = 0): Promise { + const term = xtermRef.current; + if (!term) throw new Error("Terminal not initialized"); + const fitAddon = fitAddonRef.current; + const conn = connRef.current; + + // Before mount fully settles (no fit/conn yet): fall back to a direct + // read without resize, matching prior behavior. + if (!fitAddon || !conn) { + if (scrollOffset === 0) { + const canvasEl = term.element?.querySelector("canvas"); + if (canvasEl) return canvasEl.toDataURL("image/png").split(",")[1]; + } + return renderBufferToPng(term, term.cols, term.rows, scrollOffset); + } + + return captureWithResizeSettle( + { + now: () => performance.now(), + getLastOutputAt: () => lastOutputAtRef.current, + sleep: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)), + cols: () => term.cols, + rows: () => term.rows, + fit: () => fitAddon.fit(), + resize: (cols, rows) => conn.resize(cols, rows), + awaitFrame: () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + readViewportCanvas: () => { + const canvasEl = term.element?.querySelector("canvas"); + return canvasEl ? canvasEl.toDataURL("image/png").split(",")[1] : null; + }, + renderScrollback: (offset) => renderBufferToPng(term, term.cols, term.rows, offset), + }, + scrollOffset, + ); + }, +``` + +- [ ] **Step 4: Timestamp PTY output in `onOutput`** + +In the same file, update the `conn.onOutput` callback (currently lines 145–150) to record the output timestamp. Replace: + +```tsx + conn.onOutput((data) => { + const term = xtermRef.current; + if (!term) return; + const writeData = typeof data === "string" ? data : decoder.decode(data as Uint8Array); + term.write(writeData); + }); +``` + +with: + +```tsx + conn.onOutput((data) => { + const term = xtermRef.current; + if (!term) return; + lastOutputAtRef.current = performance.now(); + const writeData = typeof data === "string" ? data : decoder.decode(data as Uint8Array); + term.write(writeData); + }); +``` + +- [ ] **Step 5: Typecheck** + +Run: `(cd electron-app && pnpm typecheck)` +Expected: PASS — no errors. (The inline deps object must structurally satisfy `CaptureDeps`.) + +- [ ] **Step 6: Run the full renderer test suite (regression)** + +Run: `(cd electron-app && pnpm vitest run)` +Expected: PASS — all existing tests (`captureToPng.test.ts`, `connectPty.test.ts`, `terminalBuffer.test.ts`, …) plus the new `screenshotSync.test.ts` green. No existing test imports the component's `captureToPng` method (the existing `captureToPng.test.ts` tests the extracted `renderBufferToPng` module), so wiring changes do not break it. + +- [ ] **Step 7: Commit** + +```bash +git add electron-app/src/renderer/components/Terminal.tsx +git commit -m "feat(capture): resize + redraw-settle before screenshot in Terminal" +``` + +--- + +### Task 4: Release-test scenario + final quality gate + +**Files:** +- Modify: `tests/release_test.md` + +**Interfaces:** +- Consumes: the wired feature from Task 3. +- Produces: a documented manual release-test scenario covering the layout-corruption regression. + +- [ ] **Step 1: Read the existing release-test doc to match format** + +Run: read `tests/release_test.md` and locate the scenario-list / heading style used by existing scenarios (e.g. the section numbering and per-step "screenshot + verify" structure). + +- [ ] **Step 2: Append a new scenario** + +Append the following scenario to `tests/release_test.md`, following the file's existing heading numbering/indentation: + +```markdown +## Screenshot layout after resize (pre-resize fix) + +Verifies that `screenshot` triggers a PTY resize + redraw-settle before capture, +so heavy TUI apps (claude) show correct layout even after the window is resized. + +Steps (CLI only, screenshot each step into the run's screenshot dir): + +1. Start a sandbox and run a full-screen TUI: + - `cli-box start claude --shell` (or any full-screen TUI such as `htop`) +2. Wait for the TUI to fully render, then take a baseline screenshot: + - `cli-box screenshot --out baseline.png` + - Verify: layout is intact (header/prompt correctly positioned). +3. Resize the sandbox window (drag the Electron window to a different size), + wait ~1s, then screenshot: + - `cli-box screenshot --out after-resize.png` + - Verify: layout reflowed to the new size — no overlapping/garbled text, + no stale half-redrawn frame. +4. Resize again (different size), immediately screenshot without manual wait: + - `cli-box screenshot --out immediate.png` + - Verify: layout still correct (the redraw-settle handled the fresh frame). + +Pass criteria: all three screenshots show a correctly laid-out TUI matching the +current window size. A failure here means the pre-resize mechanism regressed. +``` + +- [ ] **Step 3: Run the local quality gate** + +Run: `sh test.sh` +Expected: PASS — `cargo test`, `cargo clippy -D warnings`, `cargo fmt --check`, `pnpm typecheck`, `pnpm vitest run`, Playwright E2E, skill-install E2E, and the sandbox-residue check all green. (No Rust/daemon code changed, so Rust gates are unchanged; the vitest gate now includes `screenshotSync.test.ts`.) + +- [ ] **Step 4: Commit** + +```bash +git add tests/release_test.md +git commit -m "test: add screenshot layout-after-resize release scenario" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** Spec §Mechanism Steps 1–4 → Task 3 (wiring) + Tasks 1–2 (extracted logic). "Always resize" rationale → encoded in `captureWithResizeSettle` (unconditional `resize()`). Event-driven settle + 500ms timeout → `waitForRedrawSettle` (Tasks 1–2). Order guard → Task 2 "order guard" test. Both paths (viewport + scrollback) → Task 2 tests + Task 3 wiring. Testing strategy (UT + release_test) → Tasks 1–2 + Task 4. +- **Spec Step 4 ("doFit reuse") intentionally dropped:** `doFit` (sync, window-resize path) and `captureWithResizeSettle` (async, capture path) have different semantics; the shared fit+resize is a trivial 2-liner in `doFit` and extracting it would add abstraction without value (YAGNI). `doFit` is left unchanged. +- **Type consistency:** `SettleClock` (Task 1) is extended by `CaptureDeps` (Task 2) and passed to `waitForRedrawSettle` from inside `captureWithResizeSettle`. `CaptureDeps` field names (`cols`/`rows`/`fit`/`resize`/`awaitFrame`/`readViewportCanvas`/`renderScrollback`) match exactly between the interface (Task 2), the tests (Task 2 `makeSettlingDeps`), and the Terminal wiring (Task 3). +- **No placeholders:** every code/command step is complete and copy-pasteable. From a7defd05e5f87d0670da6a1a626fcb1aeade5576 Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Mon, 22 Jun 2026 22:56:57 +0800 Subject: [PATCH 03/12] feat(capture): add waitForRedrawSettle clock logic --- .../src/__tests__/screenshotSync.test.ts | 56 +++++++++++++++++++ electron-app/src/renderer/screenshotSync.ts | 52 +++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 electron-app/src/__tests__/screenshotSync.test.ts create mode 100644 electron-app/src/renderer/screenshotSync.ts diff --git a/electron-app/src/__tests__/screenshotSync.test.ts b/electron-app/src/__tests__/screenshotSync.test.ts new file mode 100644 index 0000000..ce5fe2d --- /dev/null +++ b/electron-app/src/__tests__/screenshotSync.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { waitForRedrawSettle, type SettleClock } from "../renderer/screenshotSync"; + +describe("waitForRedrawSettle", () => { + it("returns 'settled' once output arrives after baseline and goes quiet", async () => { + let t = 0; + let lastOut = 0; + // Simulate TUI redraw output landing at t≈25 (after the baseline of 0). + const clock: SettleClock = { + now: () => t, + getLastOutputAt: () => lastOut, + sleep: async (ms: number) => { + t += ms; + if (lastOut === 0 && t >= 25) lastOut = t; + }, + }; + + const result = await waitForRedrawSettle(clock, /*baseline*/ 0, /*quietMs*/ 30, /*timeoutMs*/ 500); + + expect(result).toBe("settled"); + // output at 25, quietMs 30 → settles once now - 25 >= 30 (i.e. now >= 55) + expect(t).toBeGreaterThanOrEqual(55); + expect(t).toBeLessThan(500); + }); + + it("returns 'timeout' when no new output ever arrives", async () => { + let t = 0; + const clock: SettleClock = { + now: () => t, + getLastOutputAt: () => 0, // never exceeds baseline of 0 + sleep: async (ms: number) => { + t += ms; + }, + }; + + const result = await waitForRedrawSettle(clock, 0, 30, 500); + + expect(result).toBe("timeout"); + expect(t).toBeGreaterThanOrEqual(500); + }); + + it("returns 'timeout' when only stale (pre-baseline) output exists", async () => { + let t = 100; // baseline will be 100 + const clock: SettleClock = { + now: () => t, + getLastOutputAt: () => 50, // output happened before baseline → not "new" + sleep: async (ms: number) => { + t += ms; + }, + }; + + const result = await waitForRedrawSettle(clock, 100, 30, 500); + + expect(result).toBe("timeout"); + }); +}); diff --git a/electron-app/src/renderer/screenshotSync.ts b/electron-app/src/renderer/screenshotSync.ts new file mode 100644 index 0000000..f944251 --- /dev/null +++ b/electron-app/src/renderer/screenshotSync.ts @@ -0,0 +1,52 @@ +/** + * Screenshot pre-resize orchestration, extracted from Terminal.tsx for testing. + * + * Before capturing a terminal frame we re-sync the terminal's cols/rows to the + * DOM (fit), push that size to the PTY (resize → SIGWINCH), then wait for the + * TUI to finish redrawing. "Redraw done" is approximated by watching PTY output + * settle (no new output for `quietMs`), capped by `timeoutMs`. + * + * Time sources are injected (SettleClock) so the logic is fully deterministic + * under test without relying on vitest fake-timer mocking of performance.now(). + */ + +/** Default quiescence window: output quiet for this long ⇒ redraw finished. */ +export const DEFAULT_QUIESCENCE_MS = 30; +/** Default hard cap: give up waiting and capture whatever we have. */ +export const DEFAULT_RESIZE_TIMEOUT_MS = 500; + +/** Injectable monotonic clock + last-output-at probe + async sleep. */ +export interface SettleClock { + /** Monotonic milliseconds (e.g. performance.now()). */ + now(): number; + /** Monotonic ms timestamp of the most recent PTY output write. */ + getLastOutputAt(): number; + /** Async delay (e.g. setTimeout). */ + sleep(ms: number): Promise; +} + +/** + * Wait until PTY output has gone quiet for `quietMs` after `baseline` + * (evidence the TUI reacted to the SIGWINCH and finished its redraw burst), + * or until `timeoutMs` elapses — whichever comes first. + * + * @returns "settled" if output quieted within the timeout, else "timeout". + */ +export async function waitForRedrawSettle( + clock: SettleClock, + baseline: number, + quietMs: number, + timeoutMs: number, + tickMs: number = 10, +): Promise<"settled" | "timeout"> { + const start = clock.now(); + for (;;) { + await clock.sleep(tickMs); + const now = clock.now(); + const lastOutputAt = clock.getLastOutputAt(); + const sawNewOutput = lastOutputAt > baseline; + const quiet = now - lastOutputAt >= quietMs; + if (sawNewOutput && quiet) return "settled"; + if (now - start >= timeoutMs) return "timeout"; + } +} From cd13e73820c92b7bc4dd466c44e8c47315d25b2c Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Mon, 22 Jun 2026 23:26:14 +0800 Subject: [PATCH 04/12] feat(capture): add captureWithResizeSettle orchestrator Co-Authored-By: Claude --- .../src/__tests__/screenshotSync.test.ts | 107 +++++++++++++++++- electron-app/src/renderer/screenshotSync.ts | 50 ++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) diff --git a/electron-app/src/__tests__/screenshotSync.test.ts b/electron-app/src/__tests__/screenshotSync.test.ts index ce5fe2d..cdf4ace 100644 --- a/electron-app/src/__tests__/screenshotSync.test.ts +++ b/electron-app/src/__tests__/screenshotSync.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from "vitest"; -import { waitForRedrawSettle, type SettleClock } from "../renderer/screenshotSync"; +import { + waitForRedrawSettle, + captureWithResizeSettle, + type SettleClock, + type CaptureDeps, +} from "../renderer/screenshotSync"; describe("waitForRedrawSettle", () => { it("returns 'settled' once output arrives after baseline and goes quiet", async () => { @@ -54,3 +59,103 @@ describe("waitForRedrawSettle", () => { expect(result).toBe("timeout"); }); }); + +/** Builds a CaptureDeps whose injected clock settles quickly (output at t≈5 then quiet). */ +function makeSettlingDeps(overrides: Partial = {}): { deps: CaptureDeps; calls: string[] } { + const calls: string[] = []; + let t = 0; + let lastOut = 0; + const deps: CaptureDeps = { + now: () => t, + getLastOutputAt: () => lastOut, + sleep: async (ms: number) => { + t += ms; + if (lastOut === 0 && t >= 5) lastOut = t; + }, + cols: () => 80, + rows: () => 24, + fit: () => { + calls.push("fit"); + }, + resize: (cols, rows) => { + calls.push(`resize:${cols}x${rows}`); + }, + awaitFrame: async () => { + calls.push("frame"); + }, + readViewportCanvas: () => { + calls.push("readCanvas"); + return "PNGDATA"; + }, + renderScrollback: (offset) => { + calls.push(`renderScrollback:${offset}`); + return `SCROLLBACK:${offset}`; + }, + ...overrides, + }; + return { deps, calls }; +} + +describe("captureWithResizeSettle", () => { + it("resizes and waits a frame BEFORE reading the viewport canvas (order guard)", async () => { + const { deps, calls } = makeSettlingDeps(); + + const result = await captureWithResizeSettle(deps, 0); + + expect(result).toBe("PNGDATA"); + expect(calls.indexOf("resize:80x24")).toBeLessThan(calls.indexOf("readCanvas")); + expect(calls.indexOf("frame")).toBeLessThan(calls.indexOf("readCanvas")); + expect(calls).toContain("fit"); + }); + + it("returns the viewport canvas PNG for scrollOffset 0", async () => { + const { deps, calls } = makeSettlingDeps(); + + const result = await captureWithResizeSettle(deps, 0); + + expect(result).toBe("PNGDATA"); + expect(calls).not.toContain("renderScrollback:0"); + }); + + it("renders scrollback (non-zero offset) and skips the canvas", async () => { + const { deps, calls } = makeSettlingDeps(); + + const result = await captureWithResizeSettle(deps, 5); + + expect(result).toBe("SCROLLBACK:5"); + expect(calls).not.toContain("readCanvas"); + expect(calls).toContain("renderScrollback:5"); + }); + + it("falls back to renderScrollback(0) when the viewport canvas is null", async () => { + const { deps, calls } = makeSettlingDeps({ + readViewportCanvas: () => { + calls.push("readCanvas"); + return null; + }, + }); + + const result = await captureWithResizeSettle(deps, 0); + + expect(result).toBe("SCROLLBACK:0"); + expect(calls).toContain("renderScrollback:0"); + }); + + it("uses default 30ms/500ms options when none given", async () => { + // Spy on waitForRedrawSettle indirectly: a deps whose sleep records total + // waited time proves the loop honoured the default timeout when no output. + let waited = 0; + const { deps } = makeSettlingDeps({ + getLastOutputAt: () => 0, // no new output → must hit 500ms timeout + sleep: async (ms: number) => { + waited += ms; + }, + }); + + await captureWithResizeSettle(deps, 0); + + // Default timeoutMs is 500; loop ticks in 10ms increments until >=500. + expect(waited).toBeGreaterThanOrEqual(500); + expect(waited).toBeLessThan(600); + }); +}); diff --git a/electron-app/src/renderer/screenshotSync.ts b/electron-app/src/renderer/screenshotSync.ts index f944251..18e495b 100644 --- a/electron-app/src/renderer/screenshotSync.ts +++ b/electron-app/src/renderer/screenshotSync.ts @@ -50,3 +50,53 @@ export async function waitForRedrawSettle( if (now - start >= timeoutMs) return "timeout"; } } + +/** Injectable terminal/PTY/canvas operations the orchestrator needs. */ +export interface CaptureDeps extends SettleClock { + cols(): number; + rows(): number; + /** Recompute cols/rows from the current DOM (xterm FitAddon.fit). */ + fit(): void; + /** Push new cols/rows to the PTY (WS resize → SIGWINCH). */ + resize(cols: number, rows: number): void; + /** Wait for the renderer to commit the latest buffer to the canvas (rAF). */ + awaitFrame(): Promise; + /** Read the viewport canvas as base64 PNG (no data: prefix), or null if absent. */ + readViewportCanvas(): string | null; + /** Render the scrollback at `scrollOffset` lines up as base64 PNG. */ + renderScrollback(scrollOffset: number): string; +} + +export interface CaptureOptions { + quietMs: number; + timeoutMs: number; +} + +/** + * Fit → resize → wait for redraw settle → wait one frame → capture. + * + * Guarantees `resize()` runs strictly before any canvas/buffer read, so the + * PTY has been told the current size (and the TUI given a chance to reflow) + * before we snapshot pixels. + * + * For `scrollOffset === 0` the live viewport canvas is preferred; if it is + * unavailable we fall back to buffer rendering. Non-zero offsets always render + * from the buffer. + */ +export async function captureWithResizeSettle( + deps: CaptureDeps, + scrollOffset: number, + opts: CaptureOptions = { quietMs: DEFAULT_QUIESCENCE_MS, timeoutMs: DEFAULT_RESIZE_TIMEOUT_MS }, +): Promise { + deps.fit(); + const baseline = deps.now(); + deps.resize(deps.cols(), deps.rows()); + await waitForRedrawSettle(deps, baseline, opts.quietMs, opts.timeoutMs); + await deps.awaitFrame(); + + if (scrollOffset === 0) { + const canvas = deps.readViewportCanvas(); + if (canvas !== null) return canvas; + } + return deps.renderScrollback(scrollOffset); +} From 97b7461d362859c4e884449e38e42e6cd54ce9f7 Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Mon, 22 Jun 2026 23:33:09 +0800 Subject: [PATCH 05/12] fix(capture): advance clock in default-options test The default-options test overrode sleep to bump a waited counter but left now() reading a frozen closure var, freezing the timeout check at 0 and looping forever. Override now() to return waited so the clock advances. Co-Authored-By: Claude --- electron-app/src/__tests__/screenshotSync.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/electron-app/src/__tests__/screenshotSync.test.ts b/electron-app/src/__tests__/screenshotSync.test.ts index cdf4ace..df171c4 100644 --- a/electron-app/src/__tests__/screenshotSync.test.ts +++ b/electron-app/src/__tests__/screenshotSync.test.ts @@ -146,6 +146,7 @@ describe("captureWithResizeSettle", () => { // waited time proves the loop honoured the default timeout when no output. let waited = 0; const { deps } = makeSettlingDeps({ + now: () => waited, // clock must advance with the waited counter getLastOutputAt: () => 0, // no new output → must hit 500ms timeout sleep: async (ms: number) => { waited += ms; From c764329007f2378fcf47eb5418fab7a5b77ba0d0 Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Mon, 22 Jun 2026 23:36:19 +0800 Subject: [PATCH 06/12] feat(capture): resize + redraw-settle before screenshot in Terminal Co-Authored-By: Claude --- .../src/renderer/components/Terminal.tsx | 45 ++++++++++++++----- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/electron-app/src/renderer/components/Terminal.tsx b/electron-app/src/renderer/components/Terminal.tsx index bc353d8..98c3160 100644 --- a/electron-app/src/renderer/components/Terminal.tsx +++ b/electron-app/src/renderer/components/Terminal.tsx @@ -4,6 +4,7 @@ import type { Terminal as TerminalType } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { connectPty } from "../api"; import { renderBufferToPng } from "../terminalBuffer"; +import { captureWithResizeSettle } from "../screenshotSync"; import "@xterm/xterm/css/xterm.css"; interface TerminalProps { @@ -26,6 +27,7 @@ const SandboxTerminal = forwardRef(functio const fitAddonRef = useRef(null); const fitFnRef = useRef<(() => void) | null>(null); const connRef = useRef | null>(null); + const lastOutputAtRef = useRef(0); useImperativeHandle(ref, () => ({ get terminal() { @@ -34,20 +36,40 @@ const SandboxTerminal = forwardRef(functio async captureToPng(scrollOffset: number = 0): Promise { const term = xtermRef.current; if (!term) throw new Error("Terminal not initialized"); - - // The live xterm canvas only ever shows the current viewport (offset 0). - // For any scroll offset we must render from the text buffer instead. - if (scrollOffset === 0) { - const canvasEl = term.element?.querySelector("canvas"); - if (canvasEl) { - const dataUrl = canvasEl.toDataURL("image/png"); - return dataUrl.split(",")[1]; + const fitAddon = fitAddonRef.current; + const conn = connRef.current; + + // Before mount fully settles (no fit/conn yet): fall back to a direct + // read without resize, matching prior behavior. + if (!fitAddon || !conn) { + if (scrollOffset === 0) { + const canvasEl = term.element?.querySelector("canvas"); + if (canvasEl) return canvasEl.toDataURL("image/png").split(",")[1]; } + return renderBufferToPng(term, term.cols, term.rows, scrollOffset); } - const fitAddon = fitAddonRef.current; - if (fitAddon) fitAddon.fit(); - return renderBufferToPng(term, term.cols, term.rows, scrollOffset); + return captureWithResizeSettle( + { + now: () => performance.now(), + getLastOutputAt: () => lastOutputAtRef.current, + sleep: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)), + cols: () => term.cols, + rows: () => term.rows, + fit: () => fitAddon.fit(), + resize: (cols, rows) => conn.resize(cols, rows), + awaitFrame: () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + readViewportCanvas: () => { + const canvasEl = term.element?.querySelector("canvas"); + return canvasEl ? canvasEl.toDataURL("image/png").split(",")[1] : null; + }, + renderScrollback: (offset) => renderBufferToPng(term, term.cols, term.rows, offset), + }, + scrollOffset, + ); }, }), []); @@ -145,6 +167,7 @@ const SandboxTerminal = forwardRef(functio conn.onOutput((data) => { const term = xtermRef.current; if (!term) return; + lastOutputAtRef.current = performance.now(); const writeData = typeof data === "string" ? data : decoder.decode(data as Uint8Array); term.write(writeData); }); From cf9782ce16251f0ca112d2376d740101ffda7a05 Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Mon, 22 Jun 2026 23:39:33 +0800 Subject: [PATCH 07/12] test: add screenshot layout-after-resize release scenario Co-Authored-By: Claude --- tests/release_test.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/release_test.md b/tests/release_test.md index 132f968..901e62e 100644 --- a/tests/release_test.md +++ b/tests/release_test.md @@ -27,4 +27,10 @@ - 然后输入"请分析 @/Users/zn-ice/2026/openclaw-main 这个代码的实现逻辑,然后将结论告诉我"?然后出发回车发送。实时使用`scrollback`查看当前状态,如果阻塞需要确认,则发送回车进行确认,并进行截图。一直等待执行结束,执行结束后进行截图,并校验图片是否处于最后的位置。 - 然后还是在这个沙箱中,测试`screenshot --up`, `screenshot --top`, `scrollback`的命令,将截图或文本输出保存在目标文件夹路径下,并校验是否正确 -在release_test/${{时间戳,yyyy-mm-dd-hh-mm-ss}}文件夹下,生成markdown的最终测试报告 \ No newline at end of file +在release_test/${{时间戳,yyyy-mm-dd-hh-mm-ss}}文件夹下,生成markdown的最终测试报告 +14. 执行`sh release.sh`打包编译新的cli-box,然后通过CLI命令,执行下面流程,验证 `screenshot` 在捕获前会先触发 PTY resize + 重绘稳定(pre-resize 机制),确保全屏 TUI 在窗口尺寸变化后截图布局仍然正确: + - 后面的每一步操作后都截图保存到`release_test/${{时间戳,yyyy-mm-dd-hh-mm-ss}}`文件夹下(不要用--with-frame) + - `cli-box start claude --shell`(或任意全屏 TUI 如 `htop`),等待 TUI 完全渲染完成后,执行 `cli-box screenshot --out baseline.png` 进行基线截图,校验布局完整(header / 输入框位置正确) + - 然后拖拽 Electron 窗口改变其尺寸(不同大小),等待约 1 秒后执行 `cli-box screenshot --out after-resize.png`,校验布局已按新尺寸正确重排——无重叠/乱码文字,无残留的半帧重绘 + - 再次改变窗口尺寸(又一个不同尺寸),立即执行 `cli-box screenshot --out immediate.png`(不手动等待),校验布局仍然正确(验证 redraw-settle 已处理新帧) + - 通过标准:三张截图中的 TUI 布局均与当前窗口尺寸一致、布局正确;如果截图显示 resize 后 TUI 布局错乱,则说明 pre-resize 机制发生回归 \ No newline at end of file From 87d6285ded1f73a5004aa67239013a76a8860a68 Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Tue, 23 Jun 2026 21:57:28 +0800 Subject: [PATCH 08/12] =?UTF-8?q?docs:=20revise=20screenshot=20pre-resize?= =?UTF-8?q?=20spec=20=E2=80=94=20bounded=20burst-wait=20settle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release test scenario 14 revealed two flaws in the original global-quiet settle (≤500ms): (1) it waited for global output quiet, so continuous output (spinner/streaming) always hit the 500ms cap, and steady-state screenshots paid the full 500ms for output that never arrives; (2) the "always resize triggers a redraw" rationale was wrong — TIOCSWINSZ only sends SIGWINCH when the size actually changes. Revised settle: 50ms no-output probe (skip the wait in steady state) + 30ms quiet early-exit (short reflows don't pay the full cap) + 120ms hard cap (continuous output bounded). Max added latency ≤120ms, which also removes the dependency on the never-merged 10s daemon timeout (fb1894f). Also corrects the spec's two factual errors (SIGWINCH-on-change-only; fb1894f never merged to main). Co-Authored-By: Claude --- ...2026-06-22-screenshot-pre-resize-design.md | 193 +++++++----------- 1 file changed, 72 insertions(+), 121 deletions(-) diff --git a/docs/superpowers/specs/2026-06-22-screenshot-pre-resize-design.md b/docs/superpowers/specs/2026-06-22-screenshot-pre-resize-design.md index f2455c2..9dd21bc 100644 --- a/docs/superpowers/specs/2026-06-22-screenshot-pre-resize-design.md +++ b/docs/superpowers/specs/2026-06-22-screenshot-pre-resize-design.md @@ -2,7 +2,18 @@ **Date:** 2026-06-22 **Branch:** feat/screenshot-pre-resize -**Status:** Approved +**Status:** Approved (revised 2026-06-23 — settle changed from global-quiet ≤500ms to bounded burst-wait ≤120ms; see Revision Notes) + +--- + +## Revision Notes (2026-06-23) + +The release test (scenario 14: layout after window resize) revealed two flaws in the original settle design, both corrected in this revision: + +1. **The settle waited for *global* output quiet (≤500ms cap).** Under continuous output (spinner / streaming TUI), output never quiets → every screenshot hit the 500ms cap. And in steady state (size unchanged), the settle waited the full 500ms for output that never arrives. Both coupled with the daemon's renderer-screenshot timeout. +2. **The "always resize triggers a redraw" rationale was wrong for unchanged size.** `ioctl(TIOCSWINSZ)` only sends SIGWINCH when the size actually changes (the kernel compares old vs new). Same-size resize → no signal → no redraw. So "always resize" does NOT double as a canvas refresh on unchanged size. + +The revised settle (below) is a **bounded burst-wait**: a no-output probe (skip the wait in steady state), an early-exit when the reflow burst quiets (short reflows don't pay the full cap), and a 120ms hard cap (continuous output can no longer cause long waits). Max added latency ≤120ms, which also **removes the dependency on the (never-merged) 10s daemon timeout** — see Risks. --- @@ -22,36 +33,9 @@ cli-box screenshot → daemon screenshot_handler → Electron renderer captureToPng(scroll) → 返回 PNG ``` -### 渲染端入口(现状)`electron-app/src/renderer/components/Terminal.tsx:34-51` - -```tsx -async captureToPng(scrollOffset: number = 0): Promise { - const term = xtermRef.current; - if (!term) throw new Error("Terminal not initialized"); - if (scrollOffset === 0) { - // 视口路径:直接读 canvas,没有任何 resize / relayout - const canvasEl = term.element?.querySelector("canvas"); - if (canvasEl) { - const dataUrl = canvasEl.toDataURL("image/png"); - return dataUrl.split(",")[1]; - } - } - // scrollback 路径:只有本地 fit(),没有 conn.resize(),TUI 收不到 SIGWINCH - const fitAddon = fitAddonRef.current; - if (fitAddon) fitAddon.fit(); - return renderBufferToPng(term, term.cols, term.rows, scrollOffset); -} -``` - -### 三个缺口 - -1. **视口截图(`scroll=0`,claude 全屏 TUI 常见场景)**:直接 `canvas.toDataURL()`,零 resize。xterm 的 cols/rows 与 PTY 实际尺寸漂移、或 TUI 处于半重绘状态时,canvas 上就是错乱布局。 -2. **scrollback 截图**:只 `fitAddon.fit()`(仅本地重算),无 `conn.resize()` → TUI 不 reflow。 -3. 真正会发 PTY resize 的 `doFit()`(`Terminal.tsx:115-118`)只在 mount / window resize / container ref 触发,不在截图路径上。 - -### 为何复发 +### 渲染端入口(现状)`electron-app/src/renderer/components/Terminal.tsx` -截图子系统被多次重写(2026-06-05 frame、2026-06-06 size-fix、2026-06-17 start-shell-screenshot)。2026-06-18 reliability fix(commit `17517c2`)只处理超时 / 窗口识别 / 重连,未覆盖截图前 resize。「视口路径直接读 canvas」的写法在这些迭代中引入,丢掉了 resize 逻辑。 +视口路径(`scroll=0`)直接 `canvas.toDataURL()`,零 resize;scrollback 路径只有本地 `fit()`,无 `conn.resize()`。xterm 的 cols/rows 与 PTY 实际尺寸漂移、或 TUI 处于半重绘状态时,canvas 上就是错乱布局。真正会发 PTY resize 的 `doFit()` 只在 mount / window resize / container ref 触发,不在截图路径上。 --- @@ -59,130 +43,97 @@ async captureToPng(scrollOffset: number = 0): Promise { ### Approach: renderer-side fix in `captureToPng` -| 方案 | 做法 | 取舍 | -|------|------|------| -| **1(采纳)渲染端 `captureToPng` 内修复** | 截图入口统一 fit + resize + settle | 改动最小、落在真实截图点、viewport + scrollback 双路一次性覆盖 | -| 2 daemon 截图前发 resize_request | 跨进程通知渲染端 resize 再 capture | 逻辑跨进程散开、要把 resize 编排进 WS 协议、复杂度高 | -| 3 后台周期 resync | 心搏定时 fit + resize | 不保证截图那一瞬间 canvas 是 fresh 的,治标不治本 | - -截图本质是渲染端行为,resize(xterm + PTY WS)也在渲染端,在最贴近截图点修最干净。 - -### Mechanism: fit → conn.resize → 等 redraw settle(事件驱动 + 超时兜底) - -**核心洞察**:无法直接观测 TUI 内部状态,但 TUI 收到 SIGWINCH 后重绘必然产生 PTY 输出回流渲染端。因此「重绘完成」≈「resize 之后那波输出安静下来」。 - -#### 实现(`electron-app/src/renderer/components/Terminal.tsx`) - -**Step 1 — 在现有 `conn.onOutput` 回调(Terminal.tsx:145)追加时间戳:** - -```tsx -conn.onOutput((data) => { - const term = xtermRef.current; - if (!term) return; - lastOutputAtRef.current = performance.now(); // ← 新增 - const writeData = typeof data === "string" ? data : decoder.decode(data as Uint8Array); - term.write(writeData); -}); -``` - -**Step 2 — 新增常量与 helper:** - -```tsx -const SYNC_RESIZE_TIMEOUT_MS = 500; // 超时兜底:到点直接截 -const OUTPUT_QUIESCENCE_MS = 30; // 输出停顿 ≥30ms 视为这波重绘完成 - -// fit 到当前 DOM 尺寸 + 同步给 PTY(触发 SIGWINCH)+ 等重绘稳定 -const syncResizeAndSettle = async () => { - const term = xtermRef.current; - const fitAddon = fitAddonRef.current; - const conn = connRef.current; - if (!term || !fitAddon || !conn) return; - fitAddon.fit(); - const baseline = performance.now(); // 记录发 resize 的时刻 - conn.resize(term.cols, term.rows); // → SIGWINCH → TUI 重绘 → 输出回流 - await waitForRedrawSettle(baseline, OUTPUT_QUIESCENCE_MS, SYNC_RESIZE_TIMEOUT_MS); - await nextFrame(); // 让 xterm 把最新 buffer 提交到 canvas(2×rAF) -}; - -// 阻塞:等到「resize 后有新输出 且 安静 30ms」,或 500ms 超时 -async function waitForRedrawSettle(baseline: number, quietMs: number, timeoutMs: number) { - const start = performance.now(); - while (true) { - await tick(10); // rAF 或 10ms 步进 - const now = performance.now(); - const sawNewOutput = lastOutputAtRef.current > baseline; // resize 后有新输出 - const quiet = now - lastOutputAtRef.current >= quietMs; // 这波输出停了 - if ((sawNewOutput && quiet) || now - start >= timeoutMs) break; - } -} -``` - -**Step 3 — `captureToPng` 入口统一调用(覆盖 viewport + scrollback):** - -```tsx -async captureToPng(scrollOffset: number = 0): Promise { - const term = xtermRef.current; - if (!term) throw new Error("Terminal not initialized"); - await syncResizeAndSettle(); // ← 新增:截图前 resize + 等稳定 - if (scrollOffset === 0) { - const canvasEl = term.element?.querySelector("canvas"); - if (canvasEl) { - const dataUrl = canvasEl.toDataURL("image/png"); - return dataUrl.split(",")[1]; - } +截图本质是渲染端行为,resize(xterm + PTY WS)也在渲染端,在最贴近截图点修最干净。新逻辑抽成纯的、依赖注入的 `electron-app/src/renderer/screenshotSync.ts`(仿 `terminalBuffer.ts` 先例,无需 React/xterm/fake-timer 即可单测)。 + +### Mechanism: fit → conn.resize → 有界等重绘爆发(probe + 安静早退 + 120ms 上限) + +**核心约束**:「resize 后的正确新布局」只存在于重绘输出被渲染端 `term.write()` 消费完之后。所以等待不可省——但**只需等那一次性重排爆发落地**,不等「全局输出停下」。 + +**关于 SIGWINCH(纠正)**:`ioctl(TIOCSWINSZ)` 只在尺寸**真正变化**时才向前台进程组发 SIGWINCH;同尺寸 resize 不发信号、不触发重绘。因此: +- `conn.resize(cols, rows)` **照发**——目的是修 PTY 漂移(把 PTY 强制对齐 xterm 当前尺寸),不是为了在稳态触发重绘。 +- 「有没有重排要等」由 settle 的**无输出探测**判定:探测窗口内没有任何新 PTY 输出 → 说明没有 SIGWINCH/重排(稳态)→ 立即截,不等。 + +#### 关键逻辑(`electron-app/src/renderer/screenshotSync.ts`) + +```ts +export const DEFAULT_QUIESCENCE_MS = 30; // 重排爆发安静的判据(早退用) +export const PROBE_MS = 50; // 无输出探测窗口:稳态直接截 +export const DEFAULT_RESIZE_TIMEOUT_MS = 120; // 有界爆发上限(原 500 → 120) + +// 等待 resize 引发的重排爆发落地。三段判据,全部有界: +export async function waitForRedrawSettle( + clock: SettleClock, baseline: number, + quietMs = DEFAULT_QUIESCENCE_MS, + timeoutMs = DEFAULT_RESIZE_TIMEOUT_MS, + probeMs = PROBE_MS, tickMs = 10, +): Promise<"settled" | "timeout"> { + const start = clock.now(); + for (;;) { + await clock.sleep(tickMs); + const now = clock.now(); + const lastOutputAt = clock.getLastOutputAt(); + const sawOutput = lastOutputAt > baseline; + // ① 稳态:探测窗口内一直无新输出 → 没有重排 → 立刻返回(不干等) + if (!sawOutput && now - start >= probeMs) return "settled"; + // ② 爆发已落地:看到输出且安静了 quietMs → 早退(短重排不必等满) + if (sawOutput && now - lastOutputAt >= quietMs) return "settled"; + // ③ 有界上限:持续输出永不安静 → timeoutMs 到点返回(不再退化) + if (now - start >= timeoutMs) return "timeout"; } - return renderBufferToPng(term, term.cols, term.rows, scrollOffset); } ``` -**Step 4 — `doFit`(Terminal.tsx:115-118)复用相同 fit+resize**(去掉 settle),消除重复,保持单一来源。 +`captureWithResizeSettle(deps, scrollOffset)`:`fit → baseline=now → resize → waitForRedrawSettle → awaitFrame(2×rAF) → capture`,保证 `resize()` 在任何 canvas/buffer 读取之前。`Terminal.tsx` 的 `captureToPng` 委托给它;`onOutput` 追加 `lastOutputAtRef.current = performance.now()` 喂给 settle 时钟。 ### 为什么「总是发 resize」而非「尺寸变了才发」 -PTY 尺寸漂移时,xterm 的 cols 不变(DOM 没变),若只在 cols 变化时才 resize 会漏掉漂移场景。**总是发 `conn.resize(cols, rows)`**:daemon 侧 `resize_pty` 幂等,且同尺寸 SIGWINCH 也会触发全屏 TUI 重绘——顺带解决「canvas 是旧帧」的 stale 问题,等于免费做一次强制刷新。 +为了修 PTY 漂移:PTY 与 xterm 尺寸不一致时,xterm 的 cols 跟着 DOM、不会变,若只在 cols 变化时才 resize 会漏掉漂移。**总是发 `conn.resize(cols, rows)`** 把 PTY 强制对齐 xterm 当前尺寸。注意:同尺寸时内核不发 SIGWINCH、不会重绘——「有没有重排要等」交给 settle 的无输出探测判定,而不是依赖 resize 去触发重绘。 -### 行为对照(事件驱动 + 超时兜底) +### 行为对照(有界爆发等待,全部 ≤120ms) -| 场景 | 表现 | -|------|------| -| heavy TUI 收到 SIGWINCH 重绘 | 输出回流 → 安静 30ms → **快路径,几十 ms 就截** | -| 同尺寸 SIGWINCH / TUI 无反应 | 无新输出 → **500ms 超时兜底,直接截** | -| TUI 持续输出(spinner) | 永不安静 → **500ms 超时兜底截** | +| 场景 | 命中分支 | 表现 | +|------|---------|------| +| 稳态(尺寸没变、无重排) | ① 探测无输出 | **~50ms 后立即截** | +| 短重排(重画完即安静) | ② 爆发落地 + 安静 | **~60–90ms 早退** | +| 持续输出(spinner / 流式) | ③ 永不安静 → 120ms 上限 | **~120ms(不再退化到 500ms)** | --- ## Scope - **In scope**:默认渲染端截图(viewport + scrollback,CLI sandbox 如 claude)。 -- **Out of scope(follow-up)**:`--with-frame`(ScreenCaptureKit 直捕窗口)绕过渲染端,要 resize 需跨进程编排,复杂度高;claude 问题在默认路径,本次不扩面。 +- **Out of scope(follow-up)**:`--with-frame`(ScreenCaptureKit 直捕窗口)绕过渲染端,要 resize 需跨进程编排;本次不扩面。 --- ## Testing Strategy -### UT(vitest)— 回归看护(最重要) +### UT(vitest,`screenshotSync.test.ts`)— 回归看护 -mock xterm + conn(参考 `electron-app/src/__tests__/connectPty.test.ts`): +依赖注入 fake clock,断言真实时序行为: -- **快路径**:`captureToPng(0)` 后用 fake timer 推进,resize 后触发一次 onOutput 再推进 30ms,断言 `toDataURL` 在 settle 之后才被调用。 -- **超时兜底**:mock conn 不回放任何输出,推进 500ms,断言仍完成截图(兜底生效)。 -- **顺序守卫**:断言 `conn.resize` 在 canvas/buffer 读取之前被调用——这正是当初被回退掉机制的回归守卫。 +- **稳态(probe)**:resize 后不回放任何输出,推进 ~50ms,断言立即完成截图(`settled`,不等到 120ms)。 +- **短重排早退**:resize 后触发一次 onOutput,推进 >30ms 安静,断言 ~60–90ms 内完成。 +- **持续输出有界**:resize 后持续回放输出(永不安静),推进到 120ms,断言到点完成(不再等 500ms)。 +- **顺序守卫**:断言 `conn.resize` 在 canvas/buffer 读取之前被调用——这是当初被回退机制的回归守卫。 +- 其余 `captureWithResizeSettle` 测试(viewport canvas / scrollback / null-canvas fallback / order-guard)保持。 ### E2E / release_test -按 `tests/release_test.md` 流程:启动 sandbox → 跑全屏 TUI → 改窗口尺寸 → 截图 → 人工核对布局正确,截图存档到 `release_test/YYYY-MM-DD-HH-MM-SS/`。 +`tests/release_test.md` item 14:启动全屏 TUI → 基线截图 → 改窗口尺寸 → 截图 → 校验布局按新尺寸重排(无重叠/乱码/半帧)→ 再次改尺寸立即截图 → 校验。截图存档到 `release_test/<时间戳>/`。 --- ## Risks -- **延迟**:单次截图最多 +500ms(仅当 TUI 无输出回流时;正常快路径几十 ms)。daemon renderer 超时已为 10s(commit `fb1894f`),预算充足。 -- **quiescence 偏紧**:极重绘机器上 30ms 可能偏短,导致快路径提前触发;两个常量已命名(`SYNC_RESIZE_TIMEOUT_MS` / `OUTPUT_QUIESCENCE_MS`),后续可调。 -- **nextFrame**:用 2×rAF 保证 canvas 提交最新 buffer;scrollback 路径走 `renderBufferToPng` 不依赖 canvas,harmless。 +- **延迟**:单次截图最多 +120ms(稳态 ~50ms、短重排 ~60–90ms、持续输出 ~120ms)。 +- **不再依赖 10s daemon 超时**:max +120ms 叠在基础截图耗时(大 canvas `toDataURL` + base64 + WS 往返)上仍远低于 daemon 现有 2s 超时,因此本修复**不需要** `fb1894f`(把超时 2s→10s)也能稳定工作。`fb1894f` 经核实**从未合入 main**(仅存在于 `feat/start-shell-screenshot-openclaw`),可独立合入做余量,但非本修复的前提。 +- **BURST 上限是启发式**:120ms 是「重排爆发落地」的经验估值;极复杂 TUI 的单次重排理论可能更久 → 截到部分帧。但比原 500ms 全局安静严格更好(后者持续输出下永远顶满,且稳态白等 500ms)。`PROBE_MS` / `DEFAULT_QUIESCENCE_MS` / `DEFAULT_RESIZE_TIMEOUT_MS` 均命名可调。 +- **awaitFrame**:2×rAF 保证 canvas 提交最新 buffer;scrollback 路径走 `renderBufferToPng` 不依赖 canvas,harmless。 --- ## Out of Scope - `--with-frame` 路径的截图前 resize(记为 follow-up)。 -- 调整 daemon renderer 超时(已 10s,无需动)。 +- 合并 `fb1894f`(daemon 超时 2s→10s):独立改进,非本修复前提(有界 settle 已与之解耦)。 From 02a1b7532d507ebca17084e4e78cfae787e772b8 Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Tue, 23 Jun 2026 22:06:30 +0800 Subject: [PATCH 09/12] docs: add screenshot bounded burst-wait settle plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-task TDD plan: add PROBE_MS=50, lower DEFAULT_RESIZE_TIMEOUT_MS 500→120, add a no-output probe branch to waitForRedrawSettle. Updates the settle tests to the new bounded timing (probe / early-exit / continuous). captureWithResizeSettle and Terminal.tsx unchanged. Co-Authored-By: Claude --- .../2026-06-23-screenshot-burst-settle.md | 263 ++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-23-screenshot-burst-settle.md diff --git a/docs/superpowers/plans/2026-06-23-screenshot-burst-settle.md b/docs/superpowers/plans/2026-06-23-screenshot-burst-settle.md new file mode 100644 index 0000000..873fc76 --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-screenshot-burst-settle.md @@ -0,0 +1,263 @@ +# Screenshot Bounded Burst-Wait Settle Implementation Plan + +> **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. + +**Goal:** Replace the settle's global-quiet ≤500ms wait with a bounded burst-wait (50ms no-output probe + 30ms quiet early-exit + 120ms hard cap) so continuous TUI output can no longer cause long screenshot waits. + +**Architecture:** Single-file change to the pure, dependency-injected `electron-app/src/renderer/screenshotSync.ts`: add a `PROBE_MS` constant, lower `DEFAULT_RESIZE_TIMEOUT_MS` from 500→120, and add a no-output probe branch to `waitForRedrawSettle`. `captureWithResizeSettle` and `Terminal.tsx` are unchanged — they pick up the new defaults automatically. Tests updated to the new timing semantics. + +**Tech Stack:** TypeScript · vitest (jsdom) · dependency-injected `SettleClock` (no React/xterm/fake-timer). + +## Global Constraints + +- **Renderer-side TypeScript only.** No Rust/daemon changes. **`Terminal.tsx` must NOT be touched** — it calls `captureWithResizeSettle(deps, scrollOffset)` with no options, so it inherits the new defaults transparently. +- **Constants (exact values):** `DEFAULT_QUIESCENCE_MS = 30` (unchanged), `PROBE_MS = 50` (new), `DEFAULT_RESIZE_TIMEOUT_MS = 120` (was 500). +- **`waitForRedrawSettle` signature stays compatible:** add `probeMs: number = PROBE_MS` as the last parameter; return type stays `Promise<"settled" | "timeout">`. `captureWithResizeSettle` calls it with 4 args (probeMs uses its default) — do not change that call site. +- **`captureWithResizeSettle` public signature/behavior unchanged** (`(deps, scrollOffset, opts?) => Promise`; resize-before-read guarantee intact). +- **TDD.** Update the settle tests first (RED), then the module (GREEN). +- **Test invocation:** `(cd electron-app && pnpm vitest run src/__tests__/screenshotSync.test.ts)`; full regression `(cd electron-app && pnpm vitest run)`; typecheck `(cd electron-app && pnpm typecheck)`. + +--- + +## File Structure + +| File | Responsibility | Action | +|------|----------------|--------| +| `electron-app/src/renderer/screenshotSync.ts` | `waitForRedrawSettle` (the settle clock) + `captureWithResizeSettle` (orchestrator) + constants. | Modify | +| `electron-app/src/__tests__/screenshotSync.test.ts` | Unit tests with injected fake clocks. | Modify | + +`Terminal.tsx` is intentionally **not** modified. + +--- + +### Task 1: Revise `waitForRedrawSettle` to a bounded burst-wait + +**Files:** +- Modify: `electron-app/src/renderer/screenshotSync.ts` (constants at lines 13–16; `waitForRedrawSettle` at lines 28–52; module doc comment at lines 1–11) +- Test: `electron-app/src/__tests__/screenshotSync.test.ts` (settle describe block at lines 9–61; "default options" test at lines 144–161) + +**Interfaces:** +- Consumes: existing `SettleClock`, `CaptureDeps`, `captureWithResizeSettle` (unchanged). +- Produces: revised `waitForRedrawSettle(clock, baseline, quietMs, timeoutMs, tickMs?, probeMs?)` — adds the no-output probe branch; same return type. New exported constant `PROBE_MS = 50`; `DEFAULT_RESIZE_TIMEOUT_MS` lowered to 120. + +- [ ] **Step 1: Update the settle tests to the new timing semantics (RED)** + +In `electron-app/src/__tests__/screenshotSync.test.ts`, replace the entire `describe("waitForRedrawSettle", ...)` block (currently lines 9–61) with: + +```ts +describe("waitForRedrawSettle", () => { + it("returns 'settled' once output arrives after baseline and goes quiet (early-exit)", async () => { + let t = 0; + let lastOut = 0; + // Redraw output lands at the first tick where t >= 25 (i.e. t = 30). + const clock: SettleClock = { + now: () => t, + getLastOutputAt: () => lastOut, + sleep: async (ms: number) => { + t += ms; + if (lastOut === 0 && t >= 25) lastOut = t; + }, + }; + + const result = await waitForRedrawSettle(clock, /*baseline*/ 0, /*quietMs*/ 30, /*timeoutMs*/ 120); + + expect(result).toBe("settled"); + // output at t=30, quietMs 30 → settles once now-30 >= 30 (i.e. now >= 60) + expect(t).toBeGreaterThanOrEqual(60); + expect(t).toBeLessThan(120); + }); + + it("returns 'settled' via the probe when no reflow occurs (steady state)", async () => { + let t = 0; + const clock: SettleClock = { + now: () => t, + getLastOutputAt: () => 0, // no output after baseline → no SIGWINCH/redraw + sleep: async (ms: number) => { + t += ms; + }, + }; + + const result = await waitForRedrawSettle(clock, 0, 30, 120); + + expect(result).toBe("settled"); + // PROBE_MS = 50: no output seen → settled at ~50ms, NOT the 120ms cap. + expect(t).toBeGreaterThanOrEqual(50); + expect(t).toBeLessThan(120); + }); + + it("returns 'settled' via the probe when only stale (pre-baseline) output exists", async () => { + let t = 100; // baseline = 100 + const clock: SettleClock = { + now: () => t, + getLastOutputAt: () => 50, // before baseline → not "new" output + sleep: async (ms: number) => { + t += ms; + }, + }; + + const result = await waitForRedrawSettle(clock, 100, 30, 120); + + expect(result).toBe("settled"); + // probe fires ~50ms after start (no NEW output after baseline). + expect(t).toBeGreaterThanOrEqual(150); + expect(t).toBeLessThan(220); + }); + + it("returns 'timeout' (bounded) when output flows continuously and never quiets", async () => { + let t = 0; + const clock: SettleClock = { + now: () => t, + // output "just happened" every tick → now - lastOutputAt is always 0, never quiet. + getLastOutputAt: () => t, + sleep: async (ms: number) => { + t += ms; + }, + }; + + const result = await waitForRedrawSettle(clock, 0, 30, 120); + + expect(result).toBe("timeout"); + // Hard cap bounds the wait at 120ms even under continuous output (was 500ms). + expect(t).toBeGreaterThanOrEqual(120); + expect(t).toBeLessThan(200); + }); +}); +``` + +Also update the "default options" `captureWithResizeSettle` test (currently lines 144–161). Replace its body with: + +```ts + it("uses default options (probe settles quickly when no output)", async () => { + // No new output → the PROBE branch settles at ~50ms instead of the 120ms cap. + let waited = 0; + const { deps } = makeSettlingDeps({ + now: () => waited, // clock must advance with the waited counter + getLastOutputAt: () => 0, // no new output → probe settles + sleep: async (ms: number) => { + waited += ms; + }, + }); + + await captureWithResizeSettle(deps, 0); + + // Default PROBE_MS = 50; loop ticks in 10ms increments until >= 50. + expect(waited).toBeGreaterThanOrEqual(50); + expect(waited).toBeLessThan(120); + }); +``` + +Leave the other four `captureWithResizeSettle` tests (order guard, viewport canvas, scrollback, null-canvas fallback) and the `makeSettlingDeps` helper unchanged — they use the default-output clock and settle via the quiet branch at ~40ms, unaffected by the probe/cap change. + +- [ ] **Step 2: Run the tests to verify the expected ones fail** + +Run: `(cd electron-app && pnpm vitest run src/__tests__/screenshotSync.test.ts)` +Expected: **3 failures** — the two rewritten "no output" / "stale output" tests now expect `"settled"` at ~50ms but the current implementation returns `"timeout"` at 500ms; the new "continuous output" test expects `"timeout"` at ~120ms but the current cap is 500ms (so it would hang or return at 500ms). The "early-exit" and four `captureWithResizeSettle` tests still pass. + +- [ ] **Step 3: Update `waitForRedrawSettle` + constants in the module** + +In `electron-app/src/renderer/screenshotSync.ts`: + +Replace the constants block (lines 13–16): + +```ts +/** Default quiescence window: output quiet for this long ⇒ redraw burst landed. */ +export const DEFAULT_QUIESCENCE_MS = 30; +/** No-output probe window: if no PTY output arrives this long after the resize, + * there is no SIGWINCH/redraw to wait for (steady state) — capture immediately. */ +export const PROBE_MS = 50; +/** Default hard cap: give up waiting and capture whatever we have. Bounded so + * continuous output (spinner / streaming) can't stall a screenshot. */ +export const DEFAULT_RESIZE_TIMEOUT_MS = 120; +``` + +Replace the `waitForRedrawSettle` function AND its doc comment (lines 28–52) with: + +```ts +/** + * Wait for the resize-induced redraw burst to land, bounded so continuous output + * can't stall the capture. Three exit conditions, cheapest first: + * + * 1. Probe (`probeMs`): if NO PTY output has arrived since `baseline`, there was + * no SIGWINCH/redraw (TIOCSWINSZ only signals on a real size change) — return + * "settled" immediately. Skips the wait entirely in steady state. + * 2. Quiet (`quietMs`): once output HAS arrived, return "settled" once it goes + * quiet — short reflows exit early without paying the full cap. + * 3. Cap (`timeoutMs`): continuous output never quiets — return "timeout" at the + * cap regardless. + * + * @returns "settled" (probe or quiet) or "timeout" (cap). + */ +export async function waitForRedrawSettle( + clock: SettleClock, + baseline: number, + quietMs: number, + timeoutMs: number, + tickMs: number = 10, + probeMs: number = PROBE_MS, +): Promise<"settled" | "timeout"> { + const start = clock.now(); + for (;;) { + await clock.sleep(tickMs); + const now = clock.now(); + const lastOutputAt = clock.getLastOutputAt(); + const sawNewOutput = lastOutputAt > baseline; + const quiet = now - lastOutputAt >= quietMs; + // 1. Steady state: no reflow to wait for. + if (!sawNewOutput && now - start >= probeMs) return "settled"; + // 2. Redraw burst landed and went quiet. + if (sawNewOutput && quiet) return "settled"; + // 3. Bounded cap (continuous output). + if (now - start >= timeoutMs) return "timeout"; + } +} +``` + +Also update the module's top doc comment (lines 5–7) so it no longer overstates "global quiet". Replace lines 5–7: + +```ts + * Before capturing a terminal frame we re-sync the terminal's cols/rows to the + * DOM (fit), push that size to the PTY (resize → SIGWINCH), then wait for the + * resize-induced redraw burst to land (probe / quiet / bounded cap). +``` + +Do NOT modify `captureWithResizeSettle`, `CaptureDeps`, `CaptureOptions`, or `SettleClock` — they are unchanged. + +- [ ] **Step 4: Run the screenshotSync tests to verify they pass** + +Run: `(cd electron-app && pnpm vitest run src/__tests__/screenshotSync.test.ts)` +Expected: PASS — 9 tests (4 `waitForRedrawSettle` + 5 `captureWithResizeSettle`). + +- [ ] **Step 5: Run the full renderer suite + typecheck (regression)** + +Run: `(cd electron-app && pnpm vitest run)` then `(cd electron-app && pnpm typecheck)` +Expected: both PASS — no regressions to existing capture/scrollback/connect tests; `Terminal.tsx` is untouched so its wiring is unaffected; the inline deps object in `captureToPng` still structurally satisfies `CaptureDeps` (no interface changed). + +- [ ] **Step 6: Commit** + +```bash +git add electron-app/src/renderer/screenshotSync.ts electron-app/src/__tests__/screenshotSync.test.ts +git commit -m "$(cat <<'EOF' +fix(capture): bound redraw settle — probe + 120ms cap + +The settle waited for global output quiet (≤500ms), so continuous TUI +output (spinner/streaming) always hit the 500ms cap and steady-state +screenshots paid the full 500ms for output that never arrives. Both +coupled with the daemon's 2s renderer-screenshot timeout. + +Revised: 50ms no-output probe (steady state → capture immediately), +30ms quiet early-exit (short reflows don't pay the full cap), 120ms hard +cap (continuous output bounded). Max added latency ≤120ms, which also +removes the dependency on the never-merged 10s daemon timeout (fb1894f). + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** Spec §Mechanism (probe + quiet + 120ms cap) → Task 1 Step 3. Spec §behavior table (steady ~50ms / short ~60–90ms / continuous ~120ms) → Task 1 Step 1 tests (probe test asserts ~50ms; early-exit test asserts ~60ms; continuous test asserts ~120ms cap). Spec §"why always resize" (drift fix, not redraw trigger) → reflected in the probe's doc comment + the `PROBE_MS` rationale comment. Spec §Testing (probe / short / continuous) → Task 1 Step 1. Spec §Risks (≤120ms, no 10s dependency) → validated by the bounded tests. `Terminal.tsx` unchanged per spec. +- **No placeholders:** every code/command step is complete and copy-pasteable. +- **Type consistency:** `waitForRedrawSettle` keeps its existing parameter order and return type; `probeMs` is appended last with a default, so `captureWithResizeSettle`'s 4-arg call site (`waitForRedrawSettle(deps, baseline, opts.quietMs, opts.timeoutMs)`) is unchanged and picks up `probeMs = PROBE_MS` by default. `CaptureDeps` / `CaptureOptions` / `SettleClock` field names are untouched. From 4a10cd72b32b79b7617002e94e598a4b89bb3ba5 Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Tue, 23 Jun 2026 22:09:22 +0800 Subject: [PATCH 10/12] =?UTF-8?q?fix(capture):=20bound=20redraw=20settle?= =?UTF-8?q?=20=E2=80=94=20probe=20+=20120ms=20cap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settle waited for global output quiet (≤500ms), so continuous TUI output (spinner/streaming) always hit the 500ms cap and steady-state screenshots paid the full 500ms for output that never arrives. Both coupled with the daemon's 2s renderer-screenshot timeout. Revised: 50ms no-output probe (steady state → capture immediately), 30ms quiet early-exit (short reflows don't pay the full cap), 120ms hard cap (continuous output bounded). Max added latency ≤120ms, which also removes the dependency on the never-merged 10s daemon timeout (fb1894f). Co-Authored-By: Claude --- .../src/__tests__/screenshotSync.test.ts | 67 +++++++++++++------ electron-app/src/renderer/screenshotSync.ts | 33 ++++++--- 2 files changed, 69 insertions(+), 31 deletions(-) diff --git a/electron-app/src/__tests__/screenshotSync.test.ts b/electron-app/src/__tests__/screenshotSync.test.ts index df171c4..552aebb 100644 --- a/electron-app/src/__tests__/screenshotSync.test.ts +++ b/electron-app/src/__tests__/screenshotSync.test.ts @@ -7,10 +7,10 @@ import { } from "../renderer/screenshotSync"; describe("waitForRedrawSettle", () => { - it("returns 'settled' once output arrives after baseline and goes quiet", async () => { + it("returns 'settled' once output arrives after baseline and goes quiet (early-exit)", async () => { let t = 0; let lastOut = 0; - // Simulate TUI redraw output landing at t≈25 (after the baseline of 0). + // Redraw output lands at the first tick where t >= 25 (i.e. t = 30). const clock: SettleClock = { now: () => t, getLastOutputAt: () => lastOut, @@ -20,43 +20,67 @@ describe("waitForRedrawSettle", () => { }, }; - const result = await waitForRedrawSettle(clock, /*baseline*/ 0, /*quietMs*/ 30, /*timeoutMs*/ 500); + const result = await waitForRedrawSettle(clock, /*baseline*/ 0, /*quietMs*/ 30, /*timeoutMs*/ 120); expect(result).toBe("settled"); - // output at 25, quietMs 30 → settles once now - 25 >= 30 (i.e. now >= 55) - expect(t).toBeGreaterThanOrEqual(55); - expect(t).toBeLessThan(500); + // output at t=30, quietMs 30 → settles once now-30 >= 30 (i.e. now >= 60) + expect(t).toBeGreaterThanOrEqual(60); + expect(t).toBeLessThan(120); }); - it("returns 'timeout' when no new output ever arrives", async () => { + it("returns 'settled' via the probe when no reflow occurs (steady state)", async () => { let t = 0; const clock: SettleClock = { now: () => t, - getLastOutputAt: () => 0, // never exceeds baseline of 0 + getLastOutputAt: () => 0, // no output after baseline → no SIGWINCH/redraw sleep: async (ms: number) => { t += ms; }, }; - const result = await waitForRedrawSettle(clock, 0, 30, 500); + const result = await waitForRedrawSettle(clock, 0, 30, 120); - expect(result).toBe("timeout"); - expect(t).toBeGreaterThanOrEqual(500); + expect(result).toBe("settled"); + // PROBE_MS = 50: no output seen → settled at ~50ms, NOT the 120ms cap. + expect(t).toBeGreaterThanOrEqual(50); + expect(t).toBeLessThan(120); + }); + + it("returns 'settled' via the probe when only stale (pre-baseline) output exists", async () => { + let t = 100; // baseline = 100 + const clock: SettleClock = { + now: () => t, + getLastOutputAt: () => 50, // before baseline → not "new" output + sleep: async (ms: number) => { + t += ms; + }, + }; + + const result = await waitForRedrawSettle(clock, 100, 30, 120); + + expect(result).toBe("settled"); + // probe fires ~50ms after start (no NEW output after baseline). + expect(t).toBeGreaterThanOrEqual(150); + expect(t).toBeLessThan(220); }); - it("returns 'timeout' when only stale (pre-baseline) output exists", async () => { - let t = 100; // baseline will be 100 + it("returns 'timeout' (bounded) when output flows continuously and never quiets", async () => { + let t = 0; const clock: SettleClock = { now: () => t, - getLastOutputAt: () => 50, // output happened before baseline → not "new" + // output "just happened" every tick → now - lastOutputAt is always 0, never quiet. + getLastOutputAt: () => t, sleep: async (ms: number) => { t += ms; }, }; - const result = await waitForRedrawSettle(clock, 100, 30, 500); + const result = await waitForRedrawSettle(clock, 0, 30, 120); expect(result).toBe("timeout"); + // Hard cap bounds the wait at 120ms even under continuous output (was 500ms). + expect(t).toBeGreaterThanOrEqual(120); + expect(t).toBeLessThan(200); }); }); @@ -141,13 +165,12 @@ describe("captureWithResizeSettle", () => { expect(calls).toContain("renderScrollback:0"); }); - it("uses default 30ms/500ms options when none given", async () => { - // Spy on waitForRedrawSettle indirectly: a deps whose sleep records total - // waited time proves the loop honoured the default timeout when no output. + it("uses default options (probe settles quickly when no output)", async () => { + // No new output → the PROBE branch settles at ~50ms instead of the 120ms cap. let waited = 0; const { deps } = makeSettlingDeps({ now: () => waited, // clock must advance with the waited counter - getLastOutputAt: () => 0, // no new output → must hit 500ms timeout + getLastOutputAt: () => 0, // no new output → probe settles sleep: async (ms: number) => { waited += ms; }, @@ -155,8 +178,8 @@ describe("captureWithResizeSettle", () => { await captureWithResizeSettle(deps, 0); - // Default timeoutMs is 500; loop ticks in 10ms increments until >=500. - expect(waited).toBeGreaterThanOrEqual(500); - expect(waited).toBeLessThan(600); + // Default PROBE_MS = 50; loop ticks in 10ms increments until >= 50. + expect(waited).toBeGreaterThanOrEqual(50); + expect(waited).toBeLessThan(120); }); }); diff --git a/electron-app/src/renderer/screenshotSync.ts b/electron-app/src/renderer/screenshotSync.ts index 18e495b..85d43c6 100644 --- a/electron-app/src/renderer/screenshotSync.ts +++ b/electron-app/src/renderer/screenshotSync.ts @@ -3,17 +3,20 @@ * * Before capturing a terminal frame we re-sync the terminal's cols/rows to the * DOM (fit), push that size to the PTY (resize → SIGWINCH), then wait for the - * TUI to finish redrawing. "Redraw done" is approximated by watching PTY output - * settle (no new output for `quietMs`), capped by `timeoutMs`. + * resize-induced redraw burst to land (probe / quiet / bounded cap). * * Time sources are injected (SettleClock) so the logic is fully deterministic * under test without relying on vitest fake-timer mocking of performance.now(). */ -/** Default quiescence window: output quiet for this long ⇒ redraw finished. */ +/** Default quiescence window: output quiet for this long ⇒ redraw burst landed. */ export const DEFAULT_QUIESCENCE_MS = 30; -/** Default hard cap: give up waiting and capture whatever we have. */ -export const DEFAULT_RESIZE_TIMEOUT_MS = 500; +/** No-output probe window: if no PTY output arrives this long after the resize, + * there is no SIGWINCH/redraw to wait for (steady state) — capture immediately. */ +export const PROBE_MS = 50; +/** Default hard cap: give up waiting and capture whatever we have. Bounded so + * continuous output (spinner / streaming) can't stall a screenshot. */ +export const DEFAULT_RESIZE_TIMEOUT_MS = 120; /** Injectable monotonic clock + last-output-at probe + async sleep. */ export interface SettleClock { @@ -26,11 +29,18 @@ export interface SettleClock { } /** - * Wait until PTY output has gone quiet for `quietMs` after `baseline` - * (evidence the TUI reacted to the SIGWINCH and finished its redraw burst), - * or until `timeoutMs` elapses — whichever comes first. + * Wait for the resize-induced redraw burst to land, bounded so continuous output + * can't stall the capture. Three exit conditions, cheapest first: * - * @returns "settled" if output quieted within the timeout, else "timeout". + * 1. Probe (`probeMs`): if NO PTY output has arrived since `baseline`, there was + * no SIGWINCH/redraw (TIOCSWINSZ only signals on a real size change) — return + * "settled" immediately. Skips the wait entirely in steady state. + * 2. Quiet (`quietMs`): once output HAS arrived, return "settled" once it goes + * quiet — short reflows exit early without paying the full cap. + * 3. Cap (`timeoutMs`): continuous output never quiets — return "timeout" at the + * cap regardless. + * + * @returns "settled" (probe or quiet) or "timeout" (cap). */ export async function waitForRedrawSettle( clock: SettleClock, @@ -38,6 +48,7 @@ export async function waitForRedrawSettle( quietMs: number, timeoutMs: number, tickMs: number = 10, + probeMs: number = PROBE_MS, ): Promise<"settled" | "timeout"> { const start = clock.now(); for (;;) { @@ -46,7 +57,11 @@ export async function waitForRedrawSettle( const lastOutputAt = clock.getLastOutputAt(); const sawNewOutput = lastOutputAt > baseline; const quiet = now - lastOutputAt >= quietMs; + // 1. Steady state: no reflow to wait for. + if (!sawNewOutput && now - start >= probeMs) return "settled"; + // 2. Redraw burst landed and went quiet. if (sawNewOutput && quiet) return "settled"; + // 3. Bounded cap (continuous output). if (now - start >= timeoutMs) return "timeout"; } } From e177efa989d4c4725d66088456c4a37e0bef43ad Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Tue, 23 Jun 2026 22:58:23 +0800 Subject: [PATCH 11/12] fix(capture): make awaitFrame robust to rAF throttling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release test scenario 14 (screenshot after window resize) hung at the daemon's 2s timeout. Root cause: captureToPng's awaitFrame used pure 2×requestAnimationFrame, which never fires when the app/tab is not frontmost (rAF is throttled) — e.g. after a window resize shifts focus to the terminal running cli-box. awaitFrame then never resolved, captureToPng stalled, and the daemon timed out. Confirmed via: --with-frame (ScreenCaptureKit, bypasses the renderer) succeeded while the default path hung; and activating the CLI Box app (frontmost → rAF resumes) immediately un-stuck the default screenshot. Fix: race 2×rAF against a 50ms setTimeout fallback so awaitFrame always resolves. Pre-PR code used direct canvas.toDataURL (no rAF wait) so it did not hang; this restores that robustness while keeping the frame commit for the normal frontmost case. After the fix, scenario 14's after-resize screenshot returns in 0.12s (was 2.05s timeout) with a correctly-laid-out, non-garbled claude TUI at the new window size. Co-Authored-By: Claude --- .../src/renderer/components/Terminal.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/electron-app/src/renderer/components/Terminal.tsx b/electron-app/src/renderer/components/Terminal.tsx index 98c3160..f9563b1 100644 --- a/electron-app/src/renderer/components/Terminal.tsx +++ b/electron-app/src/renderer/components/Terminal.tsx @@ -59,9 +59,21 @@ const SandboxTerminal = forwardRef(functio fit: () => fitAddon.fit(), resize: (cols, rows) => conn.resize(cols, rows), awaitFrame: () => - new Promise((resolve) => - requestAnimationFrame(() => requestAnimationFrame(() => resolve())), - ), + new Promise((resolve) => { + let done = false; + const finish = () => { + if (!done) { + done = true; + resolve(); + } + }; + // rAF for the normal (frontmost) case; setTimeout fallback so we + // never hang when rAF is throttled (app/tab not frontmost — e.g. + // after a window resize shifts focus), which would stall the + // daemon's screenshot request until its 2s timeout. + requestAnimationFrame(() => requestAnimationFrame(finish)); + setTimeout(finish, 50); + }), readViewportCanvas: () => { const canvasEl = term.element?.querySelector("canvas"); return canvasEl ? canvasEl.toDataURL("image/png").split(",")[1] : null; From 6e454ecc4697b4b511e5d83a4d3840540b037812 Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Tue, 23 Jun 2026 23:14:51 +0800 Subject: [PATCH 12/12] docs: add awaitFrame rAF-throttle fallback to screenshot spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec/impl/report consistency: the bounded-settle revision's Revision Notes now also cover the third scenario-14 finding — awaitFrame's pure 2×requestAnimationFrame hanging when the app isn't frontmost (rAF throttled after a window resize), stalling captureToPng until the daemon's 2s timeout. The fix (2×rAF raced against a setTimeout(50ms) fallback) is documented in the mechanism and risks sections, alongside the --with-frame/activate evidence that confirmed the root cause. Co-Authored-By: Claude --- .../specs/2026-06-22-screenshot-pre-resize-design.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-06-22-screenshot-pre-resize-design.md b/docs/superpowers/specs/2026-06-22-screenshot-pre-resize-design.md index 9dd21bc..780e829 100644 --- a/docs/superpowers/specs/2026-06-22-screenshot-pre-resize-design.md +++ b/docs/superpowers/specs/2026-06-22-screenshot-pre-resize-design.md @@ -2,18 +2,19 @@ **Date:** 2026-06-22 **Branch:** feat/screenshot-pre-resize -**Status:** Approved (revised 2026-06-23 — settle changed from global-quiet ≤500ms to bounded burst-wait ≤120ms; see Revision Notes) +**Status:** Approved (revised 2026-06-23 — settle → bounded burst-wait ≤120ms; awaitFrame → rAF+setTimeout race. See Revision Notes) --- ## Revision Notes (2026-06-23) -The release test (scenario 14: layout after window resize) revealed two flaws in the original settle design, both corrected in this revision: +The release test (scenario 14: layout after window resize) revealed three issues in the original design, all corrected in this revision: 1. **The settle waited for *global* output quiet (≤500ms cap).** Under continuous output (spinner / streaming TUI), output never quiets → every screenshot hit the 500ms cap. And in steady state (size unchanged), the settle waited the full 500ms for output that never arrives. Both coupled with the daemon's renderer-screenshot timeout. 2. **The "always resize triggers a redraw" rationale was wrong for unchanged size.** `ioctl(TIOCSWINSZ)` only sends SIGWINCH when the size actually changes (the kernel compares old vs new). Same-size resize → no signal → no redraw. So "always resize" does NOT double as a canvas refresh on unchanged size. +3. **`awaitFrame` used pure `2×requestAnimationFrame`, which hangs when rAF is throttled.** After a window resize the CLI Box app loses "frontmost" status (the terminal running `cli-box` is frontmost), so the renderer's rAF is throttled and `awaitFrame` never resolves → `captureToPng` stalls → the daemon's 2s screenshot timeout fires. Confirmed: `--with-frame` (ScreenCaptureKit, bypasses the renderer) succeeded while the default path hung; and activating the app (frontmost → rAF resumes) immediately un-stuck it. Pre-PR code used direct `canvas.toDataURL()` (no rAF wait), so it did not hang — this was a regression introduced by adding `awaitFrame`. -The revised settle (below) is a **bounded burst-wait**: a no-output probe (skip the wait in steady state), an early-exit when the reflow burst quiets (short reflows don't pay the full cap), and a 120ms hard cap (continuous output can no longer cause long waits). Max added latency ≤120ms, which also **removes the dependency on the (never-merged) 10s daemon timeout** — see Risks. +The revised settle (below) is a **bounded burst-wait**: a no-output probe (skip the wait in steady state), an early-exit when the reflow burst quiets (short reflows don't pay the full cap), and a 120ms hard cap (continuous output can no longer cause long waits). Max added latency ≤120ms, which also **removes the dependency on the (never-merged) 10s daemon timeout** — see Risks. And `awaitFrame` now **races `2×rAF` against a `setTimeout(50ms)` fallback** so it always resolves even when rAF is throttled (issue 3). --- @@ -83,7 +84,7 @@ export async function waitForRedrawSettle( } ``` -`captureWithResizeSettle(deps, scrollOffset)`:`fit → baseline=now → resize → waitForRedrawSettle → awaitFrame(2×rAF) → capture`,保证 `resize()` 在任何 canvas/buffer 读取之前。`Terminal.tsx` 的 `captureToPng` 委托给它;`onOutput` 追加 `lastOutputAtRef.current = performance.now()` 喂给 settle 时钟。 +`captureWithResizeSettle(deps, scrollOffset)`:`fit → baseline=now → resize → waitForRedrawSettle → awaitFrame → capture`,保证 `resize()` 在任何 canvas/buffer 读取之前。`Terminal.tsx` 的 `captureToPng` 委托给它;`onOutput` 追加 `lastOutputAtRef.current = performance.now()` 喂给 settle 时钟。`awaitFrame` 实现:`2×requestAnimationFrame` 与 `setTimeout(50ms)` **竞速**——rAF 在 app 置顶时约 32ms 触发;若 app 非置顶(rAF 被 macOS 节流,如窗口 resize 后焦点切到运行 cli-box 的终端),rAF 不触发,由 setTimeout 兜底在 50ms 解析,**永不挂起**。 ### 为什么「总是发 resize」而非「尺寸变了才发」 @@ -129,7 +130,7 @@ export async function waitForRedrawSettle( - **延迟**:单次截图最多 +120ms(稳态 ~50ms、短重排 ~60–90ms、持续输出 ~120ms)。 - **不再依赖 10s daemon 超时**:max +120ms 叠在基础截图耗时(大 canvas `toDataURL` + base64 + WS 往返)上仍远低于 daemon 现有 2s 超时,因此本修复**不需要** `fb1894f`(把超时 2s→10s)也能稳定工作。`fb1894f` 经核实**从未合入 main**(仅存在于 `feat/start-shell-screenshot-openclaw`),可独立合入做余量,但非本修复的前提。 - **BURST 上限是启发式**:120ms 是「重排爆发落地」的经验估值;极复杂 TUI 的单次重排理论可能更久 → 截到部分帧。但比原 500ms 全局安静严格更好(后者持续输出下永远顶满,且稳态白等 500ms)。`PROBE_MS` / `DEFAULT_QUIESCENCE_MS` / `DEFAULT_RESIZE_TIMEOUT_MS` 均命名可调。 -- **awaitFrame**:2×rAF 保证 canvas 提交最新 buffer;scrollback 路径走 `renderBufferToPng` 不依赖 canvas,harmless。 +- **awaitFrame**:`2×rAF` 与 `setTimeout(50ms)` 竞速,保证 canvas 提交最新 buffer;setTimeout 兜底使 app 非置顶(rAF 节流)时仍能在 50ms 解析,不会像纯 rAF 那样无限挂起(那会让 daemon 的 2s 截图超时触发——issue 3 / scenario 14 的根因)。scrollback 路径走 `renderBufferToPng` 不依赖 canvas,harmless。 ---