Merge remote-tracking branch 'origin/main' into afw/agnostic-background-ui

# Conflicts:
#	packages/a2a-server/src/commands/memory.test.ts
#	packages/a2a-server/src/commands/memory.ts
#	packages/cli/src/acp/commands/memory.ts
#	packages/core/src/services/executionLifecycleService.test.ts
#	packages/core/src/services/executionLifecycleService.ts
This commit is contained in:
Adam Weidman
2026-03-17 09:56:20 -04:00
319 changed files with 13406 additions and 4076 deletions
+1
View File
@@ -1429,6 +1429,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
pager: settings.merged.tools.shell.pager,
showColor: settings.merged.tools.shell.showColor,
sanitizationConfig: config.sanitizationConfig,
sandboxManager: config.sandboxManager,
});
const { isFocused, hasReceivedFocusEvent } = useFocus();
@@ -7,10 +7,10 @@
import {
debugLogger,
listExtensions,
getErrorMessage,
type ExtensionInstallMetadata,
} from '@google/gemini-cli-core';
import type { ExtensionUpdateInfo } from '../../config/extension.js';
import { getErrorMessage } from '../../utils/errors.js';
import {
emptyIcon,
MessageType,
@@ -123,7 +123,6 @@ async function downloadFiles({
downloads.push(
(async () => {
const endpoint = `${REPO_DOWNLOAD_URL}/refs/tags/${releaseTag}/${SOURCE_DIR}/${fileBasename}`;
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
const response = await fetch(endpoint, {
method: 'GET',
dispatcher: proxy ? new ProxyAgent(proxy) : undefined,
@@ -16,9 +16,8 @@ import {
MessageType,
} from '../types.js';
import { disableSkill, enableSkill } from '../../utils/skillSettings.js';
import { getErrorMessage } from '../../utils/errors.js';
import { getAdminErrorMessage } from '@google/gemini-cli-core';
import { getAdminErrorMessage, getErrorMessage } from '@google/gemini-cli-core';
import {
linkSkill,
renderSkillActionFeedback,
@@ -66,6 +66,7 @@ describe('FolderTrustDialog', () => {
mcps: Array.from({ length: 10 }, (_, i) => `mcp${i}`),
hooks: Array.from({ length: 10 }, (_, i) => `hook${i}`),
skills: Array.from({ length: 10 }, (_, i) => `skill${i}`),
agents: [],
settings: Array.from({ length: 10 }, (_, i) => `setting${i}`),
discoveryErrors: [],
securityWarnings: [],
@@ -95,6 +96,7 @@ describe('FolderTrustDialog', () => {
mcps: [],
hooks: [],
skills: [],
agents: [],
settings: [],
discoveryErrors: [],
securityWarnings: [],
@@ -125,6 +127,7 @@ describe('FolderTrustDialog', () => {
mcps: [],
hooks: [],
skills: [],
agents: [],
settings: [],
discoveryErrors: [],
securityWarnings: [],
@@ -152,6 +155,7 @@ describe('FolderTrustDialog', () => {
mcps: [],
hooks: [],
skills: [],
agents: [],
settings: [],
discoveryErrors: [],
securityWarnings: [],
@@ -332,6 +336,7 @@ describe('FolderTrustDialog', () => {
mcps: ['mcp1'],
hooks: ['hook1'],
skills: ['skill1'],
agents: ['agent1'],
settings: ['general', 'ui'],
discoveryErrors: [],
securityWarnings: [],
@@ -355,6 +360,8 @@ describe('FolderTrustDialog', () => {
expect(lastFrame()).toContain('- hook1');
expect(lastFrame()).toContain('• Skills (1):');
expect(lastFrame()).toContain('- skill1');
expect(lastFrame()).toContain('• Agents (1):');
expect(lastFrame()).toContain('- agent1');
expect(lastFrame()).toContain('• Setting overrides (2):');
expect(lastFrame()).toContain('- general');
expect(lastFrame()).toContain('- ui');
@@ -367,6 +374,7 @@ describe('FolderTrustDialog', () => {
mcps: [],
hooks: [],
skills: [],
agents: [],
settings: [],
discoveryErrors: [],
securityWarnings: ['Dangerous setting detected!'],
@@ -390,6 +398,7 @@ describe('FolderTrustDialog', () => {
mcps: [],
hooks: [],
skills: [],
agents: [],
settings: [],
discoveryErrors: ['Failed to load custom commands'],
securityWarnings: [],
@@ -413,6 +422,7 @@ describe('FolderTrustDialog', () => {
mcps: [],
hooks: [],
skills: [],
agents: [],
settings: [],
discoveryErrors: [],
securityWarnings: [],
@@ -446,6 +456,7 @@ describe('FolderTrustDialog', () => {
mcps: [`${ansiRed}mcp-with-ansi${ansiReset}`],
hooks: [`${ansiRed}hook-with-ansi${ansiReset}`],
skills: [`${ansiRed}skill-with-ansi${ansiReset}`],
agents: [],
settings: [`${ansiRed}setting-with-ansi${ansiReset}`],
discoveryErrors: [`${ansiRed}error-with-ansi${ansiReset}`],
securityWarnings: [`${ansiRed}warning-with-ansi${ansiReset}`],
@@ -135,6 +135,7 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
{ label: 'MCP Servers', items: discoveryResults?.mcps ?? [] },
{ label: 'Hooks', items: discoveryResults?.hooks ?? [] },
{ label: 'Skills', items: discoveryResults?.skills ?? [] },
{ label: 'Agents', items: discoveryResults?.agents ?? [] },
{ label: 'Setting overrides', items: discoveryResults?.settings ?? [] },
].filter((g) => g.items.length > 0);
+16 -3
View File
@@ -11,6 +11,7 @@ import { Box, Text, useStdout, type DOMElement } from 'ink';
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
import { theme } from '../semantic-colors.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import { escapeAtSymbols } from '../hooks/atCommandProcessor.js';
import { HalfLinePaddedBox } from './shared/HalfLinePaddedBox.js';
import {
type TextBuffer,
@@ -515,7 +516,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
stdout.write('\x1b]52;c;?\x07');
} else {
const textToInsert = await clipboardy.read();
buffer.insert(textToInsert, { paste: true });
const escapedText = settings.ui?.escapePastedAtSymbols
? escapeAtSymbols(textToInsert)
: textToInsert;
buffer.insert(escapedText, { paste: true });
if (isLargePaste(textToInsert)) {
appEvents.emit(AppEvent.TransientMessage, {
message: `Press ${formatCommand(Command.EXPAND_PASTE)} to expand pasted text`,
@@ -750,8 +755,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
pasteTimeoutRef.current = null;
}, 40);
}
// Ensure we never accidentally interpret paste as regular input.
buffer.handleInput(key);
if (settings.ui?.escapePastedAtSymbols) {
buffer.handleInput({
...key,
sequence: escapeAtSymbols(key.sequence || ''),
});
} else {
buffer.handleInput(key);
}
if (key.sequence && isLargePaste(key.sequence)) {
appEvents.emit(AppEvent.TransientMessage, {
message: `Press ${formatCommand(Command.EXPAND_PASTE)} to expand pasted text`,
@@ -1291,6 +1303,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
forceShowShellSuggestions,
keyMatchers,
isHelpDismissKey,
settings,
],
);
@@ -19,7 +19,9 @@ import {
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
AuthType,
UserTierId,
} from '@google/gemini-cli-core';
import type { Config, ModelSlashCommandEvent } from '@google/gemini-cli-core';
@@ -28,8 +30,9 @@ const mockGetDisplayString = vi.fn();
const mockLogModelSlashCommand = vi.fn();
const mockModelSlashCommandEvent = vi.fn();
vi.mock('@google/gemini-cli-core', async () => {
const actual = await vi.importActual('@google/gemini-cli-core');
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
getDisplayString: (val: string) => mockGetDisplayString(val),
@@ -40,6 +43,7 @@ vi.mock('@google/gemini-cli-core', async () => {
mockModelSlashCommandEvent(model);
}
},
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL: 'gemini-3.1-flash-lite-preview',
};
});
@@ -49,6 +53,9 @@ describe('<ModelDialog />', () => {
const mockOnClose = vi.fn();
const mockGetHasAccessToPreviewModel = vi.fn();
const mockGetGemini31LaunchedSync = vi.fn();
const mockGetProModelNoAccess = vi.fn();
const mockGetProModelNoAccessSync = vi.fn();
const mockGetUserTier = vi.fn();
interface MockConfig extends Partial<Config> {
setModel: (model: string, isTemporary?: boolean) => void;
@@ -56,6 +63,9 @@ describe('<ModelDialog />', () => {
getHasAccessToPreviewModel: () => boolean;
getIdeMode: () => boolean;
getGemini31LaunchedSync: () => boolean;
getProModelNoAccess: () => Promise<boolean>;
getProModelNoAccessSync: () => boolean;
getUserTier: () => UserTierId | undefined;
}
const mockConfig: MockConfig = {
@@ -64,6 +74,9 @@ describe('<ModelDialog />', () => {
getHasAccessToPreviewModel: mockGetHasAccessToPreviewModel,
getIdeMode: () => false,
getGemini31LaunchedSync: mockGetGemini31LaunchedSync,
getProModelNoAccess: mockGetProModelNoAccess,
getProModelNoAccessSync: mockGetProModelNoAccessSync,
getUserTier: mockGetUserTier,
};
beforeEach(() => {
@@ -71,6 +84,9 @@ describe('<ModelDialog />', () => {
mockGetModel.mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO);
mockGetHasAccessToPreviewModel.mockReturnValue(false);
mockGetGemini31LaunchedSync.mockReturnValue(false);
mockGetProModelNoAccess.mockResolvedValue(false);
mockGetProModelNoAccessSync.mockReturnValue(false);
mockGetUserTier.mockReturnValue(UserTierId.STANDARD);
// Default implementation for getDisplayString
mockGetDisplayString.mockImplementation((val: string) => {
@@ -109,6 +125,55 @@ describe('<ModelDialog />', () => {
unmount();
});
it('renders the "manual" view initially for users with no pro access and filters Pro models with correct order', async () => {
mockGetProModelNoAccessSync.mockReturnValue(true);
mockGetProModelNoAccess.mockResolvedValue(true);
mockGetHasAccessToPreviewModel.mockReturnValue(true);
mockGetUserTier.mockReturnValue(UserTierId.FREE);
mockGetDisplayString.mockImplementation((val: string) => val);
const { lastFrame, unmount } = await renderComponent();
const output = lastFrame();
expect(output).toContain('Select Model');
expect(output).not.toContain(DEFAULT_GEMINI_MODEL);
expect(output).not.toContain(PREVIEW_GEMINI_MODEL);
// Verify order: Flash Preview -> Flash Lite Preview -> Flash -> Flash Lite
const flashPreviewIdx = output.indexOf(PREVIEW_GEMINI_FLASH_MODEL);
const flashLitePreviewIdx = output.indexOf(
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
);
const flashIdx = output.indexOf(DEFAULT_GEMINI_FLASH_MODEL);
const flashLiteIdx = output.indexOf(DEFAULT_GEMINI_FLASH_LITE_MODEL);
expect(flashPreviewIdx).toBeLessThan(flashLitePreviewIdx);
expect(flashLitePreviewIdx).toBeLessThan(flashIdx);
expect(flashIdx).toBeLessThan(flashLiteIdx);
expect(output).not.toContain('Auto');
unmount();
});
it('closes dialog on escape in "manual" view for users with no pro access', async () => {
mockGetProModelNoAccessSync.mockReturnValue(true);
mockGetProModelNoAccess.mockResolvedValue(true);
const { stdin, waitUntilReady, unmount } = await renderComponent();
// Already in manual view
await act(async () => {
stdin.write('\u001B'); // Escape
});
await act(async () => {
await waitUntilReady();
});
await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled();
});
unmount();
});
it('switches to "manual" view when "Manual" is selected and uses getDisplayString for models', async () => {
mockGetDisplayString.mockImplementation((val: string) => {
if (val === DEFAULT_GEMINI_MODEL) return 'Formatted Pro Model';
@@ -369,5 +434,50 @@ describe('<ModelDialog />', () => {
});
unmount();
});
it('hides Flash Lite Preview model for users with pro access', async () => {
mockGetProModelNoAccessSync.mockReturnValue(false);
mockGetProModelNoAccess.mockResolvedValue(false);
mockGetHasAccessToPreviewModel.mockReturnValue(true);
const { lastFrame, stdin, waitUntilReady, unmount } =
await renderComponent();
// Go to manual view
await act(async () => {
stdin.write('\u001B[B'); // Manual
});
await waitUntilReady();
await act(async () => {
stdin.write('\r');
});
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL);
unmount();
});
it('shows Flash Lite Preview model for free tier users', async () => {
mockGetProModelNoAccessSync.mockReturnValue(false);
mockGetProModelNoAccess.mockResolvedValue(false);
mockGetHasAccessToPreviewModel.mockReturnValue(true);
mockGetUserTier.mockReturnValue(UserTierId.FREE);
const { lastFrame, stdin, waitUntilReady, unmount } =
await renderComponent();
// Go to manual view
await act(async () => {
stdin.write('\u001B[B'); // Manual
});
await waitUntilReady();
await act(async () => {
stdin.write('\r');
});
await waitUntilReady();
const output = lastFrame();
expect(output).toContain(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL);
unmount();
});
});
});
+49 -6
View File
@@ -5,12 +5,13 @@
*/
import type React from 'react';
import { useCallback, useContext, useMemo, useState } from 'react';
import { useCallback, useContext, useMemo, useState, useEffect } from 'react';
import { Box, Text } from 'ink';
import {
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
PREVIEW_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
@@ -21,6 +22,8 @@ import {
getDisplayString,
AuthType,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
isProModel,
UserTierId,
} from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js';
@@ -35,9 +38,26 @@ interface ModelDialogProps {
export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
const config = useContext(ConfigContext);
const settings = useSettings();
const [view, setView] = useState<'main' | 'manual'>('main');
const [hasAccessToProModel, setHasAccessToProModel] = useState<boolean>(
() => !(config?.getProModelNoAccessSync() ?? false),
);
const [view, setView] = useState<'main' | 'manual'>(() =>
config?.getProModelNoAccessSync() ? 'manual' : 'main',
);
const [persistMode, setPersistMode] = useState(false);
useEffect(() => {
async function checkAccess() {
if (!config) return;
const noAccess = await config.getProModelNoAccess();
setHasAccessToProModel(!noAccess);
if (noAccess) {
setView('manual');
}
}
void checkAccess();
}, [config]);
// Determine the Preferred Model (read once when the dialog opens).
const preferredModel = config?.getModel() || DEFAULT_GEMINI_MODEL_AUTO;
@@ -66,7 +86,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
useKeypress(
(key) => {
if (key.name === 'escape') {
if (view === 'manual') {
if (view === 'manual' && hasAccessToProModel) {
setView('main');
} else {
onClose();
@@ -115,6 +135,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
}, [shouldShowPreviewModels, manualModelSelected, useGemini31]);
const manualOptions = useMemo(() => {
const isFreeTier = config?.getUserTier() === UserTierId.FREE;
const list = [
{
value: DEFAULT_GEMINI_MODEL,
@@ -142,7 +163,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL
: previewProModel;
list.unshift(
const previewOptions = [
{
value: previewProValue,
title: getDisplayString(previewProModel),
@@ -153,10 +174,32 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
title: getDisplayString(PREVIEW_GEMINI_FLASH_MODEL),
key: PREVIEW_GEMINI_FLASH_MODEL,
},
);
];
if (isFreeTier) {
previewOptions.push({
value: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
title: getDisplayString(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL),
key: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
});
}
list.unshift(...previewOptions);
}
if (!hasAccessToProModel) {
// Filter out all Pro models for free tier
return list.filter((option) => !isProModel(option.value));
}
return list;
}, [shouldShowPreviewModels, useGemini31, useCustomToolModel]);
}, [
shouldShowPreviewModels,
useGemini31,
useCustomToolModel,
hasAccessToProModel,
config,
]);
const options = view === 'main' ? mainOptions : manualOptions;
@@ -13,9 +13,8 @@ import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { useKeypress } from '../hooks/useKeypress.js';
import path from 'node:path';
import type { Config } from '@google/gemini-cli-core';
import type { SessionInfo, TextMatch } from '../../utils/sessionUtils.js';
import type { SessionInfo } from '../../utils/sessionUtils.js';
import {
cleanMessage,
formatRelativeTime,
getSessionFiles,
} from '../../utils/sessionUtils.js';
@@ -117,157 +116,11 @@ const Kbd = ({ name, shortcut }: { name: string; shortcut: string }) => (
</>
);
/**
* Loading state component displayed while sessions are being loaded.
*/
const SessionBrowserLoading = (): React.JSX.Element => (
<Box flexDirection="column" paddingX={1}>
<Text color={Colors.Gray}>Loading sessions</Text>
</Box>
);
import { SessionBrowserLoading } from './SessionBrowser/SessionBrowserLoading.js';
import { SessionBrowserError } from './SessionBrowser/SessionBrowserError.js';
import { SessionBrowserEmpty } from './SessionBrowser/SessionBrowserEmpty.js';
/**
* Error state component displayed when session loading fails.
*/
const SessionBrowserError = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box flexDirection="column" paddingX={1}>
<Text color={Colors.AccentRed}>Error: {state.error}</Text>
<Text color={Colors.Gray}>Press q to exit</Text>
</Box>
);
/**
* Empty state component displayed when no sessions are found.
*/
const SessionBrowserEmpty = (): React.JSX.Element => (
<Box flexDirection="column" paddingX={1}>
<Text color={Colors.Gray}>No auto-saved conversations found.</Text>
<Text color={Colors.Gray}>Press q to exit</Text>
</Box>
);
/**
* Sorts an array of sessions by the specified criteria.
* @param sessions - Array of sessions to sort
* @param sortBy - Sort criteria: 'date' (lastUpdated), 'messages' (messageCount), or 'name' (displayName)
* @param reverse - Whether to reverse the sort order (ascending instead of descending)
* @returns New sorted array of sessions
*/
const sortSessions = (
sessions: SessionInfo[],
sortBy: 'date' | 'messages' | 'name',
reverse: boolean,
): SessionInfo[] => {
const sorted = [...sessions].sort((a, b) => {
switch (sortBy) {
case 'date':
return (
new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime()
);
case 'messages':
return b.messageCount - a.messageCount;
case 'name':
return a.displayName.localeCompare(b.displayName);
default:
return 0;
}
});
return reverse ? sorted.reverse() : sorted;
};
/**
* Finds all text matches for a search query within conversation messages.
* Creates TextMatch objects with context (10 chars before/after) and role information.
* @param messages - Array of messages to search through
* @param query - Search query string (case-insensitive)
* @returns Array of TextMatch objects containing match context and metadata
*/
const findTextMatches = (
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
query: string,
): TextMatch[] => {
if (!query.trim()) return [];
const lowerQuery = query.toLowerCase();
const matches: TextMatch[] = [];
for (const message of messages) {
const m = cleanMessage(message.content);
const lowerContent = m.toLowerCase();
let startIndex = 0;
while (true) {
const matchIndex = lowerContent.indexOf(lowerQuery, startIndex);
if (matchIndex === -1) break;
const contextStart = Math.max(0, matchIndex - 10);
const contextEnd = Math.min(m.length, matchIndex + query.length + 10);
const snippet = m.slice(contextStart, contextEnd);
const relativeMatchStart = matchIndex - contextStart;
const relativeMatchEnd = relativeMatchStart + query.length;
let before = snippet.slice(0, relativeMatchStart);
const match = snippet.slice(relativeMatchStart, relativeMatchEnd);
let after = snippet.slice(relativeMatchEnd);
if (contextStart > 0) before = '…' + before;
if (contextEnd < m.length) after = after + '…';
matches.push({ before, match, after, role: message.role });
startIndex = matchIndex + 1;
}
}
return matches;
};
/**
* Filters sessions based on a search query, checking titles, IDs, and full content.
* Also populates matchSnippets and matchCount for sessions with content matches.
* @param sessions - Array of sessions to filter
* @param query - Search query string (case-insensitive)
* @returns Filtered array of sessions that match the query
*/
const filterSessions = (
sessions: SessionInfo[],
query: string,
): SessionInfo[] => {
if (!query.trim()) {
return sessions.map((session) => ({
...session,
matchSnippets: undefined,
matchCount: undefined,
}));
}
const lowerQuery = query.toLowerCase();
return sessions.filter((session) => {
const titleMatch =
session.displayName.toLowerCase().includes(lowerQuery) ||
session.id.toLowerCase().includes(lowerQuery) ||
session.firstUserMessage.toLowerCase().includes(lowerQuery);
const contentMatch = session.fullContent
?.toLowerCase()
.includes(lowerQuery);
if (titleMatch || contentMatch) {
if (session.messages) {
session.matchSnippets = findTextMatches(session.messages, query);
session.matchCount = session.matchSnippets.length;
}
return true;
}
return false;
});
};
import { sortSessions, filterSessions } from './SessionBrowser/utils.js';
/**
* Search input display component.
@@ -0,0 +1,19 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
/**
* Empty state component displayed when no sessions are found.
*/
export const SessionBrowserEmpty = (): React.JSX.Element => (
<Box flexDirection="column" paddingX={1}>
<Text color={Colors.Gray}>No auto-saved conversations found.</Text>
<Text color={Colors.Gray}>Press q to exit</Text>
</Box>
);
@@ -0,0 +1,24 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
import type { SessionBrowserState } from '../SessionBrowser.js';
/**
* Error state component displayed when session loading fails.
*/
export const SessionBrowserError = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box flexDirection="column" paddingX={1}>
<Text color={Colors.AccentRed}>Error: {state.error}</Text>
<Text color={Colors.Gray}>Press q to exit</Text>
</Box>
);
@@ -0,0 +1,18 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
/**
* Loading state component displayed while sessions are being loaded.
*/
export const SessionBrowserLoading = (): React.JSX.Element => (
<Box flexDirection="column" paddingX={1}>
<Text color={Colors.Gray}>Loading sessions</Text>
</Box>
);
@@ -0,0 +1,35 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../../test-utils/render.js';
import { describe, it, expect } from 'vitest';
import { SessionBrowserLoading } from './SessionBrowserLoading.js';
import { SessionBrowserError } from './SessionBrowserError.js';
import { SessionBrowserEmpty } from './SessionBrowserEmpty.js';
import type { SessionBrowserState } from '../SessionBrowser.js';
describe('SessionBrowser UI States', () => {
it('SessionBrowserLoading renders correctly', async () => {
const { lastFrame, waitUntilReady } = render(<SessionBrowserLoading />);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('SessionBrowserError renders correctly', async () => {
const mockState = { error: 'Test error message' } as SessionBrowserState;
const { lastFrame, waitUntilReady } = render(
<SessionBrowserError state={mockState} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('SessionBrowserEmpty renders correctly', async () => {
const { lastFrame, waitUntilReady } = render(<SessionBrowserEmpty />);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -0,0 +1,18 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`SessionBrowser UI States > SessionBrowserEmpty renders correctly 1`] = `
" No auto-saved conversations found.
Press q to exit
"
`;
exports[`SessionBrowser UI States > SessionBrowserError renders correctly 1`] = `
" Error: Test error message
Press q to exit
"
`;
exports[`SessionBrowser UI States > SessionBrowserLoading renders correctly 1`] = `
" Loading sessions…
"
`;
@@ -0,0 +1,132 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { sortSessions, findTextMatches, filterSessions } from './utils.js';
import type { SessionInfo } from '../../../utils/sessionUtils.js';
describe('SessionBrowser utils', () => {
const createTestSession = (overrides: Partial<SessionInfo>): SessionInfo => ({
id: 'test-id',
file: 'test-file',
fileName: 'test-file.json',
startTime: '2025-01-01T10:00:00Z',
lastUpdated: '2025-01-01T10:00:00Z',
messageCount: 1,
displayName: 'Test Session',
firstUserMessage: 'Hello',
isCurrentSession: false,
index: 0,
...overrides,
});
describe('sortSessions', () => {
it('sorts by date ascending/descending', () => {
const older = createTestSession({
id: '1',
lastUpdated: '2025-01-01T10:00:00Z',
});
const newer = createTestSession({
id: '2',
lastUpdated: '2025-01-02T10:00:00Z',
});
const desc = sortSessions([older, newer], 'date', false);
expect(desc[0].id).toBe('2');
const asc = sortSessions([older, newer], 'date', true);
expect(asc[0].id).toBe('1');
});
it('sorts by message count ascending/descending', () => {
const more = createTestSession({ id: '1', messageCount: 10 });
const less = createTestSession({ id: '2', messageCount: 2 });
const desc = sortSessions([more, less], 'messages', false);
expect(desc[0].id).toBe('1');
const asc = sortSessions([more, less], 'messages', true);
expect(asc[0].id).toBe('2');
});
it('sorts by name ascending/descending', () => {
const apple = createTestSession({ id: '1', displayName: 'Apple' });
const banana = createTestSession({ id: '2', displayName: 'Banana' });
const asc = sortSessions([apple, banana], 'name', true);
expect(asc[0].id).toBe('2'); // Reversed alpha
const desc = sortSessions([apple, banana], 'name', false);
expect(desc[0].id).toBe('1');
});
});
describe('findTextMatches', () => {
it('returns empty array if query is practically empty', () => {
expect(
findTextMatches([{ role: 'user', content: 'hello world' }], ' '),
).toEqual([]);
});
it('finds simple matches with surrounding context', () => {
const messages: Array<{ role: 'user' | 'assistant'; content: string }> = [
{ role: 'user', content: 'What is the capital of France?' },
];
const matches = findTextMatches(messages, 'capital');
expect(matches.length).toBe(1);
expect(matches[0].match).toBe('capital');
expect(matches[0].before.endsWith('the ')).toBe(true);
expect(matches[0].after.startsWith(' of')).toBe(true);
expect(matches[0].role).toBe('user');
});
it('finds multiple matches in a single message', () => {
const messages: Array<{ role: 'user' | 'assistant'; content: string }> = [
{ role: 'user', content: 'test here test there' },
];
const matches = findTextMatches(messages, 'test');
expect(matches.length).toBe(2);
});
});
describe('filterSessions', () => {
it('returns all sessions when query is blank and clears existing snippets', () => {
const sessions = [createTestSession({ id: '1', matchCount: 5 })];
const result = filterSessions(sessions, ' ');
expect(result.length).toBe(1);
expect(result[0].matchCount).toBeUndefined();
});
it('filters by displayName', () => {
const session1 = createTestSession({
id: '1',
displayName: 'Cats and Dogs',
});
const session2 = createTestSession({ id: '2', displayName: 'Fish' });
const result = filterSessions([session1, session2], 'cat');
expect(result.length).toBe(1);
expect(result[0].id).toBe('1');
});
it('populates match snippets if it matches content inside messages array', () => {
const sessionWithMessages = createTestSession({
id: '1',
displayName: 'Unrelated Title',
fullContent: 'This mentions a giraffe',
messages: [{ role: 'user', content: 'This mentions a giraffe' }],
});
const result = filterSessions([sessionWithMessages], 'giraffe');
expect(result.length).toBe(1);
expect(result[0].matchCount).toBe(1);
expect(result[0].matchSnippets?.[0].match).toBe('giraffe');
});
});
});
@@ -0,0 +1,130 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
cleanMessage,
type SessionInfo,
type TextMatch,
} from '../../../utils/sessionUtils.js';
/**
* Sorts an array of sessions by the specified criteria.
* @param sessions - Array of sessions to sort
* @param sortBy - Sort criteria: 'date' (lastUpdated), 'messages' (messageCount), or 'name' (displayName)
* @param reverse - Whether to reverse the sort order (ascending instead of descending)
* @returns New sorted array of sessions
*/
export const sortSessions = (
sessions: SessionInfo[],
sortBy: 'date' | 'messages' | 'name',
reverse: boolean,
): SessionInfo[] => {
const sorted = [...sessions].sort((a, b) => {
switch (sortBy) {
case 'date':
return (
new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime()
);
case 'messages':
return b.messageCount - a.messageCount;
case 'name':
return a.displayName.localeCompare(b.displayName);
default:
return 0;
}
});
return reverse ? sorted.reverse() : sorted;
};
/**
* Finds all text matches for a search query within conversation messages.
* Creates TextMatch objects with context (10 chars before/after) and role information.
* @param messages - Array of messages to search through
* @param query - Search query string (case-insensitive)
* @returns Array of TextMatch objects containing match context and metadata
*/
export const findTextMatches = (
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
query: string,
): TextMatch[] => {
if (!query.trim()) return [];
const lowerQuery = query.toLowerCase();
const matches: TextMatch[] = [];
for (const message of messages) {
const m = cleanMessage(message.content);
const lowerContent = m.toLowerCase();
let startIndex = 0;
while (true) {
const matchIndex = lowerContent.indexOf(lowerQuery, startIndex);
if (matchIndex === -1) break;
const contextStart = Math.max(0, matchIndex - 10);
const contextEnd = Math.min(m.length, matchIndex + query.length + 10);
const snippet = m.slice(contextStart, contextEnd);
const relativeMatchStart = matchIndex - contextStart;
const relativeMatchEnd = relativeMatchStart + query.length;
let before = snippet.slice(0, relativeMatchStart);
const match = snippet.slice(relativeMatchStart, relativeMatchEnd);
let after = snippet.slice(relativeMatchEnd);
if (contextStart > 0) before = '…' + before;
if (contextEnd < m.length) after = after + '…';
matches.push({ before, match, after, role: message.role });
startIndex = matchIndex + 1;
}
}
return matches;
};
/**
* Filters sessions based on a search query, checking titles, IDs, and full content.
* Also populates matchSnippets and matchCount for sessions with content matches.
* @param sessions - Array of sessions to filter
* @param query - Search query string (case-insensitive)
* @returns Filtered array of sessions that match the query
*/
export const filterSessions = (
sessions: SessionInfo[],
query: string,
): SessionInfo[] => {
if (!query.trim()) {
return sessions.map((session) => ({
...session,
matchSnippets: undefined,
matchCount: undefined,
}));
}
const lowerQuery = query.toLowerCase();
return sessions.filter((session) => {
const titleMatch =
session.displayName.toLowerCase().includes(lowerQuery) ||
session.id.toLowerCase().includes(lowerQuery) ||
session.firstUserMessage.toLowerCase().includes(lowerQuery);
const contentMatch = session.fullContent
?.toLowerCase()
.includes(lowerQuery);
if (titleMatch || contentMatch) {
if (session.messages) {
session.matchSnippets = findTextMatches(session.messages, query);
session.matchCount = session.matchSnippets.length;
}
return true;
}
return false;
});
};
@@ -27,6 +27,7 @@ import {
} from '../utils/displayUtils.js';
import { computeSessionStats } from '../utils/computeStats.js';
import {
type Config,
type RetrieveUserQuotaResponse,
isActiveModel,
getDisplayString,
@@ -88,13 +89,16 @@ const Section: React.FC<SectionProps> = ({ title, children }) => (
// Logic for building the unified list of table rows
const buildModelRows = (
models: Record<string, ModelMetrics>,
config: Config,
quotas?: RetrieveUserQuotaResponse,
useGemini3_1 = false,
useCustomToolModel = false,
) => {
const getBaseModelName = (name: string) => name.replace('-001', '');
const usedModelNames = new Set(
Object.keys(models).map(getBaseModelName).map(getDisplayString),
Object.keys(models)
.map(getBaseModelName)
.map((name) => getDisplayString(name, config)),
);
// 1. Models with active usage
@@ -104,7 +108,7 @@ const buildModelRows = (
const inputTokens = metrics.tokens.input;
return {
key: name,
modelName: getDisplayString(modelName),
modelName: getDisplayString(modelName, config),
requests: metrics.api.totalRequests,
cachedTokens: cachedTokens.toLocaleString(),
inputTokens: inputTokens.toLocaleString(),
@@ -121,11 +125,11 @@ const buildModelRows = (
(b) =>
b.modelId &&
isActiveModel(b.modelId, useGemini3_1, useCustomToolModel) &&
!usedModelNames.has(getDisplayString(b.modelId)),
!usedModelNames.has(getDisplayString(b.modelId, config)),
)
.map((bucket) => ({
key: bucket.modelId!,
modelName: getDisplayString(bucket.modelId!),
modelName: getDisplayString(bucket.modelId!, config),
requests: '-',
cachedTokens: '-',
inputTokens: '-',
@@ -139,6 +143,7 @@ const buildModelRows = (
const ModelUsageTable: React.FC<{
models: Record<string, ModelMetrics>;
config: Config;
quotas?: RetrieveUserQuotaResponse;
cacheEfficiency: number;
totalCachedTokens: number;
@@ -150,6 +155,7 @@ const ModelUsageTable: React.FC<{
useCustomToolModel?: boolean;
}> = ({
models,
config,
quotas,
cacheEfficiency,
totalCachedTokens,
@@ -162,7 +168,13 @@ const ModelUsageTable: React.FC<{
}) => {
const { stdout } = useStdout();
const terminalWidth = stdout?.columns ?? 84;
const rows = buildModelRows(models, quotas, useGemini3_1, useCustomToolModel);
const rows = buildModelRows(
models,
config,
quotas,
useGemini3_1,
useCustomToolModel,
);
if (rows.length === 0) {
return null;
@@ -676,6 +688,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
</Section>
<ModelUsageTable
models={models}
config={config}
quotas={quotas}
cacheEfficiency={computed.cacheEfficiency}
totalCachedTokens={computed.totalCachedTokens}
@@ -42,6 +42,7 @@ describe('ToolConfirmationQueue', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
getModel: () => 'gemini-pro',
getDebugMode: () => false,
getTargetDir: () => '/mock/target/dir',
@@ -13,10 +13,6 @@ Tips for getting started:
2. /help for more information
3. Ask coding questions, edit code or run commands
4. Be specific for the best results
╭──────────────────────────────────────────────────────────────────────────╮
│ ? confirming_tool Confirming tool description │
│ │
╰──────────────────────────────────────────────────────────────────────────╯
Action Required (was prompted):
@@ -21,6 +21,7 @@ describe('ToolConfirmationMessage Redirection', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
} as unknown as Config;
it('should display redirection warning and tip for redirected commands', async () => {
@@ -18,7 +18,7 @@ export const TodoTray: React.FC = () => {
const uiState = useUIState();
const todos: TodoList | null = useMemo(() => {
// Find the most recent todo list written by the WriteTodosTool
// Find the most recent todo list written by tools that output a TodoList (e.g., WriteTodosTool or Tracker tools)
for (let i = uiState.history.length - 1; i >= 0; i--) {
const entry = uiState.history[i];
if (entry.type !== 'tool_group') {
@@ -37,6 +37,7 @@ describe('ToolConfirmationMessage', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
} as unknown as Config;
it('should not display urls if prompt and url are the same', async () => {
@@ -331,8 +332,8 @@ describe('ToolConfirmationMessage', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
} as unknown as Config;
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
@@ -353,6 +354,7 @@ describe('ToolConfirmationMessage', () => {
const mockConfig = {
isTrustedFolder: () => false,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
} as unknown as Config;
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
@@ -388,8 +390,8 @@ describe('ToolConfirmationMessage', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
} as unknown as Config;
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
@@ -415,8 +417,8 @@ describe('ToolConfirmationMessage', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
} as unknown as Config;
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
@@ -457,8 +459,8 @@ describe('ToolConfirmationMessage', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
} as unknown as Config;
vi.mocked(useToolActions).mockReturnValue({
confirm: vi.fn(),
cancel: vi.fn(),
@@ -485,8 +487,8 @@ describe('ToolConfirmationMessage', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => true,
getDisableAlwaysAllow: () => false,
} as unknown as Config;
vi.mocked(useToolActions).mockReturnValue({
confirm: vi.fn(),
cancel: vi.fn(),
@@ -513,8 +515,8 @@ describe('ToolConfirmationMessage', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => true,
getDisableAlwaysAllow: () => false,
} as unknown as Config;
vi.mocked(useToolActions).mockReturnValue({
confirm: vi.fn(),
cancel: vi.fn(),
@@ -86,12 +86,14 @@ export const ToolConfirmationMessage: React.FC<
const settings = useSettings();
const allowPermanentApproval =
settings.merged.security.enablePermanentToolApproval;
settings.merged.security.enablePermanentToolApproval &&
!config.getDisableAlwaysAllow();
const handlesOwnUI =
confirmationDetails.type === 'ask_user' ||
confirmationDetails.type === 'exit_plan_mode';
const isTrustedFolder = config.isTrustedFolder();
const isTrustedFolder =
config.isTrustedFolder() && !config.getDisableAlwaysAllow();
const handleConfirm = useCallback(
(outcome: ToolConfirmationOutcome, payload?: ToolConfirmationPayload) => {
@@ -118,10 +118,30 @@ describe('<ToolGroupMessage />', () => {
{ config: baseMockConfig, settings: fullVerbositySettings },
);
// Should now render confirming tools
// Should now hide confirming tools (to avoid duplication with Global Queue)
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('renders canceled tool calls', async () => {
const toolCalls = [
createToolCall({
callId: 'canceled-tool',
name: 'canceled-tool',
status: CoreToolCallStatus.Cancelled,
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ config: baseMockConfig, settings: fullVerbositySettings },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('test-tool');
expect(output).toMatchSnapshot('canceled_tool');
unmount();
});
@@ -842,7 +862,7 @@ describe('<ToolGroupMessage />', () => {
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).not.toBe('');
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
@@ -110,11 +110,12 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
() =>
toolCalls.filter((t) => {
const displayStatus = mapCoreStatusToDisplayStatus(t.status);
// We used to filter out Pending and Confirming statuses here to avoid
// duplication with the Global Queue, but this causes tools to appear to
// "vanish" from the context after approval.
// We now allow them to be visible here as well.
return displayStatus !== ToolCallStatus.Canceled;
// We hide Confirming tools from the history log because they are
// currently being rendered in the interactive ToolConfirmationQueue.
// We show everything else, including Pending (waiting to run) and
// Canceled (rejected by user), to ensure the history is complete
// and to avoid tools "vanishing" after approval.
return displayStatus !== ToolCallStatus.Confirming;
}),
[toolCalls],
@@ -49,6 +49,15 @@ exports[`<ToolGroupMessage /> > Border Color Logic > uses yellow border for shel
"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders canceled tool calls > canceled_tool 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
│ - canceled-tool A tool for testing │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders empty tool calls array 1`] = `""`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled 1`] = `
@@ -760,6 +760,48 @@ describe('BaseSettingsDialog', () => {
});
unmount();
});
it('should allow j and k characters to be typed in string edit fields without triggering navigation', async () => {
const items = createMockItems(4);
const stringItem = items.find((i) => i.type === 'string')!;
const { stdin, waitUntilReady, unmount } = await renderDialog({
items: [stringItem],
});
// Enter edit mode
await act(async () => {
stdin.write(TerminalKeys.ENTER);
});
await waitUntilReady();
// Type 'j' - should appear in field, NOT trigger navigation
await act(async () => {
stdin.write('j');
});
await waitUntilReady();
// Type 'k' - should appear in field, NOT trigger navigation
await act(async () => {
stdin.write('k');
});
await waitUntilReady();
// Commit with Enter
await act(async () => {
stdin.write(TerminalKeys.ENTER);
});
await waitUntilReady();
// j and k should be typed into the field
await waitFor(() => {
expect(mockOnEditCommit).toHaveBeenCalledWith(
'string-setting',
'test-valuejk', // entered value + j and k
expect.objectContaining({ type: 'string' }),
);
});
unmount();
});
});
describe('custom key handling', () => {
@@ -325,13 +325,18 @@ export function BaseSettingsDialog({
return;
}
// Up/Down in edit mode - commit and navigate
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
// Up/Down in edit mode - commit and navigate.
// Only trigger on non-insertable keys (arrow keys) so that typing
// j/k characters into the edit buffer is not intercepted.
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key) && !key.insertable) {
commitEdit();
moveUp();
return;
}
if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
if (
keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key) &&
!key.insertable
) {
commitEdit();
moveDown();
return;
@@ -647,6 +647,15 @@ describe('KeypressContext', () => {
sequence: `\x1b[27;6;9~`,
expected: { name: 'tab', shift: true, ctrl: true },
},
// Unicode CJK (Kitty/modifyOtherKeys scalar values)
{
sequence: '\x1b[44032u',
expected: { name: '가', sequence: '가', insertable: true },
},
{
sequence: '\x1b[27;1;44032~',
expected: { name: '가', sequence: '가', insertable: true },
},
// XTerm Function Key
{ sequence: `\x1b[1;129A`, expected: { name: 'up' } },
{ sequence: `\x1b[1;2H`, expected: { name: 'home', shift: true } },
@@ -1403,7 +1412,7 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenCalledTimes(inputString.length);
for (const char of inputString) {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({ sequence: char }),
expect.objectContaining({ sequence: char, name: char.toLowerCase() }),
);
}
});
@@ -610,20 +610,28 @@ function* emitKeys(
if (code.endsWith('u') || code.endsWith('~')) {
// CSI-u or tilde-coded functional keys: ESC [ <code> ; <mods> (u|~)
const codeNumber = parseInt(code.slice(1, -1), 10);
if (codeNumber >= 33 && codeNumber <= 126) {
const char = String.fromCharCode(codeNumber);
const mapped = KITTY_CODE_MAP[codeNumber];
if (mapped) {
name = mapped.name;
if (mapped.sequence && !ctrl && !cmd && !alt) {
sequence = mapped.sequence;
insertable = true;
}
} else if (
codeNumber >= 33 && // Printable characters start after space (32),
codeNumber <= 0x10ffff && // Valid Unicode scalar values (excluding control characters)
(codeNumber < 0xd800 || codeNumber > 0xdfff) // Exclude UTF-16 surrogate halves
) {
// Valid printable Unicode scalar values (up to Unicode maximum)
// Note: Kitty maps its special keys to the PUA (57344+), which are handled by KITTY_CODE_MAP above.
const char = String.fromCodePoint(codeNumber);
name = char.toLowerCase();
if (char >= 'A' && char <= 'Z') {
if (char !== name) {
shift = true;
}
} else {
const mapped = KITTY_CODE_MAP[codeNumber];
if (mapped) {
name = mapped.name;
if (mapped.sequence && !ctrl && !cmd && !alt) {
sequence = mapped.sequence;
insertable = true;
}
if (!ctrl && !cmd && !alt) {
sequence = char;
insertable = true;
}
}
}
@@ -696,6 +704,10 @@ function* emitKeys(
alt = ch.length > 0;
} else {
// Any other character is considered printable.
name = ch.toLowerCase();
if (ch !== name) {
shift = true;
}
insertable = true;
}
@@ -13,7 +13,11 @@ import {
afterEach,
type Mock,
} from 'vitest';
import { handleAtCommand } from './atCommandProcessor.js';
import {
handleAtCommand,
escapeAtSymbols,
unescapeLiteralAt,
} from './atCommandProcessor.js';
import {
FileDiscoveryService,
GlobTool,
@@ -1481,3 +1485,56 @@ describe('handleAtCommand', () => {
);
});
});
describe('escapeAtSymbols', () => {
it('escapes a bare @ symbol', () => {
expect(escapeAtSymbols('test@domain.com')).toBe('test\\@domain.com');
});
it('escapes a leading @ symbol', () => {
expect(escapeAtSymbols('@scope/pkg')).toBe('\\@scope/pkg');
});
it('escapes multiple @ symbols', () => {
expect(escapeAtSymbols('a@b and c@d')).toBe('a\\@b and c\\@d');
});
it('does not double-escape an already escaped @', () => {
expect(escapeAtSymbols('test\\@domain.com')).toBe('test\\@domain.com');
});
it('returns text with no @ unchanged', () => {
expect(escapeAtSymbols('hello world')).toBe('hello world');
});
it('returns empty string unchanged', () => {
expect(escapeAtSymbols('')).toBe('');
});
});
describe('unescapeLiteralAt', () => {
it('unescapes \\@ to @', () => {
expect(unescapeLiteralAt('test\\@domain.com')).toBe('test@domain.com');
});
it('unescapes a leading \\@', () => {
expect(unescapeLiteralAt('\\@scope/pkg')).toBe('@scope/pkg');
});
it('unescapes multiple \\@ sequences', () => {
expect(unescapeLiteralAt('a\\@b and c\\@d')).toBe('a@b and c@d');
});
it('returns text with no \\@ unchanged', () => {
expect(unescapeLiteralAt('hello world')).toBe('hello world');
});
it('returns empty string unchanged', () => {
expect(unescapeLiteralAt('')).toBe('');
});
it('roundtrips correctly with escapeAtSymbols', () => {
const input = 'user@example.com and @scope/pkg';
expect(unescapeLiteralAt(escapeAtSymbols(input))).toBe(input);
});
});
@@ -30,6 +30,26 @@ import type { UseHistoryManagerReturn } from './useHistoryManager.js';
const REF_CONTENT_HEADER = `\n${REFERENCE_CONTENT_START}`;
const REF_CONTENT_FOOTER = `\n${REFERENCE_CONTENT_END}`;
/**
* Escapes unescaped @ symbols so they are not interpreted as @path commands.
*/
export function escapeAtSymbols(text: string): string {
return text.replace(/(?<!\\)@/g, '\\@');
}
/**
* Unescapes \@ back to @ correctly, preserving \\@ sequences.
*/
export function unescapeLiteralAt(text: string): string {
return text.replace(/\\@/g, (match, offset, full) => {
let backslashCount = 0;
for (let i = offset - 1; i >= 0 && full[i] === '\\'; i--) {
backslashCount++;
}
return backslashCount % 2 === 0 ? '@' : '\\@';
});
}
/**
* Regex source for the path/command part of an @ reference.
* It uses strict ASCII whitespace delimiters to allow Unicode characters like NNBSP in filenames.
@@ -49,6 +69,7 @@ interface HandleAtCommandParams {
onDebugMessage: (message: string) => void;
messageId: number;
signal: AbortSignal;
escapePastedAtSymbols?: boolean;
}
interface HandleAtCommandResult {
@@ -65,7 +86,10 @@ interface AtCommandPart {
* Parses a query string to find all '@<path>' commands and text segments.
* Handles \ escaped spaces within paths.
*/
function parseAllAtCommands(query: string): AtCommandPart[] {
function parseAllAtCommands(
query: string,
escapePastedAtSymbols = false,
): AtCommandPart[] {
const parts: AtCommandPart[] = [];
let lastIndex = 0;
@@ -85,7 +109,9 @@ function parseAllAtCommands(query: string): AtCommandPart[] {
if (matchIndex > lastIndex) {
parts.push({
type: 'text',
content: query.substring(lastIndex, matchIndex),
content: escapePastedAtSymbols
? unescapeLiteralAt(query.substring(lastIndex, matchIndex))
: query.substring(lastIndex, matchIndex),
});
}
@@ -98,7 +124,12 @@ function parseAllAtCommands(query: string): AtCommandPart[] {
// Add remaining text
if (lastIndex < query.length) {
parts.push({ type: 'text', content: query.substring(lastIndex) });
parts.push({
type: 'text',
content: escapePastedAtSymbols
? unescapeLiteralAt(query.substring(lastIndex))
: query.substring(lastIndex),
});
}
// Filter out empty text parts that might result from consecutive @paths or leading/trailing spaces
@@ -635,8 +666,9 @@ export async function handleAtCommand({
onDebugMessage,
messageId: userMessageTimestamp,
signal,
escapePastedAtSymbols = false,
}: HandleAtCommandParams): Promise<HandleAtCommandResult> {
const commandParts = parseAllAtCommands(query);
const commandParts = parseAllAtCommands(query, escapePastedAtSymbols);
const { agentParts, resourceParts, fileParts } = categorizeAtCommands(
commandParts,
@@ -16,6 +16,7 @@ import {
afterEach,
type Mock,
} from 'vitest';
import { NoopSandboxManager } from '@google/gemini-cli-core';
const mockIsBinary = vi.hoisted(() => vi.fn());
const mockShellExecutionService = vi.hoisted(() => vi.fn());
@@ -134,8 +135,14 @@ describe('useShellCommandProcessor', () => {
getShellExecutionConfig: () => ({
terminalHeight: 20,
terminalWidth: 80,
sandboxManager: new NoopSandboxManager(),
sanitizationConfig: {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
enableEnvironmentVariableRedaction: false,
},
}),
} as Config;
} as unknown as Config;
mockGeminiClient = { addHistory: vi.fn() } as unknown as GeminiClient;
vi.mocked(os.platform).mockReturnValue('linux');
@@ -325,9 +325,9 @@ export const useSlashCommandProcessor = (
(async () => {
const commandService = await CommandService.create(
[
new BuiltinCommandLoader(config),
new SkillCommandLoader(config),
new McpPromptLoader(config),
new BuiltinCommandLoader(config),
new FileCommandLoader(config),
],
controller.signal,
@@ -7,9 +7,9 @@
import {
debugLogger,
checkExhaustive,
getErrorMessage,
type GeminiCLIExtension,
} from '@google/gemini-cli-core';
import { getErrorMessage } from '../../utils/errors.js';
import {
ExtensionUpdateState,
extensionUpdatesReducer,
@@ -101,12 +101,13 @@ export const useExtensionUpdates = (
return !currentState || currentState === ExtensionUpdateState.UNKNOWN;
});
if (extensionsToCheck.length === 0) return;
// eslint-disable-next-line @typescript-eslint/no-floating-promises
checkForAllExtensionUpdates(
void checkForAllExtensionUpdates(
extensionsToCheck,
extensionManager,
dispatchExtensionStateUpdate,
);
).catch((e) => {
debugLogger.warn(getErrorMessage(e));
});
}, [
extensions,
extensionManager,
@@ -202,12 +203,18 @@ export const useExtensionUpdates = (
);
}
if (scheduledUpdate) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
Promise.all(updatePromises).then((results) => {
const nonNullResults = results.filter((result) => result != null);
void Promise.allSettled(updatePromises).then((results) => {
const successfulUpdates = results
.filter(
(r): r is PromiseFulfilledResult<ExtensionUpdateInfo | undefined> =>
r.status === 'fulfilled',
)
.map((r) => r.value)
.filter((v): v is ExtensionUpdateInfo => v !== undefined);
scheduledUpdate.onCompleteCallbacks.forEach((callback) => {
try {
callback(nonNullResults);
callback(successfulUpdates);
} catch (e) {
debugLogger.warn(getErrorMessage(e));
}
+2 -1
View File
@@ -837,8 +837,8 @@ export const useGeminiStream = (
onDebugMessage,
messageId: userMessageTimestamp,
signal: abortSignal,
escapePastedAtSymbols: settings.merged.ui?.escapePastedAtSymbols,
});
if (atCommandResult.error) {
onDebugMessage(atCommandResult.error);
return { queryToSend: null, shouldProceed: false };
@@ -874,6 +874,7 @@ export const useGeminiStream = (
logger,
shellModeActive,
scheduleToolCalls,
settings,
],
);
+11 -11
View File
@@ -22,7 +22,7 @@ describe('KeyBinding', () => {
describe('constructor', () => {
it('should parse a simple key', () => {
const binding = new KeyBinding('a');
expect(binding.key).toBe('a');
expect(binding.name).toBe('a');
expect(binding.ctrl).toBe(false);
expect(binding.shift).toBe(false);
expect(binding.alt).toBe(false);
@@ -31,45 +31,45 @@ describe('KeyBinding', () => {
it('should parse ctrl+key', () => {
const binding = new KeyBinding('ctrl+c');
expect(binding.key).toBe('c');
expect(binding.name).toBe('c');
expect(binding.ctrl).toBe(true);
});
it('should parse shift+key', () => {
const binding = new KeyBinding('shift+z');
expect(binding.key).toBe('z');
expect(binding.name).toBe('z');
expect(binding.shift).toBe(true);
});
it('should parse alt+key', () => {
const binding = new KeyBinding('alt+left');
expect(binding.key).toBe('left');
expect(binding.name).toBe('left');
expect(binding.alt).toBe(true);
});
it('should parse cmd+key', () => {
const binding = new KeyBinding('cmd+f');
expect(binding.key).toBe('f');
expect(binding.name).toBe('f');
expect(binding.cmd).toBe(true);
});
it('should handle aliases (option/opt/meta)', () => {
const optionBinding = new KeyBinding('option+b');
expect(optionBinding.key).toBe('b');
expect(optionBinding.name).toBe('b');
expect(optionBinding.alt).toBe(true);
const optBinding = new KeyBinding('opt+b');
expect(optBinding.key).toBe('b');
expect(optBinding.name).toBe('b');
expect(optBinding.alt).toBe(true);
const metaBinding = new KeyBinding('meta+enter');
expect(metaBinding.key).toBe('enter');
expect(metaBinding.name).toBe('enter');
expect(metaBinding.cmd).toBe(true);
});
it('should parse multiple modifiers', () => {
const binding = new KeyBinding('ctrl+shift+alt+cmd+x');
expect(binding.key).toBe('x');
expect(binding.name).toBe('x');
expect(binding.ctrl).toBe(true);
expect(binding.shift).toBe(true);
expect(binding.alt).toBe(true);
@@ -78,14 +78,14 @@ describe('KeyBinding', () => {
it('should be case-insensitive', () => {
const binding = new KeyBinding('CTRL+Shift+F');
expect(binding.key).toBe('f');
expect(binding.name).toBe('f');
expect(binding.ctrl).toBe(true);
expect(binding.shift).toBe(true);
});
it('should handle named keys with modifiers', () => {
const binding = new KeyBinding('ctrl+enter');
expect(binding.key).toBe('enter');
expect(binding.name).toBe('enter');
expect(binding.ctrl).toBe(true);
});
+17 -14
View File
@@ -144,14 +144,14 @@ export class KeyBinding {
]);
/** The key name (e.g., 'a', 'enter', 'tab', 'escape') */
readonly key: string;
readonly name: string;
readonly shift: boolean;
readonly alt: boolean;
readonly ctrl: boolean;
readonly cmd: boolean;
constructor(pattern: string) {
let remains = pattern.toLowerCase().trim();
let remains = pattern.trim();
let shift = false;
let alt = false;
let ctrl = false;
@@ -160,31 +160,32 @@ export class KeyBinding {
let matched: boolean;
do {
matched = false;
if (remains.startsWith('ctrl+')) {
const lowerRemains = remains.toLowerCase();
if (lowerRemains.startsWith('ctrl+')) {
ctrl = true;
remains = remains.slice(5);
matched = true;
} else if (remains.startsWith('shift+')) {
} else if (lowerRemains.startsWith('shift+')) {
shift = true;
remains = remains.slice(6);
matched = true;
} else if (remains.startsWith('alt+')) {
} else if (lowerRemains.startsWith('alt+')) {
alt = true;
remains = remains.slice(4);
matched = true;
} else if (remains.startsWith('option+')) {
} else if (lowerRemains.startsWith('option+')) {
alt = true;
remains = remains.slice(7);
matched = true;
} else if (remains.startsWith('opt+')) {
} else if (lowerRemains.startsWith('opt+')) {
alt = true;
remains = remains.slice(4);
matched = true;
} else if (remains.startsWith('cmd+')) {
} else if (lowerRemains.startsWith('cmd+')) {
cmd = true;
remains = remains.slice(4);
matched = true;
} else if (remains.startsWith('meta+')) {
} else if (lowerRemains.startsWith('meta+')) {
cmd = true;
remains = remains.slice(5);
matched = true;
@@ -193,15 +194,17 @@ export class KeyBinding {
const key = remains;
if ([...key].length !== 1 && !KeyBinding.VALID_LONG_KEYS.has(key)) {
const isSingleChar = [...key].length === 1;
if (!isSingleChar && !KeyBinding.VALID_LONG_KEYS.has(key.toLowerCase())) {
throw new Error(
`Invalid keybinding key: "${key}" in "${pattern}".` +
` Must be a single character or one of: ${[...KeyBinding.VALID_LONG_KEYS].join(', ')}`,
);
}
this.key = key;
this.shift = shift;
this.name = key.toLowerCase();
this.shift = shift || (isSingleChar && this.name !== key);
this.alt = alt;
this.ctrl = ctrl;
this.cmd = cmd;
@@ -209,7 +212,7 @@ export class KeyBinding {
matches(key: Key): boolean {
return (
this.key === key.name &&
key.name === this.name &&
!!key.shift === !!this.shift &&
!!key.alt === !!this.alt &&
!!key.ctrl === !!this.ctrl &&
@@ -219,7 +222,7 @@ export class KeyBinding {
equals(other: KeyBinding): boolean {
return (
this.key === other.key &&
this.name === other.name &&
this.shift === other.shift &&
this.alt === other.alt &&
this.ctrl === other.ctrl &&
@@ -475,6 +475,22 @@ describe('keyMatchers', () => {
expect(matchers[Command.QUIT](createKey('q', { ctrl: true }))).toBe(true);
expect(matchers[Command.QUIT](createKey('q', { alt: true }))).toBe(true);
});
it('should support matching non-ASCII and CJK characters', () => {
const config = new Map(defaultKeyBindingConfig);
config.set(Command.QUIT, [new KeyBinding('Å'), new KeyBinding('가')]);
const matchers = createKeyMatchers(config);
// Å is normalized to å with shift=true by the parser
expect(matchers[Command.QUIT](createKey('å', { shift: true }))).toBe(
true,
);
expect(matchers[Command.QUIT](createKey('å'))).toBe(false);
// CJK characters do not have a lower/upper case
expect(matchers[Command.QUIT](createKey('가'))).toBe(true);
expect(matchers[Command.QUIT](createKey('나'))).toBe(false);
});
});
describe('Edge Cases', () => {
+1 -1
View File
@@ -86,7 +86,7 @@ export function formatKeyBinding(
if (binding.shift) parts.push(modMap.shift);
if (binding.cmd) parts.push(modMap.cmd);
const keyName = KEY_NAME_MAP[binding.key] || binding.key.toUpperCase();
const keyName = KEY_NAME_MAP[binding.name] || binding.name.toUpperCase();
parts.push(keyName);
return parts.join('+');
+1 -1
View File
@@ -25,7 +25,7 @@ export type HighlightToken = {
// It matches any character except strict delimiters (ASCII whitespace, comma, etc.).
// This supports URIs like `@file:///example.txt` and filenames with Unicode spaces (like NNBSP).
const HIGHLIGHT_REGEX = new RegExp(
`(^/[a-zA-Z0-9_-]+|@${AT_COMMAND_PATH_REGEX_SOURCE}|${PASTED_TEXT_PLACEHOLDER_REGEX.source})`,
`(^/[a-zA-Z0-9_-]+|(?<!\\\\)@${AT_COMMAND_PATH_REGEX_SOURCE}|${PASTED_TEXT_PLACEHOLDER_REGEX.source})`,
'g',
);