From f9fc9335f5d7b6682bedeb5aea79f8ebf0e25917 Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Tue, 10 Feb 2026 11:12:40 -0800 Subject: [PATCH] Code review cleanup for thinking display (#18720) --- packages/cli/src/test-utils/render.tsx | 1 - .../AlternateBufferQuittingDisplay.tsx | 6 - .../ui/components/HistoryItemDisplay.test.tsx | 25 ++- .../src/ui/components/HistoryItemDisplay.tsx | 12 +- .../ui/components/LoadingIndicator.test.tsx | 26 --- .../src/ui/components/LoadingIndicator.tsx | 5 +- .../cli/src/ui/components/MainContent.tsx | 18 +- .../cli/src/ui/components/QuittingDisplay.tsx | 5 - .../HistoryItemDisplay.test.tsx.snap | 6 + .../messages/ThinkingMessage.test.tsx | 40 +--- .../components/messages/ThinkingMessage.tsx | 189 +++++------------- .../components/messages/ToolGroupMessage.tsx | 2 +- .../ThinkingMessage.test.tsx.snap | 30 +++ .../cli/src/ui/utils/terminalUtils.test.ts | 77 ------- packages/cli/src/ui/utils/terminalUtils.ts | 22 -- packages/cli/src/ui/utils/textUtils.ts | 7 + 16 files changed, 125 insertions(+), 346 deletions(-) create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage.test.tsx.snap delete mode 100644 packages/cli/src/ui/utils/terminalUtils.test.ts 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, );