diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.test.ts b/packages/ui/src/components/chat/message/parts/ToolPart.test.ts index d31d076e..2042f014 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.test.ts +++ b/packages/ui/src/components/chat/message/parts/ToolPart.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test'; +import { renderTerminalOutput } from './toolOutput'; import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser'; import { tryParseJsonOutput } from '../toolRenderers'; import { getStreamingThrottleText } from '../../hooks/useStreamingTextThrottle'; @@ -7,18 +8,73 @@ import { getStreamingOutputAppend, getToolOutput } from './toolOutput'; import { getToolDescriptionFallback } from './toolRenderUtils'; describe('getToolOutput', () => { - test('prefers authoritative state output', () => { - expect(getToolOutput('bash', 'final output', 'streamed output')).toBe('final output'); - expect(getToolOutput('bash', '', 'streamed output')).toBe(''); + test('prefers state.output for completed tools', () => { + expect(getToolOutput('bash', 'final output', 'partial output', 'completed')).toBe('final output'); }); - test('falls back to streamed metadata output for bash', () => { - expect(getToolOutput('bash', undefined, 'streamed output')).toBe('streamed output'); - expect(getToolOutput('bash', undefined, '')).toBe(undefined); + test('normalizes completed bash state output while preserving final-output precedence', () => { + expect(getToolOutput('bash', '\u001B[32mFinal output\u001B[0m', 'partial output', 'completed')).toBe('Final output'); }); - test('does not expose metadata output for other tools', () => { - expect(getToolOutput('read', undefined, 'metadata output')).toBe(undefined); + test('falls back to metadata.output for bash tools without state output', () => { + expect(getToolOutput('bash', undefined, 'partial output', 'completed')).toBe('partial output'); + }); + + test('normalizes bash metadata output for completed state', () => { + expect(getToolOutput('bash', undefined, 'Progress 10%\r\u001B[2KProgress 90%', 'completed')).toBe('Progress 90%'); + }); + + test('does not normalize bash output while running', () => { + expect(getToolOutput('bash', '\u001B[32mRunning\u001B[0m', undefined, 'running')).toBe('\u001B[32mRunning\u001B[0m'); + expect(getToolOutput('bash', undefined, 'Progress\r\u001B[2K', 'running')).toBe('Progress\r\u001B[2K'); + }); + + test('ignores metadata.output for non-bash tools', () => { + expect(getToolOutput('read', undefined, 'partial output', 'completed')).toBe(undefined); + expect(getToolOutput('read', 'final output', 'partial output', 'completed')).toBe('final output'); + }); + + test('returns undefined when bash has no output', () => { + expect(getToolOutput('bash', undefined, undefined, 'completed')).toBe(undefined); + }); + + test('ignores empty metadata.output for bash', () => { + expect(getToolOutput('bash', undefined, '', 'completed')).toBe(undefined); + }); +}); + +describe('renderTerminalOutput', () => { + test('renders carriage-return progress updates as their latest value', () => { + expect(renderTerminalOutput('Downloading 10%\r\u001B[2KDownloading 90%')).toBe('Downloading 90%'); + }); + + test('removes ANSI styles while preserving the output text', () => { + expect(renderTerminalOutput('\u001B[32mComplete\u001B[0m\n')).toBe('Complete\n'); + }); + + test('applies cursor-up progress updates to the prior line', () => { + expect(renderTerminalOutput('First\nWorking\u001B[1A\r\u001B[2KDone\n')).toBe('Done\nWorking'); + }); + + test('CSI K erases from cursor to end of line', () => { + expect(renderTerminalOutput('Hello World\u001B[5G\u001B[K')).toBe('Hell'); + }); + + test('CSI 1 K erases from beginning of line through cursor, preserving suffix', () => { + expect(renderTerminalOutput('Hello World\u001B[6G\u001B[1K')).toBe(' World'); + }); + + test('CSI 2 K erases entire line', () => { + expect(renderTerminalOutput('Hello World\u001B[2K')).toBe(''); + }); + + test('handles large single-line output without quadratic slowdown', () => { + const largeLine = 'A'.repeat(50000) + '\u001B[0m'; + const start = performance.now(); + const result = renderTerminalOutput(largeLine); + const elapsed = performance.now() - start; + expect(result).toBe('A'.repeat(50000)); + expect(elapsed).toBeLessThan(1000); }); }); diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index b703c356..612ab076 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -1596,7 +1596,7 @@ const ToolExpandedContent: React.FC = React.memo(({ const stateWithData = state as ToolStateWithMetadata; const metadata = stateWithData.metadata; const input = stateWithData.input; - const rawOutput = getToolOutput(part.tool, stateWithData.output, metadata?.output); + const rawOutput = getToolOutput(part.tool, stateWithData.output, metadata?.output, state.status); const hasStringOutput = typeof rawOutput === 'string' && rawOutput.length > 0; const rawOutputString = typeof rawOutput === 'string' ? rawOutput : ''; const isStreamingBash = part.tool === 'bash' && state.status === 'running'; diff --git a/packages/ui/src/components/chat/message/parts/toolOutput.ts b/packages/ui/src/components/chat/message/parts/toolOutput.ts index 88d1d0ab..5eb8dc91 100644 --- a/packages/ui/src/components/chat/message/parts/toolOutput.ts +++ b/packages/ui/src/components/chat/message/parts/toolOutput.ts @@ -1,14 +1,133 @@ +const ensureLine = (lines: string[][], row: number): void => { + while (lines.length <= row) { + lines.push([]); + } +}; + +const writeTerminalCharacter = (lines: string[][], row: number, column: number, character: string): void => { + ensureLine(lines, row); + const line = lines[row]; + while (line.length < column) { + line.push(' '); + } + line[column] = character; +}; + +export const renderTerminalOutput = (output: string): string => { + if (!output.includes('\u001B') && !output.includes('\r') && !output.includes('\b')) { + return output; + } + + const lines: string[][] = [[]]; + let row = 0; + let column = 0; + + for (let index = 0; index < output.length; index += 1) { + const character = output[index]; + + if (character === '\n') { + row += 1; + column = 0; + ensureLine(lines, row); + continue; + } + if (character === '\r') { + column = 0; + continue; + } + if (character === '\b') { + column = Math.max(0, column - 1); + continue; + } + if (character !== '\u001B') { + writeTerminalCharacter(lines, row, column, character); + column += 1; + continue; + } + + const nextCharacter = output[index + 1]; + if (nextCharacter === '[') { + const sequenceStart = index + 2; + let sequenceEnd = sequenceStart; + while (sequenceEnd < output.length && !/[\x40-\x7E]/.test(output[sequenceEnd])) { + sequenceEnd += 1; + } + if (sequenceEnd === output.length) { + break; + } + + const command = output[sequenceEnd]; + const parameters = output.slice(sequenceStart, sequenceEnd).split(';').map((value) => Number.parseInt(value, 10) || 0); + const count = parameters[0] || 1; + if (command === 'A') { + row = Math.max(0, row - count); + } else if (command === 'B') { + row += count; + ensureLine(lines, row); + } else if (command === 'C') { + column += count; + } else if (command === 'D') { + column = Math.max(0, column - count); + } else if (command === 'G') { + column = Math.max(0, count - 1); + } else if (command === 'H' || command === 'f') { + row = Math.max(0, (parameters[0] || 1) - 1); + column = Math.max(0, (parameters[1] || 1) - 1); + ensureLine(lines, row); + } else if (command === 'K') { + ensureLine(lines, row); + const line = lines[row]; + const mode = parameters[0]; + if (mode === 1) { + for (let i = 0; i <= column && i < line.length; i += 1) { + line[i] = ' '; + } + } else if (mode === 2) { + lines[row] = []; + } else { + line.length = Math.min(line.length, column); + } + } + index = sequenceEnd; + continue; + } + + if (nextCharacter === ']') { + const terminator = output.indexOf('\u0007', index + 2); + const stringTerminator = output.indexOf('\u001B\\', index + 2); + const end = terminator === -1 + ? stringTerminator + : stringTerminator === -1 + ? terminator + : Math.min(terminator, stringTerminator); + if (end === -1) { + break; + } + index = output[end] === '\u0007' ? end : end + 1; + continue; + } + + index += 1; + } + + return lines.map((line) => line.join('')).join('\n'); +}; + export const getToolOutput = ( tool: string, stateOutput: unknown, metadataOutput: unknown, + status?: string, ): string | undefined => { + const isBash = tool === 'bash'; + const shouldNormalize = isBash && status !== 'running'; + if (typeof stateOutput === 'string') { - return stateOutput; + return shouldNormalize ? renderTerminalOutput(stateOutput) : stateOutput; } - if (tool === 'bash' && typeof metadataOutput === 'string' && metadataOutput.length > 0) { - return metadataOutput; + if (isBash && typeof metadataOutput === 'string' && metadataOutput.length > 0) { + return shouldNormalize ? renderTerminalOutput(metadataOutput) : metadataOutput; } return undefined;