diff options
| author | user <user@clank> | 2026-07-22 21:31:46 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-12 19:34:35 +0200 |
| commit | 87b042214d057f8b60858b788a419ec86ac108ba (patch) | |
| tree | 8f7fe70b7fd36adb51378680dbbb5063637ad247 /pi-ext | |
| parent | slop-tool-boxes: fix startup render storm + streaming flicker (diff) | |
| download | sl0ppi-87b042214d057f8b60858b788a419ec86ac108ba.tar.gz sl0ppi-87b042214d057f8b60858b788a419ec86ac108ba.tar.xz sl0ppi-87b042214d057f8b60858b788a419ec86ac108ba.zip | |
slop-tool-boxes: remove animation timer; time-based pulse (fix streaming flicker)
Third flicker bug: a taller-than-screen bash box flickered because the 10fps
timer forced a full updateDisplay() repaint on top of the stream's own
re-renders. Root fix: NEVER force a repaint.
- Pulse color is now a pure function of wall-clock time (Date.now() % CYCLE_MS);
no tick counter, no setInterval. Any render shows the current color.
- The pulse advances only on renders that already happen: streaming output
('the stream hydrates the pulse', irregular cadence) or start/end nudges.
- Silent long-running tools hold a static active color (flicker-free) rather
than animating.
- Also drop body side rails entirely (open-sided box); only top/bottom pulse.
Self-test: restored rows never pulse, active top border advances with time,
settles solid on end.
Diffstat (limited to 'pi-ext')
| -rw-r--r-- | pi-ext/NOTES.md | 14 | ||||
| -rw-r--r-- | pi-ext/slop-tool-boxes.ts | 88 |
2 files changed, 55 insertions, 47 deletions
diff --git a/pi-ext/NOTES.md b/pi-ext/NOTES.md index a1133d1..b6254e2 100644 --- a/pi-ext/NOTES.md +++ b/pi-ext/NOTES.md @@ -1,6 +1,18 @@ # Notes / TODO -## ✅ FIXED: tool-box flicker (streaming) + startup render storm +## ✅ FIXED: tool-box flicker × 3 (streaming rails, startup storm, streaming timer) + +**Final invariant: the pulse NEVER forces a repaint.** The pulse color is a pure +function of wall-clock time (`Date.now() % CYCLE_MS`) and there is NO animation +timer at all. It advances only on renders that were going to happen anyway — +chiefly the natural re-renders a streaming tool already triggers ("the stream +hydrates the pulse", irregular cadence). A silent long-running tool just holds a +static active color until it streams output or finishes. This killed the third +bug: a tall (taller-than-screen) bash box flickered because the old 10fps timer +forced a full `updateDisplay()` repaint on top of the stream's own re-renders. +Body has no side rails; only top/bottom borders pulse. + +### Earlier iterations (kept for history) Two distinct bugs in `slop-tool-boxes.ts`, both from the pending-tool border pulse (d3454b8), fixed in the follow-up commit: diff --git a/pi-ext/slop-tool-boxes.ts b/pi-ext/slop-tool-boxes.ts index 6f8abc5..cfa4150 100644 --- a/pi-ext/slop-tool-boxes.ts +++ b/pi-ext/slop-tool-boxes.ts @@ -3,7 +3,8 @@ * * Reframes the built-in tool rows (bash, read, edit, write, grep, find, ls) * in the same rounded-border "sl0peration" style used by the last-prompt - * topbar widget — a labelled top border, │ side rails, and a rounded bottom. + * topbar widget — a labelled top border and a rounded bottom border (open sides, + * no vertical rails). While a tool executes, the top/bottom borders pulse. * * It does NOT reimplement tool behavior or output: it grabs each built-in * tool *definition* (execute + native renderCall/renderResult, so syntax @@ -61,24 +62,31 @@ function borderColorFor(status: Status): string { } // ── 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. +// Design after three flicker bugs — the invariant is: WE NEVER FORCE A REPAINT. +// +// 1. "Active" is driven strictly by live tool_execution_start/end events keyed +// by toolCallId, never by render-time status. On session restore (`pi -c`) +// finished rows re-render but fire no start event, so they never animate. +// (An earlier version inferred "pending" from the default status and started +// a 10fps invalidate storm over every restored row — the startup lockup.) +// +// 2. There is NO animation timer. The pulse color is a pure function of +// wall-clock time, so any render shows the current color. It advances only +// on renders that were going to happen anyway — chiefly the natural re-renders +// that streaming tool output already triggers ("the stream hydrates the +// pulse", with irregular cadence). Forcing periodic re-renders of a large, +// taller-than-screen streaming box is exactly what flickered. A silent tool +// just holds a static active color until it streams output or finishes. +// +// 3. Only the horizontal top/bottom borders pulse; the body has no side rails +// at all, so a repaint never has to recolor body lines. const PULSE: Array<[number, number, number]> = [ [0xff, 0x8a, 0x1e], // amber [0xff, 0x2e, 0x88], // hot pink [0xb0, 0x5c, 0xff], // violet [0x2e, 0xd6, 0xff], // cyan ]; -const PULSE_PERIOD = 40; // frames per full palette trip (~4s at 10fps) -const FRAME_MS = 100; +const CYCLE_MS = 4000; // one full palette trip (~4s), advanced by wall-clock time function lerp(a: number, b: number, t: number): number { return Math.round(a + (b - a) * t); @@ -97,45 +105,28 @@ function fgRGB(rgb: [number, number, number], s: string): string { return `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m${s}\x1b[39m`; } -let tick = 0; -let animTimer: ReturnType<typeof setInterval> | undefined; const activeIds = new Set<string>(); // toolCallIds executing in THIS session -const rowsById = new Map<string, FrameState>(); // toolCallId -> row state (for the timer) +const rowsById = new Map<string, FrameState>(); // toolCallId -> row state (start/end nudges) function pulsePaint(): Paint { - const rgb = pulseRGB((tick % PULSE_PERIOD) / PULSE_PERIOD); + // Pure function of wall-clock time — no counter, no timer. + const rgb = pulseRGB((Date.now() % CYCLE_MS) / CYCLE_MS); return (s: string) => fgRGB(rgb, s); } function solidPaint(theme: Theme, color: string): Paint { return (s: string) => theme.fg(color, s); } -function ensureAnim(): void { - if (animTimer) return; - animTimer = setInterval(() => { - tick++; - if (activeIds.size === 0) { - clearInterval(animTimer); - animTimer = undefined; - return; - } - for (const id of activeIds) rowsById.get(id)?.ctxInvalidate?.(); - }, FRAME_MS); -} function markActive(id: string | undefined): void { - if (!id) return; - activeIds.add(id); - ensureAnim(); + if (id) activeIds.add(id); } function clearActive(id: string | undefined): void { if (id) activeIds.delete(id); } +// Settle any still-active rows (e.g. a missed end event) and clear. No timer. function stopAllAnim(): void { + for (const id of activeIds) rowsById.get(id)?.ctxInvalidate?.(); activeIds.clear(); - if (animTimer) { - clearInterval(animTimer); - animTimer = undefined; - } } function statusIcon(status: Status, theme: Theme): string { @@ -276,15 +267,20 @@ function frameBottom(width: number, paint: Paint): string { return paint(BL + H.repeat(innerWidth) + BR); } -// Wrap already-rendered inner lines with │ … │ rails and padding. -function frameBody(lines: string[], width: number, paint: Paint): string[] { - const v = paint(V); - const innerWidth = Math.max(0, width - 4); // 2 rails + 2 padding spaces +// Body lines have NO left/right rails — only the top/bottom borders frame the +// box (and pulse). This is the anti-flicker "middle ground": with no side glyphs +// there is nothing to recolor per frame, so streaming never triggers a full-box +// repaint, and the open sides read cleaner against the pulsing horizontals. A +// 2-space left margin keeps content tucked under the top border; lines are +// truncated to the old inner width so native render + diff backgrounds are +// unaffected. `paint` is unused now (kept for a stable call signature). +const BODY_INDENT = " "; +function frameBody(lines: string[], width: number, _paint: Paint): string[] { + const innerWidth = Math.max(0, width - 4); return lines.map((raw) => { let line = raw; if (visibleWidth(line) > innerWidth) line = truncateToWidth(line, innerWidth, "…"); - const pad = Math.max(0, innerWidth - visibleWidth(line)); - return `${v} ${line}${" ".repeat(pad)} ${v}`; + return BODY_INDENT + line; }); } @@ -296,9 +292,9 @@ type FrameState = { 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). + // Inner-line caches persisted on the row state so they survive updateDisplay() + // rebuilds. Keyed by width; reset when the underlying args/result change (or on + // invalidate/theme change). callArgsRef?: any; summaryText?: string; callW: number; @@ -376,7 +372,7 @@ function registerFramedTool(pi: ExtensionAPI, def: ToolDef) { // Inner-line cache lives on `st` (survives updateDisplay). Only the native // render + diff regex are expensive; the frame is rebuilt cheaply so it can - // re-paint with the pulse color each tick without recomputing content. + // re-paint with the pulse color per render without recomputing content. const innerLines = (width: number): string[] => { if (st.callW === width && st.callLines) return st.callLines; let lines = st.callInner ? st.callInner.render(Math.max(1, width - 4)) : []; |
