mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-15 06:12:50 -07:00
feat(cli): add UI to update extensions (#23682)
This commit is contained in:
@@ -127,6 +127,13 @@ available combinations.
|
|||||||
| `background.unfocusList` | Move focus from background shell list to Gemini. | `Tab` |
|
| `background.unfocusList` | Move focus from background shell list to Gemini. | `Tab` |
|
||||||
| `background.unfocusWarning` | Show warning when trying to move focus away from background shell. | `Tab` |
|
| `background.unfocusWarning` | Show warning when trying to move focus away from background shell. | `Tab` |
|
||||||
|
|
||||||
|
#### Extension Controls
|
||||||
|
|
||||||
|
| Command | Action | Keys |
|
||||||
|
| ------------------ | ------------------------------------------- | ---- |
|
||||||
|
| `extension.update` | Update the current extension if available. | `I` |
|
||||||
|
| `extension.link` | Link the current extension to a local path. | `L` |
|
||||||
|
|
||||||
<!-- KEYBINDINGS-AUTOGEN:END -->
|
<!-- KEYBINDINGS-AUTOGEN:END -->
|
||||||
|
|
||||||
## Customizing Keybindings
|
## Customizing Keybindings
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { waitFor } from '../../../test-utils/async.js';
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { ExtensionDetails } from './ExtensionDetails.js';
|
import { ExtensionDetails } from './ExtensionDetails.js';
|
||||||
import { type RegistryExtension } from '../../../config/extensionRegistryClient.js';
|
import { type RegistryExtension } from '../../../config/extensionRegistryClient.js';
|
||||||
|
import { ExtensionUpdateState } from '../../state/extensions.js';
|
||||||
|
|
||||||
const mockExtension: RegistryExtension = {
|
const mockExtension: RegistryExtension = {
|
||||||
id: 'ext1',
|
id: 'ext1',
|
||||||
@@ -48,7 +49,11 @@ describe('ExtensionDetails', () => {
|
|||||||
mockOnLink = vi.fn();
|
mockOnLink = vi.fn();
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderDetails = async (isInstalled = false) =>
|
const renderDetails = async (
|
||||||
|
isInstalled = false,
|
||||||
|
updateState?: ExtensionUpdateState,
|
||||||
|
onUpdate = vi.fn(),
|
||||||
|
) =>
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<ExtensionDetails
|
<ExtensionDetails
|
||||||
extension={mockExtension}
|
extension={mockExtension}
|
||||||
@@ -56,6 +61,8 @@ describe('ExtensionDetails', () => {
|
|||||||
onInstall={mockOnInstall}
|
onInstall={mockOnInstall}
|
||||||
onLink={mockOnLink}
|
onLink={mockOnLink}
|
||||||
isInstalled={isInstalled}
|
isInstalled={isInstalled}
|
||||||
|
updateState={updateState}
|
||||||
|
onUpdate={onUpdate}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -165,4 +172,40 @@ describe('ExtensionDetails', () => {
|
|||||||
expect(lastFrame()).toContain('[L] Link');
|
expect(lastFrame()).toContain('[L] Link');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should show update button when update is available', async () => {
|
||||||
|
const { lastFrame } = await renderDetails(
|
||||||
|
true,
|
||||||
|
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(lastFrame()).toContain('[I] Update');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onUpdate when "i" is pressed', async () => {
|
||||||
|
const mockOnUpdate = vi.fn();
|
||||||
|
const { stdin } = await renderDetails(
|
||||||
|
true,
|
||||||
|
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||||
|
mockOnUpdate,
|
||||||
|
);
|
||||||
|
await React.act(async () => {
|
||||||
|
stdin.write('i');
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnUpdate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show [Updating...] and hide "Already Installed" when update is in progress', async () => {
|
||||||
|
const { lastFrame } = await renderDetails(
|
||||||
|
true,
|
||||||
|
ExtensionUpdateState.UPDATING,
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(lastFrame()).toContain('[Updating...]');
|
||||||
|
expect(lastFrame()).not.toContain('Already Installed');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useKeypress } from '../../hooks/useKeypress.js';
|
|||||||
import { Command } from '../../key/keyMatchers.js';
|
import { Command } from '../../key/keyMatchers.js';
|
||||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||||
import { theme } from '../../semantic-colors.js';
|
import { theme } from '../../semantic-colors.js';
|
||||||
|
import { ExtensionUpdateState } from '../../state/extensions.js';
|
||||||
|
|
||||||
export interface ExtensionDetailsProps {
|
export interface ExtensionDetailsProps {
|
||||||
extension: RegistryExtension;
|
extension: RegistryExtension;
|
||||||
@@ -23,6 +24,8 @@ export interface ExtensionDetailsProps {
|
|||||||
requestConsentOverride: (consent: string) => Promise<boolean>,
|
requestConsentOverride: (consent: string) => Promise<boolean>,
|
||||||
) => void | Promise<void>;
|
) => void | Promise<void>;
|
||||||
isInstalled: boolean;
|
isInstalled: boolean;
|
||||||
|
updateState?: ExtensionUpdateState;
|
||||||
|
onUpdate?: () => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExtensionDetails({
|
export function ExtensionDetails({
|
||||||
@@ -31,6 +34,8 @@ export function ExtensionDetails({
|
|||||||
onInstall,
|
onInstall,
|
||||||
onLink,
|
onLink,
|
||||||
isInstalled,
|
isInstalled,
|
||||||
|
updateState,
|
||||||
|
onUpdate,
|
||||||
}: ExtensionDetailsProps): React.JSX.Element {
|
}: ExtensionDetailsProps): React.JSX.Element {
|
||||||
const keyMatchers = useKeyMatchers();
|
const keyMatchers = useKeyMatchers();
|
||||||
const [consentRequest, setConsentRequest] = useState<{
|
const [consentRequest, setConsentRequest] = useState<{
|
||||||
@@ -76,7 +81,12 @@ export function ExtensionDetails({
|
|||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (key.name === 'l' && isLinkable && !isInstalled && !isInstalling) {
|
if (
|
||||||
|
keyMatchers[Command.LINK_EXTENSION](key) &&
|
||||||
|
isLinkable &&
|
||||||
|
!isInstalled &&
|
||||||
|
!isInstalling
|
||||||
|
) {
|
||||||
setIsInstalling(true);
|
setIsInstalling(true);
|
||||||
void onLink(
|
void onLink(
|
||||||
(prompt: string) =>
|
(prompt: string) =>
|
||||||
@@ -86,6 +96,14 @@ export function ExtensionDetails({
|
|||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
keyMatchers[Command.UPDATE_EXTENSION](key) &&
|
||||||
|
updateState === ExtensionUpdateState.UPDATE_AVAILABLE &&
|
||||||
|
!isInstalling
|
||||||
|
) {
|
||||||
|
void onUpdate?.();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
{ isActive: true, priority: true },
|
{ isActive: true, priority: true },
|
||||||
@@ -150,6 +168,16 @@ export function ExtensionDetails({
|
|||||||
<Text color={theme.text.primary} bold>
|
<Text color={theme.text.primary} bold>
|
||||||
{extension.extensionName}
|
{extension.extensionName}
|
||||||
</Text>
|
</Text>
|
||||||
|
{updateState === ExtensionUpdateState.UPDATE_AVAILABLE && (
|
||||||
|
<Box marginLeft={1}>
|
||||||
|
<Text color={theme.status.warning}>[I] Update</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{updateState === ExtensionUpdateState.UPDATING && (
|
||||||
|
<Box marginLeft={1}>
|
||||||
|
<Text color={theme.text.secondary}>[Updating...]</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Box flexDirection="row">
|
<Box flexDirection="row">
|
||||||
<Text color={theme.text.secondary}>
|
<Text color={theme.text.secondary}>
|
||||||
@@ -258,7 +286,7 @@ export function ExtensionDetails({
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{isInstalled && (
|
{isInstalled && updateState !== ExtensionUpdateState.UPDATING && (
|
||||||
<Box flexDirection="row" marginTop={1} justifyContent="center">
|
<Box flexDirection="row" marginTop={1} justifyContent="center">
|
||||||
<Text color={theme.status.success}>Already Installed</Text>
|
<Text color={theme.status.success}>Already Installed</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
type GenericListItem,
|
type GenericListItem,
|
||||||
} from '../shared/SearchableList.js';
|
} from '../shared/SearchableList.js';
|
||||||
import { type TextBuffer } from '../shared/text-buffer.js';
|
import { type TextBuffer } from '../shared/text-buffer.js';
|
||||||
|
import { type UseHistoryManagerReturn } from '../../hooks/useHistoryManager.js';
|
||||||
|
import { ExtensionUpdateState } from '../../state/extensions.js';
|
||||||
|
|
||||||
// Mocks
|
// Mocks
|
||||||
vi.mock('../../hooks/useExtensionRegistry.js');
|
vi.mock('../../hooks/useExtensionRegistry.js');
|
||||||
@@ -97,6 +99,7 @@ describe('ExtensionRegistryView', () => {
|
|||||||
|
|
||||||
vi.mocked(useExtensionUpdates).mockReturnValue({
|
vi.mocked(useExtensionUpdates).mockReturnValue({
|
||||||
extensionsUpdateState: new Map(),
|
extensionsUpdateState: new Map(),
|
||||||
|
dispatchExtensionStateUpdate: vi.fn(),
|
||||||
} as unknown as ReturnType<typeof useExtensionUpdates>);
|
} as unknown as ReturnType<typeof useExtensionUpdates>);
|
||||||
|
|
||||||
// Mock useRegistrySearch implementation
|
// Mock useRegistrySearch implementation
|
||||||
@@ -134,6 +137,9 @@ describe('ExtensionRegistryView', () => {
|
|||||||
uiState: {
|
uiState: {
|
||||||
staticExtraHeight: 5,
|
staticExtraHeight: 5,
|
||||||
terminalHeight: 40,
|
terminalHeight: 40,
|
||||||
|
historyManager: {
|
||||||
|
addItem: vi.fn(),
|
||||||
|
} as unknown as UseHistoryManagerReturn,
|
||||||
} as Partial<UIState>,
|
} as Partial<UIState>,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -226,4 +232,42 @@ describe('ExtensionRegistryView', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should show [Update available] and hide [Installed] when update is available', async () => {
|
||||||
|
mockExtensionManager.getExtensions = vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue([{ name: 'Test Extension 1' }]);
|
||||||
|
vi.mocked(useExtensionUpdates).mockReturnValue({
|
||||||
|
extensionsUpdateState: new Map([
|
||||||
|
['Test Extension 1', ExtensionUpdateState.UPDATE_AVAILABLE],
|
||||||
|
]),
|
||||||
|
dispatchExtensionStateUpdate: vi.fn(),
|
||||||
|
} as unknown as ReturnType<typeof useExtensionUpdates>);
|
||||||
|
|
||||||
|
const { lastFrame } = await renderView();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(lastFrame()).toContain('[Update available]');
|
||||||
|
expect(lastFrame()).not.toContain('[Installed]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show [Updating...] and hide [Installed] when update is in progress', async () => {
|
||||||
|
mockExtensionManager.getExtensions = vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue([{ name: 'Test Extension 1' }]);
|
||||||
|
vi.mocked(useExtensionUpdates).mockReturnValue({
|
||||||
|
extensionsUpdateState: new Map([
|
||||||
|
['Test Extension 1', ExtensionUpdateState.UPDATING],
|
||||||
|
]),
|
||||||
|
dispatchExtensionStateUpdate: vi.fn(),
|
||||||
|
} as unknown as ReturnType<typeof useExtensionUpdates>);
|
||||||
|
|
||||||
|
const { lastFrame } = await renderView();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(lastFrame()).toContain('[Updating...]');
|
||||||
|
expect(lastFrame()).not.toContain('[Installed]');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -52,15 +52,16 @@ export function ExtensionRegistryView({
|
|||||||
'',
|
'',
|
||||||
config.getExtensionRegistryURI(),
|
config.getExtensionRegistryURI(),
|
||||||
);
|
);
|
||||||
const { terminalHeight, staticExtraHeight } = useUIState();
|
const { terminalHeight, staticExtraHeight, historyManager } = useUIState();
|
||||||
const [selectedExtension, setSelectedExtension] =
|
const [selectedExtension, setSelectedExtension] =
|
||||||
useState<RegistryExtension | null>(null);
|
useState<RegistryExtension | null>(null);
|
||||||
|
|
||||||
const { extensionsUpdateState } = useExtensionUpdates(
|
const { extensionsUpdateState, dispatchExtensionStateUpdate } =
|
||||||
extensionManager,
|
useExtensionUpdates(
|
||||||
() => 0,
|
extensionManager,
|
||||||
config.getEnableExtensionReloading(),
|
historyManager.addItem,
|
||||||
);
|
config.getEnableExtensionReloading(),
|
||||||
|
);
|
||||||
|
|
||||||
const [installedExtensions, setInstalledExtensions] = useState(() =>
|
const [installedExtensions, setInstalledExtensions] = useState(() =>
|
||||||
extensionManager.getExtensions(),
|
extensionManager.getExtensions(),
|
||||||
@@ -117,6 +118,23 @@ export function ExtensionRegistryView({
|
|||||||
[onLink, extensionManager],
|
[onLink, extensionManager],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleUpdate = useCallback(
|
||||||
|
async (extension: RegistryExtension) => {
|
||||||
|
dispatchExtensionStateUpdate({
|
||||||
|
type: 'SCHEDULE_UPDATE',
|
||||||
|
payload: {
|
||||||
|
all: false,
|
||||||
|
names: [extension.extensionName],
|
||||||
|
onComplete: () => {
|
||||||
|
// Refresh installed extensions list if needed
|
||||||
|
setInstalledExtensions(extensionManager.getExtensions());
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[dispatchExtensionStateUpdate, extensionManager],
|
||||||
|
);
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
(item: ExtensionItem, isActive: boolean, _labelWidth: number) => {
|
(item: ExtensionItem, isActive: boolean, _labelWidth: number) => {
|
||||||
const isInstalled = installedExtensions.some(
|
const isInstalled = installedExtensions.some(
|
||||||
@@ -125,7 +143,6 @@ export function ExtensionRegistryView({
|
|||||||
const updateState = extensionsUpdateState.get(
|
const updateState = extensionsUpdateState.get(
|
||||||
item.extension.extensionName,
|
item.extension.extensionName,
|
||||||
);
|
);
|
||||||
const hasUpdate = updateState === ExtensionUpdateState.UPDATE_AVAILABLE;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="row" width="100%" justifyContent="space-between">
|
<Box flexDirection="row" width="100%" justifyContent="space-between">
|
||||||
@@ -148,15 +165,20 @@ export function ExtensionRegistryView({
|
|||||||
<Box flexShrink={0} marginX={1}>
|
<Box flexShrink={0} marginX={1}>
|
||||||
<Text color={theme.text.secondary}>|</Text>
|
<Text color={theme.text.secondary}>|</Text>
|
||||||
</Box>
|
</Box>
|
||||||
{isInstalled && (
|
{updateState === ExtensionUpdateState.UPDATE_AVAILABLE ? (
|
||||||
<Box marginRight={1} flexShrink={0}>
|
|
||||||
<Text color={theme.status.success}>[Installed]</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{hasUpdate && (
|
|
||||||
<Box marginRight={1} flexShrink={0}>
|
<Box marginRight={1} flexShrink={0}>
|
||||||
<Text color={theme.status.warning}>[Update available]</Text>
|
<Text color={theme.status.warning}>[Update available]</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
) : updateState === ExtensionUpdateState.UPDATING ? (
|
||||||
|
<Box marginRight={1} flexShrink={0}>
|
||||||
|
<Text color={theme.text.secondary}>[Updating...]</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
isInstalled && (
|
||||||
|
<Box marginRight={1} flexShrink={0}>
|
||||||
|
<Text color={theme.status.success}>[Installed]</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
<Box flexShrink={1} minWidth={0}>
|
<Box flexShrink={1} minWidth={0}>
|
||||||
<Text color={theme.text.secondary} wrap="truncate-end">
|
<Text color={theme.text.secondary} wrap="truncate-end">
|
||||||
@@ -287,6 +309,12 @@ export function ExtensionRegistryView({
|
|||||||
isInstalled={installedExtensions.some(
|
isInstalled={installedExtensions.some(
|
||||||
(e) => e.name === selectedExtension.extensionName,
|
(e) => e.name === selectedExtension.extensionName,
|
||||||
)}
|
)}
|
||||||
|
updateState={extensionsUpdateState.get(
|
||||||
|
selectedExtension.extensionName,
|
||||||
|
)}
|
||||||
|
onUpdate={async () => {
|
||||||
|
await handleUpdate(selectedExtension);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -105,6 +105,10 @@ export enum Command {
|
|||||||
UNFOCUS_BACKGROUND_SHELL = 'background.unfocus',
|
UNFOCUS_BACKGROUND_SHELL = 'background.unfocus',
|
||||||
UNFOCUS_BACKGROUND_SHELL_LIST = 'background.unfocusList',
|
UNFOCUS_BACKGROUND_SHELL_LIST = 'background.unfocusList',
|
||||||
SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING = 'background.unfocusWarning',
|
SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING = 'background.unfocusWarning',
|
||||||
|
|
||||||
|
// Extension Controls
|
||||||
|
UPDATE_EXTENSION = 'extension.update',
|
||||||
|
LINK_EXTENSION = 'extension.link',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -402,6 +406,10 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
|
|||||||
[Command.UNFOCUS_BACKGROUND_SHELL, [new KeyBinding('shift+tab')]],
|
[Command.UNFOCUS_BACKGROUND_SHELL, [new KeyBinding('shift+tab')]],
|
||||||
[Command.UNFOCUS_BACKGROUND_SHELL_LIST, [new KeyBinding('tab')]],
|
[Command.UNFOCUS_BACKGROUND_SHELL_LIST, [new KeyBinding('tab')]],
|
||||||
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING, [new KeyBinding('tab')]],
|
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING, [new KeyBinding('tab')]],
|
||||||
|
|
||||||
|
// Extension Controls
|
||||||
|
[Command.UPDATE_EXTENSION, [new KeyBinding('i')]],
|
||||||
|
[Command.LINK_EXTENSION, [new KeyBinding('l')]],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
interface CommandCategory {
|
interface CommandCategory {
|
||||||
@@ -529,6 +537,10 @@ export const commandCategories: readonly CommandCategory[] = [
|
|||||||
Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING,
|
Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Extension Controls',
|
||||||
|
commands: [Command.UPDATE_EXTENSION, Command.LINK_EXTENSION],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -638,6 +650,10 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
|||||||
'Move focus from background shell list to Gemini.',
|
'Move focus from background shell list to Gemini.',
|
||||||
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]:
|
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]:
|
||||||
'Show warning when trying to move focus away from background shell.',
|
'Show warning when trying to move focus away from background shell.',
|
||||||
|
|
||||||
|
// Extension Controls
|
||||||
|
[Command.UPDATE_EXTENSION]: 'Update the current extension if available.',
|
||||||
|
[Command.LINK_EXTENSION]: 'Link the current extension to a local path.',
|
||||||
};
|
};
|
||||||
|
|
||||||
const keybindingsSchema = z.array(
|
const keybindingsSchema = z.array(
|
||||||
|
|||||||
Reference in New Issue
Block a user