Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions packages/cli/src/ui/hooks/shellCommandProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -220,7 +228,10 @@ export const useShellCommandProcessor = (
resultDisplay:
typeof currentDisplayOutput === 'string'
? currentDisplayOutput
: { ansiOutput: currentDisplayOutput },
: {
ansiOutput:
currentDisplayOutput as AnsiOutput,
},
}
: tool,
),
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 5 additions & 3 deletions packages/core/src/tools/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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)'}`,
Expand All @@ -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
Expand Down
122 changes: 122 additions & 0 deletions packages/core/src/utils/controlCharSanitizer.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
60 changes: 60 additions & 0 deletions packages/core/src/utils/controlCharSanitizer.ts
Original file line number Diff line number Diff line change
@@ -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),
})),
);
}
12 changes: 11 additions & 1 deletion packages/core/src/utils/terminalSerializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import type { IBufferCell, Terminal } from '@xterm/headless';
import { sanitizeTerminalOutput } from './controlCharSanitizer.js';
export interface AnsiToken {
text: string;
bold: boolean;
Expand Down Expand Up @@ -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
Expand Down