Merge branch 'main' into abhi/agent-factory.draft

This commit is contained in:
mkorwel
2026-02-22 04:54:15 +00:00
390 changed files with 13146 additions and 3136 deletions
@@ -166,9 +166,11 @@ async function searchResourceCandidates(
const fzf = new AsyncFzf(candidates, {
selector: (candidate: ResourceSuggestionCandidate) => candidate.searchKey,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const results = await fzf.find(normalizedPattern, {
limit: MAX_SUGGESTIONS_TO_SHOW * 3,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return results.map(
(result: { item: ResourceSuggestionCandidate }) => result.item.suggestion,
);
@@ -188,9 +190,11 @@ async function searchAgentCandidates(
const fzf = new AsyncFzf(candidates, {
selector: (s: Suggestion) => s.label,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const results = await fzf.find(normalizedPattern, {
limit: MAX_SUGGESTIONS_TO_SHOW,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return results.map((r: { item: Suggestion }) => r.item);
}
@@ -0,0 +1,101 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import {
ExtensionRegistryClient,
type RegistryExtension,
} from '../../config/extensionRegistryClient.js';
export interface UseExtensionRegistryResult {
extensions: RegistryExtension[];
loading: boolean;
error: string | null;
search: (query: string) => void;
}
export function useExtensionRegistry(
initialQuery = '',
): UseExtensionRegistryResult {
const [extensions, setExtensions] = useState<RegistryExtension[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const client = useMemo(() => new ExtensionRegistryClient(), []);
// Ref to track the latest query to avoid race conditions
const latestQueryRef = useRef(initialQuery);
// Ref for debounce timeout
const debounceTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
const searchExtensions = useCallback(
async (query: string) => {
try {
setLoading(true);
const results = await client.searchExtensions(query);
// Only update if this is still the latest query
if (query === latestQueryRef.current) {
// Check if results are different from current extensions
setExtensions((prev) => {
if (
prev.length === results.length &&
prev.every((ext, i) => ext.id === results[i].id)
) {
return prev;
}
return results;
});
setError(null);
setLoading(false);
}
} catch (err) {
if (query === latestQueryRef.current) {
setError(err instanceof Error ? err.message : String(err));
setExtensions([]);
setLoading(false);
}
}
},
[client],
);
const search = useCallback(
(query: string) => {
latestQueryRef.current = query;
// Clear existing timeout
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
// Debounce
debounceTimeoutRef.current = setTimeout(() => {
void searchExtensions(query);
}, 300);
},
[searchExtensions],
);
// Initial load
useEffect(() => {
void searchExtensions(initialQuery);
return () => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
};
}, [initialQuery, searchExtensions]);
return {
extensions,
loading,
error,
search,
};
}
@@ -36,6 +36,9 @@ vi.mock('@google/gemini-cli-core', async () => {
return {
...actual,
isHeadlessMode: vi.fn().mockReturnValue(false),
FolderTrustDiscoveryService: {
discover: vi.fn(() => new Promise(() => {})),
},
};
});
+23 -3
View File
@@ -14,7 +14,13 @@ import {
} from '../../config/trustedFolders.js';
import * as process from 'node:process';
import { type HistoryItemWithoutId, MessageType } from '../types.js';
import { coreEvents, ExitCodes, isHeadlessMode } from '@google/gemini-cli-core';
import {
coreEvents,
ExitCodes,
isHeadlessMode,
FolderTrustDiscoveryService,
type FolderDiscoveryResults,
} from '@google/gemini-cli-core';
import { runExitCleanup } from '../../utils/cleanup.js';
export const useFolderTrust = (
@@ -24,6 +30,8 @@ export const useFolderTrust = (
) => {
const [isTrusted, setIsTrusted] = useState<boolean | undefined>(undefined);
const [isFolderTrustDialogOpen, setIsFolderTrustDialogOpen] = useState(false);
const [discoveryResults, setDiscoveryResults] =
useState<FolderDiscoveryResults | null>(null);
const [isRestarting, setIsRestarting] = useState(false);
const startupMessageSent = useRef(false);
@@ -33,6 +41,19 @@ export const useFolderTrust = (
let isMounted = true;
const { isTrusted: trusted } = isWorkspaceTrusted(settings.merged);
if (trusted === undefined || trusted === false) {
void FolderTrustDiscoveryService.discover(process.cwd())
.then((results) => {
if (isMounted) {
setDiscoveryResults(results);
}
})
.catch(() => {
// Silently ignore discovery errors as they are handled within the service
// and reported via results.discoveryErrors if successful.
});
}
const showUntrustedMessage = () => {
if (trusted === false && !startupMessageSent.current) {
addItem(
@@ -100,8 +121,6 @@ export const useFolderTrust = (
onTrustChange(currentIsTrusted);
setIsTrusted(currentIsTrusted);
// logic: we restart if the trust state *effectively* changes from the previous state.
// previous state was `isTrusted`. If undefined, we assume false (untrusted).
const wasTrusted = isTrusted ?? false;
if (wasTrusted !== currentIsTrusted) {
@@ -117,6 +136,7 @@ export const useFolderTrust = (
return {
isTrusted,
isFolderTrustDialogOpen,
discoveryResults,
handleFolderTrustSelect,
isRestarting,
};
-151
View File
@@ -1,151 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useMemo, useEffect } from 'react';
import { AsyncFzf } from 'fzf';
import { useUIState } from '../contexts/UIStateContext.js';
import {
useTextBuffer,
type TextBuffer,
} from '../components/shared/text-buffer.js';
import { getCachedStringWidth } from '../utils/textUtils.js';
interface FzfResult {
item: string;
start: number;
end: number;
score: number;
positions?: number[];
}
export interface GenericListItem {
key: string;
label: string;
description?: string;
scopeMessage?: string;
}
export interface UseFuzzyListProps<T extends GenericListItem> {
items: T[];
initialQuery?: string;
onSearch?: (query: string) => void;
}
export interface UseFuzzyListResult<T extends GenericListItem> {
filteredItems: T[];
searchBuffer: TextBuffer | undefined;
searchQuery: string;
setSearchQuery: (query: string) => void;
maxLabelWidth: number;
}
export function useFuzzyList<T extends GenericListItem>({
items,
initialQuery = '',
onSearch,
}: UseFuzzyListProps<T>): UseFuzzyListResult<T> {
// Search state
const [searchQuery, setSearchQuery] = useState(initialQuery);
const [filteredKeys, setFilteredKeys] = useState<string[]>(() =>
items.map((i) => i.key),
);
// FZF instance for fuzzy searching
const { fzfInstance, searchMap } = useMemo(() => {
const map = new Map<string, string>();
const searchItems: string[] = [];
items.forEach((item) => {
searchItems.push(item.label);
map.set(item.label.toLowerCase(), item.key);
});
const fzf = new AsyncFzf(searchItems, {
fuzzy: 'v2',
casing: 'case-insensitive',
});
return { fzfInstance: fzf, searchMap: map };
}, [items]);
// Perform search
useEffect(() => {
let active = true;
if (!searchQuery.trim() || !fzfInstance) {
setFilteredKeys(items.map((i) => i.key));
return;
}
const doSearch = async () => {
const results = await fzfInstance.find(searchQuery);
if (!active) return;
const matchedKeys = new Set<string>();
results.forEach((res: FzfResult) => {
const key = searchMap.get(res.item.toLowerCase());
if (key) matchedKeys.add(key);
});
setFilteredKeys(Array.from(matchedKeys));
onSearch?.(searchQuery);
};
void doSearch().catch((error) => {
// eslint-disable-next-line no-console
console.error('Search failed:', error);
setFilteredKeys(items.map((i) => i.key)); // Reset to all items on error
});
return () => {
active = false;
};
}, [searchQuery, fzfInstance, searchMap, items, onSearch]);
// Get mainAreaWidth for search buffer viewport from UIState
const { mainAreaWidth } = useUIState();
const viewportWidth = Math.max(20, mainAreaWidth - 8);
// Search input buffer
const searchBuffer = useTextBuffer({
initialText: searchQuery,
initialCursorOffset: searchQuery.length,
viewport: {
width: viewportWidth,
height: 1,
},
singleLine: true,
onChange: (text) => setSearchQuery(text),
});
// Filtered items to display
const filteredItems = useMemo(() => {
if (!searchQuery) return items;
return items.filter((item) => filteredKeys.includes(item.key));
}, [items, filteredKeys, searchQuery]);
// Calculate max label width for alignment
const maxLabelWidth = useMemo(() => {
let max = 0;
// We use all items for consistent alignment even when filtered
items.forEach((item) => {
const labelFull =
item.label + (item.scopeMessage ? ` ${item.scopeMessage}` : '');
const lWidth = getCachedStringWidth(labelFull);
const dWidth = item.description
? getCachedStringWidth(item.description)
: 0;
max = Math.max(max, lWidth, dWidth);
});
return max;
}, [items]);
return {
filteredItems,
searchBuffer,
searchQuery,
setSearchQuery,
maxLabelWidth,
};
}
@@ -970,6 +970,10 @@ export const useGeminiStream = (
'Response stopped due to prohibited image content.',
[FinishReason.NO_IMAGE]:
'Response stopped because no image was generated.',
[FinishReason.IMAGE_RECITATION]:
'Response stopped due to image recitation policy.',
[FinishReason.IMAGE_OTHER]:
'Response stopped due to other image-related reasons.',
};
const message = finishReasonMessages[finishReason];
@@ -155,9 +155,10 @@ describe('useQuotaAndFallback', () => {
expect(request?.isTerminalQuotaError).toBe(true);
const message = request!.message;
expect(message).toContain('Usage limit reached for gemini-pro.');
expect(message).toContain('Usage limit reached for all Pro models.');
expect(message).toContain('Access resets at'); // From getResetTimeMessage
expect(message).toContain('/stats model for usage details');
expect(message).toContain('/model to switch models.');
expect(message).toContain('/auth to switch to API key.');
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
@@ -176,6 +177,77 @@ describe('useQuotaAndFallback', () => {
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
});
it('should show the model name for a terminal quota error on a non-pro model', async () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
}),
);
const handler = setFallbackHandlerSpy.mock
.calls[0][0] as FallbackModelHandler;
let promise: Promise<FallbackIntent | null>;
const error = new TerminalQuotaError(
'flash quota',
mockGoogleApiError,
1000 * 60 * 5,
);
act(() => {
promise = handler('gemini-flash', 'gemini-pro', error);
});
const request = result.current.proQuotaRequest;
expect(request).not.toBeNull();
expect(request?.failedModel).toBe('gemini-flash');
const message = request!.message;
expect(message).toContain('Usage limit reached for gemini-flash.');
expect(message).not.toContain('all Pro models');
act(() => {
result.current.handleProQuotaChoice('retry_later');
});
await promise!;
});
it('should handle terminal quota error without retry delay', async () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
}),
);
const handler = setFallbackHandlerSpy.mock
.calls[0][0] as FallbackModelHandler;
let promise: Promise<FallbackIntent | null>;
const error = new TerminalQuotaError('no delay', mockGoogleApiError);
act(() => {
promise = handler('gemini-pro', 'gemini-flash', error);
});
const request = result.current.proQuotaRequest;
const message = request!.message;
expect(message).not.toContain('Access resets at');
expect(message).toContain('Usage limit reached for all Pro models.');
act(() => {
result.current.handleProQuotaChoice('retry_later');
});
await promise!;
});
it('should handle race conditions by stopping subsequent requests', async () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
@@ -14,9 +14,9 @@ import {
TerminalQuotaError,
ModelNotFoundError,
type UserTierId,
PREVIEW_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL,
VALID_GEMINI_MODELS,
isProModel,
getDisplayString,
} from '@google/gemini-cli-core';
import { useCallback, useEffect, useRef, useState } from 'react';
import { type UseHistoryManagerReturn } from './useHistoryManager.js';
@@ -67,11 +67,9 @@ export function useQuotaAndFallback({
let message: string;
let isTerminalQuotaError = false;
let isModelNotFoundError = false;
const usageLimitReachedModel =
failedModel === DEFAULT_GEMINI_MODEL ||
failedModel === PREVIEW_GEMINI_MODEL
? 'all Pro models'
: failedModel;
const usageLimitReachedModel = isProModel(failedModel)
? 'all Pro models'
: failedModel;
if (error instanceof TerminalQuotaError) {
isTerminalQuotaError = true;
// Common part of the message for both tiers
@@ -87,7 +85,7 @@ export function useQuotaAndFallback({
isModelNotFoundError = true;
if (VALID_GEMINI_MODELS.has(failedModel)) {
const messageLines = [
`It seems like you don't have access to ${failedModel}.`,
`It seems like you don't have access to ${getDisplayString(failedModel)}.`,
`Your admin might have disabled the access. Contact them to enable the Preview Release Channel.`,
];
message = messageLines.join('\n');
@@ -0,0 +1,57 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useRef } from 'react';
import type { TextBuffer } from '../components/shared/text-buffer.js';
import type { GenericListItem } from '../components/shared/SearchableList.js';
import { useSearchBuffer } from './useSearchBuffer.js';
export interface UseRegistrySearchResult<T extends GenericListItem> {
filteredItems: T[];
searchBuffer: TextBuffer | undefined;
searchQuery: string;
setSearchQuery: (query: string) => void;
maxLabelWidth: number;
}
export function useRegistrySearch<T extends GenericListItem>(props: {
items: T[];
initialQuery?: string;
onSearch?: (query: string) => void;
}): UseRegistrySearchResult<T> {
const { items, initialQuery = '', onSearch } = props;
const [searchQuery, setSearchQuery] = useState(initialQuery);
const isFirstRender = useRef(true);
const onSearchRef = useRef(onSearch);
onSearchRef.current = onSearch;
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
onSearchRef.current?.(searchQuery);
}, [searchQuery]);
const searchBuffer = useSearchBuffer({
initialText: searchQuery,
onChange: setSearchQuery,
});
const maxLabelWidth = 0;
const filteredItems = items;
return {
filteredItems,
searchBuffer,
searchQuery,
setSearchQuery,
maxLabelWidth,
};
}
@@ -0,0 +1,41 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
useTextBuffer,
type TextBuffer,
} from '../components/shared/text-buffer.js';
import { useUIState } from '../contexts/UIStateContext.js';
const MIN_VIEWPORT_WIDTH = 20;
const VIEWPORT_WIDTH_OFFSET = 8;
export interface UseSearchBufferProps {
initialText?: string;
onChange: (text: string) => void;
}
export function useSearchBuffer({
initialText = '',
onChange,
}: UseSearchBufferProps): TextBuffer {
const { mainAreaWidth } = useUIState();
const viewportWidth = Math.max(
MIN_VIEWPORT_WIDTH,
mainAreaWidth - VIEWPORT_WIDTH_OFFSET,
);
return useTextBuffer({
initialText,
initialCursorOffset: initialText.length,
viewport: {
width: viewportWidth,
height: 1,
},
singleLine: true,
onChange,
});
}
@@ -60,6 +60,7 @@ export const useSessionBrowser = (
const originalFilePath = path.join(chatsDir, fileName);
// Load up the conversation.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const conversation: ConversationRecord = JSON.parse(
await fs.readFile(originalFilePath, 'utf8'),
);
@@ -271,6 +271,7 @@ function useCommandSuggestions(
const fzfInstance = getFzfForCommands(commandsToSearch);
if (fzfInstance) {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const fzfResults = await fzfInstance.fzf.find(partial);
if (signal.aborted) return;
const uniqueCommands = new Set<SlashCommand>();
@@ -22,6 +22,7 @@ export const useStateAndRef = <
(newStateOrCallback) => {
let newValue: T;
if (typeof newStateOrCallback === 'function') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
newValue = newStateOrCallback(ref.current);
} else {
newValue = newStateOrCallback;