diff --git a/packages/cli/src/config/extensions/github.test.ts b/packages/cli/src/config/extensions/github.test.ts index 117d65a0c7..0c53155ec7 100644 --- a/packages/cli/src/config/extensions/github.test.ts +++ b/packages/cli/src/config/extensions/github.test.ts @@ -12,6 +12,7 @@ import type { ExtensionManager } from '../extension-manager.js'; import { fetchReleaseFromGithub, type GeminiCLIExtension, + type GithubReleaseData, } from '@google/gemini-cli-core'; import type { ExtensionConfig } from '../extension.js'; @@ -150,7 +151,10 @@ describe('github.ts (CLI specific)', () => { }); it('should return UPDATE_AVAILABLE if github release tag differs', async () => { - vi.mocked(fetchReleaseFromGithub).mockResolvedValue({ tag_name: 'v2.0.0' } as any); + vi.mocked(fetchReleaseFromGithub).mockResolvedValue({ + tag_name: 'v2.0.0', + assets: [], + } as unknown as GithubReleaseData); const ext = { installMetadata: { diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 47974b194e..e8a4e7f249 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -595,6 +595,7 @@ export const mockUIActions: UIActions = { handleRestart: vi.fn(), handleNewAgentsSelect: vi.fn(), handleTeamSelect: vi.fn(), + handleRefreshTeams: vi.fn(), setIsTeamCreatorActive: vi.fn(), getPreferredEditor: vi.fn(), clearAccountSuspension: vi.fn(), diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 02ed67b230..f908b149c1 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1431,6 +1431,11 @@ Logging in with Google... Restarting Gemini CLI to continue. }, [config, refreshStatic], ); + + const handleRefreshTeams = useCallback(async () => { + await config.getTeamRegistry().reload(); + setForceRerenderKey((prev) => prev + 1); + }, [config]); /** * Determines if the input prompt should be active and accept user input. * Input is disabled during: @@ -2631,6 +2636,7 @@ Logging in with Google... Restarting Gemini CLI to continue. setNewAgents(null); }, handleTeamSelect, + handleRefreshTeams, setIsTeamCreatorActive, getPreferredEditor, clearAccountSuspension: () => { @@ -2694,6 +2700,7 @@ Logging in with Google... Restarting Gemini CLI to continue. historyManager, getPreferredEditor, handleTeamSelect, + handleRefreshTeams, setIsTeamCreatorActive, ], ); diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 604b835bc5..fd560effef 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -67,6 +67,7 @@ export const DialogManager = ({ ); } diff --git a/packages/cli/src/ui/components/StatusRow.tsx b/packages/cli/src/ui/components/StatusRow.tsx index 27f0bcc908..2c3d924b54 100644 --- a/packages/cli/src/ui/components/StatusRow.tsx +++ b/packages/cli/src/ui/components/StatusRow.tsx @@ -25,6 +25,7 @@ import { HorizontalLine } from './shared/HorizontalLine.js'; import { ApprovalModeIndicator } from './ApprovalModeIndicator.js'; import { ShellModeIndicator } from './ShellModeIndicator.js'; import { RawMarkdownIndicator } from './RawMarkdownIndicator.js'; +import { ProviderTag } from './shared/ProviderTag.js'; import { useComposerStatus } from '../hooks/useComposerStatus.js'; /** @@ -146,14 +147,6 @@ export const StatusNode: React.FC<{ ); }; -const PROVIDER_COLORS: Record = { - 'claude-code': '#C15F3C', // Claude Orange (Exact) - codex: '#FFFFFF', // Codex White - gemini: '#A855F7', // Gemini Purple - antigravity: '#93C5FD', // Antigravity Light Blue - gemma: '#60A5FA', // Gemma Blue -}; - /** * Renders an indicator for the currently active agent team. */ @@ -180,29 +173,14 @@ const ActiveTeamIndicator: React.FC = () => { return ( [ Team: {activeTeam.displayName} ( - {sortedProviders.map((p, i) => { - const label = - p === 'claude-code' - ? 'Claude Code' - : p === 'codex' - ? 'Codex' - : p === 'antigravity' - ? 'Antigravity' - : p === 'gemma' - ? 'Gemma' - : 'Gemini CLI'; - const color = PROVIDER_COLORS[p] || theme.text.secondary; - return ( - - - {label} - - {i < sortedProviders.length - 1 && ( - , - )} - - ); - })} + {sortedProviders.map((p, i) => ( + + + {i < sortedProviders.length - 1 && ( + , + )} + + ))} ) ] ); diff --git a/packages/cli/src/ui/components/TeamCreatorWizard.tsx b/packages/cli/src/ui/components/TeamCreatorWizard.tsx index 4c34b6da35..ed419e4f03 100644 --- a/packages/cli/src/ui/components/TeamCreatorWizard.tsx +++ b/packages/cli/src/ui/components/TeamCreatorWizard.tsx @@ -10,7 +10,6 @@ import { Box, Text, useStdin } from 'ink'; import { type ScaffoldTeamAgent, scaffoldTeam, - type EditorType, } from '@google/gemini-cli-core'; import { theme } from '../semantic-colors.js'; import { TextInput } from './shared/TextInput.js'; @@ -18,13 +17,12 @@ import { useTextBuffer } from './shared/text-buffer.js'; import { BaseSelectionList } from './shared/BaseSelectionList.js'; import { DialogFooter } from './shared/DialogFooter.js'; import { useConfig } from '../contexts/ConfigContext.js'; -import { useUIActions } from '../contexts/UIActionsContext.js'; -import { useKeypress, type Key } from '../hooks/useKeypress.js'; +import { useKeypress } from '../hooks/useKeypress.js'; import { Command } from '../key/keyMatchers.js'; import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; import { type SelectionListItem } from '../hooks/useSelectionList.js'; -import { useSettingsStore } from '../contexts/SettingsContext.js'; import { formatCommand } from '../key/keybindingUtils.js'; +import { ProviderTag, PROVIDER_COLORS } from './shared/ProviderTag.js'; interface TeamCreatorWizardProps { onComplete: () => void; @@ -38,14 +36,6 @@ type WizardStep = | 'confirmation' | 'success'; -const PROVIDER_COLORS: Record = { - 'claude-code': '#C15F3C', // Claude Orange (Exact) - codex: '#FFFFFF', // Codex White - gemini: '#A855F7', // Gemini Purple - antigravity: '#93C5FD', // Antigravity Light Blue - gemma: '#60A5FA', // Gemma Blue -}; - const EXTERNAL_TEMPLATES: RosterListItem[] = [ { name: 'claude-coder', @@ -79,7 +69,7 @@ const EXTERNAL_TEMPLATES: RosterListItem[] = [ interface RosterListItem { name: string; - kind: 'local' | 'external' | 'meta'; + kind: 'local' | 'external'; provider?: string; description?: string; sourcePath?: string; @@ -87,31 +77,11 @@ interface RosterListItem { logoColor?: string; } -function ProviderTag({ provider }: { provider: string }) { - const label = - provider === 'claude-code' - ? 'Claude Code' - : provider === 'codex' - ? 'Codex' - : provider === 'antigravity' - ? 'Antigravity' - : provider === 'gemma' - ? 'Gemma' - : 'Gemini CLI'; - const color = PROVIDER_COLORS[provider] || theme.ui.comment; - return ( - - [{label}] - - ); -} - export function TeamCreatorWizard({ onComplete, onCancel, }: TeamCreatorWizardProps): React.JSX.Element { const config = useConfig(); - const { handleTeamSelect } = useUIActions(); const keyMatchers = useKeyMatchers(); const [step, setStep] = useState('identity'); const [teamName, setTeamName] = useState(''); @@ -122,7 +92,7 @@ export function TeamCreatorWizard({ const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); const [createdPath, setCreatedPath] = useState(null); - const [rosterMode, setRosterMode] = useState<'list' | 'action'>('list'); + const [rosterMode] = useState<'list' | 'action'>('list'); const localAgents = useMemo( () => @@ -152,270 +122,136 @@ export function TeamCreatorWizard({ name: a.name, kind: 'local' as const, description: a.description, - sourcePath: a.metadata?.filePath, - logo: !a.metadata?.filePath ? logo : undefined, // Only for built-ins - logoColor: !a.metadata?.filePath ? logoColor : undefined, + sourcePath: (a as any).sourcePath, + logo, + logoColor, }; }); - const external: RosterListItem[] = EXTERNAL_TEMPLATES.map((a) => ({ - name: a.name, - kind: 'external' as const, - provider: a.provider, - description: a.description, - logo: a.logo, - logoColor: a.logoColor, - })); + + const external: RosterListItem[] = EXTERNAL_TEMPLATES; + return [...local, ...external]; }, [localAgents]); - const { settings } = useSettingsStore(); - const { stdin, setRawMode } = useStdin(); - const getPreferredEditor = useCallback( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - () => settings.merged.general.preferredEditor as EditorType, - [settings.merged.general.preferredEditor], - ); + const teamProviders = useMemo(() => { + const providers = new Set(); + for (const name of selectedAgents) { + const agent = allAvailableAgents.find((a) => a.name === name); + if (agent?.provider) { + providers.add(agent.provider); + } else { + providers.add('gemini'); + } + } + return Array.from(providers).sort(); + }, [selectedAgents, allAvailableAgents]); const handleNext = useCallback(() => { if (step === 'identity') { - if (!teamName.trim()) { - setError('Slug is required'); + if (!teamName || !displayName) { + setError('Team ID and Display Name are required.'); return; } - if (!displayName.trim()) { - setError('Display Name is required'); - return; - } - setError(null); setStep('roster'); } else if (step === 'roster') { setStep('objective'); } else if (step === 'objective') { - if (!instructions.trim()) { - setError('Objective/Instructions are required'); + if (!instructions) { + setError('Team mission instructions are required.'); return; } - setError(null); setStep('confirmation'); + } else if (step === 'confirmation') { + void handleSubmit(); } }, [step, teamName, displayName, instructions]); - const handleBack = useCallback(() => { - if (step === 'roster') { - setStep('identity'); - setRosterMode('list'); // Reset roster mode when going back - } else if (step === 'objective') { - setStep('roster'); - } else if (step === 'confirmation') { - setStep('objective'); - } else if (step === 'identity') { - onCancel(); - } else if (step === 'success') { - onComplete(); - } - }, [step, onCancel, onComplete]); - - const handleToggleAgent = (name: string) => { - const next = new Set(selectedAgents); - if (next.has(name)) next.delete(name); - else next.add(name); - setSelectedAgents(next); - }; - - const handleCreate = useCallback(async () => { + const handleSubmit = async () => { setIsSubmitting(true); setError(null); try { - const agentsToScaffold: ScaffoldTeamAgent[] = allAvailableAgents - .filter((a) => selectedAgents.has(a.name) && a.kind !== 'meta') - .map((a) => ({ - name: a.name, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - kind: a.kind as 'local' | 'external', - provider: a.provider, - description: a.description, - sourcePath: a.sourcePath, - })); + const agents: ScaffoldTeamAgent[] = Array.from(selectedAgents).map( + (name) => { + const agent = allAvailableAgents.find((a) => a.name === name)!; + return { + name, + kind: agent.kind, + provider: agent.provider, + description: agent.description, + sourcePath: agent.sourcePath, + }; + }, + ); + const path = await scaffoldTeam({ name: teamName, displayName, description, instructions, - agents: agentsToScaffold, + agents, targetDir: config.storage.getProjectTeamsDir(), }); + setCreatedPath(path); - await config.getTeamRegistry().reload(); setStep('success'); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); + } catch (e: any) { + setError(e.message || 'Failed to create team.'); + } finally { setIsSubmitting(false); } - }, [ - allAvailableAgents, - selectedAgents, - teamName, - displayName, - description, - instructions, - config, - ]); + }; + + const handleBack = useCallback(() => { + if (step === 'roster') setStep('identity'); + else if (step === 'objective') setStep('roster'); + else if (step === 'confirmation') setStep('objective'); + }, [step]); - // Identity Step Buffers const nameBuffer = useTextBuffer({ initialText: teamName, - viewport: { width: 40, height: 1 }, + viewport: { width: 100, height: 100 }, onChange: setTeamName, - singleLine: true, }); const displayNameBuffer = useTextBuffer({ initialText: displayName, - viewport: { width: 40, height: 1 }, + viewport: { width: 100, height: 100 }, onChange: setDisplayName, - singleLine: true, }); - const descBuffer = useTextBuffer({ + const descriptionBuffer = useTextBuffer({ initialText: description, - viewport: { width: 80, height: 1 }, + viewport: { width: 100, height: 100 }, onChange: setDescription, - singleLine: true, }); - - // Objective Step Buffer const instructionsBuffer = useTextBuffer({ initialText: instructions, - viewport: { width: 80, height: 5 }, + viewport: { width: 100, height: 100 }, onChange: setInstructions, - stdin, - setRawMode, - getPreferredEditor, }); - const [activeIdentityField, setActiveIdentityField] = useState<0 | 1 | 2>(0); - - const handleIdentityKeyPress = useCallback( - (key: Key) => { - const isNext = - keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key) || - (key.name === 'tab' && !key.shift); - const isPrev = - keyMatchers[Command.DIALOG_NAVIGATION_UP](key) || - (key.name === 'tab' && key.shift); - - if (isNext) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - setActiveIdentityField(((activeIdentityField + 1) % 3) as 0 | 1 | 2); - return true; - } - if (isPrev) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - setActiveIdentityField(((activeIdentityField + 2) % 3) as 0 | 1 | 2); - return true; - } + const { stdin } = useStdin(); + useKeypress( + (key) => { if (keyMatchers[Command.ESCAPE](key)) { onCancel(); return true; } - return false; - }, - [activeIdentityField, keyMatchers, onCancel], - ); - - // Handle Tab for "Back" and any key for success exit - const handleGlobalKeys = useCallback( - (key: Key) => { - if (step === 'success') { - if (key.sequence?.toLowerCase() === 'y') { - void handleTeamSelect(teamName); - } - onComplete(); - return true; - } - if (key.name === 'tab') { - if (step === 'roster') { - setRosterMode((prev) => (prev === 'list' ? 'action' : 'list')); - return true; - } + if (key.name === 'b' && !stdin.isPaused()) { handleBack(); return true; } return false; }, - [step, handleBack, onComplete, handleTeamSelect, teamName], + { isActive: step !== 'identity' && step !== 'success' }, ); - useKeypress(handleGlobalKeys, { isActive: true, priority: true }); - - useKeypress(handleIdentityKeyPress, { - isActive: step === 'identity', - priority: true, - }); - - const handleRosterKeyPress = useCallback( - (key: Key) => { - if (key.sequence?.toLowerCase() === 'b') { - handleBack(); - return true; - } - if (rosterMode === 'action') { - if (keyMatchers[Command.RETURN](key)) { - handleNext(); - return true; - } - } - if (keyMatchers[Command.ESCAPE](key)) { - onCancel(); - return true; - } - return false; - }, - [handleNext, handleBack, onCancel, rosterMode, keyMatchers], - ); - - useKeypress(handleRosterKeyPress, { - isActive: step === 'roster', - priority: true, - }); - - const handleObjectiveKeyPress = useCallback( - (key: Key) => { - if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) { - void instructionsBuffer.openInExternalEditor(); - return true; - } - if (keyMatchers[Command.ESCAPE](key)) { - onCancel(); - return true; - } - return false; - }, - [instructionsBuffer, keyMatchers, onCancel], - ); - - useKeypress(handleObjectiveKeyPress, { - isActive: step === 'objective', - priority: true, - }); - - const handleConfirmationKeyPress = useCallback( - (key: Key) => { - if (keyMatchers[Command.RETURN](key)) { - void handleCreate(); - return true; - } - if (keyMatchers[Command.ESCAPE](key)) { - onCancel(); - return true; - } - return false; - }, - [handleCreate, keyMatchers, onCancel], - ); - - useKeypress(handleConfirmationKeyPress, { - isActive: step === 'confirmation' && !isSubmitting, - priority: true, - }); + const handleToggleAgent = useCallback((name: string) => { + setSelectedAgents((prev) => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + }, []); if (step === 'identity') { return ( @@ -430,38 +266,41 @@ export function TeamCreatorWizard({ Step 1: Team Identity - - Slug Name (e.g., coding-team): - - setActiveIdentityField(1)} - /> + Unique Team ID (slug): + + + - - - Display Name (e.g., The Coding Team): - + + Display Name: + + setActiveIdentityField(2)} + placeholder="e.g. Frontend Experts" /> - - Short Description: + + Description (Optional): + + + + {error && ( + + Error: {error} + + )} + @@ -469,11 +308,13 @@ export function TeamCreatorWizard({ } if (step === 'roster') { - const rosterItems: Array> = - allAvailableAgents.map((a) => ({ + const items: SelectionListItem[] = allAvailableAgents.map( + (a) => ({ key: a.name, + label: a.name, value: a, - })); + }), + ); return ( - - - Step 2: Team Roster - - - - Selected: {selectedAgents.size} - - - - - + + Step 2: Team Roster + + - {rosterMode === 'list' - ? 'Select agents to include in your team:' - : 'Switch back to the list if you need to select more agents:'} + Select the agents that will participate in this team. - + - items={rosterItems} - onSelect={(item) => handleToggleAgent(item.name)} - maxItemsToShow={5} - showScrollArrows={true} - isFocused={rosterMode === 'list'} + items={items} + onSelect={(value) => handleToggleAgent(value.name)} renderItem={(item, context) => { const value = item.value; - const isChecked = selectedAgents.has(value.name); return ( - - - - [{isChecked ? 'x' : ' '}] - - - {' '} - {value.logo && ( - - {value.logo}{' '} - - )} - {value.name} - - {(() => { - const p = value.provider || 'gemini'; - const label = - p === 'claude-code' - ? 'Claude Code' - : p === 'codex' - ? 'Codex' - : p === 'antigravity' - ? 'Antigravity' - : p === 'gemma' - ? 'Gemma' - : 'Gemini CLI'; - const color = PROVIDER_COLORS[p] || theme.ui.comment; - return ( - - {' '} - [{label}] - - ); - })()} + + + + + {selectedAgents.has(value.name) ? '☑' : '☐'} + + + + + {' '} + {value.logo && ( + + {value.logo}{' '} + + )} + {value.name} + + + + + + {value.description && ( + + {' '} + {value.description} + + )} - {value.description && ( - - {' '} - {value.description} - - )} ); }} + isFocused={rosterMode === 'list'} /> - {rosterMode === 'action' ? '> ' : ' '}[ Done - Continue to - Objective ] + {rosterMode === 'action' ? '> ' : ' '} + [ Done - Continue to Objective ] 0 && ( + Providers: + {teamProviders.map((p, i) => ( + + + + ))} + + Referencing agents: {Array.from(selectedAgents).map((name, i) => { const agent = allAvailableAgents.find((a) => a.name === name); @@ -634,7 +445,9 @@ export function TeamCreatorWizard({ {agent.logo}{' '} )} - @{name} + + @{name}{' '} + {i < selectedAgents.size - 1 && ( , @@ -680,7 +493,7 @@ export function TeamCreatorWizard({ width="100%" > - Step 4: Confirm Team Creation + Step 4: Confirm Team Configuration @@ -707,7 +520,9 @@ export function TeamCreatorWizard({ {agent.logo}{' '} )} - {name} + + {name}{' '} + ); @@ -719,23 +534,23 @@ export function TeamCreatorWizard({ )} + {isSubmitting && ( + + Creating team... + + )} + {error && ( Error: {error} )} - - {isSubmitting ? ( - Creating team... - ) : ( - - )} - + ); } @@ -750,28 +565,21 @@ export function TeamCreatorWizard({ width="100%" > - Team Created Successfully! + Success! Team Created - Your new team "{displayName}" is now ready to use. + Team {displayName} has been saved to: - {createdPath && ( - - Created at: - {createdPath} - - )} + {createdPath} + + + You can now select this team from the main team selection menu. + + - - Would you like to switch to this team now? (y/N) - - - - - Press any other key to finish... - + [Enter] Finish ); diff --git a/packages/cli/src/ui/components/TeamSelectionDialog.tsx b/packages/cli/src/ui/components/TeamSelectionDialog.tsx index 39dd83e462..5c88fd02fd 100644 --- a/packages/cli/src/ui/components/TeamSelectionDialog.tsx +++ b/packages/cli/src/ui/components/TeamSelectionDialog.tsx @@ -10,45 +10,22 @@ import { Box, Text } from 'ink'; import { type TeamDefinition, type AgentDefinition, + installRegistryTeam, } from '@google/gemini-cli-core'; import { theme } from '../semantic-colors.js'; import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; +import { TeamRegistryView } from './views/TeamRegistryView.js'; +import { useConfig } from '../contexts/ConfigContext.js'; +import { ProviderTag } from './shared/ProviderTag.js'; interface TeamSelectionDialogProps { teams: TeamDefinition[]; onSelect: (teamName: string | undefined) => void; + onRefreshTeams?: () => void | Promise; } -const PROVIDER_COLORS: Record = { - 'claude-code': '#C15F3C', // Claude Orange (Exact) - codex: '#FFFFFF', // Codex White - gemini: '#A855F7', // Gemini Purple - antigravity: '#93C5FD', // Antigravity Light Blue - gemma: '#60A5FA', // Gemma Blue -}; - -const ProviderTag: React.FC<{ provider: string }> = ({ provider }) => { - const label = - provider === 'claude-code' - ? 'Claude Code' - : provider === 'codex' - ? 'Codex' - : provider === 'antigravity' - ? 'Antigravity' - : provider === 'gemma' - ? 'Gemma' - : 'Gemini CLI'; - const color = PROVIDER_COLORS[provider] || theme.text.secondary; - - return ( - - [{label}] - - ); -}; - const MultiModelBadge: React.FC = () => ( @@ -87,8 +64,10 @@ function getProviderTags(agents: AgentDefinition[]): React.ReactNode { export function TeamSelectionDialog({ teams: discoveredTeams, onSelect, + onRefreshTeams, }: TeamSelectionDialogProps): React.JSX.Element { const uiActions = useUIActions(); + const config = useConfig(); const [view, setView] = useState<'select' | 'marketplace'>('select'); useKeypress( @@ -160,7 +139,7 @@ export function TeamSelectionDialog({ list.push({ value: 'marketplace', - title: 'Browse Team Marketplace', + title: 'Browse Agent Teams', description: 'Discover and install teams from the community', key: 'marketplace', titleSuffix: undefined, @@ -193,39 +172,36 @@ export function TeamSelectionDialog({ [onSelect, uiActions], ); + const handleInstallTeam = useCallback( + async (registryTeam: any) => { + try { + const targetDir = config.storage.getProjectTeamsDir(); + await installRegistryTeam(registryTeam, targetDir); + await onRefreshTeams?.(); + // After installation, we go back to selection screen + setView('select'); + } catch (error) { + // Errors are currently handled by the UI feedback system via coreEvents if they bubble up + } + }, + [config, onRefreshTeams], + ); + if (view === 'marketplace') { return ( - - Agent Team Marketplace - - - - Explore and download community-contributed agent teams. - - - - The community marketplace is currently under development. Soon you - will be able to browse hundreds of specialized teams. - - - - - Press any key to go back to selection... - - - + setView('select')} + /> ); } diff --git a/packages/cli/src/ui/components/shared/ProviderTag.tsx b/packages/cli/src/ui/components/shared/ProviderTag.tsx new file mode 100644 index 0000000000..b22abf94a5 --- /dev/null +++ b/packages/cli/src/ui/components/shared/ProviderTag.tsx @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { Text } from 'ink'; +import { theme } from '../../semantic-colors.js'; + +export const PROVIDER_COLORS: Record = { + 'claude-code': '#C15F3C', // Claude Orange + codex: '#FFFFFF', // Codex White + gemini: '#A855F7', // Gemini Purple + antigravity: '#93C5FD', // Antigravity Light Blue + gemma: '#60A5FA', // Gemma Blue +}; + +export function getProviderLabel(provider: string): string { + switch (provider) { + case 'claude-code': + return 'Claude Code'; + case 'codex': + return 'Codex'; + case 'antigravity': + return 'Antigravity'; + case 'gemma': + return 'Gemma'; + default: + return 'Gemini CLI'; + } +} + +export const ProviderTag: React.FC<{ provider: string }> = ({ provider }) => { + const label = getProviderLabel(provider); + const color = PROVIDER_COLORS[provider] || theme.text.secondary; + + return ( + + [{label}] + + ); +}; diff --git a/packages/cli/src/ui/components/views/TeamDetailsView.tsx b/packages/cli/src/ui/components/views/TeamDetailsView.tsx new file mode 100644 index 0000000000..2c8c96c8f2 --- /dev/null +++ b/packages/cli/src/ui/components/views/TeamDetailsView.tsx @@ -0,0 +1,183 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useState } from 'react'; +import { Box, Text } from 'ink'; +import { + type RegistryTeam, +} from '@google/gemini-cli-core'; +import { useKeypress } from '../../hooks/useKeypress.js'; +import { Command } from '../../key/keyMatchers.js'; +import { useKeyMatchers } from '../../hooks/useKeyMatchers.js'; +import { theme } from '../../semantic-colors.js'; +import { ProviderTag } from '../shared/ProviderTag.js'; + +export interface TeamDetailsViewProps { + team: RegistryTeam; + onBack: () => void; + onInstall: () => void | Promise; + isInstalled: boolean; +} + +export function TeamDetailsView({ + team, + onBack, + onInstall, + isInstalled, +}: TeamDetailsViewProps): React.JSX.Element { + const keyMatchers = useKeyMatchers(); + const [isInstalling, setIsInstalling] = useState(false); + + useKeypress( + (key) => { + if (keyMatchers[Command.ESCAPE](key)) { + onBack(); + return true; + } + + if (keyMatchers[Command.RETURN](key) && !isInstalled && !isInstalling) { + setIsInstalling(true); + void (async () => { + await onInstall(); + setIsInstalling(false); + })(); + return true; + } + return false; + }, + { isActive: true, priority: true }, + ); + + if (isInstalling) { + return ( + + + Installing Team {team.displayName}... + + + ); + } + + return ( + + {/* Header Row */} + + + + {'>'} Agent Teams {'>'}{' '} + + + {team.displayName} + + + + + {team.version ? `v${team.version}` : ''} |{' '} + + + + {String(team.stars || 0)} |{' '} + + @{team.name} + + + + {/* Description */} + + {team.description} + + + {/* Author */} + + Author: + {team.author || 'Community'} + + + {/* Agents Roster */} + + + Team Roster: + + {team.agents.map((agent) => ( + + + @{agent.name} + + + + + + + {agent.description} + + + + ))} + + + {/* Instructions Summary */} + + + Team Mission: + + + + {team.instructions} + + + + + {/* Spacer to push installation UI to bottom */} + + + {/* Installation UI */} + {!isInstalled ? ( + + + This team will be installed to your local .gemini/teams/ directory. + Agent teams may contain custom instructions and configurations. + + + [{'Enter'}] Install Team + + [Esc] Back + + + + ) : ( + + Team Already Installed + + [Esc] Back + + + )} + + ); +} diff --git a/packages/cli/src/ui/components/views/TeamRegistryView.tsx b/packages/cli/src/ui/components/views/TeamRegistryView.tsx new file mode 100644 index 0000000000..f90be1266d --- /dev/null +++ b/packages/cli/src/ui/components/views/TeamRegistryView.tsx @@ -0,0 +1,244 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useMemo, useCallback, useState } from 'react'; +import { Box, Text } from 'ink'; +import { + type RegistryTeam, + type TeamDefinition, +} from '@google/gemini-cli-core'; +import { + SearchableList, + type GenericListItem, +} from '../shared/SearchableList.js'; +import { theme } from '../../semantic-colors.js'; + +import { useTeamRegistry } from '../../hooks/useTeamRegistry.js'; +import { useConfig } from '../../contexts/ConfigContext.js'; +import { useRegistrySearch } from '../../hooks/useRegistrySearch.js'; +import { useUIState } from '../../contexts/UIStateContext.js'; +import { TeamDetailsView } from './TeamDetailsView.js'; +import { ProviderTag } from '../shared/ProviderTag.js'; + +export interface TeamRegistryViewProps { + onInstall?: (team: RegistryTeam) => void | Promise; + onClose?: () => void; + installedTeams: TeamDefinition[]; +} + +interface TeamListItem extends GenericListItem { + team: RegistryTeam; +} + +export function TeamRegistryView({ + onInstall, + onClose, + installedTeams, +}: TeamRegistryViewProps): React.JSX.Element { + const config = useConfig(); + const { teams, loading, error, search } = useTeamRegistry( + '', + config.getTeamRegistryURI(), + ); + const { terminalHeight, staticExtraHeight } = useUIState(); + const [selectedTeam, setSelectedTeam] = useState(null); + + const items: TeamListItem[] = useMemo( + () => + teams.map((team) => ({ + key: team.id, + label: team.displayName, + description: team.description, + team, + })), + [teams], + ); + + const handleSelect = useCallback((item: TeamListItem) => { + setSelectedTeam(item.team); + }, []); + + const handleBack = useCallback(() => { + setSelectedTeam(null); + }, []); + + const handleInstallAction = useCallback( + async (team: RegistryTeam) => { + await onInstall?.(team); + // Go back to list view after install + setSelectedTeam(null); + }, + [onInstall], + ); + + const renderItem = useCallback( + (item: TeamListItem, isActive: boolean, _labelWidth: number) => { + const isInstalled = installedTeams.some((t) => t.name === item.team.name); + const providers = Array.from( + new Set(item.team.agents.map((a) => a.provider || 'gemini')), + ).sort(); + + return ( + + + + + + {isActive ? '● ' : ' '} + + + + + {item.label} + + + + | + + {isInstalled && ( + + [Installed] + + )} + + + {item.description} + + + + + + + {' '} + {item.team.stars || 0} + + + + + + {item.team.agents.length} agent + {item.team.agents.length === 1 ? '' : 's'}{' '} + + + {providers.map((p) => ( + + + + ))} + + + + ); + }, + [installedTeams], + ); + + const header = useMemo( + () => ( + + + + Browse and search Agent Teams from the registry. + + + + + {installedTeams.length > 0 && + `${installedTeams.length} teams installed`} + + + + ), + [installedTeams.length], + ); + + const footer = useCallback( + ({ + startIndex, + endIndex, + totalVisible, + }: { + startIndex: number; + endIndex: number; + totalVisible: number; + }) => ( + + ({startIndex + 1}-{endIndex}) / {totalVisible} + + ), + [], + ); + + const maxItemsToShow = useMemo(() => { + const staticHeight = 10; + const availableTerminalHeight = terminalHeight - staticExtraHeight; + const remainingHeight = Math.max(0, availableTerminalHeight - staticHeight); + const itemHeight = 3; // Increased due to multi-line rendering + return Math.max(4, Math.floor(remainingHeight / itemHeight)); + }, [terminalHeight, staticExtraHeight]); + + if (loading && items.length === 0) { + return ( + + Loading Agent Teams... + + ); + } + + if (error) { + return ( + + Error loading teams: + {error} + + ); + } + + return ( + <> + + + title="Browse Agent Teams" + items={items} + onSelect={handleSelect} + onClose={onClose || (() => {})} + searchPlaceholder="Search agent teams" + renderItem={renderItem} + header={header} + footer={footer} + maxItemsToShow={maxItemsToShow} + useSearch={useRegistrySearch} + onSearch={search} + resetSelectionOnItemsChange={true} + isFocused={!selectedTeam} + /> + + {selectedTeam && ( + { + await handleInstallAction(selectedTeam); + }} + isInstalled={installedTeams.some((t) => t.name === selectedTeam.name)} + /> + )} + + ); +} diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 92501691fd..271b40a563 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -92,6 +92,7 @@ export interface UIActions { handleRestart: () => void; handleNewAgentsSelect: (choice: NewAgentsChoice) => Promise; handleTeamSelect: (teamName: string | undefined) => void; + handleRefreshTeams: () => Promise; setIsTeamCreatorActive: (active: boolean) => void; getPreferredEditor: () => EditorType; diff --git a/packages/cli/src/ui/hooks/useTeamRegistry.ts b/packages/cli/src/ui/hooks/useTeamRegistry.ts new file mode 100644 index 0000000000..0d872747f4 --- /dev/null +++ b/packages/cli/src/ui/hooks/useTeamRegistry.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import { + TeamRegistryClient, + type RegistryTeam, +} from '@google/gemini-cli-core'; + +export interface UseTeamRegistryResult { + teams: RegistryTeam[]; + loading: boolean; + error: string | null; + search: (query: string) => void; +} + +export function useTeamRegistry( + initialQuery = '', + registryURI?: string, +): UseTeamRegistryResult { + const [teams, setTeams] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const client = useMemo( + () => new TeamRegistryClient(registryURI), + [registryURI], + ); + + // Ref to track the latest query to avoid race conditions + const latestQueryRef = useRef(initialQuery); + + // Ref for debounce timeout + const debounceTimeoutRef = useRef(undefined); + + const searchTeams = useCallback( + async (query: string) => { + try { + setLoading(true); + const results = await client.searchTeams(query); + + // Only update if this is still the latest query + if (query === latestQueryRef.current) { + // Check if results are different from current teams + setTeams((prev) => { + if ( + prev.length === results.length && + prev.every((team, i) => team.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)); + setTeams([]); + 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 searchTeams(query); + }, 300); + }, + [searchTeams], + ); + + // Initial load + useEffect(() => { + void searchTeams(initialQuery); + + return () => { + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } + }; + }, [initialQuery, searchTeams]); + + return { + teams, + loading, + error, + search, + }; +} diff --git a/packages/core/src/agents/teamRegistryClient.ts b/packages/core/src/agents/teamRegistryClient.ts index b42e869a06..66eb27d1f7 100644 --- a/packages/core/src/agents/teamRegistryClient.ts +++ b/packages/core/src/agents/teamRegistryClient.ts @@ -69,7 +69,10 @@ export class TeamRegistryClient { } } catch (error) { TeamRegistryClient.fetchPromise = null; - throw error; + if (error instanceof Error) { + throw error; + } + throw new Error(String(error)); } })(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c2b42bf3a1..4f8ff58f30 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -186,6 +186,8 @@ export * from './agents/types.js'; export * from './agents/agentLoader.js'; export * from './agents/local-executor.js'; export * from './agents/agent-scheduler.js'; +export * from './agents/teamRegistry.js'; +export * from './agents/teamRegistryClient.js'; export * from './agents/teamScaffolder.js'; // Export browser session management