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;