From c334f02d51dcbd1de23faed38449e67044a4644c Mon Sep 17 00:00:00 2001
From: Adam Weidman <65992621+adamfweidman@users.noreply.github.com>
Date: Thu, 25 Sep 2025 14:07:17 -0400
Subject: [PATCH] feat(escape ansi): escape ansi ctrl codes from model output
before displaying to user (#8636)
---
package-lock.json | 9 +-
packages/cli/package.json | 3 +-
.../ui/components/HistoryItemDisplay.test.tsx | 70 +++++++-
.../src/ui/components/HistoryItemDisplay.tsx | 136 ++++++++------
packages/cli/src/ui/utils/textUtils.test.ts | 170 ++++++++++++++++++
packages/cli/src/ui/utils/textUtils.ts | 68 +++++++
6 files changed, 390 insertions(+), 66 deletions(-)
create mode 100644 packages/cli/src/ui/utils/textUtils.test.ts
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;
+}