aboutsummaryrefslogtreecommitdiffstats
path: root/pi-ext
diff options
context:
space:
mode:
authoruser <user@clank>2026-07-22 21:16:42 +0200
committeruser <user@clank>2026-08-12 19:34:35 +0200
commitdf63c33b996bd02fd8fc5121cf9a6680e54d502d (patch)
tree6e7ca155986589ca4d8b197da4cbff8c2962befb /pi-ext
parentAdd .gitignore (audit/subagent artifacts) + NOTES.md flicker safety bug (diff)
downloadsl0ppi-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.md27
-rw-r--r--pi-ext/slop-tool-boxes.ts132
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);
}