diff --git a/package-lock.json b/package-lock.json index 3326ebfb4f..671c9d45a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5300,9 +5300,9 @@ } }, "node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" @@ -17190,6 +17190,7 @@ "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.15.1", "@types/update-notifier": "^6.0.8", + "ansi-regex": "^6.2.2", "command-exists": "^1.2.9", "comment-json": "^4.2.5", "diff": "^7.0.0", @@ -17311,9 +17312,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } diff --git a/packages/cli/package.json b/packages/cli/package.json index 0d92cd05ab..a64c35138a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -33,6 +33,7 @@ "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.15.1", "@types/update-notifier": "^6.0.8", + "ansi-regex": "^6.2.2", "command-exists": "^1.2.9", "comment-json": "^4.2.5", "diff": "^7.0.0", @@ -47,8 +48,8 @@ "open": "^10.1.2", "react": "^19.1.0", "read-package-up": "^11.0.0", - "simple-git": "^3.28.0", "shell-quote": "^1.8.3", + "simple-git": "^3.28.0", "string-width": "^7.1.0", "strip-ansi": "^7.1.0", "strip-json-comments": "^3.1.1", diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx index 180d66c6a3..a5fc4558f6 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx @@ -7,14 +7,18 @@ import { render } from 'ink-testing-library'; import { describe, it, expect, vi } from 'vitest'; import { HistoryItemDisplay } from './HistoryItemDisplay.js'; -import type { HistoryItem } from '../types.js'; +import { type HistoryItem, ToolCallStatus } from '../types.js'; import { MessageType } from '../types.js'; import { SessionStatsProvider } from '../contexts/SessionContext.js'; -import type { Config } from '@google/gemini-cli-core'; +import type { + Config, + ToolExecuteConfirmationDetails, +} from '@google/gemini-cli-core'; +import { ToolGroupMessage } from './messages/ToolGroupMessage.js'; // Mock child components vi.mock('./messages/ToolGroupMessage.js', () => ({ - ToolGroupMessage: () =>
, + ToolGroupMessage: vi.fn(() =>
), })); describe('', () => { @@ -126,4 +130,64 @@ describe('', () => { ); expect(lastFrame()).toContain('Agent powering down. Goodbye!'); }); + + it('should escape ANSI codes in text content', () => { + const historyItem: HistoryItem = { + id: 1, + type: 'user', + text: 'Hello, \u001b[31mred\u001b[0m world!', + }; + + const { lastFrame } = render( + , + ); + + // The ANSI codes should be escaped for display. + expect(lastFrame()).toContain('Hello, \\u001b[31mred\\u001b[0m world!'); + // The raw ANSI codes should not be present. + expect(lastFrame()).not.toContain('Hello, \u001b[31mred\u001b[0m world!'); + }); + + it('should escape ANSI codes in tool confirmation details', () => { + const historyItem: HistoryItem = { + id: 1, + type: 'tool_group', + tools: [ + { + callId: '123', + name: 'run_shell_command', + description: 'Run a shell command', + resultDisplay: 'blank', + status: ToolCallStatus.Confirming, + confirmationDetails: { + type: 'exec', + title: 'Run Shell Command', + command: 'echo "\u001b[31mhello\u001b[0m"', + rootCommand: 'echo', + onConfirm: async () => {}, + }, + }, + ], + }; + + render( + , + ); + + const passedProps = vi.mocked(ToolGroupMessage).mock.calls[0][0]; + const confirmationDetails = passedProps.toolCalls[0] + .confirmationDetails as ToolExecuteConfirmationDetails; + + expect(confirmationDetails.command).toBe( + 'echo "\\u001b[31mhello\\u001b[0m"', + ); + }); }); diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index cee2895576..bfca4845b6 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -5,6 +5,8 @@ */ import type React from 'react'; +import { useMemo } from 'react'; +import { escapeAnsiCtrlCodes } from '../utils/textUtils.js'; import type { HistoryItem } from '../types.js'; import { UserMessage } from './messages/UserMessage.js'; import { UserShellMessage } from './messages/UserShellMessage.js'; @@ -45,60 +47,80 @@ export const HistoryItemDisplay: React.FC = ({ isFocused = true, activeShellPtyId, embeddedShellFocused, -}) => ( - - {/* Render standard message types */} - {item.type === 'user' && } - {item.type === 'user_shell' && } - {item.type === 'gemini' && ( - - )} - {item.type === 'gemini_content' && ( - - )} - {item.type === 'info' && } - {item.type === 'warning' && } - {item.type === 'error' && } - {item.type === 'about' && ( - - )} - {item.type === 'help' && commands && } - {item.type === 'stats' && } - {item.type === 'model_stats' && } - {item.type === 'tool_stats' && } - {item.type === 'quit' && } - {item.type === 'tool_group' && ( - - )} - {item.type === 'compression' && ( - - )} - {item.type === 'extensions_list' && } - -); +}) => { + const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]); + + return ( + + {/* Render standard message types */} + {itemForDisplay.type === 'user' && ( + + )} + {itemForDisplay.type === 'user_shell' && ( + + )} + {itemForDisplay.type === 'gemini' && ( + + )} + {itemForDisplay.type === 'gemini_content' && ( + + )} + {itemForDisplay.type === 'info' && ( + + )} + {itemForDisplay.type === 'warning' && ( + + )} + {itemForDisplay.type === 'error' && ( + + )} + {itemForDisplay.type === 'about' && ( + + )} + {itemForDisplay.type === 'help' && commands && ( + + )} + {itemForDisplay.type === 'stats' && ( + + )} + {itemForDisplay.type === 'model_stats' && } + {itemForDisplay.type === 'tool_stats' && } + {itemForDisplay.type === 'quit' && ( + + )} + {itemForDisplay.type === 'tool_group' && ( + + )} + {itemForDisplay.type === 'compression' && ( + + )} + {itemForDisplay.type === 'extensions_list' && } + + ); +}; diff --git a/packages/cli/src/ui/utils/textUtils.test.ts b/packages/cli/src/ui/utils/textUtils.test.ts new file mode 100644 index 0000000000..4adce119a6 --- /dev/null +++ b/packages/cli/src/ui/utils/textUtils.test.ts @@ -0,0 +1,170 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import type { + ToolCallConfirmationDetails, + ToolEditConfirmationDetails, +} from '@google/gemini-cli-core'; +import { escapeAnsiCtrlCodes } from './textUtils.js'; + +describe('textUtils', () => { + describe('escapeAnsiCtrlCodes', () => { + describe('escapeAnsiCtrlCodes string case study', () => { + it('should replace ANSI escape codes with a visible representation', () => { + const text = '\u001b[31mHello\u001b[0m'; + const expected = '\\u001b[31mHello\\u001b[0m'; + expect(escapeAnsiCtrlCodes(text)).toBe(expected); + + const text2 = "sh -e 'good && bad# \u001b[9D\u001b[K && good"; + const expected2 = "sh -e 'good && bad# \\u001b[9D\\u001b[K && good"; + expect(escapeAnsiCtrlCodes(text2)).toBe(expected2); + }); + + it('should not change a string with no ANSI codes', () => { + const text = 'Hello, world!'; + expect(escapeAnsiCtrlCodes(text)).toBe(text); + }); + + it('should handle an empty string', () => { + expect(escapeAnsiCtrlCodes('')).toBe(''); + }); + + describe('toolConfirmationDetails case study', () => { + it('should sanitize command and rootCommand for exec type', () => { + const details: ToolCallConfirmationDetails = { + title: '\u001b[34mfake-title\u001b[0m', + type: 'exec', + command: '\u001b[31mmls -l\u001b[0m', + rootCommand: '\u001b[32msudo apt-get update\u001b[0m', + onConfirm: async () => {}, + }; + + const sanitized = escapeAnsiCtrlCodes(details); + + if (sanitized.type === 'exec') { + expect(sanitized.title).toBe('\\u001b[34mfake-title\\u001b[0m'); + expect(sanitized.command).toBe('\\u001b[31mmls -l\\u001b[0m'); + expect(sanitized.rootCommand).toBe( + '\\u001b[32msudo apt-get update\\u001b[0m', + ); + } + }); + + it('should sanitize properties for edit type', () => { + const details: ToolCallConfirmationDetails = { + type: 'edit', + title: '\u001b[34mEdit File\u001b[0m', + fileName: '\u001b[31mfile.txt\u001b[0m', + filePath: '/path/to/\u001b[32mfile.txt\u001b[0m', + fileDiff: + 'diff --git a/file.txt b/file.txt\n--- a/\u001b[33mfile.txt\u001b[0m\n+++ b/file.txt', + onConfirm: async () => {}, + } as unknown as ToolEditConfirmationDetails; + + const sanitized = escapeAnsiCtrlCodes(details); + + if (sanitized.type === 'edit') { + expect(sanitized.title).toBe('\\u001b[34mEdit File\\u001b[0m'); + expect(sanitized.fileName).toBe('\\u001b[31mfile.txt\\u001b[0m'); + expect(sanitized.filePath).toBe( + '/path/to/\\u001b[32mfile.txt\\u001b[0m', + ); + expect(sanitized.fileDiff).toBe( + 'diff --git a/file.txt b/file.txt\n--- a/\\u001b[33mfile.txt\\u001b[0m\n+++ b/file.txt', + ); + } + }); + + it('should sanitize properties for mcp type', () => { + const details: ToolCallConfirmationDetails = { + type: 'mcp', + title: '\u001b[34mCloud Run\u001b[0m', + serverName: '\u001b[31mmy-server\u001b[0m', + toolName: '\u001b[32mdeploy\u001b[0m', + toolDisplayName: '\u001b[33mDeploy Service\u001b[0m', + onConfirm: async () => {}, + }; + + const sanitized = escapeAnsiCtrlCodes(details); + + if (sanitized.type === 'mcp') { + expect(sanitized.title).toBe('\\u001b[34mCloud Run\\u001b[0m'); + expect(sanitized.serverName).toBe('\\u001b[31mmy-server\\u001b[0m'); + expect(sanitized.toolName).toBe('\\u001b[32mdeploy\\u001b[0m'); + expect(sanitized.toolDisplayName).toBe( + '\\u001b[33mDeploy Service\\u001b[0m', + ); + } + }); + + it('should sanitize properties for info type', () => { + const details: ToolCallConfirmationDetails = { + type: 'info', + title: '\u001b[34mWeb Search\u001b[0m', + prompt: '\u001b[31mSearch for cats\u001b[0m', + urls: ['https://\u001b[32mgoogle.com\u001b[0m'], + onConfirm: async () => {}, + }; + + const sanitized = escapeAnsiCtrlCodes(details); + + if (sanitized.type === 'info') { + expect(sanitized.title).toBe('\\u001b[34mWeb Search\\u001b[0m'); + expect(sanitized.prompt).toBe( + '\\u001b[31mSearch for cats\\u001b[0m', + ); + expect(sanitized.urls?.[0]).toBe( + 'https://\\u001b[32mgoogle.com\\u001b[0m', + ); + } + }); + }); + + it('should not change the object if no sanitization is needed', () => { + const details: ToolCallConfirmationDetails = { + type: 'info', + title: 'Web Search', + prompt: 'Search for cats', + urls: ['https://google.com'], + onConfirm: async () => {}, + }; + + const sanitized = escapeAnsiCtrlCodes(details); + expect(sanitized).toBe(details); + }); + + it('should handle nested objects and arrays', () => { + const details = { + a: '\u001b[31mred\u001b[0m', + b: { + c: '\u001b[32mgreen\u001b[0m', + d: ['\u001b[33myellow\u001b[0m', { e: '\u001b[34mblue\u001b[0m' }], + }, + f: 123, + g: null, + h: () => '\u001b[35mpurple\u001b[0m', + }; + + const sanitized = escapeAnsiCtrlCodes(details); + + expect(sanitized.a).toBe('\\u001b[31mred\\u001b[0m'); + if (typeof sanitized.b === 'object' && sanitized.b !== null) { + const b = sanitized.b as { c: string; d: Array }; + expect(b.c).toBe('\\u001b[32mgreen\\u001b[0m'); + expect(b.d[0]).toBe('\\u001b[33myellow\\u001b[0m'); + if (typeof b.d[1] === 'object' && b.d[1] !== null) { + const e = b.d[1] as { e: string }; + expect(e.e).toBe('\\u001b[34mblue\\u001b[0m'); + } + } + expect(sanitized.f).toBe(123); + expect(sanitized.g).toBe(null); + expect(sanitized.h()).toBe('\u001b[35mpurple\u001b[0m'); + }); + }); + }); +}); diff --git a/packages/cli/src/ui/utils/textUtils.ts b/packages/cli/src/ui/utils/textUtils.ts index 98f690eae3..ecea36316a 100644 --- a/packages/cli/src/ui/utils/textUtils.ts +++ b/packages/cli/src/ui/utils/textUtils.ts @@ -5,6 +5,7 @@ */ import stripAnsi from 'strip-ansi'; +import ansiRegex from 'ansi-regex'; import { stripVTControlCharacters } from 'node:util'; import stringWidth from 'string-width'; @@ -146,3 +147,70 @@ export const getCachedStringWidth = (str: string): number => { export const clearStringWidthCache = (): void => { stringWidthCache.clear(); }; + +const regex = ansiRegex(); + +/* Recursively traverses a JSON-like structure (objects, arrays, primitives) + * and escapes all ANSI control characters found in any string values. + * + * This function is designed to be robust, handling deeply nested objects and + * arrays. It applies a regex-based replacement to all string values to + * safely escape control characters. + * + * To optimize performance, this function uses a "copy-on-write" strategy. + * It avoids allocating new objects or arrays if no nested string values + * required escaping, returning the original object reference in such cases. + * + * @param obj The JSON-like value (object, array, string, etc.) to traverse. + * @returns A new value with all nested string fields escaped, or the + * original `obj` reference if no changes were necessary. + */ +export function escapeAnsiCtrlCodes(obj: T): T { + if (typeof obj === 'string') { + if (obj.search(regex) === -1) { + return obj; // No changes return original string + } + + regex.lastIndex = 0; // needed for global regex + return obj.replace(regex, (match) => + JSON.stringify(match).slice(1, -1), + ) as T; + } + + if (obj === null || typeof obj !== 'object') { + return obj; + } + + if (Array.isArray(obj)) { + let newArr: unknown[] | null = null; + + for (let i = 0; i < obj.length; i++) { + const value = obj[i]; + const escapedValue = escapeAnsiCtrlCodes(value); + if (escapedValue !== value) { + if (newArr === null) { + newArr = [...obj]; + } + newArr[i] = escapedValue; + } + } + return (newArr !== null ? newArr : obj) as T; + } + + let newObj: T | null = null; + const keys = Object.keys(obj); + + for (const key of keys) { + const value = (obj as Record)[key]; + const escapedValue = escapeAnsiCtrlCodes(value); + + if (escapedValue !== value) { + if (newObj === null) { + newObj = { ...obj }; + } + (newObj as Record)[key] = escapedValue; + } + } + + return newObj !== null ? newObj : obj; +}