diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx
index 33ce70d403..0c8eac325e 100644
--- a/packages/cli/src/test-utils/render.tsx
+++ b/packages/cli/src/test-utils/render.tsx
@@ -44,7 +44,6 @@ vi.mock('../ui/utils/terminalUtils.js', () => ({
isLowColorDepth: vi.fn(() => false),
getColorDepth: vi.fn(() => 24),
isITerm2: vi.fn(() => false),
- shouldUseEmoji: vi.fn(() => true),
}));
// Wrapper around ink-testing-library's render that ensures act() is called
diff --git a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx
index bc54fd72db..fec35d46c3 100644
--- a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx
+++ b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx
@@ -6,7 +6,6 @@
import { Box, Text } from 'ink';
import { useUIState } from '../contexts/UIStateContext.js';
-import { useSettings } from '../contexts/SettingsContext.js';
import { AppHeader } from './AppHeader.js';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { QuittingDisplay } from './QuittingDisplay.js';
@@ -16,18 +15,15 @@ import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { ToolStatusIndicator, ToolInfo } from './messages/ToolShared.js';
import { theme } from '../semantic-colors.js';
-import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
export const AlternateBufferQuittingDisplay = () => {
const { version } = useAppContext();
const uiState = useUIState();
- const settings = useSettings();
const config = useConfig();
const confirmingTool = useConfirmingTool();
const showPromptedTool =
config.isEventDrivenSchedulerEnabled() && confirmingTool !== null;
- const inlineThinkingMode = getInlineThinkingMode(settings);
// We render the entire chat history and header here to ensure that the
// conversation history is visible to the user after the app quits and the
@@ -51,7 +47,6 @@ export const AlternateBufferQuittingDisplay = () => {
item={h}
isPending={false}
commands={uiState.slashCommands}
- inlineThinkingMode={inlineThinkingMode}
/>
))}
{uiState.pendingHistoryItems.map((item, i) => (
@@ -64,7 +59,6 @@ export const AlternateBufferQuittingDisplay = () => {
isFocused={false}
activeShellPtyId={uiState.activePtyId}
embeddedShellFocused={uiState.embeddedShellFocused}
- inlineThinkingMode={inlineThinkingMode}
/>
))}
{showPromptedTool && (
diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
index 40c71fe327..b232ff948a 100644
--- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
+++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
@@ -15,6 +15,7 @@ import type {
} from '@google/gemini-cli-core';
import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
import { renderWithProviders } from '../../test-utils/render.js';
+import { createMockSettings } from '../../test-utils/settings.js';
// Mock child components
vi.mock('./messages/ToolGroupMessage.js', () => ({
@@ -240,14 +241,15 @@ describe('', () => {
thought: { subject: 'Thinking', description: 'test' },
};
const { lastFrame } = renderWithProviders(
- ,
+ ,
+ {
+ settings: createMockSettings({
+ merged: { ui: { inlineThinkingMode: 'full' } },
+ }),
+ },
);
- expect(lastFrame()).toContain('Thinking');
+ expect(lastFrame()).toMatchSnapshot();
});
it('does not render thinking item when disabled', () => {
@@ -257,11 +259,12 @@ describe('', () => {
thought: { subject: 'Thinking', description: 'test' },
};
const { lastFrame } = renderWithProviders(
- ,
+ ,
+ {
+ settings: createMockSettings({
+ merged: { ui: { inlineThinkingMode: 'off' } },
+ }),
+ },
);
expect(lastFrame()).toBe('');
diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx
index a5ee265f64..41340c1b08 100644
--- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx
+++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx
@@ -35,7 +35,8 @@ import { ChatList } from './views/ChatList.js';
import { HooksList } from './views/HooksList.js';
import { ModelMessage } from './messages/ModelMessage.js';
import { ThinkingMessage } from './messages/ThinkingMessage.js';
-import type { InlineThinkingMode } from '../utils/inlineThinkingMode.js';
+import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
+import { useSettings } from '../contexts/SettingsContext.js';
interface HistoryItemDisplayProps {
item: HistoryItem;
@@ -47,7 +48,6 @@ interface HistoryItemDisplayProps {
activeShellPtyId?: number | null;
embeddedShellFocused?: boolean;
availableTerminalHeightGemini?: number;
- inlineThinkingMode?: InlineThinkingMode;
}
export const HistoryItemDisplay: React.FC = ({
@@ -60,18 +60,16 @@ export const HistoryItemDisplay: React.FC = ({
activeShellPtyId,
embeddedShellFocused,
availableTerminalHeightGemini,
- inlineThinkingMode = 'off',
}) => {
+ const settings = useSettings();
+ const inlineThinkingMode = getInlineThinkingMode(settings);
const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]);
return (
{/* Render standard message types */}
{itemForDisplay.type === 'thinking' && inlineThinkingMode !== 'off' && (
-
+
)}
{itemForDisplay.type === 'user' && (
diff --git a/packages/cli/src/ui/components/LoadingIndicator.test.tsx b/packages/cli/src/ui/components/LoadingIndicator.test.tsx
index e640c62b6d..ff9d081716 100644
--- a/packages/cli/src/ui/components/LoadingIndicator.test.tsx
+++ b/packages/cli/src/ui/components/LoadingIndicator.test.tsx
@@ -12,7 +12,6 @@ import { StreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import { vi } from 'vitest';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
-import * as terminalUtils from '../utils/terminalUtils.js';
// Mock GeminiRespondingSpinner
vi.mock('./GeminiRespondingSpinner.js', () => ({
@@ -35,12 +34,7 @@ vi.mock('../hooks/useTerminalSize.js', () => ({
useTerminalSize: vi.fn(),
}));
-vi.mock('../utils/terminalUtils.js', () => ({
- shouldUseEmoji: vi.fn(() => true),
-}));
-
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
-const shouldUseEmojiMock = vi.mocked(terminalUtils.shouldUseEmoji);
const renderWithContext = (
ui: React.ReactElement,
@@ -230,26 +224,6 @@ describe('', () => {
unmount();
});
- it('should use ASCII fallback thought indicator when emoji is unavailable', () => {
- shouldUseEmojiMock.mockReturnValue(false);
- const props = {
- thought: {
- subject: 'Thinking with fallback',
- description: 'details',
- },
- elapsedTime: 5,
- };
- const { lastFrame, unmount } = renderWithContext(
- ,
- StreamingState.Responding,
- );
- const output = lastFrame();
- expect(output).toContain('o Thinking with fallback');
- expect(output).not.toContain('💬');
- shouldUseEmojiMock.mockReturnValue(true);
- unmount();
- });
-
it('should prioritize thought.subject over currentLoadingPhrase', () => {
const props = {
thought: {
diff --git a/packages/cli/src/ui/components/LoadingIndicator.tsx b/packages/cli/src/ui/components/LoadingIndicator.tsx
index 3d6a838370..2d603ebbdd 100644
--- a/packages/cli/src/ui/components/LoadingIndicator.tsx
+++ b/packages/cli/src/ui/components/LoadingIndicator.tsx
@@ -15,7 +15,6 @@ import { formatDuration } from '../utils/formatters.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
-import { shouldUseEmoji } from '../utils/terminalUtils.js';
interface LoadingIndicatorProps {
currentLoadingPhrase?: string;
@@ -59,9 +58,7 @@ export const LoadingIndicator: React.FC = ({
const hasThoughtIndicator =
currentLoadingPhrase !== INTERACTIVE_SHELL_WAITING_PHRASE &&
Boolean(thought?.subject?.trim());
- const thinkingIndicator = hasThoughtIndicator
- ? `${shouldUseEmoji() ? '💬' : 'o'} `
- : '';
+ const thinkingIndicator = hasThoughtIndicator ? '💬 ' : '';
const cancelAndTimerContent =
showCancelAndTimer &&
diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx
index c8007df110..32c70e8cad 100644
--- a/packages/cli/src/ui/components/MainContent.tsx
+++ b/packages/cli/src/ui/components/MainContent.tsx
@@ -8,7 +8,6 @@ import { Box, Static } from 'ink';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useAppContext } from '../contexts/AppContext.js';
-import { useSettings } from '../contexts/SettingsContext.js';
import { AppHeader } from './AppHeader.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import {
@@ -21,7 +20,6 @@ import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
import { useConfig } from '../contexts/ConfigContext.js';
-import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
const MemoizedAppHeader = memo(AppHeader);
@@ -33,7 +31,6 @@ const MemoizedAppHeader = memo(AppHeader);
export const MainContent = () => {
const { version } = useAppContext();
const uiState = useUIState();
- const settings = useSettings();
const config = useConfig();
const isAlternateBuffer = useAlternateBuffer();
@@ -56,8 +53,6 @@ export const MainContent = () => {
availableTerminalHeight,
} = uiState;
- const inlineThinkingMode = getInlineThinkingMode(settings);
-
const historyItems = useMemo(
() =>
uiState.history.map((h) => (
@@ -69,7 +64,6 @@ export const MainContent = () => {
item={h}
isPending={false}
commands={uiState.slashCommands}
- inlineThinkingMode={inlineThinkingMode}
/>
)),
[
@@ -77,7 +71,6 @@ export const MainContent = () => {
mainAreaWidth,
staticAreaMaxItemHeight,
uiState.slashCommands,
- inlineThinkingMode,
],
);
@@ -99,7 +92,6 @@ export const MainContent = () => {
isFocused={!uiState.isEditorDialogOpen}
activeShellPtyId={uiState.activePtyId}
embeddedShellFocused={uiState.embeddedShellFocused}
- inlineThinkingMode={inlineThinkingMode}
/>
))}
{showConfirmationQueue && confirmingTool && (
@@ -113,7 +105,6 @@ export const MainContent = () => {
isAlternateBuffer,
availableTerminalHeight,
mainAreaWidth,
- inlineThinkingMode,
uiState.isEditorDialogOpen,
uiState.activePtyId,
uiState.embeddedShellFocused,
@@ -145,20 +136,13 @@ export const MainContent = () => {
item={item.item}
isPending={false}
commands={uiState.slashCommands}
- inlineThinkingMode={inlineThinkingMode}
/>
);
} else {
return pendingItems;
}
},
- [
- version,
- mainAreaWidth,
- uiState.slashCommands,
- inlineThinkingMode,
- pendingItems,
- ],
+ [version, mainAreaWidth, uiState.slashCommands, pendingItems],
);
if (isAlternateBuffer) {
diff --git a/packages/cli/src/ui/components/QuittingDisplay.tsx b/packages/cli/src/ui/components/QuittingDisplay.tsx
index 407b970ed7..ee81f92012 100644
--- a/packages/cli/src/ui/components/QuittingDisplay.tsx
+++ b/packages/cli/src/ui/components/QuittingDisplay.tsx
@@ -6,18 +6,14 @@
import { Box } from 'ink';
import { useUIState } from '../contexts/UIStateContext.js';
-import { useSettings } from '../contexts/SettingsContext.js';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
-import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
export const QuittingDisplay = () => {
const uiState = useUIState();
- const settings = useSettings();
const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
const availableTerminalHeight = terminalHeight;
- const inlineThinkingMode = getInlineThinkingMode(settings);
if (!uiState.quittingMessages) {
return null;
@@ -34,7 +30,6 @@ export const QuittingDisplay = () => {
terminalWidth={terminalWidth}
item={item}
isPending={false}
- inlineThinkingMode={inlineThinkingMode}
/>
))}
diff --git a/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap
index 1f6288c292..a3aea5c93a 100644
--- a/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap
@@ -385,3 +385,9 @@ exports[` > renders InfoMessage for "info" type with multi
⚡ Line 2
⚡ Line 3"
`;
+
+exports[` > thinking items > renders thinking item when enabled 1`] = `
+" Thinking
+ │ test
+"
+`;
diff --git a/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx b/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx
index eab85866e6..4f4ee6d5d4 100644
--- a/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx
+++ b/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx
@@ -13,84 +13,66 @@ describe('ThinkingMessage', () => {
const { lastFrame } = renderWithProviders(
,
);
- expect(lastFrame()).toContain('Planning');
+ expect(lastFrame()).toMatchSnapshot();
});
it('uses description when subject is empty', () => {
const { lastFrame } = renderWithProviders(
,
);
- expect(lastFrame()).toContain('Processing details');
+ expect(lastFrame()).toMatchSnapshot();
});
- it('renders full mode with left vertical rule and full text', () => {
+ it('renders full mode with left border and full text', () => {
const { lastFrame } = renderWithProviders(
,
);
- expect(lastFrame()).toContain('│');
- expect(lastFrame()).not.toContain('┌');
- expect(lastFrame()).not.toContain('┐');
- expect(lastFrame()).not.toContain('└');
- expect(lastFrame()).not.toContain('┘');
- expect(lastFrame()).toContain('Planning');
- expect(lastFrame()).toContain('I am planning the solution.');
+ expect(lastFrame()).toMatchSnapshot();
});
- it('starts left rule below the bold summary line in full mode', () => {
+ it('indents summary line correctly', () => {
const { lastFrame } = renderWithProviders(
,
);
- const lines = (lastFrame() ?? '').split('\n');
- expect(lines[0] ?? '').toContain('Summary line');
- expect(lines[0] ?? '').not.toContain('│');
- expect(lines.slice(1).join('\n')).toContain('│');
+ expect(lastFrame()).toMatchSnapshot();
});
- it('normalizes escaped newline tokens so literal \\n\\n is not shown', () => {
+ it('normalizes escaped newline tokens', () => {
const { lastFrame } = renderWithProviders(
,
);
- expect(lastFrame()).toContain('Matching the Blocks');
- expect(lastFrame()).not.toContain('\\n\\n');
+ expect(lastFrame()).toMatchSnapshot();
});
it('renders empty state gracefully', () => {
const { lastFrame } = renderWithProviders(
- ,
+ ,
);
- expect(lastFrame()).not.toContain('Planning');
+ expect(lastFrame()).toBe('');
});
});
diff --git a/packages/cli/src/ui/components/messages/ThinkingMessage.tsx b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx
index f23addb0d7..86882307e7 100644
--- a/packages/cli/src/ui/components/messages/ThinkingMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx
@@ -9,163 +9,72 @@ import { useMemo } from 'react';
import { Box, Text } from 'ink';
import type { ThoughtSummary } from '@google/gemini-cli-core';
import { theme } from '../../semantic-colors.js';
+import { normalizeEscapedNewlines } from '../../utils/textUtils.js';
interface ThinkingMessageProps {
thought: ThoughtSummary;
- terminalWidth: number;
-}
-
-const THINKING_LEFT_PADDING = 1;
-
-function splitGraphemes(value: string): string[] {
- if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) {
- const segmenter = new Intl.Segmenter(undefined, {
- granularity: 'grapheme',
- });
- return Array.from(segmenter.segment(value), (segment) => segment.segment);
- }
-
- return Array.from(value);
-}
-
-function normalizeEscapedNewlines(value: string): string {
- return value.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n');
-}
-
-function normalizeThoughtLines(thought: ThoughtSummary): string[] {
- const subject = normalizeEscapedNewlines(thought.subject).trim();
- const description = normalizeEscapedNewlines(thought.description).trim();
-
- if (!subject && !description) {
- return [];
- }
-
- if (!subject) {
- return description
- .split('\n')
- .map((line) => line.trim())
- .filter(Boolean);
- }
-
- const bodyLines = description
- .split('\n')
- .map((line) => line.trim())
- .filter(Boolean);
- return [subject, ...bodyLines];
-}
-
-function graphemeLength(value: string): number {
- return splitGraphemes(value).length;
-}
-
-function chunkToWidth(value: string, width: number): string[] {
- if (width <= 0) {
- return [''];
- }
-
- const graphemes = splitGraphemes(value);
- if (graphemes.length === 0) {
- return [''];
- }
-
- const chunks: string[] = [];
- for (let index = 0; index < graphemes.length; index += width) {
- chunks.push(graphemes.slice(index, index + width).join(''));
- }
- return chunks;
-}
-
-function wrapLineToWidth(line: string, width: number): string[] {
- if (width <= 0) {
- return [''];
- }
-
- const normalized = line.trim();
- if (!normalized) {
- return [''];
- }
-
- const words = normalized.split(/\s+/);
- const wrapped: string[] = [];
- let current = '';
-
- for (const word of words) {
- const wordChunks = chunkToWidth(word, width);
-
- for (const wordChunk of wordChunks) {
- if (!current) {
- current = wordChunk;
- continue;
- }
-
- if (graphemeLength(current) + 1 + graphemeLength(wordChunk) <= width) {
- current = `${current} ${wordChunk}`;
- } else {
- wrapped.push(current);
- current = wordChunk;
- }
- }
- }
-
- if (current) {
- wrapped.push(current);
- }
-
- return wrapped;
}
+/**
+ * Renders a model's thought as a distinct bubble.
+ * Leverages Ink layout for wrapping and borders.
+ */
export const ThinkingMessage: React.FC = ({
thought,
- terminalWidth,
}) => {
- const fullLines = useMemo(() => normalizeThoughtLines(thought), [thought]);
- const fullSummaryDisplayLines = useMemo(() => {
- const contentWidth = Math.max(terminalWidth - THINKING_LEFT_PADDING - 2, 1);
- return fullLines.length > 0
- ? wrapLineToWidth(fullLines[0], contentWidth)
- : [];
- }, [fullLines, terminalWidth]);
- const fullBodyDisplayLines = useMemo(() => {
- const contentWidth = Math.max(terminalWidth - THINKING_LEFT_PADDING - 2, 1);
- return fullLines
- .slice(1)
- .flatMap((line) => wrapLineToWidth(line, contentWidth));
- }, [fullLines, terminalWidth]);
+ const { summary, body } = useMemo(() => {
+ const subject = normalizeEscapedNewlines(thought.subject).trim();
+ const description = normalizeEscapedNewlines(thought.description).trim();
- if (
- fullSummaryDisplayLines.length === 0 &&
- fullBodyDisplayLines.length === 0
- ) {
+ if (!subject && !description) {
+ return { summary: '', body: '' };
+ }
+
+ if (!subject) {
+ const lines = description
+ .split('\n')
+ .map((l) => l.trim())
+ .filter(Boolean);
+ return {
+ summary: lines[0] || '',
+ body: lines.slice(1).join('\n'),
+ };
+ }
+
+ return {
+ summary: subject,
+ body: description,
+ };
+ }, [thought]);
+
+ if (!summary && !body) {
return null;
}
return (
-
- {fullSummaryDisplayLines.map((line, index) => (
-
-
-
-
-
- {line}
+
+ {summary && (
+
+
+ {summary}
- ))}
- {fullBodyDisplayLines.map((line, index) => (
-
-
- │
-
-
- {line}
+ )}
+ {body && (
+
+
+ {body}
- ))}
+ )}
);
};
diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
index bca56febf7..f9225b60e7 100644
--- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
@@ -247,7 +247,7 @@ export const ToolGroupMessage: React.FC = ({
*/
(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && (
indents summary line correctly 1`] = `
+" Summary line
+ │ First body line
+"
+`;
+
+exports[`ThinkingMessage > normalizes escaped newline tokens 1`] = `
+" Matching the Blocks
+ │ Some more text
+"
+`;
+
+exports[`ThinkingMessage > renders full mode with left border and full text 1`] = `
+" Planning
+ │ I am planning the solution.
+"
+`;
+
+exports[`ThinkingMessage > renders subject line 1`] = `
+" Planning
+ │ test
+"
+`;
+
+exports[`ThinkingMessage > uses description when subject is empty 1`] = `
+" Processing details
+"
+`;
diff --git a/packages/cli/src/ui/utils/terminalUtils.test.ts b/packages/cli/src/ui/utils/terminalUtils.test.ts
deleted file mode 100644
index f12b3e03ba..0000000000
--- a/packages/cli/src/ui/utils/terminalUtils.test.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { isITerm2, resetITerm2Cache, shouldUseEmoji } from './terminalUtils.js';
-
-describe('terminalUtils', () => {
- beforeEach(() => {
- vi.stubEnv('TERM_PROGRAM', '');
- vi.stubEnv('LC_ALL', '');
- vi.stubEnv('LC_CTYPE', '');
- vi.stubEnv('LANG', '');
- vi.stubEnv('TERM', '');
- resetITerm2Cache();
- });
-
- afterEach(() => {
- vi.unstubAllEnvs();
- vi.restoreAllMocks();
- });
-
- describe('isITerm2', () => {
- it('should detect iTerm2 via TERM_PROGRAM', () => {
- vi.stubEnv('TERM_PROGRAM', 'iTerm.app');
- expect(isITerm2()).toBe(true);
- });
-
- it('should return false if not iTerm2', () => {
- vi.stubEnv('TERM_PROGRAM', 'vscode');
- expect(isITerm2()).toBe(false);
- });
-
- it('should cache the result', () => {
- vi.stubEnv('TERM_PROGRAM', 'iTerm.app');
- expect(isITerm2()).toBe(true);
-
- // Change env but should still be true due to cache
- vi.stubEnv('TERM_PROGRAM', 'vscode');
- expect(isITerm2()).toBe(true);
-
- resetITerm2Cache();
- expect(isITerm2()).toBe(false);
- });
- });
-
- describe('shouldUseEmoji', () => {
- it('should return true when UTF-8 is supported', () => {
- vi.stubEnv('LANG', 'en_US.UTF-8');
- expect(shouldUseEmoji()).toBe(true);
- });
-
- it('should return true when utf8 (no hyphen) is supported', () => {
- vi.stubEnv('LANG', 'en_US.utf8');
- expect(shouldUseEmoji()).toBe(true);
- });
-
- it('should check LC_ALL first', () => {
- vi.stubEnv('LC_ALL', 'en_US.UTF-8');
- vi.stubEnv('LANG', 'C');
- expect(shouldUseEmoji()).toBe(true);
- });
-
- it('should return false when UTF-8 is not supported', () => {
- vi.stubEnv('LANG', 'C');
- expect(shouldUseEmoji()).toBe(false);
- });
-
- it('should return false on linux console (TERM=linux)', () => {
- vi.stubEnv('LANG', 'en_US.UTF-8');
- vi.stubEnv('TERM', 'linux');
- expect(shouldUseEmoji()).toBe(false);
- });
- });
-});
diff --git a/packages/cli/src/ui/utils/terminalUtils.ts b/packages/cli/src/ui/utils/terminalUtils.ts
index b0a3b93034..18cd08f952 100644
--- a/packages/cli/src/ui/utils/terminalUtils.ts
+++ b/packages/cli/src/ui/utils/terminalUtils.ts
@@ -43,25 +43,3 @@ export function isITerm2(): boolean {
export function resetITerm2Cache(): void {
cachedIsITerm2 = undefined;
}
-
-/**
- * Returns true if the terminal likely supports emoji.
- */
-export function shouldUseEmoji(): boolean {
- const locale = (
- process.env['LC_ALL'] ||
- process.env['LC_CTYPE'] ||
- process.env['LANG'] ||
- ''
- ).toLowerCase();
- const supportsUtf8 = locale.includes('utf-8') || locale.includes('utf8');
- if (!supportsUtf8) {
- return false;
- }
-
- if (process.env['TERM'] === 'linux') {
- return false;
- }
-
- return true;
-}
diff --git a/packages/cli/src/ui/utils/textUtils.ts b/packages/cli/src/ui/utils/textUtils.ts
index c56f2f4430..d2ad40c148 100644
--- a/packages/cli/src/ui/utils/textUtils.ts
+++ b/packages/cli/src/ui/utils/textUtils.ts
@@ -143,6 +143,13 @@ export function sanitizeForDisplay(str: string, maxLength?: number): string {
return sanitized;
}
+/**
+ * Normalizes escaped newline characters (e.g., "\\n") into actual newline characters.
+ */
+export function normalizeEscapedNewlines(value: string): string {
+ return value.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n');
+}
+
const stringWidthCache = new LRUCache(
LRU_BUFFER_PERF_CACHE_LIMIT,
);