mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 00:01:24 -07:00
Compare commits
24 Commits
planner
...
show_thinking
| Author | SHA1 | Date | |
|---|---|---|---|
| 697bc92110 | |||
| ab743ce94f | |||
| 62d0461d3a | |||
| e2cc788134 | |||
| bebc51982c | |||
| 874b8e1501 | |||
| 2490db7200 | |||
| f35143cdd3 | |||
| c5d2c0b2e3 | |||
| a55d278905 | |||
| 4620d5bb15 | |||
| 8b04b21648 | |||
| 364954851a | |||
| 8841361a6f | |||
| 66bbfa0dd4 | |||
| 0d8a3a0c8a | |||
| c773ae4681 | |||
| 1f9177f8a0 | |||
| cbf2efd6d7 | |||
| 648fd5dadd | |||
| f351a33426 | |||
| d58a127753 | |||
| 3a513f9890 | |||
| 3aefc66b7b |
@@ -42,6 +42,8 @@ they appear in the UI.
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------------ | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
|
||||
| Show Inline Thinking (Full) | `ui.showInlineThinkingFull` | Show full model thinking details inline. | `false` |
|
||||
| Show Inline Thinking (Summary) | `ui.showInlineThinkingSummary` | Show a short summary of model thinking inline. | `false` |
|
||||
| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` |
|
||||
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
|
||||
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
|
||||
|
||||
@@ -179,6 +179,14 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`ui.showInlineThinkingFull`** (boolean):
|
||||
- **Description:** Show full model thinking details inline.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ui.showInlineThinkingSummary`** (boolean):
|
||||
- **Description:** Show a short summary of model thinking inline.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ui.showStatusInTitle`** (boolean):
|
||||
- **Description:** Show Gemini CLI model thoughts in the terminal window title
|
||||
during the working phase
|
||||
|
||||
@@ -373,6 +373,24 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Hide the window title bar',
|
||||
showInDialog: true,
|
||||
},
|
||||
showInlineThinkingFull: {
|
||||
type: 'boolean',
|
||||
label: 'Show Inline Thinking (Full)',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Show full model thinking details inline.',
|
||||
showInDialog: true,
|
||||
},
|
||||
showInlineThinkingSummary: {
|
||||
type: 'boolean',
|
||||
label: 'Show Inline Thinking (Summary)',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Show a short summary of model thinking inline.',
|
||||
showInDialog: true,
|
||||
},
|
||||
showStatusInTitle: {
|
||||
type: 'boolean',
|
||||
label: 'Show Thoughts in Title',
|
||||
|
||||
@@ -42,6 +42,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 inlineEnabled = getInlineThinkingMode(settings) !== 'off';
|
||||
|
||||
// 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}
|
||||
inlineEnabled={inlineEnabled}
|
||||
/>
|
||||
))}
|
||||
{uiState.pendingHistoryItems.map((item, i) => (
|
||||
@@ -59,6 +64,7 @@ export const AlternateBufferQuittingDisplay = () => {
|
||||
isFocused={false}
|
||||
activeShellPtyId={uiState.activePtyId}
|
||||
embeddedShellFocused={uiState.embeddedShellFocused}
|
||||
inlineEnabled={inlineEnabled}
|
||||
/>
|
||||
))}
|
||||
{showPromptedTool && (
|
||||
|
||||
@@ -232,6 +232,34 @@ 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} inlineEnabled={true} />,
|
||||
);
|
||||
|
||||
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} inlineEnabled={false} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([true, false])(
|
||||
'gemini items (alternateBuffer=%s)',
|
||||
(useAlternateBuffer) => {
|
||||
|
||||
@@ -34,6 +34,7 @@ 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';
|
||||
|
||||
interface HistoryItemDisplayProps {
|
||||
item: HistoryItem;
|
||||
@@ -45,6 +46,7 @@ interface HistoryItemDisplayProps {
|
||||
activeShellPtyId?: number | null;
|
||||
embeddedShellFocused?: boolean;
|
||||
availableTerminalHeightGemini?: number;
|
||||
inlineEnabled?: boolean;
|
||||
}
|
||||
|
||||
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
@@ -57,12 +59,22 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
activeShellPtyId,
|
||||
embeddedShellFocused,
|
||||
availableTerminalHeightGemini,
|
||||
inlineEnabled,
|
||||
}) => {
|
||||
const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" key={itemForDisplay.id} width={terminalWidth}>
|
||||
{/* Render standard message types */}
|
||||
{itemForDisplay.type === 'thinking' && inlineEnabled && (
|
||||
<ThinkingMessage
|
||||
thought={itemForDisplay.thought}
|
||||
terminalWidth={terminalWidth}
|
||||
availableTerminalHeight={
|
||||
isPending ? availableTerminalHeight : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'user' && (
|
||||
<UserMessage text={itemForDisplay.text} width={terminalWidth} />
|
||||
)}
|
||||
|
||||
@@ -12,6 +12,21 @@ import { Box, Text } from 'ink';
|
||||
import type React from 'react';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../contexts/SettingsContext.js', async () => {
|
||||
const actual = await vi.importActual('../contexts/SettingsContext.js');
|
||||
return {
|
||||
...actual,
|
||||
useSettings: () => ({
|
||||
merged: {
|
||||
ui: {
|
||||
showInlineThinkingFull: false,
|
||||
showInlineThinkingSummary: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../contexts/AppContext.js', async () => {
|
||||
const actual = await vi.importActual('../contexts/AppContext.js');
|
||||
return {
|
||||
|
||||
@@ -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 inlineEnabled = getInlineThinkingMode(settings) !== 'off';
|
||||
|
||||
const historyItems = useMemo(
|
||||
() =>
|
||||
uiState.history.map((h) => (
|
||||
@@ -64,6 +69,7 @@ export const MainContent = () => {
|
||||
item={h}
|
||||
isPending={false}
|
||||
commands={uiState.slashCommands}
|
||||
inlineEnabled={inlineEnabled}
|
||||
/>
|
||||
)),
|
||||
[
|
||||
@@ -71,6 +77,7 @@ export const MainContent = () => {
|
||||
mainAreaWidth,
|
||||
staticAreaMaxItemHeight,
|
||||
uiState.slashCommands,
|
||||
inlineEnabled,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -91,6 +98,7 @@ export const MainContent = () => {
|
||||
isFocused={!uiState.isEditorDialogOpen}
|
||||
activeShellPtyId={uiState.activePtyId}
|
||||
embeddedShellFocused={uiState.embeddedShellFocused}
|
||||
inlineEnabled={inlineEnabled}
|
||||
/>
|
||||
))}
|
||||
{showConfirmationQueue && confirmingTool && (
|
||||
@@ -104,6 +112,7 @@ export const MainContent = () => {
|
||||
isAlternateBuffer,
|
||||
availableTerminalHeight,
|
||||
mainAreaWidth,
|
||||
inlineEnabled,
|
||||
uiState.isEditorDialogOpen,
|
||||
uiState.activePtyId,
|
||||
uiState.embeddedShellFocused,
|
||||
@@ -135,13 +144,20 @@ export const MainContent = () => {
|
||||
item={item.item}
|
||||
isPending={false}
|
||||
commands={uiState.slashCommands}
|
||||
inlineEnabled={inlineEnabled}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return pendingItems;
|
||||
}
|
||||
},
|
||||
[version, mainAreaWidth, uiState.slashCommands, pendingItems],
|
||||
[
|
||||
version,
|
||||
mainAreaWidth,
|
||||
uiState.slashCommands,
|
||||
inlineEnabled,
|
||||
pendingItems,
|
||||
],
|
||||
);
|
||||
|
||||
if (isAlternateBuffer) {
|
||||
|
||||
@@ -12,6 +12,16 @@ 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: {
|
||||
showInlineThinkingFull: false,
|
||||
showInlineThinkingSummary: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
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 inlineEnabled = getInlineThinkingMode(settings) !== 'off';
|
||||
|
||||
if (!uiState.quittingMessages) {
|
||||
return null;
|
||||
@@ -30,6 +34,7 @@ export const QuittingDisplay = () => {
|
||||
terminalWidth={terminalWidth}
|
||||
item={item}
|
||||
isPending={false}
|
||||
inlineEnabled={inlineEnabled}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -137,7 +137,7 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
height={0}
|
||||
height={1}
|
||||
width={mainAreaWidth}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { ThinkingMessage } from './ThinkingMessage.js';
|
||||
|
||||
describe('ThinkingMessage', () => {
|
||||
it('renders thinking subject', () => {
|
||||
const { lastFrame } = render(
|
||||
<ThinkingMessage
|
||||
thought={{ subject: 'Planning', description: 'test' }}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Planning');
|
||||
});
|
||||
|
||||
it('renders with thought subject', () => {
|
||||
const { lastFrame } = render(
|
||||
<ThinkingMessage
|
||||
thought={{ subject: 'Processing', description: 'test' }}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Processing');
|
||||
});
|
||||
|
||||
it('renders thought content', () => {
|
||||
const { lastFrame } = render(
|
||||
<ThinkingMessage
|
||||
thought={{
|
||||
subject: 'Planning',
|
||||
description: 'I am planning the solution.',
|
||||
}}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Planning');
|
||||
expect(lastFrame()).toContain('I am planning the solution.');
|
||||
});
|
||||
|
||||
it('renders empty state gracefully', () => {
|
||||
const { lastFrame } = render(
|
||||
<ThinkingMessage
|
||||
thought={{ subject: '', description: '' }}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).not.toContain('Planning');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { ThoughtSummary } from '@google/gemini-cli-core';
|
||||
import { MaxSizedBox, MINIMUM_MAX_HEIGHT } from '../shared/MaxSizedBox.js';
|
||||
import { IconText } from '../shared/IconText.js';
|
||||
|
||||
interface ThinkingMessageProps {
|
||||
thought: ThoughtSummary;
|
||||
terminalWidth: number;
|
||||
availableTerminalHeight?: number;
|
||||
}
|
||||
|
||||
export const ThinkingMessage: React.FC<ThinkingMessageProps> = ({
|
||||
thought,
|
||||
terminalWidth,
|
||||
availableTerminalHeight,
|
||||
}) => {
|
||||
const subject = thought.subject.trim();
|
||||
const description = thought.description.trim();
|
||||
const headerText = subject || description;
|
||||
const bodyText = subject ? description : '';
|
||||
const contentMaxWidth = Math.max(terminalWidth - 4, 1);
|
||||
const contentMaxHeight =
|
||||
availableTerminalHeight !== undefined
|
||||
? Math.max(availableTerminalHeight - 4, MINIMUM_MAX_HEIGHT)
|
||||
: undefined;
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor="magenta"
|
||||
width={terminalWidth}
|
||||
paddingX={1}
|
||||
marginBottom={1}
|
||||
flexDirection="column"
|
||||
>
|
||||
<MaxSizedBox
|
||||
maxHeight={contentMaxHeight}
|
||||
maxWidth={contentMaxWidth}
|
||||
overflowDirection="top"
|
||||
>
|
||||
{headerText && (
|
||||
<Box flexDirection="column">
|
||||
<IconText
|
||||
icon="💬"
|
||||
fallbackIcon="◆"
|
||||
text={headerText}
|
||||
color="magenta"
|
||||
bold
|
||||
/>
|
||||
{bodyText && <Text>{bodyText}</Text>}
|
||||
</Box>
|
||||
)}
|
||||
</MaxSizedBox>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -239,7 +239,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
*/
|
||||
(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && (
|
||||
<Box
|
||||
height={0}
|
||||
height={1}
|
||||
width={terminalWidth}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { shouldUseEmoji } from '../../utils/terminalUtils.js';
|
||||
|
||||
interface IconTextProps {
|
||||
icon: string;
|
||||
fallbackIcon?: string;
|
||||
text: string;
|
||||
color?: string;
|
||||
bold?: boolean;
|
||||
}
|
||||
|
||||
export const IconText: React.FC<IconTextProps> = ({
|
||||
icon,
|
||||
fallbackIcon,
|
||||
text,
|
||||
color,
|
||||
bold,
|
||||
}) => {
|
||||
const resolvedIcon = shouldUseEmoji() ? icon : (fallbackIcon ?? icon);
|
||||
return (
|
||||
<Text color={color} bold={bold}>
|
||||
{resolvedIcon} {text}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
@@ -53,6 +53,7 @@ import type {
|
||||
HistoryItem,
|
||||
HistoryItemWithoutId,
|
||||
HistoryItemToolGroup,
|
||||
HistoryItemThinking,
|
||||
IndividualToolCallDisplay,
|
||||
SlashCommandProcessorResult,
|
||||
HistoryItemModel,
|
||||
@@ -62,6 +63,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';
|
||||
@@ -77,6 +79,42 @@ import {
|
||||
} from './useToolScheduler.js';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const MAX_THOUGHT_SUMMARY_LENGTH = 140;
|
||||
|
||||
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 summarizeThought(thought: ThoughtSummary): ThoughtSummary {
|
||||
const subject = thought.subject.trim();
|
||||
if (subject) {
|
||||
return { subject, description: '' };
|
||||
}
|
||||
|
||||
const description = thought.description.trim();
|
||||
if (!description) {
|
||||
return { subject: '', description: '' };
|
||||
}
|
||||
|
||||
const descriptionGraphemes = splitGraphemes(description);
|
||||
if (descriptionGraphemes.length <= MAX_THOUGHT_SUMMARY_LENGTH) {
|
||||
return { subject: description, description: '' };
|
||||
}
|
||||
|
||||
const trimmed = descriptionGraphemes
|
||||
.slice(0, MAX_THOUGHT_SUMMARY_LENGTH - 3)
|
||||
.join('')
|
||||
.trimEnd();
|
||||
return { subject: `${trimmed}...`, description: '' };
|
||||
}
|
||||
import { useSessionStats } from '../contexts/SessionContext.js';
|
||||
import { useKeypress } from './useKeypress.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
@@ -196,6 +234,7 @@ export const useGeminiStream = (
|
||||
const [thought, setThought] = useState<ThoughtSummary | null>(null);
|
||||
const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] =
|
||||
useStateAndRef<HistoryItemWithoutId | null>(null);
|
||||
|
||||
const [lastGeminiActivityTime, setLastGeminiActivityTime] =
|
||||
useState<number>(0);
|
||||
const [pushedToolCallIds, pushedToolCallIdsRef, setPushedToolCallIds] =
|
||||
@@ -760,6 +799,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);
|
||||
}
|
||||
@@ -803,6 +843,31 @@ export const useGeminiStream = (
|
||||
[addItem, pendingHistoryItemRef, setPendingHistoryItem],
|
||||
);
|
||||
|
||||
const handleThoughtEvent = useCallback(
|
||||
(eventValue: ThoughtSummary, userMessageTimestamp: number) => {
|
||||
setThought(eventValue);
|
||||
|
||||
const inlineThinkingMode = getInlineThinkingMode(settings);
|
||||
if (inlineThinkingMode === 'off') {
|
||||
return;
|
||||
}
|
||||
|
||||
const thoughtForDisplay =
|
||||
inlineThinkingMode === 'summary'
|
||||
? summarizeThought(eventValue)
|
||||
: eventValue;
|
||||
|
||||
addItem(
|
||||
{
|
||||
type: 'thinking',
|
||||
thought: thoughtForDisplay,
|
||||
} as HistoryItemThinking,
|
||||
userMessageTimestamp,
|
||||
);
|
||||
},
|
||||
[addItem, settings],
|
||||
);
|
||||
|
||||
const handleUserCancelledEvent = useCallback(
|
||||
(userMessageTimestamp: number) => {
|
||||
if (turnCancelledRef.current) {
|
||||
@@ -1075,7 +1140,7 @@ export const useGeminiStream = (
|
||||
switch (event.type) {
|
||||
case ServerGeminiEventType.Thought:
|
||||
setLastGeminiActivityTime(Date.now());
|
||||
setThought(event.value);
|
||||
handleThoughtEvent(event.value, userMessageTimestamp);
|
||||
break;
|
||||
case ServerGeminiEventType.Content:
|
||||
setLastGeminiActivityTime(Date.now());
|
||||
@@ -1162,6 +1227,7 @@ export const useGeminiStream = (
|
||||
},
|
||||
[
|
||||
handleContentEvent,
|
||||
handleThoughtEvent,
|
||||
handleUserCancelledEvent,
|
||||
handleErrorEvent,
|
||||
scheduleToolCalls,
|
||||
|
||||
@@ -210,6 +210,11 @@ export interface ChatDetail {
|
||||
mtime: string;
|
||||
}
|
||||
|
||||
export type HistoryItemThinking = HistoryItemBase & {
|
||||
type: 'thinking';
|
||||
thought: ThoughtSummary;
|
||||
};
|
||||
|
||||
export type HistoryItemChatList = HistoryItemBase & {
|
||||
type: 'chat_list';
|
||||
chats: ChatDetail[];
|
||||
@@ -333,6 +338,7 @@ export type HistoryItemWithoutId =
|
||||
| HistoryItemAgentsList
|
||||
| HistoryItemMcpStatus
|
||||
| HistoryItemChatList
|
||||
| HistoryItemThinking
|
||||
| HistoryItemHooksList;
|
||||
|
||||
export type HistoryItem = HistoryItemWithoutId & { id: number };
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
export type InlineThinkingMode = 'off' | 'summary' | 'full';
|
||||
|
||||
export function getInlineThinkingMode(
|
||||
settings: LoadedSettings,
|
||||
): InlineThinkingMode {
|
||||
const ui = settings.merged.ui;
|
||||
|
||||
if (ui?.showInlineThinkingFull) {
|
||||
return 'full';
|
||||
}
|
||||
|
||||
if (ui?.showInlineThinkingSummary) {
|
||||
return 'summary';
|
||||
}
|
||||
|
||||
return 'off';
|
||||
}
|
||||
@@ -5,12 +5,16 @@
|
||||
*/
|
||||
|
||||
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('ITERM_SESSION_ID', '');
|
||||
vi.stubEnv('LC_ALL', '');
|
||||
vi.stubEnv('LC_CTYPE', '');
|
||||
vi.stubEnv('LANG', '');
|
||||
vi.stubEnv('TERM', '');
|
||||
resetITerm2Cache();
|
||||
});
|
||||
|
||||
@@ -19,30 +23,61 @@ 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 detect iTerm2 via ITERM_SESSION_ID', () => {
|
||||
vi.stubEnv('ITERM_SESSION_ID', 'w0t0p0:6789...');
|
||||
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 detect iTerm2 via ITERM_SESSION_ID', () => {
|
||||
vi.stubEnv('ITERM_SESSION_ID', 'w0t0p0:6789...');
|
||||
expect(isITerm2()).toBe(true);
|
||||
});
|
||||
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 false if not iTerm2', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
expect(isITerm2()).toBe(false);
|
||||
});
|
||||
it('should return true when utf8 (no hyphen) is supported', () => {
|
||||
vi.stubEnv('LANG', 'en_US.utf8');
|
||||
expect(shouldUseEmoji()).toBe(true);
|
||||
});
|
||||
|
||||
it('should cache the result', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'iTerm.app');
|
||||
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);
|
||||
});
|
||||
|
||||
// Change env but should still be true due to cache
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
expect(isITerm2()).toBe(true);
|
||||
it('should return false when UTF-8 is not supported', () => {
|
||||
vi.stubEnv('LANG', 'C');
|
||||
expect(shouldUseEmoji()).toBe(false);
|
||||
});
|
||||
|
||||
resetITerm2Cache();
|
||||
expect(isITerm2()).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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,3 +45,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;
|
||||
}
|
||||
|
||||
@@ -187,6 +187,20 @@
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"showInlineThinkingFull": {
|
||||
"title": "Show Inline Thinking (Full)",
|
||||
"description": "Show full model thinking details inline.",
|
||||
"markdownDescription": "Show full model thinking details inline.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"showInlineThinkingSummary": {
|
||||
"title": "Show Inline Thinking (Summary)",
|
||||
"description": "Show a short summary of model thinking inline.",
|
||||
"markdownDescription": "Show a short summary of model thinking inline.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"showStatusInTitle": {
|
||||
"title": "Show Thoughts in Title",
|
||||
"description": "Show Gemini CLI model thoughts in the terminal window title during the working phase",
|
||||
|
||||
Reference in New Issue
Block a user