diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.ts b/packages/cli/src/ui/hooks/shellCommandProcessor.ts index 65037942b9..0e69e38ccb 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.ts @@ -30,6 +30,10 @@ import path from 'node:path'; import os from 'node:os'; import fs from 'node:fs'; import { themeManager } from '../../ui/themes/theme-manager.js'; +import { + sanitizeTerminalOutput, + sanitizeAnsiOutput, +} from '../../../../core/src/utils/controlCharSanitizer.js'; export const OUTPUT_UPDATE_INTERVAL_MS = 1000; const MAX_OUTPUT_LENGTH = 10000; @@ -201,7 +205,11 @@ export const useShellCommandProcessor = ( '[Binary output detected. Halting stream...]'; } } else { - currentDisplayOutput = cumulativeStdout; + // Sanitize control characters to prevent rendering issues + currentDisplayOutput = + typeof cumulativeStdout === 'string' + ? sanitizeTerminalOutput(cumulativeStdout) + : { ansiOutput: sanitizeAnsiOutput(cumulativeStdout) }; } // Throttle pending UI updates, but allow forced updates. @@ -220,7 +228,10 @@ export const useShellCommandProcessor = ( resultDisplay: typeof currentDisplayOutput === 'string' ? currentDisplayOutput - : { ansiOutput: currentDisplayOutput }, + : { + ansiOutput: + currentDisplayOutput as AnsiOutput, + }, } : tool, ), @@ -264,8 +275,10 @@ export const useShellCommandProcessor = ( mainContent = '[Command produced binary output, which is not shown.]'; } else { + // Sanitize control characters in final output mainContent = - result.output.trim() || '(Command produced no output)'; + sanitizeTerminalOutput(result.output).trim() || + '(Command produced no output)'; } let finalOutput = mainContent; diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index e55d03626e..e88fb15b3e 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -42,6 +42,7 @@ import { stripShellWrapper, } from '../utils/shell-utils.js'; import { createDebugLogger } from '../utils/debugLogger.js'; +import { sanitizeTerminalOutput } from '../utils/controlCharSanitizer.js'; const debugLogger = createDebugLogger('SHELL'); @@ -323,7 +324,7 @@ export class ShellToolInvocation extends BaseToolInvocation< llmContent = [ `Command: ${this.params.command}`, `Directory: ${this.params.directory || '(root)'}`, - `Output: ${result.output || '(empty)'}`, + `Output: ${sanitizeTerminalOutput(result.output) || '(empty)'}`, `Error: ${finalError}`, // Use the cleaned error string. `Exit Code: ${result.exitCode ?? '(none)'}`, `Signal: ${result.signal ?? '(none)'}`, @@ -338,8 +339,9 @@ export class ShellToolInvocation extends BaseToolInvocation< if (this.config.getDebugMode()) { returnDisplayMessage = llmContent; } else { - if (result.output.trim()) { - returnDisplayMessage = result.output; + const sanitizedOutput = sanitizeTerminalOutput(result.output); + if (sanitizedOutput.trim()) { + returnDisplayMessage = sanitizedOutput; } else { if (result.aborted) { // Check if it was a timeout or user cancellation diff --git a/packages/core/src/utils/controlCharSanitizer.test.ts b/packages/core/src/utils/controlCharSanitizer.test.ts new file mode 100644 index 0000000000..3a295320b5 --- /dev/null +++ b/packages/core/src/utils/controlCharSanitizer.test.ts @@ -0,0 +1,122 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + sanitizeTerminalOutput, + sanitizeAnsiOutput, +} from './controlCharSanitizer.js'; + +describe('sanitizeTerminalOutput', () => { + it('should handle empty strings', () => { + expect(sanitizeTerminalOutput('')).toBe(''); + expect(sanitizeTerminalOutput(null as unknown as string)).toBeNull(); + }); + + it('should normalize Windows line endings', () => { + expect(sanitizeTerminalOutput('line1\r\nline2\r\nline3')).toBe( + 'line1\nline2\nline3', + ); + }); + + it('should handle standalone carriage returns', () => { + expect(sanitizeTerminalOutput('progress:\rupdating...')).toBe( + 'progress:\nupdating...', + ); + }); + + it('should preserve tabs and newlines', () => { + expect(sanitizeTerminalOutput('col1\tcol2\nrow2')).toBe('col1\tcol2\nrow2'); + }); + + it('should remove NULL characters', () => { + expect(sanitizeTerminalOutput('text\x00more')).toBe('textmore'); + }); + + it('should remove backspace characters', () => { + expect(sanitizeTerminalOutput('text\x08more')).toBe('textmore'); + }); + + it('should remove vertical tab and form feed', () => { + expect(sanitizeTerminalOutput('text\x0B\x0Cmore')).toBe('textmore'); + }); + + it('should remove escape sequences', () => { + expect(sanitizeTerminalOutput('text\x0E\x1Fmore')).toBe('textmore'); + }); + + it('should handle mixed control characters', () => { + expect( + sanitizeTerminalOutput('line1\r\n\x00line2\rupdating\x08fixed'), + ).toBe('line1\nline2\nupdatingfixed'); + }); + + it('should handle real-world command output', () => { + const windowsOutput = + 'C:\\Users>dir\r\n Volume in drive C has no label.\r\n\r\n Directory of C:\\Users'; + expect(sanitizeTerminalOutput(windowsOutput)).toBe( + 'C:\\Users>dir\n Volume in drive C has no label.\n\n Directory of C:\\Users', + ); + }); +}); + +describe('sanitizeAnsiOutput', () => { + it('should handle empty AnsiOutput', () => { + expect(sanitizeAnsiOutput([])).toEqual([]); + }); + + it('should sanitize text tokens', () => { + const input = [ + [ + { + text: 'line1\r\nline2', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + fg: '', + bg: '', + }, + { + text: '\ttabbed', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + fg: '', + bg: '', + }, + ], + ]; + const expected = [ + [ + { + text: 'line1\nline2', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + fg: '', + bg: '', + }, + { + text: '\ttabbed', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + fg: '', + bg: '', + }, + ], + ]; + expect(sanitizeAnsiOutput(input)).toEqual(expected); + }); +}); diff --git a/packages/core/src/utils/controlCharSanitizer.ts b/packages/core/src/utils/controlCharSanitizer.ts new file mode 100644 index 0000000000..4a7fe73ef3 --- /dev/null +++ b/packages/core/src/utils/controlCharSanitizer.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Sanitizes terminal output by handling control characters that can cause + * rendering issues in Ink-based UIs. + * + * This handles carriage returns and other control characters that can cause + * display issues, inspired by Claude Code's approach to terminal output handling. + * + * @param output - Raw terminal output string + * @returns Sanitized output safe for Ink rendering + */ +export function sanitizeTerminalOutput(output: string): string { + if (!output) { + return output; + } + + let sanitized = output; + + // Step 1: Normalize Windows-style line endings (\r\n) to Unix-style (\n) + sanitized = sanitized.replace(/\r\n/g, '\n'); + + // Step 2: Handle standalone \r (carriage return without newline) + // A standalone \r moves cursor to beginning of line without advancing + // We convert it to \n to preserve line structure + sanitized = sanitized.replace(/\r(?!\n)/g, '\n'); + + // Step 3: Remove other problematic control characters + // Keep \t (tab) and \n (newline) as they're safe for Ink + // Remove: \x00-\x08 (NULL-BS), \x0B (\v), \x0C (\f), \x0E-\x1F (SO-US) + // eslint-disable-next-line no-control-regex + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, ''); + + return sanitized; +} + +/** + * Sanitizes an AnsiOutput object's text tokens. + * + * @param ansiOutput - Raw AnsiOutput from terminal serializer + * @returns Sanitized AnsiOutput safe for Ink rendering + */ +export function sanitizeAnsiOutput( + ansiOutput: import('./terminalSerializer').AnsiOutput, +): import('./terminalSerializer').AnsiOutput { + if (!ansiOutput) { + return ansiOutput; + } + + return ansiOutput.map((line) => + line.map((token) => ({ + ...token, + text: sanitizeTerminalOutput(token.text), + })), + ); +} diff --git a/packages/core/src/utils/terminalSerializer.ts b/packages/core/src/utils/terminalSerializer.ts index 7bcd2a4ce6..ee5ba364b1 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'; +import { sanitizeTerminalOutput } from './controlCharSanitizer.js'; export interface AnsiToken { text: string; bold: boolean; @@ -193,7 +194,16 @@ export function serializeTerminalToObject(terminal: Terminal): AnsiOutput { result.push(currentLine); } - return result; + // Sanitize all text tokens to remove problematic control characters + const sanitizedResult: import('./terminalSerializer').AnsiOutput = result.map( + (line) => + line.map((token) => ({ + ...token, + text: sanitizeTerminalOutput(token.text), + })), + ); + + return sanitizedResult; } // ANSI color palette from https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit