mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-20 19:11:23 -07:00
Inline thinking bubbles with summary/full modes (#18033)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
@@ -392,6 +392,19 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Hide the window title bar',
|
||||
showInDialog: true,
|
||||
},
|
||||
inlineThinkingMode: {
|
||||
type: 'enum',
|
||||
label: 'Inline Thinking',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: 'off',
|
||||
description: 'Display model thinking inline: off or full.',
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'off', label: 'Off' },
|
||||
{ value: 'full', label: 'Full' },
|
||||
],
|
||||
},
|
||||
showStatusInTitle: {
|
||||
type: 'boolean',
|
||||
label: 'Show Thoughts in Title',
|
||||
|
||||
@@ -44,6 +44,7 @@ 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
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
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';
|
||||
@@ -15,15 +16,18 @@ 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
|
||||
@@ -47,6 +51,7 @@ export const AlternateBufferQuittingDisplay = () => {
|
||||
item={h}
|
||||
isPending={false}
|
||||
commands={uiState.slashCommands}
|
||||
inlineThinkingMode={inlineThinkingMode}
|
||||
/>
|
||||
))}
|
||||
{uiState.pendingHistoryItems.map((item, i) => (
|
||||
@@ -59,6 +64,7 @@ export const AlternateBufferQuittingDisplay = () => {
|
||||
isFocused={false}
|
||||
activeShellPtyId={uiState.activePtyId}
|
||||
embeddedShellFocused={uiState.embeddedShellFocused}
|
||||
inlineThinkingMode={inlineThinkingMode}
|
||||
/>
|
||||
))}
|
||||
{showPromptedTool && (
|
||||
|
||||
@@ -31,9 +31,18 @@ import type { SessionMetrics } from '../contexts/SessionContext.js';
|
||||
|
||||
// Mock child components
|
||||
vi.mock('./LoadingIndicator.js', () => ({
|
||||
LoadingIndicator: ({ thought }: { thought?: string }) => (
|
||||
<Text>LoadingIndicator{thought ? `: ${thought}` : ''}</Text>
|
||||
),
|
||||
LoadingIndicator: ({
|
||||
thought,
|
||||
thoughtLabel,
|
||||
}: {
|
||||
thought?: { subject?: string } | string;
|
||||
thoughtLabel?: string;
|
||||
}) => {
|
||||
const fallbackText =
|
||||
typeof thought === 'string' ? thought : thought?.subject;
|
||||
const text = thoughtLabel ?? fallbackText;
|
||||
return <Text>LoadingIndicator{text ? `: ${text}` : ''}</Text>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./ContextSummaryDisplay.js', () => ({
|
||||
@@ -287,7 +296,25 @@ describe('Composer', () => {
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('LoadingIndicator');
|
||||
expect(output).toContain('LoadingIndicator: Processing');
|
||||
});
|
||||
|
||||
it('renders generic thinking text in loading indicator when full inline thinking is enabled', () => {
|
||||
const uiState = createMockUIState({
|
||||
streamingState: StreamingState.Responding,
|
||||
thought: {
|
||||
subject: 'Detailed in-history thought',
|
||||
description: 'Full text is already in history',
|
||||
},
|
||||
});
|
||||
const settings = createMockSettings({
|
||||
ui: { inlineThinkingMode: 'full' },
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState, settings);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('LoadingIndicator: Thinking ...');
|
||||
});
|
||||
|
||||
it('keeps shortcuts hint visible while loading', () => {
|
||||
|
||||
@@ -30,6 +30,7 @@ import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import { StreamingState, ToolCallStatus } from '../types.js';
|
||||
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
|
||||
import { TodoTray } from './messages/Todo.js';
|
||||
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
|
||||
|
||||
export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
const config = useConfig();
|
||||
@@ -38,6 +39,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
const uiState = useUIState();
|
||||
const uiActions = useUIActions();
|
||||
const { vimEnabled, vimMode } = useVimMode();
|
||||
const inlineThinkingMode = getInlineThinkingMode(settings);
|
||||
const terminalWidth = process.stdout.columns;
|
||||
const isNarrow = isNarrowWidth(terminalWidth);
|
||||
const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5));
|
||||
@@ -117,6 +119,9 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
? undefined
|
||||
: uiState.currentLoadingPhrase
|
||||
}
|
||||
thoughtLabel={
|
||||
inlineThinkingMode === 'full' ? 'Thinking ...' : undefined
|
||||
}
|
||||
elapsedTime={uiState.elapsedTime}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -232,6 +232,42 @@ describe('<HistoryItemDisplay />', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('thinking items', () => {
|
||||
it('renders thinking item when enabled', () => {
|
||||
const item: HistoryItem = {
|
||||
...baseItem,
|
||||
type: 'thinking',
|
||||
thought: { subject: 'Thinking', description: 'test' },
|
||||
};
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<HistoryItemDisplay
|
||||
{...baseItem}
|
||||
item={item}
|
||||
inlineThinkingMode="full"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Thinking');
|
||||
});
|
||||
|
||||
it('does not render thinking item when disabled', () => {
|
||||
const item: HistoryItem = {
|
||||
...baseItem,
|
||||
type: 'thinking',
|
||||
thought: { subject: 'Thinking', description: 'test' },
|
||||
};
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<HistoryItemDisplay
|
||||
{...baseItem}
|
||||
item={item}
|
||||
inlineThinkingMode="off"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([true, false])(
|
||||
'gemini items (alternateBuffer=%s)',
|
||||
(useAlternateBuffer) => {
|
||||
|
||||
@@ -34,6 +34,8 @@ import { McpStatus } from './views/McpStatus.js';
|
||||
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';
|
||||
|
||||
interface HistoryItemDisplayProps {
|
||||
item: HistoryItem;
|
||||
@@ -45,6 +47,7 @@ interface HistoryItemDisplayProps {
|
||||
activeShellPtyId?: number | null;
|
||||
embeddedShellFocused?: boolean;
|
||||
availableTerminalHeightGemini?: number;
|
||||
inlineThinkingMode?: InlineThinkingMode;
|
||||
}
|
||||
|
||||
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
@@ -57,12 +60,19 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
activeShellPtyId,
|
||||
embeddedShellFocused,
|
||||
availableTerminalHeightGemini,
|
||||
inlineThinkingMode = 'off',
|
||||
}) => {
|
||||
const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" key={itemForDisplay.id} width={terminalWidth}>
|
||||
{/* Render standard message types */}
|
||||
{itemForDisplay.type === 'thinking' && inlineThinkingMode !== 'off' && (
|
||||
<ThinkingMessage
|
||||
thought={itemForDisplay.thought}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'user' && (
|
||||
<UserMessage text={itemForDisplay.text} width={terminalWidth} />
|
||||
)}
|
||||
|
||||
@@ -12,6 +12,7 @@ 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', () => ({
|
||||
@@ -34,7 +35,12 @@ 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,
|
||||
@@ -217,12 +223,33 @@ describe('<LoadingIndicator />', () => {
|
||||
const output = lastFrame();
|
||||
expect(output).toBeDefined();
|
||||
if (output) {
|
||||
expect(output).toContain('💬');
|
||||
expect(output).toContain('Thinking about something...');
|
||||
expect(output).not.toContain('and other stuff.');
|
||||
}
|
||||
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(
|
||||
<LoadingIndicator {...props} />,
|
||||
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: {
|
||||
@@ -237,11 +264,24 @@ describe('<LoadingIndicator />', () => {
|
||||
StreamingState.Responding,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('💬');
|
||||
expect(output).toContain('This should be displayed');
|
||||
expect(output).not.toContain('This should not be displayed');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not display thought icon for non-thought loading phrases', () => {
|
||||
const { lastFrame, unmount } = renderWithContext(
|
||||
<LoadingIndicator
|
||||
currentLoadingPhrase="some random tip..."
|
||||
elapsedTime={3}
|
||||
/>,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
expect(lastFrame()).not.toContain('💬');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should truncate long primary text instead of wrapping', () => {
|
||||
const { lastFrame, unmount } = renderWithContext(
|
||||
<LoadingIndicator
|
||||
|
||||
@@ -15,6 +15,7 @@ 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;
|
||||
@@ -22,6 +23,7 @@ interface LoadingIndicatorProps {
|
||||
inline?: boolean;
|
||||
rightContent?: React.ReactNode;
|
||||
thought?: ThoughtSummary | null;
|
||||
thoughtLabel?: string;
|
||||
showCancelAndTimer?: boolean;
|
||||
}
|
||||
|
||||
@@ -31,6 +33,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
inline = false,
|
||||
rightContent,
|
||||
thought,
|
||||
thoughtLabel,
|
||||
showCancelAndTimer = true,
|
||||
}) => {
|
||||
const streamingState = useStreamingContext();
|
||||
@@ -50,7 +53,15 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
const primaryText =
|
||||
currentLoadingPhrase === INTERACTIVE_SHELL_WAITING_PHRASE
|
||||
? currentLoadingPhrase
|
||||
: thought?.subject || currentLoadingPhrase;
|
||||
: thought?.subject
|
||||
? (thoughtLabel ?? thought.subject)
|
||||
: currentLoadingPhrase;
|
||||
const hasThoughtIndicator =
|
||||
currentLoadingPhrase !== INTERACTIVE_SHELL_WAITING_PHRASE &&
|
||||
Boolean(thought?.subject?.trim());
|
||||
const thinkingIndicator = hasThoughtIndicator
|
||||
? `${shouldUseEmoji() ? '💬' : 'o'} `
|
||||
: '';
|
||||
|
||||
const cancelAndTimerContent =
|
||||
showCancelAndTimer &&
|
||||
@@ -72,6 +83,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
</Box>
|
||||
{primaryText && (
|
||||
<Text color={theme.text.accent} wrap="truncate-end">
|
||||
{thinkingIndicator}
|
||||
{primaryText}
|
||||
</Text>
|
||||
)}
|
||||
@@ -105,6 +117,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
</Box>
|
||||
{primaryText && (
|
||||
<Text color={theme.text.accent} wrap="truncate-end">
|
||||
{thinkingIndicator}
|
||||
{primaryText}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -16,6 +16,20 @@ import { SHELL_COMMAND_NAME } from '../constants.js';
|
||||
import type { UIState } from '../contexts/UIStateContext.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../contexts/SettingsContext.js', async () => {
|
||||
const actual = await vi.importActual('../contexts/SettingsContext.js');
|
||||
return {
|
||||
...actual,
|
||||
useSettings: () => ({
|
||||
merged: {
|
||||
ui: {
|
||||
inlineThinkingMode: 'off',
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../contexts/AppContext.js', async () => {
|
||||
const actual = await vi.importActual('../contexts/AppContext.js');
|
||||
return {
|
||||
@@ -68,6 +82,7 @@ describe('MainContent', () => {
|
||||
availableTerminalHeight: 24,
|
||||
slashCommands: [],
|
||||
constrainHeight: false,
|
||||
thought: null,
|
||||
isEditorDialogOpen: false,
|
||||
activePtyId: undefined,
|
||||
embeddedShellFocused: false,
|
||||
@@ -185,6 +200,7 @@ describe('MainContent', () => {
|
||||
terminalHeight: 50,
|
||||
terminalWidth: 100,
|
||||
mainAreaWidth: 100,
|
||||
thought: null,
|
||||
embeddedShellFocused,
|
||||
activePtyId: embeddedShellFocused ? ptyId : undefined,
|
||||
constrainHeight,
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 {
|
||||
@@ -20,6 +21,7 @@ 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);
|
||||
@@ -31,6 +33,7 @@ const MemoizedAppHeader = memo(AppHeader);
|
||||
export const MainContent = () => {
|
||||
const { version } = useAppContext();
|
||||
const uiState = useUIState();
|
||||
const settings = useSettings();
|
||||
const config = useConfig();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
|
||||
@@ -53,6 +56,8 @@ export const MainContent = () => {
|
||||
availableTerminalHeight,
|
||||
} = uiState;
|
||||
|
||||
const inlineThinkingMode = getInlineThinkingMode(settings);
|
||||
|
||||
const historyItems = useMemo(
|
||||
() =>
|
||||
uiState.history.map((h) => (
|
||||
@@ -64,6 +69,7 @@ export const MainContent = () => {
|
||||
item={h}
|
||||
isPending={false}
|
||||
commands={uiState.slashCommands}
|
||||
inlineThinkingMode={inlineThinkingMode}
|
||||
/>
|
||||
)),
|
||||
[
|
||||
@@ -71,6 +77,7 @@ export const MainContent = () => {
|
||||
mainAreaWidth,
|
||||
staticAreaMaxItemHeight,
|
||||
uiState.slashCommands,
|
||||
inlineThinkingMode,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -92,6 +99,7 @@ export const MainContent = () => {
|
||||
isFocused={!uiState.isEditorDialogOpen}
|
||||
activeShellPtyId={uiState.activePtyId}
|
||||
embeddedShellFocused={uiState.embeddedShellFocused}
|
||||
inlineThinkingMode={inlineThinkingMode}
|
||||
/>
|
||||
))}
|
||||
{showConfirmationQueue && confirmingTool && (
|
||||
@@ -105,6 +113,7 @@ export const MainContent = () => {
|
||||
isAlternateBuffer,
|
||||
availableTerminalHeight,
|
||||
mainAreaWidth,
|
||||
inlineThinkingMode,
|
||||
uiState.isEditorDialogOpen,
|
||||
uiState.activePtyId,
|
||||
uiState.embeddedShellFocused,
|
||||
@@ -136,13 +145,20 @@ export const MainContent = () => {
|
||||
item={item.item}
|
||||
isPending={false}
|
||||
commands={uiState.slashCommands}
|
||||
inlineThinkingMode={inlineThinkingMode}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return pendingItems;
|
||||
}
|
||||
},
|
||||
[version, mainAreaWidth, uiState.slashCommands, pendingItems],
|
||||
[
|
||||
version,
|
||||
mainAreaWidth,
|
||||
uiState.slashCommands,
|
||||
inlineThinkingMode,
|
||||
pendingItems,
|
||||
],
|
||||
);
|
||||
|
||||
if (isAlternateBuffer) {
|
||||
|
||||
@@ -12,6 +12,15 @@ import { useUIState, type UIState } from '../contexts/UIStateContext.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
|
||||
vi.mock('../contexts/UIStateContext.js');
|
||||
vi.mock('../contexts/SettingsContext.js', () => ({
|
||||
useSettings: () => ({
|
||||
merged: {
|
||||
ui: {
|
||||
inlineThinkingMode: 'off',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock('../hooks/useTerminalSize.js');
|
||||
vi.mock('./HistoryItemDisplay.js', async () => {
|
||||
const { Text } = await vi.importActual('ink');
|
||||
|
||||
@@ -6,14 +6,18 @@
|
||||
|
||||
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;
|
||||
@@ -30,6 +34,7 @@ export const QuittingDisplay = () => {
|
||||
terminalWidth={terminalWidth}
|
||||
item={item}
|
||||
isPending={false}
|
||||
inlineThinkingMode={inlineThinkingMode}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -140,7 +140,7 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
height={0}
|
||||
height={1}
|
||||
width={mainAreaWidth}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { ThinkingMessage } from './ThinkingMessage.js';
|
||||
|
||||
describe('ThinkingMessage', () => {
|
||||
it('renders subject line', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ThinkingMessage
|
||||
thought={{ subject: 'Planning', description: 'test' }}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Planning');
|
||||
});
|
||||
|
||||
it('uses description when subject is empty', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ThinkingMessage
|
||||
thought={{ subject: '', description: 'Processing details' }}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Processing details');
|
||||
});
|
||||
|
||||
it('renders full mode with left vertical rule and full text', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ThinkingMessage
|
||||
thought={{
|
||||
subject: 'Planning',
|
||||
description: 'I am planning the solution.',
|
||||
}}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
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.');
|
||||
});
|
||||
|
||||
it('starts left rule below the bold summary line in full mode', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ThinkingMessage
|
||||
thought={{
|
||||
subject: 'Summary line',
|
||||
description: 'First body line',
|
||||
}}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
const lines = (lastFrame() ?? '').split('\n');
|
||||
expect(lines[0] ?? '').toContain('Summary line');
|
||||
expect(lines[0] ?? '').not.toContain('│');
|
||||
expect(lines.slice(1).join('\n')).toContain('│');
|
||||
});
|
||||
|
||||
it('normalizes escaped newline tokens so literal \\n\\n is not shown', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ThinkingMessage
|
||||
thought={{
|
||||
subject: 'Matching the Blocks',
|
||||
description: '\\n\\n',
|
||||
}}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Matching the Blocks');
|
||||
expect(lastFrame()).not.toContain('\\n\\n');
|
||||
});
|
||||
|
||||
it('renders empty state gracefully', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ThinkingMessage
|
||||
thought={{ subject: '', description: '' }}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).not.toContain('Planning');
|
||||
});
|
||||
});
|
||||
171
packages/cli/src/ui/components/messages/ThinkingMessage.tsx
Normal file
171
packages/cli/src/ui/components/messages/ThinkingMessage.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { ThoughtSummary } from '@google/gemini-cli-core';
|
||||
import { theme } from '../../semantic-colors.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;
|
||||
}
|
||||
|
||||
export const ThinkingMessage: React.FC<ThinkingMessageProps> = ({
|
||||
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]);
|
||||
|
||||
if (
|
||||
fullSummaryDisplayLines.length === 0 &&
|
||||
fullBodyDisplayLines.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
marginBottom={1}
|
||||
paddingLeft={THINKING_LEFT_PADDING}
|
||||
flexDirection="column"
|
||||
>
|
||||
{fullSummaryDisplayLines.map((line, index) => (
|
||||
<Box key={`summary-line-row-${index}`} flexDirection="row">
|
||||
<Box width={2}>
|
||||
<Text> </Text>
|
||||
</Box>
|
||||
<Text color={theme.text.primary} bold italic wrap="truncate-end">
|
||||
{line}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
{fullBodyDisplayLines.map((line, index) => (
|
||||
<Box key={`body-line-row-${index}`} flexDirection="row">
|
||||
<Box width={2}>
|
||||
<Text color={theme.border.default}>│ </Text>
|
||||
</Box>
|
||||
<Text color={theme.text.secondary} italic wrap="truncate-end">
|
||||
{line}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -247,7 +247,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
*/
|
||||
(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && (
|
||||
<Box
|
||||
height={0}
|
||||
height={1}
|
||||
width={contentWidth}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
|
||||
@@ -2505,6 +2505,110 @@ describe('useGeminiStream', () => {
|
||||
});
|
||||
});
|
||||
describe('Thought Reset', () => {
|
||||
it('should keep full thinking entries in history when mode is full', async () => {
|
||||
const fullThinkingSettings: LoadedSettings = {
|
||||
...mockLoadedSettings,
|
||||
merged: {
|
||||
...mockLoadedSettings.merged,
|
||||
ui: { inlineThinkingMode: 'full' },
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
mockSendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
type: ServerGeminiEventType.Thought,
|
||||
value: {
|
||||
subject: 'Full thought',
|
||||
description: 'Detailed thinking',
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: ServerGeminiEventType.Content,
|
||||
value: 'Response',
|
||||
};
|
||||
})(),
|
||||
);
|
||||
|
||||
const { result } = renderHookWithProviders(() =>
|
||||
useGeminiStream(
|
||||
new MockedGeminiClientClass(mockConfig),
|
||||
[],
|
||||
mockAddItem,
|
||||
mockConfig,
|
||||
fullThinkingSettings,
|
||||
mockOnDebugMessage,
|
||||
mockHandleSlashCommand,
|
||||
false,
|
||||
() => 'vscode' as EditorType,
|
||||
() => {},
|
||||
() => Promise.resolve(),
|
||||
false,
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
80,
|
||||
24,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('Test query');
|
||||
});
|
||||
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'thinking',
|
||||
thought: expect.objectContaining({ subject: 'Full thought' }),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps thought transient and clears it on first non-thought event', async () => {
|
||||
mockSendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
type: ServerGeminiEventType.Thought,
|
||||
value: {
|
||||
subject: 'Assessing intent',
|
||||
description: 'Inspecting context',
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: ServerGeminiEventType.Content,
|
||||
value: 'Model response content',
|
||||
};
|
||||
yield {
|
||||
type: ServerGeminiEventType.Finished,
|
||||
value: { reason: 'STOP', usageMetadata: undefined },
|
||||
};
|
||||
})(),
|
||||
);
|
||||
|
||||
const { result } = renderTestHook();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('Test query');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'gemini',
|
||||
text: 'Model response content',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.thought).toBeNull();
|
||||
expect(mockAddItem).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'thinking' }),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reset thought to null when starting a new prompt', async () => {
|
||||
// First, simulate a response with a thought
|
||||
mockSendMessageStream.mockReturnValue(
|
||||
|
||||
@@ -50,6 +50,7 @@ import type {
|
||||
import { type Part, type PartListUnion, FinishReason } from '@google/genai';
|
||||
import type {
|
||||
HistoryItem,
|
||||
HistoryItemThinking,
|
||||
HistoryItemWithoutId,
|
||||
HistoryItemToolGroup,
|
||||
IndividualToolCallDisplay,
|
||||
@@ -61,6 +62,7 @@ import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
|
||||
import { useShellCommandProcessor } from './shellCommandProcessor.js';
|
||||
import { handleAtCommand } from './atCommandProcessor.js';
|
||||
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
|
||||
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
|
||||
import { useStateAndRef } from './useStateAndRef.js';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import { useLogger } from './useLogger.js';
|
||||
@@ -192,9 +194,11 @@ export const useGeminiStream = (
|
||||
const turnCancelledRef = useRef(false);
|
||||
const activeQueryIdRef = useRef<string | null>(null);
|
||||
const [isResponding, setIsResponding] = useState<boolean>(false);
|
||||
const [thought, setThought] = useState<ThoughtSummary | null>(null);
|
||||
const [thought, thoughtRef, setThought] =
|
||||
useStateAndRef<ThoughtSummary | null>(null);
|
||||
const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] =
|
||||
useStateAndRef<HistoryItemWithoutId | null>(null);
|
||||
|
||||
const [lastGeminiActivityTime, setLastGeminiActivityTime] =
|
||||
useState<number>(0);
|
||||
const [pushedToolCallIds, pushedToolCallIdsRef, setPushedToolCallIds] =
|
||||
@@ -753,6 +757,7 @@ export const useGeminiStream = (
|
||||
pendingHistoryItemRef.current?.type !== 'gemini' &&
|
||||
pendingHistoryItemRef.current?.type !== 'gemini_content'
|
||||
) {
|
||||
// Flush any pending item before starting gemini content
|
||||
if (pendingHistoryItemRef.current) {
|
||||
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
||||
}
|
||||
@@ -798,6 +803,23 @@ export const useGeminiStream = (
|
||||
[addItem, pendingHistoryItemRef, setPendingHistoryItem],
|
||||
);
|
||||
|
||||
const handleThoughtEvent = useCallback(
|
||||
(eventValue: ThoughtSummary, userMessageTimestamp: number) => {
|
||||
setThought(eventValue);
|
||||
|
||||
if (getInlineThinkingMode(settings) === 'full') {
|
||||
addItem(
|
||||
{
|
||||
type: 'thinking',
|
||||
thought: eventValue,
|
||||
} as HistoryItemThinking,
|
||||
userMessageTimestamp,
|
||||
);
|
||||
}
|
||||
},
|
||||
[addItem, settings, setThought],
|
||||
);
|
||||
|
||||
const handleUserCancelledEvent = useCallback(
|
||||
(userMessageTimestamp: number) => {
|
||||
if (turnCancelledRef.current) {
|
||||
@@ -1067,10 +1089,17 @@ export const useGeminiStream = (
|
||||
let geminiMessageBuffer = '';
|
||||
const toolCallRequests: ToolCallRequestInfo[] = [];
|
||||
for await (const event of stream) {
|
||||
if (
|
||||
event.type !== ServerGeminiEventType.Thought &&
|
||||
thoughtRef.current !== null
|
||||
) {
|
||||
setThought(null);
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case ServerGeminiEventType.Thought:
|
||||
setLastGeminiActivityTime(Date.now());
|
||||
setThought(event.value);
|
||||
handleThoughtEvent(event.value, userMessageTimestamp);
|
||||
break;
|
||||
case ServerGeminiEventType.Content:
|
||||
setLastGeminiActivityTime(Date.now());
|
||||
@@ -1157,6 +1186,8 @@ export const useGeminiStream = (
|
||||
},
|
||||
[
|
||||
handleContentEvent,
|
||||
handleThoughtEvent,
|
||||
thoughtRef,
|
||||
handleUserCancelledEvent,
|
||||
handleErrorEvent,
|
||||
scheduleToolCalls,
|
||||
@@ -1171,6 +1202,7 @@ export const useGeminiStream = (
|
||||
addItem,
|
||||
pendingHistoryItemRef,
|
||||
setPendingHistoryItem,
|
||||
setThought,
|
||||
],
|
||||
);
|
||||
const submitQuery = useCallback(
|
||||
@@ -1351,6 +1383,7 @@ export const useGeminiStream = (
|
||||
config,
|
||||
startNewPrompt,
|
||||
getPromptCount,
|
||||
setThought,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -220,6 +220,11 @@ export interface ChatDetail {
|
||||
mtime: string;
|
||||
}
|
||||
|
||||
export type HistoryItemThinking = HistoryItemBase & {
|
||||
type: 'thinking';
|
||||
thought: ThoughtSummary;
|
||||
};
|
||||
|
||||
export type HistoryItemChatList = HistoryItemBase & {
|
||||
type: 'chat_list';
|
||||
chats: ChatDetail[];
|
||||
@@ -343,6 +348,7 @@ export type HistoryItemWithoutId =
|
||||
| HistoryItemAgentsList
|
||||
| HistoryItemMcpStatus
|
||||
| HistoryItemChatList
|
||||
| HistoryItemThinking
|
||||
| HistoryItemHooksList;
|
||||
|
||||
export type HistoryItem = HistoryItemWithoutId & { id: number };
|
||||
|
||||
15
packages/cli/src/ui/utils/inlineThinkingMode.ts
Normal file
15
packages/cli/src/ui/utils/inlineThinkingMode.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
export type InlineThinkingMode = 'off' | 'full';
|
||||
|
||||
export function getInlineThinkingMode(
|
||||
settings: LoadedSettings,
|
||||
): InlineThinkingMode {
|
||||
return settings.merged.ui?.inlineThinkingMode ?? 'off';
|
||||
}
|
||||
@@ -5,11 +5,15 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { isITerm2, resetITerm2Cache } from './terminalUtils.js';
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -18,25 +22,56 @@ describe('terminalUtils', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should detect iTerm2 via TERM_PROGRAM', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'iTerm.app');
|
||||
expect(isITerm2()).toBe(true);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return false if not iTerm2', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
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 cache the result', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'iTerm.app');
|
||||
expect(isITerm2()).toBe(true);
|
||||
it('should return true when utf8 (no hyphen) is supported', () => {
|
||||
vi.stubEnv('LANG', 'en_US.utf8');
|
||||
expect(shouldUseEmoji()).toBe(true);
|
||||
});
|
||||
|
||||
// Change env but should still be true due to cache
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
expect(isITerm2()).toBe(true);
|
||||
it('should check LC_ALL first', () => {
|
||||
vi.stubEnv('LC_ALL', 'en_US.UTF-8');
|
||||
vi.stubEnv('LANG', 'C');
|
||||
expect(shouldUseEmoji()).toBe(true);
|
||||
});
|
||||
|
||||
resetITerm2Cache();
|
||||
expect(isITerm2()).toBe(false);
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,3 +43,25 @@ 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user