diff options
| author | user <user@clank> | 2026-07-22 21:16:42 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-12 19:34:35 +0200 |
| commit | df63c33b996bd02fd8fc5121cf9a6680e54d502d (patch) | |
| tree | 6e7ca155986589ca4d8b197da4cbff8c2962befb /pi-ext | |
| parent | Add .gitignore (audit/subagent artifacts) + NOTES.md flicker safety bug (diff) | |
| download | sl0ppi-df63c33b996bd02fd8fc5121cf9a6680e54d502d.tar.gz sl0ppi-df63c33b996bd02fd8fc5121cf9a6680e54d502d.tar.xz sl0ppi-df63c33b996bd02fd8fc5121cf9a6680e54d502d.zip | |
slop-tool-boxes: fix startup render storm + streaming flicker
Two bugs from the pending-tool border pulse:
1. Streaming flicker: pulsing the side rails recolored every body line each
frame, forcing a full-box repaint per streamed token. Now only top/bottom
borders pulse; rails/body use a static status color.
2. Startup render storm (pi -c never accepts input): 'pending' was inferred
from the render-time default status, so every RESTORED tool row rendered as
pending -> all added to the 10fps animation set -> unbounded invalidate loop.
Animation is now driven strictly by live tool_execution_start/end events
keyed by toolCallId; restored rows fire no start event so they never animate.
Self-test covers: restored rows don't pulse, live rows pulse only between
start/end, rails static during a run.
Diffstat (limited to 'pi-ext')
| -rw-r--r-- | pi-ext/NOTES.md | 27 | ||||
| -rw-r--r-- | pi-ext/slop-tool-boxes.ts | 132 |
2 files changed, 113 insertions, 46 deletions
diff --git a/pi-ext/NOTES.md b/pi-ext/NOTES.md index 5d0980c..a1133d1 100644 --- a/pi-ext/NOTES.md +++ b/pi-ext/NOTES.md @@ -1,6 +1,31 @@ # Notes / TODO -## ⚠️ Redraw flicker during tool calls (SAFETY — investigate before shipping wider) +## ✅ FIXED: tool-box flicker (streaming) + startup render storm + +Two distinct bugs in `slop-tool-boxes.ts`, both from the pending-tool border +pulse (d3454b8), fixed in the follow-up commit: + +1. **Streaming flicker** (flicker as tokens stream into an in-flight edit/write): + the pulse recolored the side rails `│` on *every body line every frame*, so + each new token repainted the whole (growing) box instead of just the new + line. **Fix:** only the horizontal top/bottom borders pulse; side rails/body + use a static status color, so streaming redraws only the new content line. + +2. **Startup render storm** (`pi -c` flickers so hard it never accepts input): + "pending" was inferred from the default row status at render time, and + `ensureState` defaults to `pending`. On session restore every finished tool + row re-rendered as "pending" → all added to the animation set → a 10fps + invalidate storm that never settled. **Fix:** animation is now driven ONLY by + live `tool_execution_start`/`tool_execution_end` events keyed by toolCallId. + Restored rows fire no start event, so they never animate. Timer stops when no + toolCallId is active (plus agent_settled/session_shutdown safety net). + +Guard test: `slop-tool-boxes` self-test asserts restored rows don't pulse, live +rows pulse only between start/end, and rails stay static during a run. + +--- + +## (historical) ⚠️ Redraw flicker during tool calls **Symptom:** A strong redraw/flicker effect during a `write` tool call — severe enough to trigger a photosensitive seizure. This is a health-safety bug, not diff --git a/pi-ext/slop-tool-boxes.ts b/pi-ext/slop-tool-boxes.ts index 2875a5f..6f8abc5 100644 --- a/pi-ext/slop-tool-boxes.ts +++ b/pi-ext/slop-tool-boxes.ts @@ -60,13 +60,17 @@ function borderColorFor(status: Status): string { return status === "error" ? "error" : status === "pending" ? "warning" : "dim"; } -// ── Pulsing border for in-progress (pending) tools ────────────────────────── -// Same truecolor palette pulse as the last-prompt topbar. Only PENDING tool -// rows animate; a single module-level timer re-paints their frames at 10fps by -// calling each row's context.invalidate(). render() reuses width+content-cached -// inner lines, so a tick only re-frames cheap strings — it never re-runs the -// native tool render. The timer stops the instant no row is pending, so it can -// never become a runaway (mirrors the last-prompt safety reasoning). +// ── Pulsing border for in-progress tools ──────────────────────────────────── +// Only tools that are ACTUALLY EXECUTING IN THIS SESSION animate. "Active" is +// driven strictly by live tool_execution_start/end events keyed by toolCallId — +// NOT by render-time status. This is critical: on session restore (`pi -c`) +// every finished tool row re-renders, and if we inferred "pending" from the +// default status we'd mark them all active and start a 10fps invalidate storm +// that never settles (the startup-flicker lockup). Restored rows fire no +// tool_execution_start, so they never pulse. A single module-level timer +// re-paints active rows at 10fps and stops the instant none are active. +// Anti-flicker: only the horizontal top/bottom borders pulse; side rails stay a +// static color so streaming output never triggers a full-box repaint. const PULSE: Array<[number, number, number]> = [ [0xff, 0x8a, 0x1e], // amber [0xff, 0x2e, 0x88], // hot pink @@ -95,7 +99,8 @@ function fgRGB(rgb: [number, number, number], s: string): string { let tick = 0; let animTimer: ReturnType<typeof setInterval> | undefined; -const pendingRows = new Set<FrameState>(); +const activeIds = new Set<string>(); // toolCallIds executing in THIS session +const rowsById = new Map<string, FrameState>(); // toolCallId -> row state (for the timer) function pulsePaint(): Paint { const rgb = pulseRGB((tick % PULSE_PERIOD) / PULSE_PERIOD); @@ -109,21 +114,27 @@ function ensureAnim(): void { if (animTimer) return; animTimer = setInterval(() => { tick++; - if (pendingRows.size === 0) { + if (activeIds.size === 0) { clearInterval(animTimer); animTimer = undefined; return; } - // Re-paint each pending row. Safe to delete-during-iterate if a row settles. - for (const st of pendingRows) st.ctxInvalidate?.(); + for (const id of activeIds) rowsById.get(id)?.ctxInvalidate?.(); }, FRAME_MS); } -function setRowPending(st: FrameState, on: boolean): void { - if (on) { - pendingRows.add(st); - ensureAnim(); - } else { - pendingRows.delete(st); +function markActive(id: string | undefined): void { + if (!id) return; + activeIds.add(id); + ensureAnim(); +} +function clearActive(id: string | undefined): void { + if (id) activeIds.delete(id); +} +function stopAllAnim(): void { + activeIds.clear(); + if (animTimer) { + clearInterval(animTimer); + animTimer = undefined; } } @@ -284,6 +295,7 @@ type FrameState = { status: Status; resultActive: boolean; // true once the result slot starts rendering (it then owns the bottom border) ctxInvalidate?: () => void; // row rerender trigger (updateDisplay + requestRender) + toolCallId?: string; // used to match live tool_execution_start/end events // Inner-line caches persisted on the row state so they survive the // updateDisplay() rebuilds our pulse timer triggers each tick. Keyed by width; // reset when the underlying args/result change (or on invalidate/theme change). @@ -307,7 +319,17 @@ function ensureState(context: any): FrameState { resultW: -1, }; } - return context.state.__slopFrame as FrameState; + const st = context.state.__slopFrame as FrameState; + if (context.toolCallId) { + st.toolCallId = context.toolCallId; + rowsById.set(context.toolCallId, st); + } + return st; +} + +// A row animates only if its tool is executing in THIS session (event-driven). +function isActiveRow(st: FrameState): boolean { + return !!st.toolCallId && activeIds.has(st.toolCallId); } // A context for the native renderer: its own state bag + its own lastComponent. @@ -349,8 +371,8 @@ function registerFramedTool(pi: ExtensionAPI, def: ToolDef) { st.summaryText = summarize ? summarize(args) : undefined; st.callW = -1; } - // Pending rows animate; make sure the timer knows about this one. - setRowPending(st, st.status === "pending"); + // (Whether this row animates is decided by live tool_execution events, not + // here — see isActiveRow / the event handlers in the default export.) // Inner-line cache lives on `st` (survives updateDisplay). Only the native // render + diff regex are expensive; the frame is rebuilt cheaply so it can @@ -369,24 +391,32 @@ function registerFramedTool(pi: ExtensionAPI, def: ToolDef) { let cl: string[] = []; return { render(width: number): string[] { - const status = st.status; + const activeNow = isActiveRow(st); + const status: Status = activeNow ? "pending" : st.status; const base = statusIcon(status, theme) + " " + theme.fg("toolTitle", theme.bold(displayName)); const label = st.summaryText ? base + " " + theme.fg("accent", st.summaryText) : base; const inner = innerLines(width); - const build = (paint: Paint): string[] => { - const out = [frameTop(width, label, paint), ...frameBody(inner, width, paint)]; + // IMPORTANT (anti-flicker): the side rails + body use a STATIC status + // color; only the horizontal top/bottom borders pulse. Pulsing the + // rails recolors every body line each frame, which forces a full-box + // repaint while a tool streams output (edit/write) and reads as violent + // flicker. Keeping rails static means streaming only redraws the new + // content line, and the pulse only touches 1-2 border lines. (NOTES.md) + const build = (border: Paint, rail: Paint): string[] => { + const out = [frameTop(width, label, border), ...frameBody(inner, width, rail)]; // While the result slot hasn't started rendering yet (still-running // tool), close the box here so the frame is never bottomless. - if (!st.resultActive) out.push(frameBottom(width, paint)); + if (!st.resultActive) out.push(frameBottom(width, border)); return out; }; - if (status === "pending") { - cw = -1; // force recompute of the solid cache once it settles - return build(pulsePaint()); + const rail = solidPaint(theme, borderColorFor(status)); + if (activeNow) { + cw = -1; // don't cache while animating; recompute solid once settled + return build(pulsePaint(), rail); } if (width === cw) return cl; - cl = build(solidPaint(theme, borderColorFor(status))); + cl = build(rail, rail); cw = width; return cl; }, @@ -423,7 +453,7 @@ function registerFramedTool(pi: ExtensionAPI, def: ToolDef) { st.resultExpanded = options.expanded; st.resultW = -1; } - setRowPending(st, st.status === "pending"); + // (Animation is event-driven; see isActiveRow / default export handlers.) const preview = RESULT_PREVIEW[displayName]; const innerLines = (width: number): string[] => { @@ -445,16 +475,20 @@ function registerFramedTool(pi: ExtensionAPI, def: ToolDef) { return { render(width: number): string[] { const inner = innerLines(width); - const build = (paint: Paint): string[] => [ - ...frameBody(inner, width, paint), - frameBottom(width, paint), + const activeNow = isActiveRow(st); + // Anti-flicker: static rails/body, only the bottom border pulses. + const railColor = activeNow ? "warning" : borderColorFor(st.status); + const rail = solidPaint(theme, railColor); + const build = (border: Paint): string[] => [ + ...frameBody(inner, width, rail), + frameBottom(width, border), ]; - if (st.status === "pending") { + if (activeNow) { cw = -1; return build(pulsePaint()); } if (width === cw) return cl; - cl = build(solidPaint(theme, borderColorFor(st.status))); + cl = build(rail); cw = width; return cl; }, @@ -483,16 +517,24 @@ export default function (pi: ExtensionAPI) { for (const def of defs) registerFramedTool(pi, def); - // Safety net: guarantee the pulse timer stops even on abort/error paths where - // a final renderResult (which normally clears pending) may not run. agent_end - // and agent_settled both fire when the run is no longer producing tool output. - const stopAll = () => { - pendingRows.clear(); - if (animTimer) { - clearInterval(animTimer); - animTimer = undefined; - } + // Animation is driven ONLY by live execution events, so restored/finished rows + // (e.g. on `pi -c`) never animate. A row pulses from tool_execution_start until + // tool_execution_end; the refresh() nudges the row to repaint immediately. + const refresh = (id: string | undefined) => { + if (id) rowsById.get(id)?.ctxInvalidate?.(); }; - pi.on("agent_settled", stopAll); - pi.on("session_shutdown", stopAll); + pi.on("tool_execution_start", (event: any) => { + markActive(event.toolCallId); + refresh(event.toolCallId); + }); + pi.on("tool_execution_end", (event: any) => { + clearActive(event.toolCallId); + refresh(event.toolCallId); + }); + + // Safety net: guarantee the timer stops even if an end event is missed + // (abort/error). agent_settled fires when the run is no longer producing tool + // output; session_shutdown on teardown. + pi.on("agent_settled", stopAllAnim); + pi.on("session_shutdown", stopAllAnim); } |
