aboutsummaryrefslogtreecommitdiffstats
path: root/pi-ext
diff options
context:
space:
mode:
authoruser <user@clank>2026-07-22 21:31:46 +0200
committeruser <user@clank>2026-08-12 19:34:35 +0200
commit87b042214d057f8b60858b788a419ec86ac108ba (patch)
tree8f7fe70b7fd36adb51378680dbbb5063637ad247 /pi-ext
parentslop-tool-boxes: fix startup render storm + streaming flicker (diff)
downloadsl0ppi-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.md14
-rw-r--r--pi-ext/slop-tool-boxes.ts88
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)) : [];