diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx
index 49c29532d1..f8283cca31 100644
--- a/packages/cli/src/gemini.tsx
+++ b/packages/cli/src/gemini.tsx
@@ -85,6 +85,7 @@ import { DualOutputContext } from './dualOutput/DualOutputContext.js';
import { RemoteInputWatcher } from './remoteInput/RemoteInputWatcher.js';
import { RemoteInputContext } from './remoteInput/RemoteInputContext.js';
import { installTerminalRedrawOptimizer } from './ui/utils/terminalRedrawOptimizer.js';
+import { installSynchronizedOutput } from './ui/utils/synchronizedOutput.js';
const debugLogger = createDebugLogger('STARTUP');
@@ -174,6 +175,10 @@ export async function startInteractiveUI(
process.stdout.isTTY && !config.getScreenReader()
? installTerminalRedrawOptimizer(process.stdout)
: () => {};
+ const restoreSynchronizedOutput =
+ process.stdout.isTTY && !config.getScreenReader()
+ ? installSynchronizedOutput(process.stdout)
+ : () => {};
// Create dual output bridge if --json-fd or --json-file is specified.
// Errors are caught so a bad fd/path degrades gracefully instead of
@@ -297,6 +302,7 @@ export async function startInteractiveUI(
// operational, preventing garbled terminal output after the app exits.
disableKittyProtocol();
instance.unmount();
+ restoreSynchronizedOutput();
restoreTerminalRedrawOptimizer();
});
}
diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx
index 031a9b61ef..8732fa29f7 100644
--- a/packages/cli/src/ui/AppContainer.test.tsx
+++ b/packages/cli/src/ui/AppContainer.test.tsx
@@ -15,6 +15,7 @@ import {
} from 'vitest';
import { render, cleanup } from 'ink-testing-library';
import { AppContainer, dedupeNewestFirst } from './AppContainer.js';
+import ansiEscapes from 'ansi-escapes';
import {
type Config,
makeFakeConfig,
@@ -426,6 +427,83 @@ describe('AppContainer State Management', () => {
}).not.toThrow();
});
+ it('refreshStatic clears the terminal before remounting history', () => {
+ render(
+ ,
+ );
+
+ capturedUIActions.refreshStatic();
+
+ expect(mockStdout.write).toHaveBeenCalledWith(ansiEscapes.clearTerminal);
+ });
+
+ it('handleClearScreen avoids a second clearTerminal write', () => {
+ const clearSpy = vi.spyOn(console, 'clear').mockImplementation(() => {});
+
+ render(
+ ,
+ );
+
+ capturedUIActions.handleClearScreen();
+
+ expect(clearSpy).toHaveBeenCalledTimes(1);
+ expect(mockStdout.write).not.toHaveBeenCalledWith(
+ ansiEscapes.clearTerminal,
+ );
+
+ clearSpy.mockRestore();
+ });
+
+ it('passes a remount-only refresh callback to slash commands', () => {
+ let slashRefreshStatic: (() => void) | undefined;
+ mockedUseSlashCommandProcessor.mockImplementation(
+ (
+ _config,
+ _settings,
+ _addItem,
+ _clearItems,
+ _loadHistory,
+ refreshStatic,
+ ) => {
+ slashRefreshStatic = refreshStatic;
+ return {
+ handleSlashCommand: vi.fn(),
+ slashCommands: [],
+ pendingHistoryItems: [],
+ commandContext: {},
+ shellConfirmationRequest: null,
+ confirmationRequest: null,
+ };
+ },
+ );
+
+ render(
+ ,
+ );
+
+ slashRefreshStatic?.();
+
+ expect(slashRefreshStatic).toBeDefined();
+ expect(mockStdout.write).not.toHaveBeenCalledWith(
+ ansiEscapes.clearTerminal,
+ );
+ });
+
it('provides ConfigContext with config object', () => {
expect(() => {
render(
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index a0786b5ff3..d15158b951 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -478,10 +478,14 @@ export const AppContainer = (props: AppContainerProps) => {
fetchUserMessages();
}, [historyManager.history, logger]);
+ const remountStaticHistory = useCallback(() => {
+ setHistoryRemountKey((prev) => prev + 1);
+ }, []);
+
const refreshStatic = useCallback(() => {
stdout.write(ansiEscapes.clearTerminal);
- setHistoryRemountKey((prev) => prev + 1);
- }, [setHistoryRemountKey, stdout]);
+ remountStaticHistory();
+ }, [remountStaticHistory, stdout]);
const {
isThemeDialogOpen,
@@ -704,7 +708,7 @@ export const AppContainer = (props: AppContainerProps) => {
historyManager.addItem,
historyManager.clearItems,
historyManager.loadHistory,
- refreshStatic,
+ remountStaticHistory,
toggleVimEnabled,
isProcessing,
setIsProcessing,
@@ -1242,8 +1246,8 @@ export const AppContainer = (props: AppContainerProps) => {
const handleClearScreen = useCallback(() => {
historyManager.clearItems();
clearScreen();
- refreshStatic();
- }, [historyManager, refreshStatic]);
+ remountStaticHistory();
+ }, [historyManager, remountStaticHistory]);
const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit);
diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx
index a8e57c8a18..8e2fb0d339 100644
--- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx
+++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx
@@ -547,6 +547,76 @@ describe('', () => {
expect(output).toContain('line 30');
});
+ it('pre-slices large non-shell string output before MaxSizedBox layout', () => {
+ const longString = Array.from(
+ { length: 5000 },
+ (_, i) => `line ${i + 1}`,
+ ).join('\n');
+ const { lastFrame } = renderWithContext(
+ ,
+ StreamingState.Idle,
+ );
+ const output = lastFrame()!;
+
+ expect(output).toContain('... first 4995 lines hidden ...');
+ expect(output).not.toContain('line 4995');
+ expect(output).toContain('line 4996');
+ expect(output).toContain('line 4997');
+ expect(output).toContain('line 4998');
+ expect(output).toContain('line 4999');
+ expect(output).toContain('line 5000');
+ });
+
+ it('pre-slices single-line output by visual width before MaxSizedBox layout', () => {
+ const longSingleLine = Array.from({ length: 1000 }, (_, i) =>
+ String(i % 10),
+ ).join('');
+ const { lastFrame } = renderWithContext(
+ ,
+ StreamingState.Idle,
+ );
+ const output = lastFrame()!;
+
+ expect(output).toMatch(/\.\.\. first \d+ lin/);
+ expect(output).not.toContain(longSingleLine);
+ expect(output).toContain(longSingleLine.slice(-10));
+ });
+
+ it('does not pre-slice string output that exactly fits available height', () => {
+ const exactFitString = Array.from(
+ { length: 6 },
+ (_, i) => `line ${i + 1}`,
+ ).join('\n');
+ const { lastFrame } = renderWithContext(
+ ,
+ StreamingState.Idle,
+ );
+ const output = lastFrame()!;
+
+ expect(output).not.toContain('lines hidden');
+ expect(output).toContain('line 1');
+ expect(output).toContain('line 6');
+ });
+
it.each([
['negative', -1],
['fractional', 1.5],
diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx
index 64f616cfc9..417c00010b 100644
--- a/packages/cli/src/ui/components/messages/ToolMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx
@@ -12,7 +12,7 @@ import { DiffRenderer } from './DiffRenderer.js';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { AnsiOutputText, ShellStatsBar } from '../AnsiOutput.js';
import type { ShellStatsBarProps } from '../AnsiOutput.js';
-import { MaxSizedBox } from '../shared/MaxSizedBox.js';
+import { MaxSizedBox, MINIMUM_MAX_HEIGHT } from '../shared/MaxSizedBox.js';
import { TodoDisplay } from '../TodoDisplay.js';
import type {
TodoResultDisplay,
@@ -31,6 +31,7 @@ import { theme } from '../../semantic-colors.js';
import { useSettings } from '../../contexts/SettingsContext.js';
import type { LoadedSettings } from '../../../config/settings.js';
import { useCompactMode } from '../../contexts/CompactModeContext.js';
+import { getCachedStringWidth, toCodePoints } from '../../utils/textUtils.js';
import {
ToolStatusIndicator,
@@ -48,6 +49,65 @@ const DEFAULT_SHELL_OUTPUT_MAX_LINES = 5;
const MAXIMUM_RESULT_DISPLAY_CHARACTERS = 1000000;
export type TextEmphasis = 'high' | 'medium' | 'low';
+function sliceTextForMaxHeight(
+ text: string,
+ maxHeight: number | undefined,
+ maxWidth: number,
+): { text: string; hiddenLinesCount: number } {
+ if (maxHeight === undefined) {
+ return { text, hiddenLinesCount: 0 };
+ }
+
+ const targetMaxHeight = Math.max(Math.round(maxHeight), MINIMUM_MAX_HEIGHT);
+ const visibleContentHeight = targetMaxHeight - 1;
+ const visualWidth = Math.max(1, Math.floor(maxWidth));
+ const visibleLines: string[] = [];
+ let visualLineCount = 0;
+ let currentLine = '';
+ let currentLineWidth = 0;
+
+ const appendVisibleLine = (line: string) => {
+ visualLineCount += 1;
+ visibleLines.push(line);
+ if (visibleLines.length > visibleContentHeight) {
+ visibleLines.shift();
+ }
+ };
+
+ const flushCurrentLine = () => {
+ appendVisibleLine(currentLine);
+ currentLine = '';
+ currentLineWidth = 0;
+ };
+
+ for (const char of toCodePoints(text)) {
+ if (char === '\n') {
+ flushCurrentLine();
+ continue;
+ }
+
+ const charWidth = Math.max(getCachedStringWidth(char), 1);
+ if (currentLineWidth > 0 && currentLineWidth + charWidth > visualWidth) {
+ flushCurrentLine();
+ }
+
+ currentLine += char;
+ currentLineWidth += charWidth;
+ }
+
+ flushCurrentLine();
+
+ if (visualLineCount <= targetMaxHeight) {
+ return { text, hiddenLinesCount: 0 };
+ }
+
+ const hiddenLinesCount = visualLineCount - visibleContentHeight;
+ return {
+ text: visibleLines.join('\n'),
+ hiddenLinesCount,
+ };
+}
+
type DisplayRendererResult =
| { type: 'none' }
| { type: 'todo'; data: TodoResultDisplay }
@@ -234,11 +294,21 @@ const StringResultRenderer: React.FC<{
);
}
+ const sliced = sliceTextForMaxHeight(
+ displayData,
+ availableHeight,
+ childWidth,
+ );
+
return (
-
+
- {displayData}
+ {sliced.text}
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
index 7483e93494..b9ad835f32 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
+++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
@@ -6,7 +6,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { Mock, MockInstance } from 'vitest';
-import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { useGeminiStream, classifyApiError } from './useGeminiStream.js';
import * as atCommandProcessor from './atCommandProcessor.js';
@@ -238,6 +238,10 @@ describe('useGeminiStream', () => {
handleAtCommandSpy = vi.spyOn(atCommandProcessor, 'handleAtCommand');
});
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
const mockLoadedSettings: LoadedSettings = {
merged: { preferredEditor: 'vscode' },
user: { path: '/user/settings.json', settings: {} },
@@ -823,6 +827,165 @@ describe('useGeminiStream', () => {
});
describe('Cancellation', () => {
+ it('buffers streamed content until the throttle interval elapses', async () => {
+ vi.useFakeTimers();
+
+ let releaseStream!: () => void;
+ const holdStream = new Promise((resolve) => {
+ releaseStream = resolve;
+ });
+
+ const mockStream = (async function* () {
+ yield {
+ type: ServerGeminiEventType.Content,
+ value: 'Hel',
+ };
+ yield {
+ type: ServerGeminiEventType.Content,
+ value: 'lo',
+ };
+ await holdStream;
+ })();
+ mockSendMessageStream.mockReturnValue(mockStream);
+
+ const { result } = renderTestHook();
+
+ act(() => {
+ void result.current.submitQuery('test query');
+ });
+
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
+
+ expect(result.current.pendingHistoryItems).toEqual([]);
+
+ await act(async () => {
+ vi.advanceTimersByTime(60);
+ });
+
+ expect(result.current.pendingHistoryItems).toEqual([
+ expect.objectContaining({
+ type: 'gemini',
+ text: 'Hello',
+ }),
+ ]);
+
+ act(() => {
+ result.current.cancelOngoingRequest();
+ });
+
+ await act(async () => {
+ releaseStream();
+ });
+ });
+
+ it('buffers streamed thoughts until the throttle interval elapses', async () => {
+ vi.useFakeTimers();
+
+ let releaseStream!: () => void;
+ const holdStream = new Promise((resolve) => {
+ releaseStream = resolve;
+ });
+
+ const mockStream = (async function* () {
+ yield {
+ type: ServerGeminiEventType.Thought,
+ value: { description: 'Think' },
+ };
+ yield {
+ type: ServerGeminiEventType.Thought,
+ value: { description: 'ing' },
+ };
+ await holdStream;
+ })();
+ mockSendMessageStream.mockReturnValue(mockStream);
+
+ const { result } = renderTestHook();
+
+ act(() => {
+ void result.current.submitQuery('test query');
+ });
+
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
+ expect(result.current.pendingHistoryItems).toEqual([]);
+
+ await act(async () => {
+ vi.advanceTimersByTime(60);
+ });
+
+ expect(result.current.pendingHistoryItems).toEqual([
+ expect.objectContaining({
+ type: 'gemini_thought',
+ text: 'Thinking',
+ }),
+ ]);
+ expect(result.current.thought).toEqual({ description: 'Thinking' });
+
+ act(() => {
+ result.current.cancelOngoingRequest();
+ });
+
+ await act(async () => {
+ releaseStream();
+ });
+ });
+
+ it('flushes buffered content before cancellation', async () => {
+ vi.useFakeTimers();
+
+ let releaseStream!: () => void;
+ const holdStream = new Promise((resolve) => {
+ releaseStream = resolve;
+ });
+
+ const mockStream = (async function* () {
+ yield {
+ type: ServerGeminiEventType.Content,
+ value: 'Initial',
+ };
+ await holdStream;
+ })();
+ mockSendMessageStream.mockReturnValue(mockStream);
+
+ const { result } = renderTestHook();
+
+ act(() => {
+ void result.current.submitQuery('test query');
+ });
+
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
+
+ act(() => {
+ result.current.cancelOngoingRequest();
+ });
+
+ expect(mockAddItem).toHaveBeenCalledWith(
+ {
+ type: 'gemini',
+ text: 'Initial',
+ },
+ expect.any(Number),
+ );
+
+ await act(async () => {
+ releaseStream();
+ });
+ });
+
it('should cancel an in-progress stream when cancelOngoingRequest is called', async () => {
const mockStream = (async function* () {
yield { type: 'content', value: 'Part 1' };
@@ -992,6 +1155,10 @@ describe('useGeminiStream', () => {
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
});
+ await act(async () => {
+ await Promise.resolve();
+ });
+
// Cancel the request
act(() => {
result.current.cancelOngoingRequest();
@@ -2811,6 +2978,13 @@ describe('useGeminiStream', () => {
result.current.cancelOngoingRequest();
});
+ expect(mockAddItem).toHaveBeenCalledWith(
+ {
+ type: 'gemini',
+ text: 'First call content',
+ },
+ expect.any(Number),
+ );
expect(mainAbortSignal?.aborted).toBe(true);
} finally {
resolveFirstCall();
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts
index d680a736d5..fd80a6ec43 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.ts
+++ b/packages/cli/src/ui/hooks/useGeminiStream.ts
@@ -178,6 +178,11 @@ enum StreamProcessingStatus {
}
const EDIT_TOOL_NAMES = new Set(['replace', 'write_file']);
+const STREAM_UPDATE_THROTTLE_MS = 60;
+
+type BufferedStreamEvent =
+ | { kind: 'content'; value: string }
+ | { kind: 'thought'; value: ThoughtSummary };
function showCitations(settings: LoadedSettings): boolean {
const enabled = settings?.merged?.ui?.showCitations;
@@ -216,6 +221,7 @@ export const useGeminiStream = (
) => {
const [initError, setInitError] = useState(null);
const abortControllerRef = useRef(null);
+ const flushBufferedStreamEventsRef = useRef void>>(new Set());
const turnCancelledRef = useRef(false);
const isSubmittingQueryRef = useRef(false);
const lastPromptRef = useRef(null);
@@ -483,6 +489,9 @@ export const useGeminiStream = (
if (turnCancelledRef.current) {
return;
}
+ for (const flushBufferedStreamEvents of flushBufferedStreamEventsRef.current) {
+ flushBufferedStreamEvents();
+ }
turnCancelledRef.current = true;
isSubmittingQueryRef.current = false;
abortControllerRef.current?.abort();
@@ -1121,133 +1130,226 @@ export const useGeminiStream = (
let geminiMessageBuffer = '';
let thoughtBuffer = '';
const toolCallRequests: ToolCallRequestInfo[] = [];
- dualOutput?.startAssistantMessage();
- for await (const event of stream) {
- dualOutput?.processEvent(event);
- switch (event.type) {
- case ServerGeminiEventType.Thought:
- // If the thought has a subject, it's a discrete status update rather than
- // a streamed textual thought, so we update the thought state directly.
- if (event.value.subject) {
- setThought(event.value);
- } else {
- thoughtBuffer = handleThoughtEvent(
- event.value,
- thoughtBuffer,
- userMessageTimestamp,
- );
+ const bufferedEvents: BufferedStreamEvent[] = [];
+ let flushTimer: ReturnType | null = null;
+
+ const discardBufferedStreamEvents = () => {
+ if (flushTimer) {
+ clearTimeout(flushTimer);
+ flushTimer = null;
+ }
+ bufferedEvents.length = 0;
+ };
+
+ const flushBufferedStreamEvents = () => {
+ if (flushTimer) {
+ clearTimeout(flushTimer);
+ flushTimer = null;
+ }
+
+ if (bufferedEvents.length === 0) {
+ return;
+ }
+
+ while (bufferedEvents.length > 0) {
+ const nextEvent = bufferedEvents.shift()!;
+
+ if (nextEvent.kind === 'content') {
+ let mergedContent = nextEvent.value;
+
+ while (bufferedEvents[0]?.kind === 'content') {
+ const queuedContent = bufferedEvents.shift();
+ if (queuedContent?.kind !== 'content') {
+ break;
+ }
+ mergedContent += queuedContent.value;
}
- break;
- case ServerGeminiEventType.Content:
+
geminiMessageBuffer = handleContentEvent(
- event.value,
+ mergedContent,
geminiMessageBuffer,
userMessageTimestamp,
);
- break;
- case ServerGeminiEventType.ToolCallRequest:
- toolCallRequests.push(event.value);
- // Count tool call args JSON toward token estimation (matches
- // Claude Code's input_json_delta handling).
- try {
- const argsJson = JSON.stringify(event.value.args);
- streamingResponseLengthRef.current += argsJson.length;
- } catch {
- // Best-effort — don't block on serialization errors
+ continue;
+ }
+
+ let mergedThought = nextEvent.value;
+
+ while (bufferedEvents[0]?.kind === 'thought') {
+ const queuedThought = bufferedEvents.shift();
+ if (queuedThought?.kind !== 'thought') {
+ break;
}
- break;
- case ServerGeminiEventType.UserCancelled:
- handleUserCancelledEvent(userMessageTimestamp);
- break;
- case ServerGeminiEventType.Error:
- handleErrorEvent(event.value, userMessageTimestamp);
- break;
- case ServerGeminiEventType.ChatCompressed:
- handleChatCompressionEvent(event.value, userMessageTimestamp);
- break;
- case ServerGeminiEventType.ToolCallConfirmation:
- case ServerGeminiEventType.ToolCallResponse:
- // do nothing
- break;
- case ServerGeminiEventType.MaxSessionTurns:
- handleMaxSessionTurnsEvent();
- break;
- case ServerGeminiEventType.SessionTokenLimitExceeded:
- handleSessionTokenLimitExceededEvent(event.value);
- break;
- case ServerGeminiEventType.Finished:
- handleFinishedEvent(
- event as ServerGeminiFinishedEvent,
- userMessageTimestamp,
- );
- break;
- case ServerGeminiEventType.Citation:
- handleCitationEvent(event.value, userMessageTimestamp);
- break;
- case ServerGeminiEventType.LoopDetected:
- // handle later because we want to move pending history to history
- // before we add loop detected message to history
- loopDetectedRef.current = true;
- break;
- case ServerGeminiEventType.Retry:
- // On fresh restart (escalation / rate-limit / invalid stream),
- // clear pending content and buffers to discard the failed attempt.
- // On continuation (recovery), keep the pending gemini item AND
- // buffers so the model's continuation text appends to them —
- // otherwise handleContentEvent would see a null pending item,
- // create a fresh one, and reset the buffer to just the new chunk,
- // losing the partial text we meant to preserve.
- if (!event.isContinuation) {
+ mergedThought = {
+ subject: queuedThought.value.subject || mergedThought.subject,
+ description: `${mergedThought.description ?? ''}${
+ queuedThought.value.description ?? ''
+ }`,
+ };
+ }
+
+ thoughtBuffer = handleThoughtEvent(
+ mergedThought,
+ thoughtBuffer,
+ userMessageTimestamp,
+ );
+ }
+ };
+
+ const scheduleBufferedStreamFlush = () => {
+ if (flushTimer) {
+ return;
+ }
+
+ flushTimer = setTimeout(() => {
+ flushBufferedStreamEvents();
+ }, STREAM_UPDATE_THROTTLE_MS);
+ };
+
+ flushBufferedStreamEventsRef.current.add(flushBufferedStreamEvents);
+ dualOutput?.startAssistantMessage();
+ try {
+ for await (const event of stream) {
+ dualOutput?.processEvent(event);
+ switch (event.type) {
+ case ServerGeminiEventType.Thought:
+ // If the thought has a subject, it's a discrete status update rather than
+ // a streamed textual thought, so we update the thought state directly.
+ if (event.value.subject) {
+ flushBufferedStreamEvents();
+ setThought(event.value);
+ } else {
+ bufferedEvents.push({ kind: 'thought', value: event.value });
+ scheduleBufferedStreamFlush();
+ }
+ break;
+ case ServerGeminiEventType.Content:
+ bufferedEvents.push({ kind: 'content', value: event.value });
+ scheduleBufferedStreamFlush();
+ break;
+ case ServerGeminiEventType.ToolCallRequest:
+ flushBufferedStreamEvents();
+ toolCallRequests.push(event.value);
+ // Count tool call args JSON toward token estimation (matches
+ // Claude Code's input_json_delta handling).
+ try {
+ const argsJson = JSON.stringify(event.value.args);
+ streamingResponseLengthRef.current += argsJson.length;
+ } catch {
+ // Best-effort — don't block on serialization errors
+ }
+ break;
+ case ServerGeminiEventType.UserCancelled:
+ flushBufferedStreamEvents();
+ handleUserCancelledEvent(userMessageTimestamp);
+ break;
+ case ServerGeminiEventType.Error:
+ flushBufferedStreamEvents();
+ handleErrorEvent(event.value, userMessageTimestamp);
+ break;
+ case ServerGeminiEventType.ChatCompressed:
+ flushBufferedStreamEvents();
+ handleChatCompressionEvent(event.value, userMessageTimestamp);
+ break;
+ case ServerGeminiEventType.ToolCallConfirmation:
+ case ServerGeminiEventType.ToolCallResponse:
+ flushBufferedStreamEvents();
+ break;
+ case ServerGeminiEventType.MaxSessionTurns:
+ flushBufferedStreamEvents();
+ handleMaxSessionTurnsEvent();
+ break;
+ case ServerGeminiEventType.SessionTokenLimitExceeded:
+ flushBufferedStreamEvents();
+ handleSessionTokenLimitExceededEvent(event.value);
+ break;
+ case ServerGeminiEventType.Finished:
+ flushBufferedStreamEvents();
+ handleFinishedEvent(
+ event as ServerGeminiFinishedEvent,
+ userMessageTimestamp,
+ );
+ break;
+ case ServerGeminiEventType.Citation:
+ flushBufferedStreamEvents();
+ handleCitationEvent(event.value, userMessageTimestamp);
+ break;
+ case ServerGeminiEventType.LoopDetected:
+ flushBufferedStreamEvents();
+ // handle later because we want to move pending history to history
+ // before we add loop detected message to history
+ loopDetectedRef.current = true;
+ break;
+ case ServerGeminiEventType.Retry:
+ // On fresh restart (escalation / rate-limit / invalid stream),
+ // clear pending content and buffers to discard the failed attempt.
+ // On continuation (recovery), keep the pending gemini item AND
+ // buffers so the model's continuation text appends to them —
+ // otherwise handleContentEvent would see a null pending item,
+ // create a fresh one, and reset the buffer to just the new chunk,
+ // losing the partial text we meant to preserve.
+ if (!event.isContinuation) {
+ discardBufferedStreamEvents();
+ if (pendingHistoryItemRef.current) {
+ setPendingHistoryItem(null);
+ }
+ geminiMessageBuffer = '';
+ thoughtBuffer = '';
+ } else {
+ flushBufferedStreamEvents();
+ }
+ // Always discard tool call requests from the truncated/failed
+ // attempt to prevent duplicate execution after escalation or
+ // recovery. The recovery path now skips turns that already
+ // contain a functionCall (see geminiChat.ts), so this only
+ // clears stale requests from pre-RETRY accumulation.
+ toolCallRequests.length = 0;
+ // Show retry info if available (rate-limit / throttling errors)
+ if (event.retryInfo) {
+ startRetryCountdown(event.retryInfo);
+ } else {
+ // The retry attempt is starting now, so any prior retry UI is stale.
+ clearRetryCountdown();
+ }
+ break;
+ case ServerGeminiEventType.HookSystemMessage:
+ flushBufferedStreamEvents();
+ // Display system message from Stop hooks with "Stop says:" prefix
+ // First commit any pending AI response to ensure correct ordering
if (pendingHistoryItemRef.current) {
+ addItem(pendingHistoryItemRef.current, userMessageTimestamp);
setPendingHistoryItem(null);
}
- geminiMessageBuffer = '';
- thoughtBuffer = '';
- }
- // Always discard tool call requests from the truncated/failed
- // attempt to prevent duplicate execution after escalation or
- // recovery. The recovery path now skips turns that already
- // contain a functionCall (see geminiChat.ts), so this only
- // clears stale requests from pre-RETRY accumulation.
- toolCallRequests.length = 0;
- // Show retry info if available (rate-limit / throttling errors)
- if (event.retryInfo) {
- startRetryCountdown(event.retryInfo);
- } else {
- // The retry attempt is starting now, so any prior retry UI is stale.
- clearRetryCountdown();
- }
- break;
- case ServerGeminiEventType.HookSystemMessage:
- // Display system message from Stop hooks with "Stop says:" prefix
- // First commit any pending AI response to ensure correct ordering
- if (pendingHistoryItemRef.current) {
- addItem(pendingHistoryItemRef.current, userMessageTimestamp);
- setPendingHistoryItem(null);
+ addItem(
+ {
+ type: 'stop_hook_system_message',
+ message: event.value,
+ } as HistoryItemWithoutId,
+ userMessageTimestamp,
+ );
+ break;
+ case ServerGeminiEventType.UserPromptSubmitBlocked:
+ flushBufferedStreamEvents();
+ handleUserPromptSubmitBlockedEvent(
+ event.value,
+ userMessageTimestamp,
+ );
+ break;
+ case ServerGeminiEventType.StopHookLoop:
+ flushBufferedStreamEvents();
+ handleStopHookLoopEvent(event.value, userMessageTimestamp);
+ break;
+ default: {
+ // enforces exhaustive switch-case
+ const unreachable: never = event;
+ return unreachable;
}
- addItem(
- {
- type: 'stop_hook_system_message',
- message: event.value,
- } as HistoryItemWithoutId,
- userMessageTimestamp,
- );
- break;
- case ServerGeminiEventType.UserPromptSubmitBlocked:
- handleUserPromptSubmitBlockedEvent(
- event.value,
- userMessageTimestamp,
- );
- break;
- case ServerGeminiEventType.StopHookLoop:
- handleStopHookLoopEvent(event.value, userMessageTimestamp);
- break;
- default: {
- // enforces exhaustive switch-case
- const unreachable: never = event;
- return unreachable;
}
}
+ } finally {
+ flushBufferedStreamEvents();
+ discardBufferedStreamEvents();
+ flushBufferedStreamEventsRef.current.delete(flushBufferedStreamEvents);
}
dualOutput?.finalizeAssistantMessage();
if (toolCallRequests.length > 0) {
diff --git a/packages/cli/src/ui/utils/synchronizedOutput.test.ts b/packages/cli/src/ui/utils/synchronizedOutput.test.ts
new file mode 100644
index 0000000000..f9c9da0e1c
--- /dev/null
+++ b/packages/cli/src/ui/utils/synchronizedOutput.test.ts
@@ -0,0 +1,191 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import {
+ BEGIN_SYNCHRONIZED_UPDATE,
+ END_SYNCHRONIZED_UPDATE,
+ getSynchronizedOutputStatsSnapshot,
+ installSynchronizedOutput,
+ resetSynchronizedOutputStats,
+ terminalSupportsSynchronizedOutput,
+} from './synchronizedOutput.js';
+import { installTerminalRedrawOptimizer } from './terminalRedrawOptimizer.js';
+
+const ESC = '\u001B[';
+const ERASE_LINE = `${ESC}2K`;
+const CURSOR_UP_ONE = `${ESC}1A`;
+const CURSOR_DOWN_ONE = `${ESC}1B`;
+const CURSOR_LEFT = `${ESC}G`;
+
+function createStdout(write: NodeJS.WriteStream['write']): NodeJS.WriteStream {
+ return {
+ isTTY: true,
+ write,
+ } as NodeJS.WriteStream;
+}
+
+describe('terminalSupportsSynchronizedOutput', () => {
+ it.each([
+ [{ TERM_PROGRAM: 'WezTerm' }, true],
+ [{ TERM_PROGRAM: 'iTerm.app' }, true],
+ [{ TERM: 'xterm-kitty' }, true],
+ [{ KITTY_WINDOW_ID: '1' }, true],
+ [{ TERM_PROGRAM: 'Apple_Terminal' }, false],
+ [{ TERM_PROGRAM: 'JetBrains-JediTerm' }, false],
+ [{ TERM_PROGRAM: 'WezTerm', TMUX: '/tmp/tmux' }, false],
+ [{ TERM_PROGRAM: 'WezTerm', SSH_TTY: '/dev/pts/1' }, false],
+ [{ TERM_PROGRAM: 'WezTerm', SSH_CLIENT: '127.0.0.1 1 2' }, false],
+ [{ TERM_PROGRAM: 'WezTerm', QWEN_CODE_SYNCHRONIZED_OUTPUT: '0' }, false],
+ [
+ {
+ TERM_PROGRAM: 'WezTerm',
+ QWEN_CODE_DISABLE_SYNCHRONIZED_OUTPUT: '1',
+ QWEN_CODE_FORCE_SYNCHRONIZED_OUTPUT: '1',
+ },
+ false,
+ ],
+ [
+ {
+ TERM_PROGRAM: 'Apple_Terminal',
+ QWEN_CODE_FORCE_SYNCHRONIZED_OUTPUT: '1',
+ },
+ true,
+ ],
+ ])('detects support for %j', (env, expected) => {
+ expect(terminalSupportsSynchronizedOutput(env)).toBe(expected);
+ });
+});
+
+describe('installSynchronizedOutput', () => {
+ afterEach(() => {
+ resetSynchronizedOutputStats();
+ });
+
+ it('does not install for non-TTY stdout', () => {
+ const write = vi.fn(() => true) as NodeJS.WriteStream['write'];
+ const stdout = {
+ isTTY: false,
+ write,
+ } as NodeJS.WriteStream;
+
+ const restore = installSynchronizedOutput(stdout, {
+ TERM_PROGRAM: 'WezTerm',
+ });
+
+ expect(stdout.write).toBe(write);
+ restore();
+ });
+
+ it('wraps one synchronous write burst in balanced BSU and ESU markers', async () => {
+ const writes: string[] = [];
+ const write = vi.fn((chunk: string | Uint8Array) => {
+ writes.push(typeof chunk === 'string' ? chunk : chunk.toString());
+ return true;
+ }) as NodeJS.WriteStream['write'];
+ const stdout = createStdout(write);
+
+ const restore = installSynchronizedOutput(stdout, {
+ TERM_PROGRAM: 'WezTerm',
+ });
+
+ stdout.write('frame-a');
+ stdout.write(Buffer.from('frame-b'));
+ await Promise.resolve();
+
+ expect(writes).toEqual([
+ BEGIN_SYNCHRONIZED_UPDATE,
+ 'frame-a',
+ 'frame-b',
+ END_SYNCHRONIZED_UPDATE,
+ ]);
+ expect(getSynchronizedOutputStatsSnapshot()).toEqual({
+ synchronizedOutputFrameCount: 1,
+ synchronizedOutputBeginCount: 1,
+ synchronizedOutputEndCount: 1,
+ });
+
+ restore();
+ expect(stdout.write).toBe(write);
+ });
+
+ it('preserves write return values and callbacks', async () => {
+ const callback = vi.fn();
+ const write = vi.fn(
+ (
+ _chunk: string | Uint8Array,
+ encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),
+ ) => {
+ if (typeof encodingOrCallback === 'function') {
+ encodingOrCallback();
+ }
+ return false;
+ },
+ ) as NodeJS.WriteStream['write'];
+ const stdout = createStdout(write);
+
+ const restore = installSynchronizedOutput(stdout, {
+ TERM_PROGRAM: 'iTerm.app',
+ });
+
+ const result = stdout.write('payload', callback);
+ await Promise.resolve();
+
+ expect(result).toBe(false);
+ expect(callback).toHaveBeenCalledTimes(1);
+ restore();
+ });
+
+ it('composes after terminal redraw optimization without losing erase optimization', async () => {
+ const writes: string[] = [];
+ const write = vi.fn((chunk: string | Uint8Array) => {
+ writes.push(typeof chunk === 'string' ? chunk : chunk.toString());
+ return true;
+ }) as NodeJS.WriteStream['write'];
+ const stdout = createStdout(write);
+ const restoreRedrawOptimizer = installTerminalRedrawOptimizer(stdout);
+ const restoreSynchronizedOutput = installSynchronizedOutput(stdout, {
+ TERM_PROGRAM: 'WezTerm',
+ });
+
+ stdout.write(
+ `${ERASE_LINE}${CURSOR_UP_ONE}${ERASE_LINE}${CURSOR_UP_ONE}${ERASE_LINE}${CURSOR_LEFT}`,
+ );
+ await Promise.resolve();
+
+ expect(writes).toEqual([
+ BEGIN_SYNCHRONIZED_UPDATE,
+ `${ESC}2A${ERASE_LINE}${CURSOR_DOWN_ONE}${ERASE_LINE}${CURSOR_DOWN_ONE}${ERASE_LINE}${ESC}2A${CURSOR_LEFT}`,
+ END_SYNCHRONIZED_UPDATE,
+ ]);
+
+ restoreSynchronizedOutput();
+ restoreRedrawOptimizer();
+ expect(stdout.write).toBe(write);
+ });
+
+ it('closes an open frame before restore', () => {
+ const writes: string[] = [];
+ const write = vi.fn((chunk: string | Uint8Array) => {
+ writes.push(typeof chunk === 'string' ? chunk : chunk.toString());
+ return true;
+ }) as NodeJS.WriteStream['write'];
+ const stdout = createStdout(write);
+ const restore = installSynchronizedOutput(stdout, {
+ TERM_PROGRAM: 'WezTerm',
+ });
+
+ stdout.write('payload');
+ restore();
+
+ expect(writes).toEqual([
+ BEGIN_SYNCHRONIZED_UPDATE,
+ 'payload',
+ END_SYNCHRONIZED_UPDATE,
+ ]);
+ expect(stdout.write).toBe(write);
+ });
+});
diff --git a/packages/cli/src/ui/utils/synchronizedOutput.ts b/packages/cli/src/ui/utils/synchronizedOutput.ts
new file mode 100644
index 0000000000..fb75786ad2
--- /dev/null
+++ b/packages/cli/src/ui/utils/synchronizedOutput.ts
@@ -0,0 +1,131 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export const BEGIN_SYNCHRONIZED_UPDATE = '\u001B[?2026h';
+export const END_SYNCHRONIZED_UPDATE = '\u001B[?2026l';
+
+export interface SynchronizedOutputStatsSnapshot {
+ synchronizedOutputFrameCount: number;
+ synchronizedOutputBeginCount: number;
+ synchronizedOutputEndCount: number;
+}
+
+const synchronizedOutputStats: SynchronizedOutputStatsSnapshot = {
+ synchronizedOutputFrameCount: 0,
+ synchronizedOutputBeginCount: 0,
+ synchronizedOutputEndCount: 0,
+};
+
+let installed = false;
+
+export function getSynchronizedOutputStatsSnapshot(): SynchronizedOutputStatsSnapshot {
+ return { ...synchronizedOutputStats };
+}
+
+export function resetSynchronizedOutputStats(): void {
+ synchronizedOutputStats.synchronizedOutputFrameCount = 0;
+ synchronizedOutputStats.synchronizedOutputBeginCount = 0;
+ synchronizedOutputStats.synchronizedOutputEndCount = 0;
+}
+
+export function terminalSupportsSynchronizedOutput(
+ env: NodeJS.ProcessEnv = process.env,
+): boolean {
+ if (
+ env['QWEN_CODE_DISABLE_SYNCHRONIZED_OUTPUT'] === '1' ||
+ env['QWEN_CODE_SYNCHRONIZED_OUTPUT'] === '0'
+ ) {
+ return false;
+ }
+
+ if (
+ env['QWEN_CODE_FORCE_SYNCHRONIZED_OUTPUT'] === '1' ||
+ env['QWEN_CODE_SYNCHRONIZED_OUTPUT'] === '1'
+ ) {
+ return true;
+ }
+
+ if (env['TMUX'] || env['SSH_TTY'] || env['SSH_CLIENT']) {
+ return false;
+ }
+
+ const termProgram = env['TERM_PROGRAM'];
+ if (termProgram === 'WezTerm' || termProgram === 'iTerm.app') {
+ return true;
+ }
+
+ const term = env['TERM'];
+ return Boolean(env['KITTY_WINDOW_ID'] || term?.includes('kitty'));
+}
+
+export function installSynchronizedOutput(
+ stdout: NodeJS.WriteStream = process.stdout,
+ env: NodeJS.ProcessEnv = process.env,
+): () => void {
+ if (installed || !stdout.isTTY || !terminalSupportsSynchronizedOutput(env)) {
+ return () => {};
+ }
+
+ const originalWrite = stdout.write;
+ let inFrame = false;
+
+ const writeControlSequence = (sequence: string) => {
+ originalWrite.call(stdout, sequence);
+ };
+
+ const endFrame = () => {
+ if (!inFrame) {
+ return;
+ }
+
+ inFrame = false;
+ synchronizedOutputStats.synchronizedOutputEndCount += 1;
+ writeControlSequence(END_SYNCHRONIZED_UPDATE);
+ };
+
+ const patchedWrite = function (
+ this: NodeJS.WriteStream,
+ chunk: unknown,
+ encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),
+ callback?: (error?: Error | null) => void,
+ ) {
+ if (!inFrame) {
+ inFrame = true;
+ synchronizedOutputStats.synchronizedOutputFrameCount += 1;
+ synchronizedOutputStats.synchronizedOutputBeginCount += 1;
+ writeControlSequence(BEGIN_SYNCHRONIZED_UPDATE);
+ queueMicrotask(endFrame);
+ }
+
+ return originalWrite.call(
+ this,
+ chunk as string | Uint8Array,
+ encodingOrCallback as BufferEncoding,
+ callback,
+ );
+ } as typeof stdout.write;
+
+ const exitHandler = () => {
+ try {
+ endFrame();
+ } catch {
+ // stdout may already be closed during process shutdown.
+ }
+ };
+
+ stdout.write = patchedWrite;
+ installed = true;
+ process.once('exit', exitHandler);
+
+ return () => {
+ if (stdout.write === patchedWrite) {
+ endFrame();
+ stdout.write = originalWrite;
+ }
+ process.removeListener('exit', exitHandler);
+ installed = false;
+ };
+}
diff --git a/packages/cli/src/ui/utils/terminalRedrawOptimizer.test.ts b/packages/cli/src/ui/utils/terminalRedrawOptimizer.test.ts
index 53d747c14c..2e411758eb 100644
--- a/packages/cli/src/ui/utils/terminalRedrawOptimizer.test.ts
+++ b/packages/cli/src/ui/utils/terminalRedrawOptimizer.test.ts
@@ -6,8 +6,10 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
+ getTerminalRedrawStatsSnapshot,
installTerminalRedrawOptimizer,
optimizeMultilineEraseLines,
+ resetTerminalRedrawStats,
} from './terminalRedrawOptimizer.js';
const ESC = '\u001B[';
@@ -56,6 +58,7 @@ describe('optimizeMultilineEraseLines', () => {
describe('installTerminalRedrawOptimizer', () => {
afterEach(() => {
vi.unstubAllEnvs();
+ resetTerminalRedrawStats();
});
it('optimizes string writes and restores the original writer', () => {
@@ -87,6 +90,30 @@ describe('installTerminalRedrawOptimizer', () => {
expect(write).toHaveBeenCalledWith(input, undefined, undefined);
});
+ it('tracks write, byte, clear, and erase optimization counters', () => {
+ const write = vi.fn(() => true);
+ const stdout = { write } as unknown as NodeJS.WriteStream;
+ installTerminalRedrawOptimizer(stdout);
+
+ stdout.write(
+ `${ERASE_LINE}${CURSOR_UP_ONE}${ERASE_LINE}${CURSOR_UP_ONE}${ERASE_LINE}${CURSOR_LEFT}`,
+ );
+ stdout.write(Buffer.from('ok'));
+ stdout.write('\u001B[2J\u001B[3J\u001B[H');
+
+ expect(getTerminalRedrawStatsSnapshot()).toEqual({
+ stdoutWriteCount: 3,
+ stdoutBytes:
+ Buffer.byteLength(
+ `${ESC}2A${ERASE_LINE}${CURSOR_DOWN_ONE}${ERASE_LINE}${CURSOR_DOWN_ONE}${ERASE_LINE}${ESC}2A${CURSOR_LEFT}`,
+ ) +
+ Buffer.byteLength('ok') +
+ Buffer.byteLength('\u001B[2J\u001B[3J\u001B[H'),
+ clearTerminalCount: 1,
+ eraseLinesOptimizedCount: 1,
+ });
+ });
+
it('can be disabled for terminal compatibility fallback', () => {
vi.stubEnv('QWEN_CODE_LEGACY_ERASE_LINES', '1');
const write = vi.fn(() => true);
diff --git a/packages/cli/src/ui/utils/terminalRedrawOptimizer.ts b/packages/cli/src/ui/utils/terminalRedrawOptimizer.ts
index 166c687ed8..b82186a6d8 100644
--- a/packages/cli/src/ui/utils/terminalRedrawOptimizer.ts
+++ b/packages/cli/src/ui/utils/terminalRedrawOptimizer.ts
@@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
+import ansiEscapes from 'ansi-escapes';
+
const ESC = '\u001B[';
const ERASE_LINE = `${ESC}2K`;
const CURSOR_UP_ONE = `${ESC}1A`;
@@ -33,6 +35,79 @@ function countOccurrences(value: string, search: string): number {
return count;
}
+export interface TerminalRedrawStatsSnapshot {
+ stdoutWriteCount: number;
+ stdoutBytes: number;
+ clearTerminalCount: number;
+ eraseLinesOptimizedCount: number;
+}
+
+const terminalRedrawStats: TerminalRedrawStatsSnapshot = {
+ stdoutWriteCount: 0,
+ stdoutBytes: 0,
+ clearTerminalCount: 0,
+ eraseLinesOptimizedCount: 0,
+};
+
+export function getTerminalRedrawStatsSnapshot(): TerminalRedrawStatsSnapshot {
+ return { ...terminalRedrawStats };
+}
+
+export function resetTerminalRedrawStats(): void {
+ terminalRedrawStats.stdoutWriteCount = 0;
+ terminalRedrawStats.stdoutBytes = 0;
+ terminalRedrawStats.clearTerminalCount = 0;
+ terminalRedrawStats.eraseLinesOptimizedCount = 0;
+}
+
+function getChunkByteLength(
+ chunk: string | Uint8Array,
+ encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),
+): number {
+ if (typeof chunk === 'string') {
+ const encoding =
+ typeof encodingOrCallback === 'string' ? encodingOrCallback : undefined;
+ return Buffer.byteLength(chunk, encoding);
+ }
+
+ return chunk.byteLength;
+}
+
+function optimizeMultilineEraseLinesWithCount(output: string): {
+ output: string;
+ optimizedSequenceCount: number;
+} {
+ let optimizedSequenceCount = 0;
+
+ const optimizedOutput = output.replace(
+ MULTILINE_ERASE_LINES_PATTERN,
+ (sequence) => {
+ const lineCount = countOccurrences(sequence, ERASE_LINE);
+ const cursorUpCount = lineCount - 1;
+
+ if (cursorUpCount <= 1) {
+ return sequence;
+ }
+
+ optimizedSequenceCount += 1;
+
+ let boundedErase = `${ESC}${cursorUpCount}A`;
+
+ for (let line = 0; line < lineCount; line++) {
+ boundedErase += ERASE_LINE;
+
+ if (line < lineCount - 1) {
+ boundedErase += CURSOR_DOWN_ONE;
+ }
+ }
+
+ return `${boundedErase}${ESC}${cursorUpCount}A${CURSOR_LEFT}`;
+ },
+ );
+
+ return { output: optimizedOutput, optimizedSequenceCount };
+}
+
/**
* Ink clears dynamic output via ansi-escapes.eraseLines(), which emits a
* clear-line + cursor-up pair for every previous line. That can make terminal
@@ -40,26 +115,7 @@ function countOccurrences(value: string, search: string): number {
* upward cursor movement while still clearing only the same old frame lines.
*/
export function optimizeMultilineEraseLines(output: string): string {
- return output.replace(MULTILINE_ERASE_LINES_PATTERN, (sequence) => {
- const lineCount = countOccurrences(sequence, ERASE_LINE);
- const cursorUpCount = lineCount - 1;
-
- if (cursorUpCount <= 1) {
- return sequence;
- }
-
- let boundedErase = `${ESC}${cursorUpCount}A`;
-
- for (let line = 0; line < lineCount; line++) {
- boundedErase += ERASE_LINE;
-
- if (line < lineCount - 1) {
- boundedErase += CURSOR_DOWN_ONE;
- }
- }
-
- return `${boundedErase}${ESC}${cursorUpCount}A${CURSOR_LEFT}`;
- });
+ return optimizeMultilineEraseLinesWithCount(output).output;
}
export function installTerminalRedrawOptimizer(
@@ -77,8 +133,35 @@ export function installTerminalRedrawOptimizer(
encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),
callback?: (error?: Error | null) => void,
) {
- const optimizedChunk =
- typeof chunk === 'string' ? optimizeMultilineEraseLines(chunk) : chunk;
+ const optimizedResult =
+ typeof chunk === 'string'
+ ? optimizeMultilineEraseLinesWithCount(chunk)
+ : undefined;
+ const optimizedChunk = optimizedResult?.output ?? chunk;
+
+ if (
+ typeof optimizedChunk === 'string' ||
+ optimizedChunk instanceof Uint8Array ||
+ Buffer.isBuffer(optimizedChunk)
+ ) {
+ terminalRedrawStats.stdoutWriteCount += 1;
+ terminalRedrawStats.stdoutBytes += getChunkByteLength(
+ optimizedChunk,
+ encodingOrCallback,
+ );
+
+ if (typeof optimizedChunk === 'string') {
+ terminalRedrawStats.clearTerminalCount += countOccurrences(
+ optimizedChunk,
+ ansiEscapes.clearTerminal,
+ );
+ }
+ }
+
+ if (optimizedResult) {
+ terminalRedrawStats.eraseLinesOptimizedCount +=
+ optimizedResult.optimizedSequenceCount;
+ }
return originalWrite.call(
this,
diff --git a/packages/core/src/services/shellExecutionService.test.ts b/packages/core/src/services/shellExecutionService.test.ts
index 17a0a525e0..5cc203929e 100644
--- a/packages/core/src/services/shellExecutionService.test.ts
+++ b/packages/core/src/services/shellExecutionService.test.ts
@@ -33,6 +33,26 @@ const mockIsBinary = vi.hoisted(() => vi.fn());
const mockPlatform = vi.hoisted(() => vi.fn());
const mockGetPty = vi.hoisted(() => vi.fn());
const mockSerializeTerminalToObject = vi.hoisted(() => vi.fn());
+const mockSerializeTerminalToText = vi.hoisted(() =>
+ vi.fn((terminal: pkg.Terminal): string => {
+ const buffer = terminal.buffer.active;
+ const lines: string[] = [];
+
+ for (let i = 0; i < buffer.length; i++) {
+ const line = buffer.getLine(i);
+ const lineContent = line ? line.translateToString(true) : '';
+
+ if (line?.isWrapped && lines.length > 0) {
+ lines[lines.length - 1] += lineContent;
+ continue;
+ }
+
+ lines.push(lineContent);
+ }
+
+ return lines.join('\n').trimEnd();
+ }),
+);
const mockGetShellConfiguration = vi.hoisted(() =>
vi.fn().mockReturnValue({
executable: 'bash',
@@ -74,6 +94,7 @@ vi.mock('../utils/getPty.js', () => ({
}));
vi.mock('../utils/terminalSerializer.js', () => ({
serializeTerminalToObject: mockSerializeTerminalToObject,
+ serializeTerminalToText: mockSerializeTerminalToText,
}));
vi.mock('../utils/shell-utils.js', () => ({
getShellConfiguration: mockGetShellConfiguration,
@@ -122,6 +143,17 @@ const createExpectedAnsiOutput = (text: string | string[]): AnsiOutput => {
return expected;
};
+const createAnsiToken = (text: string) => ({
+ text,
+ bold: false,
+ italic: false,
+ underline: false,
+ dim: false,
+ inverse: false,
+ fg: '',
+ bg: '',
+});
+
const setupConflictingPathEnv = () => {
process.env = {
...originalProcessEnv,
@@ -135,6 +167,21 @@ const expectNormalizedWindowsPathEnv = (env: NodeJS.ProcessEnv) => {
expect(env['Path']).toBeUndefined();
};
+const waitForDataEventCount = async (
+ onOutputEventMock: Mock<(event: ShellOutputEvent) => void>,
+ expectedCount: number,
+) => {
+ for (let attempt = 0; attempt < 20; attempt++) {
+ const dataEvents = onOutputEventMock.mock.calls.filter(
+ ([event]) => event.type === 'data',
+ );
+ if (dataEvents.length >= expectedCount) {
+ return;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ }
+};
+
describe('ShellExecutionService', () => {
let mockPtyProcess: EventEmitter & {
pid: number;
@@ -364,6 +411,23 @@ describe('ShellExecutionService', () => {
expect(result.output).toBe('Compressing objects: 100% (7/7), done.');
});
+
+ it('should not persist narrow terminal soft wraps as transcript newlines', async () => {
+ const { result } = await simulateExecution(
+ 'narrow-output',
+ (pty) => {
+ pty.onData.mock.calls[0][0]('abcdefghijklmnopqrstuvwxyz\nshort\n');
+ pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
+ },
+ {
+ ...shellExecutionConfig,
+ terminalWidth: 8,
+ terminalHeight: 4,
+ },
+ );
+
+ expect(result.output).toBe('abcdefghijklmnopqrstuvwxyz\nshort');
+ });
});
describe('pty interaction', () => {
@@ -709,6 +773,59 @@ describe('ShellExecutionService', () => {
);
});
+ it('does not re-emit live output when only soft-wrap segmentation changes', async () => {
+ const coloredShellExecutionConfig = {
+ ...shellExecutionConfig,
+ showColor: true,
+ disableDynamicLineTrimming: true,
+ };
+ const firstWrappedOutput = [
+ [createAnsiToken('abcd')],
+ [createAnsiToken('efgh')],
+ ];
+ const rewrappedOutput = [
+ [createAnsiToken('ab')],
+ [createAnsiToken('cdef')],
+ [createAnsiToken('gh')],
+ ];
+ const logicalOutput = [[createAnsiToken('abcdefgh')]];
+ let rawRenderCount = 0;
+
+ mockSerializeTerminalToObject.mockImplementation(
+ (
+ _terminal,
+ _scrollOffset,
+ options?: { unwrapWrappedLines?: boolean },
+ ) => {
+ if (options?.unwrapWrappedLines) {
+ return logicalOutput;
+ }
+
+ rawRenderCount += 1;
+ return rawRenderCount === 1 ? firstWrappedOutput : rewrappedOutput;
+ },
+ );
+
+ await simulateExecution(
+ 'narrow-output',
+ (pty) => {
+ pty.onData.mock.calls[0][0]('abcdefgh');
+ pty.onData.mock.calls[0][0]('\r');
+ pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
+ },
+ coloredShellExecutionConfig,
+ );
+
+ const dataEvents = onOutputEventMock.mock.calls.filter(
+ ([event]) => event.type === 'data',
+ );
+ expect(dataEvents).toHaveLength(1);
+ expect(dataEvents[0][0]).toEqual({
+ type: 'data',
+ chunk: firstWrappedOutput,
+ });
+ });
+
it('should call onOutputEvent with AnsiOutput when showColor is false', async () => {
await simulateExecution(
'ls --color=auto',
@@ -733,6 +850,47 @@ describe('ShellExecutionService', () => {
);
});
+ it('does not re-emit default plain live output when only soft-wrap segmentation changes', async () => {
+ const abortController = new AbortController();
+ const handle = await ShellExecutionService.execute(
+ 'narrow-output',
+ '/test/dir',
+ onOutputEventMock,
+ abortController.signal,
+ true,
+ {
+ ...shellExecutionConfig,
+ terminalWidth: 4,
+ terminalHeight: 4,
+ showColor: false,
+ disableDynamicLineTrimming: false,
+ },
+ );
+
+ await new Promise((resolve) => process.nextTick(resolve));
+ mockPtyProcess.onData.mock.calls[0][0]('abcdefgh');
+ await waitForDataEventCount(onOutputEventMock, 1);
+
+ ShellExecutionService.resizePty(handle.pid!, 2, 4);
+ mockPtyProcess.onData.mock.calls[0][0]('\r');
+ mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
+ await handle.result;
+
+ const dataEvents = onOutputEventMock.mock.calls.filter(
+ ([event]) => event.type === 'data',
+ );
+ expect(dataEvents).toHaveLength(1);
+ const firstDataEvent = dataEvents[0][0];
+ if (firstDataEvent.type !== 'data') {
+ throw new Error('Expected a shell data event.');
+ }
+ const chunk = firstDataEvent.chunk as AnsiOutput;
+ expect(chunk.map((line) => line[0]?.text).filter(Boolean)).toEqual([
+ 'abcd',
+ 'efgh',
+ ]);
+ });
+
it('should handle multi-line output correctly when showColor is false', async () => {
await simulateExecution(
'ls --color=auto',
diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts
index 113ddb5a2a..06a6288816 100644
--- a/packages/core/src/services/shellExecutionService.ts
+++ b/packages/core/src/services/shellExecutionService.ts
@@ -17,6 +17,7 @@ import { getShellConfiguration } from '../utils/shell-utils.js';
import pkg from '@xterm/headless';
import {
serializeTerminalToObject,
+ serializeTerminalToText,
type AnsiOutput,
} from '../utils/terminalSerializer.js';
import { normalizePathEnvForWindows } from '../utils/windowsPath.js';
@@ -135,17 +136,6 @@ const isExpectedPtyExitRaceError = (error: unknown): boolean => {
);
};
-const getFullBufferText = (terminal: pkg.Terminal): string => {
- const buffer = terminal.buffer.active;
- const lines: string[] = [];
- for (let i = 0; i < buffer.length; i++) {
- const line = buffer.getLine(i);
- const lineContent = line ? line.translateToString(true) : '';
- lines.push(lineContent);
- }
- return lines.join('\n').trimEnd();
-};
-
const replayTerminalOutput = async (
output: string,
cols: number,
@@ -163,7 +153,90 @@ const replayTerminalOutput = async (
replayTerminal.write(output, () => resolve());
});
- return getFullBufferText(replayTerminal);
+ return serializeTerminalToText(replayTerminal);
+};
+
+const getLastNonEmptyAnsiLineIndex = (output: AnsiOutput): number => {
+ for (let i = output.length - 1; i >= 0; i--) {
+ const line = output[i];
+ if (
+ line
+ .map((segment) => segment.text)
+ .join('')
+ .trim().length > 0
+ ) {
+ return i;
+ }
+ }
+
+ return -1;
+};
+
+const trimTrailingEmptyAnsiLines = (output: AnsiOutput): AnsiOutput =>
+ output.slice(0, getLastNonEmptyAnsiLineIndex(output) + 1);
+
+const areAnsiOutputsEqual = (
+ left: AnsiOutput | null,
+ right: AnsiOutput,
+): boolean => {
+ if (!left || left.length !== right.length) {
+ return false;
+ }
+
+ return left.every((leftLine, lineIndex) => {
+ const rightLine = right[lineIndex];
+ if (leftLine.length !== rightLine.length) {
+ return false;
+ }
+
+ return leftLine.every((leftToken, tokenIndex) => {
+ const rightToken = rightLine[tokenIndex];
+ return (
+ leftToken.text === rightToken.text &&
+ leftToken.bold === rightToken.bold &&
+ leftToken.italic === rightToken.italic &&
+ leftToken.underline === rightToken.underline &&
+ leftToken.dim === rightToken.dim &&
+ leftToken.inverse === rightToken.inverse &&
+ leftToken.fg === rightToken.fg &&
+ leftToken.bg === rightToken.bg
+ );
+ });
+ });
+};
+
+const createPlainAnsiLine = (text: string) => [
+ {
+ text,
+ bold: false,
+ italic: false,
+ underline: false,
+ dim: false,
+ inverse: false,
+ fg: '',
+ bg: '',
+ },
+];
+
+const serializePlainViewportToAnsiOutput = (
+ terminal: pkg.Terminal,
+ unwrapWrappedLines = false,
+): AnsiOutput => {
+ const buffer = terminal.buffer.active;
+ const lines: AnsiOutput = [];
+
+ for (let y = 0; y < terminal.rows; y++) {
+ const line = buffer.getLine(buffer.viewportY + y);
+ const lineContent = line ? line.translateToString(true) : '';
+
+ if (unwrapWrappedLines && line?.isWrapped && lines.length > 0) {
+ lines[lines.length - 1][0].text += lineContent;
+ } else {
+ lines.push(createPlainAnsiLine(lineContent));
+ }
+ }
+
+ return lines;
};
interface ProcessCleanupStrategy {
@@ -536,7 +609,7 @@ export class ShellExecutionService {
let processingChain = Promise.resolve();
let decoder: TextDecoder | null = null;
- let output: string | AnsiOutput | null = null;
+ let outputComparison: AnsiOutput | null = null;
const outputChunks: Buffer[] = [];
const error: Error | null = null;
let exited = false;
@@ -558,7 +631,7 @@ export class ShellExecutionService {
if (!shellExecutionConfig.disableDynamicLineTrimming) {
if (!hasStartedOutput) {
- const bufferText = getFullBufferText(headlessTerminal);
+ const bufferText = serializeTerminalToText(headlessTerminal);
if (bufferText.trim().length === 0) {
return;
}
@@ -567,53 +640,36 @@ export class ShellExecutionService {
}
let newOutput: AnsiOutput;
+ let newOutputComparison: AnsiOutput;
if (shellExecutionConfig.showColor) {
newOutput = serializeTerminalToObject(headlessTerminal);
+ newOutputComparison = serializeTerminalToObject(
+ headlessTerminal,
+ 0,
+ { unwrapWrappedLines: true },
+ );
} else {
- const buffer = headlessTerminal.buffer.active;
- const lines: AnsiOutput = [];
- for (let y = 0; y < headlessTerminal.rows; y++) {
- const line = buffer.getLine(buffer.viewportY + y);
- const lineContent = line ? line.translateToString(true) : '';
- lines.push([
- {
- text: lineContent,
- bold: false,
- italic: false,
- underline: false,
- dim: false,
- inverse: false,
- fg: '',
- bg: '',
- },
- ]);
- }
- newOutput = lines;
- }
-
- let lastNonEmptyLine = -1;
- for (let i = newOutput.length - 1; i >= 0; i--) {
- const line = newOutput[i];
- if (
- line
- .map((segment) => segment.text)
- .join('')
- .trim().length > 0
- ) {
- lastNonEmptyLine = i;
- break;
- }
+ newOutput = serializePlainViewportToAnsiOutput(headlessTerminal);
+ newOutputComparison = serializePlainViewportToAnsiOutput(
+ headlessTerminal,
+ true,
+ );
}
- const trimmedOutput = newOutput.slice(0, lastNonEmptyLine + 1);
+ const trimmedOutput = trimTrailingEmptyAnsiLines(newOutput);
+ const trimmedOutputComparison =
+ trimTrailingEmptyAnsiLines(newOutputComparison);
const finalOutput = shellExecutionConfig.disableDynamicLineTrimming
? newOutput
: trimmedOutput;
+ const finalOutputComparison = shellExecutionConfig
+ .disableDynamicLineTrimming
+ ? newOutputComparison
+ : trimmedOutputComparison;
- // Using stringify for a quick deep comparison.
- if (JSON.stringify(output) !== JSON.stringify(finalOutput)) {
- output = finalOutput;
+ if (!areAnsiOutputsEqual(outputComparison, finalOutputComparison)) {
+ outputComparison = finalOutputComparison;
onOutputEvent({
type: 'data',
chunk: finalOutput,
@@ -759,11 +815,11 @@ export class ShellExecutionService {
rows,
);
} else {
- fullOutput = getFullBufferText(headlessTerminal);
+ fullOutput = serializeTerminalToText(headlessTerminal);
}
} catch {
try {
- fullOutput = getFullBufferText(headlessTerminal);
+ fullOutput = serializeTerminalToText(headlessTerminal);
} catch {
// Ignore fallback rendering errors and resolve with empty text.
}
diff --git a/packages/core/src/utils/terminalSerializer.test.ts b/packages/core/src/utils/terminalSerializer.test.ts
index fd6241d04d..fe3123beff 100644
--- a/packages/core/src/utils/terminalSerializer.test.ts
+++ b/packages/core/src/utils/terminalSerializer.test.ts
@@ -8,6 +8,7 @@ import { describe, it, expect } from 'vitest';
import { Terminal } from '@xterm/headless';
import {
serializeTerminalToObject,
+ serializeTerminalToText,
convertColorToHex,
ColorMode,
} from './terminalSerializer.js';
@@ -171,7 +172,80 @@ describe('terminalSerializer', () => {
expect(result[0][0].bg).toBe('#008000');
expect(result[0][0].text).toBe('Styled text');
});
+
+ it('can unwrap soft-wrapped ANSI rows for live output comparison', async () => {
+ const terminal = new Terminal({
+ cols: 8,
+ rows: 4,
+ allowProposedApi: true,
+ scrollback: 100,
+ convertEol: true,
+ });
+
+ await writeToTerminal(terminal, 'abcdefghijkl\nshort\n');
+
+ const result = serializeTerminalToObject(terminal, 0, {
+ unwrapWrappedLines: true,
+ });
+ const visibleText = result
+ .map((line) => line.map((token) => token.text).join('').trimEnd())
+ .filter(Boolean);
+
+ expect(visibleText).toEqual(['abcdefghijkl', 'short']);
+ expect(result[0]).toHaveLength(1);
+ });
+ });
+
+ describe('serializeTerminalToText', () => {
+ it('unwraps soft-wrapped narrow terminal lines for transcript text', async () => {
+ const terminal = new Terminal({
+ cols: 10,
+ rows: 4,
+ allowProposedApi: true,
+ scrollback: 100,
+ convertEol: true,
+ });
+
+ await writeToTerminal(terminal, 'abcdefghijklmnopqrstuvwxyz\n');
+
+ expect(serializeTerminalToText(terminal)).toBe(
+ 'abcdefghijklmnopqrstuvwxyz',
+ );
+ });
+
+ it('keeps explicit newlines while unwrapping visual continuation rows', async () => {
+ const terminal = new Terminal({
+ cols: 8,
+ rows: 4,
+ allowProposedApi: true,
+ scrollback: 100,
+ convertEol: true,
+ });
+
+ await writeToTerminal(terminal, 'abcdefghijkl\nshort\n');
+
+ expect(serializeTerminalToText(terminal)).toBe('abcdefghijkl\nshort');
+ });
+
+ it('does not treat resize reflow as duplicated transcript lines', async () => {
+ const terminal = new Terminal({
+ cols: 12,
+ rows: 4,
+ allowProposedApi: true,
+ scrollback: 100,
+ convertEol: true,
+ });
+
+ await writeToTerminal(terminal, 'abcdefghijklmnopqrstuvwxyz\n123456\n');
+ terminal.resize(6, 4);
+ await writeToTerminal(terminal, 'done\n');
+
+ expect(serializeTerminalToText(terminal)).toBe(
+ 'abcdefghijklmnopqrstuvwxyz\n123456\ndone',
+ );
+ });
});
+
describe('convertColorToHex', () => {
it('should convert RGB color to hex', () => {
const color = (100 << 16) | (200 << 8) | 50;
diff --git a/packages/core/src/utils/terminalSerializer.ts b/packages/core/src/utils/terminalSerializer.ts
index e12fe25aa5..94f85a5c37 100644
--- a/packages/core/src/utils/terminalSerializer.ts
+++ b/packages/core/src/utils/terminalSerializer.ts
@@ -5,6 +5,7 @@
*/
import type { IBufferCell, Terminal } from '@xterm/headless';
+
export interface AnsiToken {
text: string;
bold: boolean;
@@ -19,6 +20,49 @@ export interface AnsiToken {
export type AnsiLine = AnsiToken[];
export type AnsiOutput = AnsiLine[];
+export interface SerializeTerminalToObjectOptions {
+ unwrapWrappedLines?: boolean;
+}
+
+const canMergeAnsiTokens = (left: AnsiToken, right: AnsiToken): boolean =>
+ left.bold === right.bold &&
+ left.italic === right.italic &&
+ left.underline === right.underline &&
+ left.dim === right.dim &&
+ left.inverse === right.inverse &&
+ left.fg === right.fg &&
+ left.bg === right.bg;
+
+const appendAnsiLineTokens = (target: AnsiLine, source: AnsiLine) => {
+ for (const token of source) {
+ const previous = target[target.length - 1];
+ if (previous && canMergeAnsiTokens(previous, token)) {
+ previous.text += token.text;
+ } else {
+ target.push({ ...token });
+ }
+ }
+};
+
+export function serializeTerminalToText(terminal: Terminal): string {
+ const buffer = terminal.buffer.active;
+ const lines: string[] = [];
+
+ for (let i = 0; i < buffer.length; i++) {
+ const line = buffer.getLine(i);
+ const lineContent = line ? line.translateToString(true) : '';
+
+ if (line?.isWrapped && lines.length > 0) {
+ lines[lines.length - 1] += lineContent;
+ continue;
+ }
+
+ lines.push(lineContent);
+ }
+
+ return lines.join('\n').trimEnd();
+}
+
const enum Attribute {
inverse = 1,
bold = 2,
@@ -134,6 +178,7 @@ class Cell {
export function serializeTerminalToObject(
terminal: Terminal,
scrollOffset: number = 0,
+ options: SerializeTerminalToObjectOptions = {},
): AnsiOutput {
const buffer = terminal.buffer.active;
const defaultFg = '';
@@ -199,7 +244,11 @@ export function serializeTerminalToObject(
currentLine.push(token);
}
- result.push(currentLine);
+ if (options.unwrapWrappedLines && line.isWrapped && result.length > 0) {
+ appendAnsiLineTokens(result[result.length - 1], currentLine);
+ } else {
+ result.push(currentLine);
+ }
}
return result;