|
1 | 1 | import { spawn } from "node:child_process"; |
2 | 2 | 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"; |
4 | 5 | import { type AgentBundle, createAgent } from "../agent/agent.js"; |
5 | 6 | import { ConfigError } from "../agent/config.js"; |
6 | 7 | import { initialState, reducer } from "../agent/events.js"; |
@@ -123,11 +124,72 @@ function ChatApp({ bundle, onExit }: ChatAppProps) { |
123 | 124 | return out; |
124 | 125 | }, [state.messages, persistedHistory]); |
125 | 126 |
|
| 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 | + |
126 | 143 | 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 | + |
127 | 161 | 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 | + } |
128 | 182 | dispatch({ type: "agent-event", event }); |
129 | 183 | }); |
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 | + }; |
131 | 193 | }, [bundle]); |
132 | 194 |
|
133 | 195 | useEffect(() => { |
|
0 commit comments