diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index fe870e0e9c8..71abb9bb004 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1303,11 +1303,18 @@ export const AppContainer = (props: AppContainerProps) => { ), ); const showScrollbar = settings.merged.ui?.showScrollbar ?? true; + const refreshStaticRef = useRef | null>(null); const refreshStatic = useCallback(() => { - if (!useTerminalBuffer) { - stdout.write(ansiEscapes.clearTerminal); + if (refreshStaticRef.current) { + clearTimeout(refreshStaticRef.current); } - remountStaticHistory(); + refreshStaticRef.current = setTimeout(() => { + refreshStaticRef.current = null; + if (!useTerminalBuffer) { + stdout.write(ansiEscapes.clearTerminal); + } + remountStaticHistory(); + }, 0); }, [useTerminalBuffer, remountStaticHistory, stdout]); // Keep the static header in sync with model changes without polling. diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 3dea6b5c00c..bb7703a3f62 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -6074,7 +6074,7 @@ describe('useGeminiStream', () => { expect(result.current.pendingHistoryItems).toEqual([]); await act(async () => { - vi.advanceTimersByTime(60); + vi.advanceTimersByTime(100); }); expect(result.current.pendingHistoryItems).toEqual([ @@ -6134,7 +6134,7 @@ describe('useGeminiStream', () => { }); await act(async () => { - vi.advanceTimersByTime(60); + vi.advanceTimersByTime(100); }); expect(result.current.pendingHistoryItems).toEqual([]); @@ -6146,7 +6146,7 @@ describe('useGeminiStream', () => { }); await act(async () => { - vi.advanceTimersByTime(60); + vi.advanceTimersByTime(100); }); expect(result.current.pendingHistoryItems).toEqual([ @@ -6203,7 +6203,7 @@ describe('useGeminiStream', () => { expect(result.current.pendingHistoryItems).toEqual([]); await act(async () => { - vi.advanceTimersByTime(60); + vi.advanceTimersByTime(100); }); expect(result.current.pendingHistoryItems).toEqual([ @@ -6262,7 +6262,7 @@ describe('useGeminiStream', () => { }); await act(async () => { - vi.advanceTimersByTime(60); + vi.advanceTimersByTime(100); }); const thoughtItems = mockAddItem.mock.calls @@ -6794,7 +6794,7 @@ describe('useGeminiStream', () => { }); await act(async () => { - vi.advanceTimersByTime(60); + vi.advanceTimersByTime(100); }); expect(result.current.pendingHistoryItems).toEqual([]); @@ -6807,7 +6807,7 @@ describe('useGeminiStream', () => { }); await act(async () => { - vi.advanceTimersByTime(60); + vi.advanceTimersByTime(100); }); expect(result.current.pendingHistoryItems).toEqual([ diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 0837efb7518..f1f2c65a187 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -352,7 +352,7 @@ const EDIT_TOOL_NAMES = new Set([ ToolNames.WRITE_FILE, ToolNames.NOTEBOOK_EDIT, ]); -const STREAM_UPDATE_THROTTLE_MS = 60; +const STREAM_UPDATE_THROTTLE_MS = 100; const STREAM_PENDING_ITEM_MAX_CHARS = 16_384; // Rows kept in reserve below the commit budget so the incremental commit fires // BEFORE MarkdownDisplay's safety-net clip (which reserves 2). Keeping the diff --git a/packages/core/src/agents/runtime/agent-core.test.ts b/packages/core/src/agents/runtime/agent-core.test.ts index c100f92be2c..2c84550a444 100644 --- a/packages/core/src/agents/runtime/agent-core.test.ts +++ b/packages/core/src/agents/runtime/agent-core.test.ts @@ -44,6 +44,7 @@ import type { ContentGenerator, ContentGeneratorConfig, } from '../../core/contentGenerator.js'; +import { AgentEventEmitter, AgentEventType } from './agent-events.js'; import { getInvocationContext, runWithInvocationContext, @@ -898,6 +899,33 @@ describe('AgentCore.prepareTools', () => { }); }); +describe('AgentCore STREAM_TEXT batching (#2928)', () => { + it('batches thought and response text separately per chunk', () => { + const emitter = new AgentEventEmitter(); + const events: Array<{ text: string; thought: boolean }> = []; + emitter.on(AgentEventType.STREAM_TEXT, (e) => { + events.push({ text: e.text, thought: e.thought ?? false }); + }); + emitter.emit(AgentEventType.STREAM_TEXT, { + subagentId: 'test', + round: 1, + text: 'reasoning', + thought: true, + timestamp: Date.now(), + }); + emitter.emit(AgentEventType.STREAM_TEXT, { + subagentId: 'test', + round: 1, + text: 'output', + thought: false, + timestamp: Date.now(), + }); + expect(events).toHaveLength(2); + expect(events[0].thought).toBe(true); + expect(events[1].thought).toBe(false); + }); +}); + describe('extractParentToolNames', () => { const configWithTools = ( tools: Array<{ functionDeclarations?: FunctionDeclaration[] }>, diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index 191b5abdc09..c8e921f73de 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -1001,21 +1001,41 @@ export class AgentCore { } const content = resp.candidates?.[0]?.content; const parts = content?.parts || []; + // #2928: Batch STREAM_TEXT emits per chunk to reduce UI flicker + // during parallel sub-agent execution. Accumulate text parts + // then emit once instead of per-part. + let chunkThoughtText = ''; + let chunkStreamText = ''; for (const p of parts) { const txt = p.text; const isThought = p.thought ?? false; - if (txt && isThought) roundThoughtText += txt; - if (txt && !isThought) roundText += txt; - if (txt) - this.eventEmitter?.emit(AgentEventType.STREAM_TEXT, { - subagentId: this.subagentId, - runId, - round: turnCounter, - text: txt, - thought: isThought, - timestamp: Date.now(), - }); + if (txt && isThought) { + roundThoughtText += txt; + chunkThoughtText += txt; + } + if (txt && !isThought) { + roundText += txt; + chunkStreamText += txt; + } } + if (chunkThoughtText) + this.eventEmitter?.emit(AgentEventType.STREAM_TEXT, { + subagentId: this.subagentId, + runId, + round: turnCounter, + text: chunkThoughtText, + thought: true, + timestamp: Date.now(), + }); + if (chunkStreamText) + this.eventEmitter?.emit(AgentEventType.STREAM_TEXT, { + subagentId: this.subagentId, + runId, + round: turnCounter, + text: chunkStreamText, + thought: false, + timestamp: Date.now(), + }); if (resp.usageMetadata) lastUsage = resp.usageMetadata; const thoughtSummary = getThoughtSummary(resp); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 87cac7a6df3..1f0fb79e6d5 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -5,6 +5,7 @@ import { useEffect, useLayoutEffect, useMemo, + useTransition, useRef, useState, type CSSProperties, @@ -5346,6 +5347,7 @@ export function App({ return options; }, [connection.models]); const [compactMode, setCompactMode] = useState(false); + const [, startTransition] = useTransition(); const compactModeRef = useRef(compactMode); compactModeRef.current = compactMode; @@ -5428,7 +5430,7 @@ export function App({ const handleToggleCompact = useCallback(() => { const previous = compactModeRef.current; const next = !compactModeRef.current; - setCompactMode(next); + startTransition(() => setCompactMode(next)); setWorkspaceSetting('workspace', COMPACT_MODE_SETTING_KEY, next).catch( (error: unknown) => { setCompactMode(previous);