Skip to content

Commit e835cb9

Browse files
committed
perf(ui): coalesce streaming events into one React commit per frame
Pi-agent-core fires one message_update per token and one tool_execution_update per chunk of streaming tool stdout. A fast model + long bash output produces 100+ events/sec, each running the full reducer + React tree diff + Yoga layout. That's the source of the scroll/render jankiness users complained about — not Ink's renderer itself. Throttle to a 16ms (60fps) cap. Each frame, the latest pending message_update and the latest pending tool_execution_update per tool id are flushed; intermediates are dropped (they're idempotent state replacements). Non-coalesceable events (message_start/end, tool_execution_start/end, turn_*, agent_*) flush the queue first so event ordering relative to the reducer stays correct.
1 parent 67fc63f commit e835cb9

1 file changed

Lines changed: 64 additions & 2 deletions

File tree

src/ui/App.tsx

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { spawn } from "node:child_process";
22
import { Box, Text, useApp } from "ink";
3-
import { useEffect, useMemo, useReducer, useState } from "react";
3+
import { useEffect, useMemo, useReducer, useRef, useState } from "react";
4+
import type { AgentEvent } from "@earendil-works/pi-agent-core";
45
import { type AgentBundle, createAgent } from "../agent/agent.js";
56
import { ConfigError } from "../agent/config.js";
67
import { initialState, reducer } from "../agent/events.js";
@@ -123,11 +124,72 @@ function ChatApp({ bundle, onExit }: ChatAppProps) {
123124
return out;
124125
}, [state.messages, persistedHistory]);
125126

127+
// Coalesce high-frequency streaming events (per-token assistant updates
128+
// and per-chunk tool stdout) to one React commit per frame instead of
129+
// per event. Pi-agent-core emits one message_update per token, and a
130+
// fast model + long tool output can fire 100+ Hz — each dispatch runs
131+
// the full reducer + React tree diff + Yoga layout for everything on
132+
// screen. Throttling here is the single biggest cause of perceived
133+
// scroll/render jankiness; everything else (Static for finalized
134+
// messages, memoized children) is icing.
135+
//
136+
// Keyed coalescing: message_update has one slot, tool_execution_update
137+
// has one slot per tool id. Latest event wins. Non-coalesceable events
138+
// (message_start/end, tool_execution_start/end, turn_*, agent_*) flush
139+
// any pending updates first so ordering stays correct.
140+
const pendingRef = useRef<Map<string, AgentEvent>>(new Map());
141+
const flushTimerRef = useRef<NodeJS.Timeout | null>(null);
142+
126143
useEffect(() => {
144+
const STREAM_FRAME_MS = 16; // ~60fps cap
145+
146+
const flush = () => {
147+
flushTimerRef.current = null;
148+
if (pendingRef.current.size === 0) return;
149+
const events = [...pendingRef.current.values()];
150+
pendingRef.current.clear();
151+
for (const event of events) {
152+
dispatch({ type: "agent-event", event });
153+
}
154+
};
155+
156+
const scheduleFlush = () => {
157+
if (flushTimerRef.current != null) return;
158+
flushTimerRef.current = setTimeout(flush, STREAM_FRAME_MS);
159+
};
160+
127161
const unsubscribe = bundle.subscribe((event) => {
162+
if (event.type === "message_update") {
163+
pendingRef.current.set("msg", event);
164+
scheduleFlush();
165+
return;
166+
}
167+
if (event.type === "tool_execution_update") {
168+
pendingRef.current.set(`tool:${event.toolCallId}`, event);
169+
scheduleFlush();
170+
return;
171+
}
172+
// Any other event flushes the queue before dispatching so the
173+
// reducer sees pending streaming updates before the terminal
174+
// event (message_end, tool_execution_end, etc.).
175+
if (pendingRef.current.size > 0) {
176+
if (flushTimerRef.current != null) {
177+
clearTimeout(flushTimerRef.current);
178+
flushTimerRef.current = null;
179+
}
180+
flush();
181+
}
128182
dispatch({ type: "agent-event", event });
129183
});
130-
return unsubscribe;
184+
185+
return () => {
186+
unsubscribe();
187+
if (flushTimerRef.current != null) {
188+
clearTimeout(flushTimerRef.current);
189+
flushTimerRef.current = null;
190+
}
191+
pendingRef.current.clear();
192+
};
131193
}, [bundle]);
132194

133195
useEffect(() => {

0 commit comments

Comments
 (0)