Compare commits

...

28 Commits

Author SHA1 Message Date
Dmitry Lyalin 697bc92110 refactor: move emoji detection to terminalUtils and add tests 2026-02-02 17:53:23 -05:00
Dmitry Lyalin ab743ce94f Merge branch 'main' into show_thinking 2026-02-02 07:39:08 -08:00
Victor Vianna 381669dce0 chore: delete autoAccept setting unused in production (#17862)
Co-authored-by: Victor Hugo Vianna Silva <victorvianna@google.com>
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
2026-02-02 13:49:17 +00:00
Dmitry Lyalin 62d0461d3a Merge branch 'main' into show_thinking 2026-02-01 12:45:55 -08:00
Dmitry Lyalin e2cc788134 misc. 2026-02-01 15:44:54 -05:00
Dmitry Lyalin bebc51982c Fix inline thinking box width 2026-02-01 15:16:22 -05:00
Bryan Morgan 707b3e85d5 fix(ci): prevent stale PR closer from incorrectly closing new PRs (#18069) 2026-02-01 15:13:29 -05:00
Dmitry Lyalin 874b8e1501 Merge branch 'google-gemini:main' into show_thinking 2026-02-01 12:08:20 -08:00
Dmitry Lyalin 2490db7200 Remove deprecated inline thinking setting 2026-02-01 15:05:36 -05:00
Bryan Morgan 7d36cc004f feat(acp): add session resume support (#18043)
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
2026-02-01 19:16:57 +00:00
Dmitry Lyalin cb4f0c6fa4 Fix Esc cancel during streaming (#18039) 2026-02-01 16:16:14 +00:00
Dmitry Lyalin f35143cdd3 Regenerate settings schema and docs 2026-01-31 19:16:00 -05:00
Dmitry Lyalin c5d2c0b2e3 Add reusable IconText with emoji fallback 2026-01-31 14:19:02 -05:00
Dmitry Lyalin a55d278905 Add emoji thought bubble with fallback 2026-01-31 11:35:59 -05:00
Dmitry Lyalin 4620d5bb15 Simplify inline thinking display 2026-01-31 11:29:12 -05:00
Dmitry Lyalin 8b04b21648 Add inline thinking mode helper 2026-01-31 11:23:44 -05:00
Dmitry Lyalin 364954851a Show inline thoughts as individual bubbles 2026-01-31 11:22:47 -05:00
Dmitry Lyalin 8841361a6f Stabilize inline thinking box during streaming 2026-01-31 11:07:06 -05:00
Dmitry Lyalin 66bbfa0dd4 Fix missing bottom border in tool boxes 2026-01-31 10:57:33 -05:00
Dmitry Lyalin 0d8a3a0c8a Merge remote-tracking branch 'origin/show_thinking' into show_thinking 2026-01-31 10:45:42 -05:00
Dmitry Lyalin c773ae4681 Fix inline thinking in alternate buffer and pending renders 2026-01-31 10:44:30 -05:00
Dmitry Lyalin 1f9177f8a0 Merge branch 'google-gemini:main' into show_thinking 2026-01-31 07:39:10 -08:00
Dmitry Lyalin cbf2efd6d7 Merge branch 'main' into show_thinking
# Conflicts:
#	packages/cli/src/ui/components/MainContent.test.tsx
#	packages/cli/src/ui/components/MainContent.tsx
#	packages/cli/src/ui/hooks/useGeminiStream.ts
2026-01-31 10:35:42 -05:00
Dmitry Lyalin 648fd5dadd Unify thought handling into pendingHistoryItem by removing thoughtsBuffer and simplifying flushing logic via handleThoughtEvent 2026-01-04 19:15:57 -05:00
Dmitry Lyalin f351a33426 Merge branch 'main' into show_thinking 2026-01-04 18:51:29 -05:00
Dmitry Lyalin d58a127753 test commit 2026-01-03 19:00:15 -05:00
Dmitry Lyalin 3a513f9890 Fixing broken tests, adding UI setting to docs 2025-12-31 15:09:51 -05:00
Dmitry Lyalin 3aefc66b7b first commit 2025-12-31 15:00:35 -05:00
34 changed files with 1146 additions and 279 deletions
@@ -43,14 +43,17 @@ jobs:
// 1. Fetch maintainers for verification
let maintainerLogins = new Set();
let teamFetchSucceeded = false;
try {
const members = await github.paginate(github.rest.teams.listMembersInOrg, {
org: context.repo.owner,
team_slug: 'gemini-cli-maintainers'
});
maintainerLogins = new Set(members.map(m => m.login.toLowerCase()));
teamFetchSucceeded = true;
core.info(`Successfully fetched ${maintainerLogins.size} team members from gemini-cli-maintainers`);
} catch (e) {
core.warning('Failed to fetch team members');
core.warning(`Failed to fetch team members from gemini-cli-maintainers: ${e.message}. Falling back to author_association only.`);
}
const isMaintainer = (login, assoc) => {
@@ -154,7 +157,17 @@ jobs:
const labels = pr.labels.map(l => l.name.toLowerCase());
if (labels.includes('help wanted') || labels.includes('🔒 maintainer only')) continue;
let lastActivity = new Date(0);
// Skip PRs that were created less than 30 days ago - they cannot be stale yet
const prCreatedAt = new Date(pr.created_at);
if (prCreatedAt > thirtyDaysAgo) {
const daysOld = Math.floor((Date.now() - prCreatedAt.getTime()) / (1000 * 60 * 60 * 24));
core.info(`PR #${pr.number} was created ${daysOld} days ago. Skipping staleness check.`);
continue;
}
// Initialize lastActivity to PR creation date (not epoch) as a safety baseline.
// This ensures we never incorrectly mark a PR as stale due to failed activity lookups.
let lastActivity = new Date(pr.created_at);
try {
const reviews = await github.paginate(github.rest.pulls.listReviews, {
owner: context.repo.owner,
@@ -178,8 +191,12 @@ jobs:
if (d > lastActivity) lastActivity = d;
}
}
} catch (e) {}
} catch (e) {
core.warning(`Failed to fetch reviews/comments for PR #${pr.number}: ${e.message}`);
}
// For maintainer PRs, the PR creation itself counts as maintainer activity.
// (Now redundant since we initialize to pr.created_at, but kept for clarity)
if (maintainerPr) {
const d = new Date(pr.created_at);
if (d > lastActivity) lastActivity = d;
+2 -1
View File
@@ -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` |
@@ -97,7 +99,6 @@ they appear in the UI.
| -------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
| Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` |
| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` |
| Auto Accept | `tools.autoAccept` | Automatically accept and execute tool calls that are considered safe (e.g., read-only operations). | `false` |
| Approval Mode | `tools.approvalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` |
| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` |
| Enable Tool Output Truncation | `tools.enableToolOutputTruncation` | Enable truncation of large tool outputs. | `true` |
-8
View File
@@ -215,14 +215,6 @@ a few things you can try in order of recommendation:
MCP servers of their own. This should not be used as an airtight security
mechanism.
- **`autoAccept`** (boolean):
- **Description:** Controls whether the CLI automatically accepts and executes
tool calls that are considered safe (e.g., read-only operations) without
explicit user confirmation. If set to `true`, the CLI will bypass the
confirmation prompt for tools deemed safe.
- **Default:** `false`
- **Example:** `"autoAccept": true`
- **`theme`** (string):
- **Description:** Sets the visual [theme](../cli/themes.md) for Gemini CLI.
- **Default:** `"Default"`
+8 -5
View File
@@ -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
@@ -661,11 +669,6 @@ their corresponding top-level category object in your `settings.json` file.
performance.
- **Default:** `true`
- **`tools.autoAccept`** (boolean):
- **Description:** Automatically accept and execute tool calls that are
considered safe (e.g., read-only operations).
- **Default:** `false`
- **`tools.approvalMode`** (enum):
- **Description:** The default approval mode for tool execution. 'default'
prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is
@@ -164,7 +164,6 @@ describe('Policy Engine Integration Tests', () => {
it('should handle complex mixed configurations', async () => {
const settings: Settings = {
tools: {
autoAccept: true, // Allows read-only tools
allowed: ['custom-tool', 'my-server__special-tool'],
exclude: ['glob', 'dangerous-tool'],
},
@@ -438,7 +437,6 @@ describe('Policy Engine Integration Tests', () => {
it('should verify priority ordering works correctly in practice', async () => {
const settings: Settings = {
tools: {
autoAccept: true, // Priority 50
allowed: ['specific-tool'], // Priority 100
exclude: ['blocked-tool'], // Priority 200
},
@@ -614,7 +612,6 @@ describe('Policy Engine Integration Tests', () => {
it('should verify rules are created with correct priorities', async () => {
const settings: Settings = {
tools: {
autoAccept: true,
allowed: ['tool1', 'tool2'],
exclude: ['tool3'],
},
@@ -124,7 +124,6 @@ describe('settings-validation', () => {
},
tools: {
sandbox: 'inherit',
autoAccept: false,
},
};
+18 -11
View File
@@ -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',
@@ -1041,17 +1059,6 @@ const SETTINGS_SCHEMA = {
},
},
},
autoAccept: {
type: 'boolean',
label: 'Auto Accept',
category: 'Tools',
requiresRestart: false,
default: false,
description: oneLine`
Automatically accept and execute tool calls that are considered safe (e.g., read-only operations).
`,
showInDialog: true,
},
approvalMode: {
type: 'enum',
label: 'Approval Mode',
+1
View File
@@ -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} />
)}
@@ -482,6 +482,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return false;
}
if (
key.name === 'escape' &&
(streamingState === StreamingState.Responding ||
streamingState === StreamingState.WaitingForConfirmation)
) {
return false;
}
if (key.name === 'paste') {
// Record paste time to prevent accidental auto-submission
if (!isTerminalPasteTrusted(kittyProtocol.enabled)) {
@@ -977,6 +985,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
backgroundShells.size,
backgroundShellHeight,
history,
streamingState,
],
);
@@ -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 {
+17 -1
View File
@@ -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>
@@ -1391,7 +1391,6 @@ describe('SettingsDialog', () => {
},
tools: {
enableInteractiveShell: true,
autoAccept: true,
useRipgrep: true,
},
security: {
@@ -1484,7 +1483,6 @@ describe('SettingsDialog', () => {
userSettings: {
tools: {
enableInteractiveShell: true,
autoAccept: false,
useRipgrep: true,
truncateToolOutputThreshold: 25000,
truncateToolOutputLines: 500,
@@ -1537,7 +1535,6 @@ describe('SettingsDialog', () => {
},
tools: {
enableInteractiveShell: false,
autoAccept: false,
useRipgrep: false,
},
security: {
@@ -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>
);
};
+67 -1
View File
@@ -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,
@@ -24,7 +24,14 @@ import { coreEvents } from '@google/gemini-cli-core';
// Mock modules
vi.mock('fs/promises');
vi.mock('path');
vi.mock('../../utils/sessionUtils.js');
vi.mock('../../utils/sessionUtils.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../utils/sessionUtils.js')>();
return {
...actual,
getSessionFiles: vi.fn(),
};
});
const MOCKED_PROJECT_TEMP_DIR = '/test/project/temp';
const MOCKED_CHATS_DIR = '/test/project/temp/chats';
+5 -191
View File
@@ -13,11 +13,12 @@ import type {
ConversationRecord,
ResumedSessionData,
} from '@google/gemini-cli-core';
import type { Part } from '@google/genai';
import { partListUnionToString, coreEvents } from '@google/gemini-cli-core';
import { checkExhaustive } from '../../utils/checks.js';
import { coreEvents } from '@google/gemini-cli-core';
import type { SessionInfo } from '../../utils/sessionUtils.js';
import { MessageType, ToolCallStatus } from '../types.js';
import { convertSessionToHistoryFormats } from '../../utils/sessionUtils.js';
import type { Part } from '@google/genai';
export { convertSessionToHistoryFormats };
export const useSessionBrowser = (
config: Config,
@@ -112,190 +113,3 @@ export const useSessionBrowser = (
),
};
};
/**
* Converts session/conversation data into UI history and Gemini client history formats.
*/
export function convertSessionToHistoryFormats(
messages: ConversationRecord['messages'],
): {
uiHistory: HistoryItemWithoutId[];
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>;
} {
const uiHistory: HistoryItemWithoutId[] = [];
for (const msg of messages) {
// Add the message only if it has content
const displayContentString = msg.displayContent
? partListUnionToString(msg.displayContent)
: undefined;
const contentString = partListUnionToString(msg.content);
const uiText = displayContentString || contentString;
if (uiText.trim()) {
let messageType: MessageType;
switch (msg.type) {
case 'user':
messageType = MessageType.USER;
break;
case 'info':
messageType = MessageType.INFO;
break;
case 'error':
messageType = MessageType.ERROR;
break;
case 'warning':
messageType = MessageType.WARNING;
break;
case 'gemini':
messageType = MessageType.GEMINI;
break;
default:
checkExhaustive(msg);
messageType = MessageType.GEMINI;
break;
}
uiHistory.push({
type: messageType,
text: uiText,
});
}
// Add tool calls if present
if (
msg.type !== 'user' &&
'toolCalls' in msg &&
msg.toolCalls &&
msg.toolCalls.length > 0
) {
uiHistory.push({
type: 'tool_group',
tools: msg.toolCalls.map((tool) => ({
callId: tool.id,
name: tool.displayName || tool.name,
description: tool.description || '',
renderOutputAsMarkdown: tool.renderOutputAsMarkdown ?? true,
status:
tool.status === 'success'
? ToolCallStatus.Success
: ToolCallStatus.Error,
resultDisplay: tool.resultDisplay,
confirmationDetails: undefined,
})),
});
}
}
// Convert to Gemini client history format
const clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }> = [];
for (const msg of messages) {
// Skip system/error messages and user slash commands
if (msg.type === 'info' || msg.type === 'error' || msg.type === 'warning') {
continue;
}
if (msg.type === 'user') {
// Skip user slash commands
const contentString = partListUnionToString(msg.content);
if (
contentString.trim().startsWith('/') ||
contentString.trim().startsWith('?')
) {
continue;
}
// Add regular user message
clientHistory.push({
role: 'user',
parts: Array.isArray(msg.content)
? (msg.content as Part[])
: [{ text: contentString }],
});
} else if (msg.type === 'gemini') {
// Handle Gemini messages with potential tool calls
const hasToolCalls = msg.toolCalls && msg.toolCalls.length > 0;
if (hasToolCalls) {
// Create model message with function calls
const modelParts: Part[] = [];
// Add text content if present
const contentString = partListUnionToString(msg.content);
if (msg.content && contentString.trim()) {
modelParts.push({ text: contentString });
}
// Add function calls
for (const toolCall of msg.toolCalls!) {
modelParts.push({
functionCall: {
name: toolCall.name,
args: toolCall.args,
...(toolCall.id && { id: toolCall.id }),
},
});
}
clientHistory.push({
role: 'model',
parts: modelParts,
});
// Create single function response message with all tool call responses
const functionResponseParts: Part[] = [];
for (const toolCall of msg.toolCalls!) {
if (toolCall.result) {
// Convert PartListUnion result to function response format
let responseData: Part;
if (typeof toolCall.result === 'string') {
responseData = {
functionResponse: {
id: toolCall.id,
name: toolCall.name,
response: {
output: toolCall.result,
},
},
};
} else if (Array.isArray(toolCall.result)) {
// toolCall.result is an array containing properly formatted
// function responses
functionResponseParts.push(...(toolCall.result as Part[]));
continue;
} else {
// Fallback for non-array results
responseData = toolCall.result;
}
functionResponseParts.push(responseData);
}
}
// Only add user message if we have function responses
if (functionResponseParts.length > 0) {
clientHistory.push({
role: 'user',
parts: functionResponseParts,
});
}
} else {
// Regular Gemini message without tool calls
const contentString = partListUnionToString(msg.content);
if (msg.content && contentString.trim()) {
clientHistory.push({
role: 'model',
parts: [{ text: contentString }],
});
}
}
}
}
return {
uiHistory,
clientHistory,
};
}
+6
View File
@@ -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';
}
+55 -20
View File
@@ -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;
}
+194
View File
@@ -16,6 +16,13 @@ import {
import * as fs from 'node:fs/promises';
import path from 'node:path';
import { stripUnsafeCharacters } from '../ui/utils/textUtils.js';
import type { Part } from '@google/genai';
import { checkExhaustive } from './checks.js';
import {
MessageType,
ToolCallStatus,
type HistoryItemWithoutId,
} from '../ui/types.js';
/**
* Constant for the resume "latest" identifier.
@@ -514,3 +521,190 @@ export class SessionSelector {
}
}
}
/**
* Converts session/conversation data into UI history and Gemini client history formats.
*/
export function convertSessionToHistoryFormats(
messages: ConversationRecord['messages'],
): {
uiHistory: HistoryItemWithoutId[];
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>;
} {
const uiHistory: HistoryItemWithoutId[] = [];
for (const msg of messages) {
// Add the message only if it has content
const displayContentString = msg.displayContent
? partListUnionToString(msg.displayContent)
: undefined;
const contentString = partListUnionToString(msg.content);
const uiText = displayContentString || contentString;
if (uiText.trim()) {
let messageType: MessageType;
switch (msg.type) {
case 'user':
messageType = MessageType.USER;
break;
case 'info':
messageType = MessageType.INFO;
break;
case 'error':
messageType = MessageType.ERROR;
break;
case 'warning':
messageType = MessageType.WARNING;
break;
case 'gemini':
messageType = MessageType.GEMINI;
break;
default:
checkExhaustive(msg);
messageType = MessageType.GEMINI;
break;
}
uiHistory.push({
type: messageType,
text: uiText,
});
}
// Add tool calls if present
if (
msg.type !== 'user' &&
'toolCalls' in msg &&
msg.toolCalls &&
msg.toolCalls.length > 0
) {
uiHistory.push({
type: 'tool_group',
tools: msg.toolCalls.map((tool) => ({
callId: tool.id,
name: tool.displayName || tool.name,
description: tool.description || '',
renderOutputAsMarkdown: tool.renderOutputAsMarkdown ?? true,
status:
tool.status === 'success'
? ToolCallStatus.Success
: ToolCallStatus.Error,
resultDisplay: tool.resultDisplay,
confirmationDetails: undefined,
})),
});
}
}
// Convert to Gemini client history format
const clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }> = [];
for (const msg of messages) {
// Skip system/error messages and user slash commands
if (msg.type === 'info' || msg.type === 'error' || msg.type === 'warning') {
continue;
}
if (msg.type === 'user') {
// Skip user slash commands
const contentString = partListUnionToString(msg.content);
if (
contentString.trim().startsWith('/') ||
contentString.trim().startsWith('?')
) {
continue;
}
// Add regular user message
clientHistory.push({
role: 'user',
parts: Array.isArray(msg.content)
? (msg.content as Part[])
: [{ text: contentString }],
});
} else if (msg.type === 'gemini') {
// Handle Gemini messages with potential tool calls
const hasToolCalls = msg.toolCalls && msg.toolCalls.length > 0;
if (hasToolCalls) {
// Create model message with function calls
const modelParts: Part[] = [];
// Add text content if present
const contentString = partListUnionToString(msg.content);
if (msg.content && contentString.trim()) {
modelParts.push({ text: contentString });
}
// Add function calls
for (const toolCall of msg.toolCalls!) {
modelParts.push({
functionCall: {
name: toolCall.name,
args: toolCall.args,
...(toolCall.id && { id: toolCall.id }),
},
});
}
clientHistory.push({
role: 'model',
parts: modelParts,
});
// Create single function response message with all tool call responses
const functionResponseParts: Part[] = [];
for (const toolCall of msg.toolCalls!) {
if (toolCall.result) {
// Convert PartListUnion result to function response format
let responseData: Part;
if (typeof toolCall.result === 'string') {
responseData = {
functionResponse: {
id: toolCall.id,
name: toolCall.name,
response: {
output: toolCall.result,
},
},
};
} else if (Array.isArray(toolCall.result)) {
// toolCall.result is an array containing properly formatted
// function responses
functionResponseParts.push(...(toolCall.result as Part[]));
continue;
} else {
// Fallback for non-array results
responseData = toolCall.result;
}
functionResponseParts.push(responseData);
}
}
// Only add user message if we have function responses
if (functionResponseParts.length > 0) {
clientHistory.push({
role: 'user',
parts: functionResponseParts,
});
}
} else {
// Regular Gemini message without tool calls
const contentString = partListUnionToString(msg.content);
if (msg.content && contentString.trim()) {
clientHistory.push({
role: 'model',
parts: [{ text: contentString }],
});
}
}
}
}
return {
uiHistory,
clientHistory,
};
}
@@ -0,0 +1,224 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
type Mocked,
type Mock,
} from 'vitest';
import { GeminiAgent } from './zedIntegration.js';
import * as acp from '@agentclientprotocol/sdk';
import { AuthType, type Config } from '@google/gemini-cli-core';
import { loadCliConfig, type CliArgs } from '../config/config.js';
import {
SessionSelector,
convertSessionToHistoryFormats,
} from '../utils/sessionUtils.js';
import type { LoadedSettings } from '../config/settings.js';
vi.mock('../config/config.js', () => ({
loadCliConfig: vi.fn(),
}));
vi.mock('../utils/sessionUtils.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../utils/sessionUtils.js')>();
return {
...actual,
SessionSelector: vi.fn(),
convertSessionToHistoryFormats: vi.fn(),
};
});
describe('GeminiAgent Session Resume', () => {
let mockConfig: Mocked<Config>;
let mockSettings: Mocked<LoadedSettings>;
let mockArgv: CliArgs;
let mockConnection: Mocked<acp.AgentSideConnection>;
let agent: GeminiAgent;
beforeEach(() => {
mockConfig = {
refreshAuth: vi.fn().mockResolvedValue(undefined),
initialize: vi.fn().mockResolvedValue(undefined),
getFileSystemService: vi.fn(),
setFileSystemService: vi.fn(),
getGeminiClient: vi.fn().mockReturnValue({
initialize: vi.fn().mockResolvedValue(undefined),
resumeChat: vi.fn().mockResolvedValue(undefined),
getChat: vi.fn().mockReturnValue({}),
}),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
},
} as unknown as Mocked<Config>;
mockSettings = {
merged: {
security: { auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE } },
mcpServers: {},
},
setValue: vi.fn(),
} as unknown as Mocked<LoadedSettings>;
mockArgv = {} as unknown as CliArgs;
mockConnection = {
sessionUpdate: vi.fn().mockResolvedValue(undefined),
} as unknown as Mocked<acp.AgentSideConnection>;
(loadCliConfig as Mock).mockResolvedValue(mockConfig);
agent = new GeminiAgent(mockConfig, mockSettings, mockArgv, mockConnection);
});
it('should advertise loadSession capability', async () => {
const response = await agent.initialize({
protocolVersion: acp.PROTOCOL_VERSION,
});
expect(response.agentCapabilities?.loadSession).toBe(true);
});
it('should load a session, resume chat, and stream all message types', async () => {
const sessionId = 'existing-session-id';
const sessionData = {
sessionId,
messages: [
{ type: 'user', content: [{ text: 'Hello' }] },
{
type: 'gemini',
content: [{ text: 'Hi there' }],
thoughts: [{ subject: 'Thinking', description: 'about greeting' }],
toolCalls: [
{
id: 'call-1',
name: 'test_tool',
displayName: 'Test Tool',
status: 'success',
resultDisplay: 'Tool output',
},
],
},
{
type: 'gemini',
content: [{ text: 'Trying a write' }],
toolCalls: [
{
id: 'call-2',
name: 'write_file',
displayName: 'Write File',
status: 'error',
resultDisplay: 'Permission denied',
},
],
},
],
};
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
getTool: vi.fn().mockReturnValue({ kind: 'read' }),
});
(SessionSelector as unknown as Mock).mockImplementation(() => ({
resolveSession: vi.fn().mockResolvedValue({
sessionData,
sessionPath: '/path/to/session.json',
}),
}));
const mockClientHistory = [
{ role: 'user', parts: [{ text: 'Hello' }] },
{ role: 'model', parts: [{ text: 'Hi there' }] },
];
(convertSessionToHistoryFormats as unknown as Mock).mockReturnValue({
clientHistory: mockClientHistory,
uiHistory: [],
});
const response = await agent.loadSession({
sessionId,
cwd: '/tmp',
mcpServers: [],
});
expect(response).toEqual({});
// Verify resumeChat received the correct arguments
expect(mockConfig.getGeminiClient().resumeChat).toHaveBeenCalledWith(
mockClientHistory,
expect.objectContaining({
conversation: sessionData,
filePath: '/path/to/session.json',
}),
);
await vi.waitFor(() => {
// User message
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'user_message_chunk',
content: expect.objectContaining({ text: 'Hello' }),
}),
}),
);
// Agent thought
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'agent_thought_chunk',
content: expect.objectContaining({
text: '**Thinking**\nabout greeting',
}),
}),
}),
);
// Agent message
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'agent_message_chunk',
content: expect.objectContaining({ text: 'Hi there' }),
}),
}),
);
// Successful tool call → 'completed'
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'tool_call',
toolCallId: 'call-1',
status: 'completed',
title: 'Test Tool',
kind: 'read',
content: [
{
type: 'content',
content: { type: 'text', text: 'Tool output' },
},
],
}),
}),
);
// Failed tool call → 'failed'
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'tool_call',
toolCallId: 'call-2',
status: 'failed',
title: 'Write File',
}),
}),
);
});
});
});
@@ -129,7 +129,7 @@ describe('GeminiAgent', () => {
expect(response.protocolVersion).toBe(acp.PROTOCOL_VERSION);
expect(response.authMethods).toHaveLength(3);
expect(response.agentCapabilities?.loadSession).toBe(false);
expect(response.agentCapabilities?.loadSession).toBe(true);
});
it('should authenticate correctly', async () => {
@@ -273,6 +273,7 @@ describe('Session', () => {
mockChat = {
sendMessageStream: vi.fn(),
addHistory: vi.fn(),
recordCompletedToolCalls: vi.fn(),
} as unknown as Mocked<GeminiChat>;
mockTool = {
kind: 'native',
@@ -293,6 +294,7 @@ describe('Session', () => {
} as unknown as Mocked<MessageBus>;
mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
getPreviewFeatures: vi.fn().mockReturnValue({}),
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
getFileService: vi.fn().mockReturnValue({
@@ -10,6 +10,7 @@ import type {
ToolResult,
ToolCallConfirmationDetails,
FilterFilesOptions,
ConversationRecord,
} from '@google/gemini-cli-core';
import {
AuthType,
@@ -32,6 +33,7 @@ import {
createWorkingStdio,
startupProfiler,
Kind,
partListUnionToString,
} from '@google/gemini-cli-core';
import * as acp from '@agentclientprotocol/sdk';
import { AcpFileSystemService } from './fileSystemService.js';
@@ -47,6 +49,10 @@ import { randomUUID } from 'node:crypto';
import type { CliArgs } from '../config/config.js';
import { loadCliConfig } from '../config/config.js';
import { runExitCleanup } from '../utils/cleanup.js';
import {
SessionSelector,
convertSessionToHistoryFormats,
} from '../utils/sessionUtils.js';
export async function runZedIntegration(
config: Config,
@@ -107,7 +113,7 @@ export class GeminiAgent {
protocolVersion: acp.PROTOCOL_VERSION,
authMethods,
agentCapabilities: {
loadSession: false,
loadSession: true,
promptCapabilities: {
image: true,
audio: true,
@@ -146,23 +152,11 @@ export class GeminiAgent {
mcpServers,
}: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
const sessionId = randomUUID();
const config = await this.newSessionConfig(sessionId, cwd, mcpServers);
let isAuthenticated = false;
if (this.settings.merged.security.auth.selectedType) {
try {
await config.refreshAuth(
this.settings.merged.security.auth.selectedType,
);
isAuthenticated = true;
} catch (e) {
debugLogger.error(`Authentication failed: ${e}`);
}
}
if (!isAuthenticated) {
throw acp.RequestError.authRequired();
}
const config = await this.initializeSessionConfig(
sessionId,
cwd,
mcpServers,
);
if (this.clientCapabilities?.fs) {
const acpFileSystemService = new AcpFileSystemService(
@@ -184,6 +178,88 @@ export class GeminiAgent {
};
}
async loadSession({
sessionId,
cwd,
mcpServers,
}: acp.LoadSessionRequest): Promise<acp.LoadSessionResponse> {
const config = await this.initializeSessionConfig(
sessionId,
cwd,
mcpServers,
);
const sessionSelector = new SessionSelector(config);
const { sessionData, sessionPath } =
await sessionSelector.resolveSession(sessionId);
if (this.clientCapabilities?.fs) {
const acpFileSystemService = new AcpFileSystemService(
this.connection,
sessionId,
this.clientCapabilities.fs,
config.getFileSystemService(),
);
config.setFileSystemService(acpFileSystemService);
}
const { clientHistory } = convertSessionToHistoryFormats(
sessionData.messages,
);
const geminiClient = config.getGeminiClient();
await geminiClient.initialize();
await geminiClient.resumeChat(clientHistory, {
conversation: sessionData,
filePath: sessionPath,
});
const session = new Session(
sessionId,
geminiClient.getChat(),
config,
this.connection,
);
this.sessions.set(sessionId, session);
// Stream history back to client
// eslint-disable-next-line @typescript-eslint/no-floating-promises
session.streamHistory(sessionData.messages);
return {};
}
private async initializeSessionConfig(
sessionId: string,
cwd: string,
mcpServers: acp.McpServer[],
): Promise<Config> {
const selectedAuthType = this.settings.merged.security.auth.selectedType;
if (!selectedAuthType) {
throw acp.RequestError.authRequired();
}
// 1. Create config WITHOUT initializing it (no MCP servers started yet)
const config = await this.newSessionConfig(sessionId, cwd, mcpServers);
// 2. Authenticate BEFORE initializing configuration or starting MCP servers.
// This satisfies the security requirement to verify the user before executing
// potentially unsafe server definitions.
try {
await config.refreshAuth(selectedAuthType);
} catch (e) {
debugLogger.error(`Authentication failed: ${e}`);
throw acp.RequestError.authRequired();
}
// 3. Now that we are authenticated, it is safe to initialize the config
// which starts the MCP servers and other heavy resources.
await config.initialize();
startupProfiler.flush(config);
return config;
}
async newSessionConfig(
sessionId: string,
cwd: string,
@@ -228,8 +304,6 @@ export class GeminiAgent {
const config = await loadCliConfig(settings, sessionId, this.argv, { cwd });
await config.initialize();
startupProfiler.flush(config);
return config;
}
@@ -269,6 +343,73 @@ export class Session {
this.pendingPrompt = null;
}
async streamHistory(messages: ConversationRecord['messages']): Promise<void> {
for (const msg of messages) {
const contentString = partListUnionToString(msg.content);
if (msg.type === 'user') {
if (contentString.trim()) {
await this.sendUpdate({
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: contentString },
});
}
} else if (msg.type === 'gemini') {
// Thoughts
if (msg.thoughts) {
for (const thought of msg.thoughts) {
const thoughtText = `**${thought.subject}**\n${thought.description}`;
await this.sendUpdate({
sessionUpdate: 'agent_thought_chunk',
content: { type: 'text', text: thoughtText },
});
}
}
// Message text
if (contentString.trim()) {
await this.sendUpdate({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: contentString },
});
}
// Tool calls
if (msg.toolCalls) {
for (const toolCall of msg.toolCalls) {
const toolCallContent: acp.ToolCallContent[] = [];
if (toolCall.resultDisplay) {
if (typeof toolCall.resultDisplay === 'string') {
toolCallContent.push({
type: 'content',
content: { type: 'text', text: toolCall.resultDisplay },
});
} else if ('fileName' in toolCall.resultDisplay) {
toolCallContent.push({
type: 'diff',
path: toolCall.resultDisplay.fileName,
oldText: toolCall.resultDisplay.originalContent,
newText: toolCall.resultDisplay.newContent,
});
}
}
const tool = this.config.getToolRegistry().getTool(toolCall.name);
await this.sendUpdate({
sessionUpdate: 'tool_call',
toolCallId: toolCall.id,
status: toolCall.status === 'success' ? 'completed' : 'failed',
title: toolCall.displayName || toolCall.name,
content: toolCallContent,
kind: tool ? toAcpToolKind(tool.kind) : 'other',
});
}
}
}
}
}
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
this.pendingPrompt?.abort();
const pendingSend = new AbortController();
@@ -533,6 +674,33 @@ export class Session {
),
);
this.chat.recordCompletedToolCalls(this.config.getActiveModel(), [
{
status: 'success',
request: {
callId,
name: fc.name,
args,
isClientInitiated: false,
prompt_id: promptId,
},
tool,
invocation,
response: {
callId,
responseParts: convertToFunctionResponse(
fc.name,
callId,
toolResult.llmContent,
this.config.getActiveModel(),
),
resultDisplay: toolResult.returnDisplay,
error: undefined,
errorType: undefined,
},
},
]);
return convertToFunctionResponse(
fc.name,
callId,
@@ -551,6 +719,35 @@ export class Session {
],
});
this.chat.recordCompletedToolCalls(this.config.getActiveModel(), [
{
status: 'error',
request: {
callId,
name: fc.name,
args,
isClientInitiated: false,
prompt_id: promptId,
},
tool,
response: {
callId,
responseParts: [
{
functionResponse: {
id: callId,
name: fc.name ?? '',
response: { error: error.message },
},
},
],
resultDisplay: error.message,
error,
errorType: undefined,
},
},
]);
return errorResponse(error);
}
}
+14 -7
View File
@@ -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",
@@ -1112,13 +1126,6 @@
},
"additionalProperties": false
},
"autoAccept": {
"title": "Auto Accept",
"description": "Automatically accept and execute tool calls that are considered safe (e.g., read-only operations).",
"markdownDescription": "Automatically accept and execute tool calls that are considered safe (e.g., read-only operations).\n\n- Category: `Tools`\n- Requires restart: `no`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"approvalMode": {
"title": "Approval Mode",
"description": "The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet.",