diff --git a/.gemini/settings.json b/.gemini/settings.json index eb7741997b..ea8bc4ae1b 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -6,6 +6,7 @@ "topicUpdateNarration": true }, "general": { - "devtools": true + "devtools": true, + "activeTeam": "test-team" } -} +} \ No newline at end of file diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 27953c60a9..657b7f3259 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -894,6 +894,7 @@ export async function loadCliConfig( targetDir: cwd, includeDirectoryTree, includeDirectories, + activeTeam: settings.general?.activeTeam, loadMemoryFromIncludeDirectories: settings.context?.loadMemoryFromIncludeDirectories || false, discoveryMaxDirs: settings.context?.discoveryMaxDirs, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 1578b920ef..f7025c3baa 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -199,6 +199,15 @@ const SETTINGS_SCHEMA = { description: 'The preferred editor to open files in.', showInDialog: false, }, + activeTeam: { + type: 'string', + label: 'Active Team', + category: 'General', + requiresRestart: false, + default: undefined as string | undefined, + description: 'The currently active agent team slug.', + showInDialog: false, + }, vimMode: { type: 'boolean', label: 'Vim Mode', diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index c1cbd5621e..f1b05c765e 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -57,6 +57,7 @@ import { toolsCommand } from '../ui/commands/toolsCommand.js'; import { skillsCommand } from '../ui/commands/skillsCommand.js'; import { settingsCommand } from '../ui/commands/settingsCommand.js'; import { tasksCommand } from '../ui/commands/tasksCommand.js'; +import { teamCommand } from '../ui/commands/teamCommand.js'; import { vimCommand } from '../ui/commands/vimCommand.js'; import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js'; import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js'; @@ -222,6 +223,7 @@ export class BuiltinCommandLoader implements ICommandLoader { : []), settingsCommand, tasksCommand, + teamCommand, vimCommand, setupGithubCommand, terminalSetupCommand, diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 11a45f99d3..47974b194e 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -526,6 +526,11 @@ const baseMockUiState = { pendingHistoryItems: [], mainControlsRef: () => {}, rootUiRef: { current: null }, + adminSettingsChanged: false, + newAgents: null, + isTeamSelectionActive: false, + isTeamCreatorActive: false, + showIsExpandableHint: false, }; export const mockAppState: AppState = { @@ -533,7 +538,7 @@ export const mockAppState: AppState = { startupWarnings: [], }; -const mockUIActions: UIActions = { +export const mockUIActions: UIActions = { handleThemeSelect: vi.fn(), closeThemeDialog: vi.fn(), handleThemeHighlight: vi.fn(), @@ -590,6 +595,7 @@ const mockUIActions: UIActions = { handleRestart: vi.fn(), handleNewAgentsSelect: vi.fn(), handleTeamSelect: 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 999d0852b7..d07fdc463c 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -416,6 +416,7 @@ export const AppContainer = (props: AppContainerProps) => { const [isConfigInitialized, setConfigInitialized] = useState(false); const [isTeamSelectionActive, setIsTeamSelectionActive] = useState(false); + const [isTeamCreatorActive, setIsTeamCreatorActive] = useState(false); const logger = useLogger(config.storage); const { inputHistory, addInput, initializeFromLogger } = @@ -958,6 +959,8 @@ Logging in with Google... Restarting Gemini CLI to continue. }, toggleShortcutsHelp: () => setShortcutsHelpVisible((visible) => !visible), setText: stableSetText, + setIsTeamSelectionActive, + setIsTeamCreatorActive, }), [ setAuthState, @@ -977,6 +980,8 @@ Logging in with Google... Restarting Gemini CLI to continue. toggleDebugProfiler, setShortcutsHelpVisible, stableSetText, + setIsTeamSelectionActive, + setIsTeamCreatorActive, ], ); @@ -1421,12 +1426,12 @@ Logging in with Google... Restarting Gemini CLI to continue. const handleTeamSelect = useCallback( (teamName: string | undefined) => { config.setActiveTeam(teamName); + settings.setValue(SettingScope.Workspace, 'general.activeTeam', teamName); setIsTeamSelectionActive(false); refreshStatic(); }, - [config, refreshStatic], + [config, refreshStatic, settings], ); - /** * Determines if the input prompt should be active and accept user input. * Input is disabled during: @@ -2067,6 +2072,7 @@ Logging in with Google... Restarting Gemini CLI to continue. const dialogsVisible = isTeamSelectionActive || + isTeamCreatorActive || shouldShowIdePrompt || shouldShowIdePrompt || isFolderTrustDialogOpen || @@ -2399,6 +2405,7 @@ Logging in with Google... Restarting Gemini CLI to continue. adminSettingsChanged, newAgents, isTeamSelectionActive, + isTeamCreatorActive, showIsExpandableHint, hintMode: config.isModelSteeringEnabled() && isToolExecuting(pendingHistoryItems), @@ -2527,6 +2534,7 @@ Logging in with Google... Restarting Gemini CLI to continue. newAgents, showIsExpandableHint, isTeamSelectionActive, + isTeamCreatorActive, ], ); @@ -2624,6 +2632,7 @@ Logging in with Google... Restarting Gemini CLI to continue. setNewAgents(null); }, handleTeamSelect, + setIsTeamCreatorActive, getPreferredEditor, clearAccountSuspension: () => { setAccountSuspensionInfo(null); @@ -2686,6 +2695,7 @@ Logging in with Google... Restarting Gemini CLI to continue. historyManager, getPreferredEditor, handleTeamSelect, + setIsTeamCreatorActive, ], ); diff --git a/packages/cli/src/ui/commands/teamCommand.ts b/packages/cli/src/ui/commands/teamCommand.ts new file mode 100644 index 0000000000..8fe4e4dd24 --- /dev/null +++ b/packages/cli/src/ui/commands/teamCommand.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + CommandKind, + type SlashCommand, + type SlashCommandActionReturn, +} from './types.js'; + +/** + * Slash command to manage agent teams. + */ +export const teamCommand: SlashCommand = { + name: 'team', + description: 'Manage agent teams', + kind: CommandKind.BUILT_IN, + autoExecute: false, + subCommands: [ + { + name: 'select', + description: 'Open the team selection dialog', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (): Promise => ({ + type: 'dialog', + dialog: 'teamSelection', + }), + }, + { + name: 'create', + description: 'Create a new agent team interactively', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (): Promise => ({ + type: 'dialog', + dialog: 'teamCreator', + }), + }, + ], +}; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 466e70c994..987a85efc4 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -126,6 +126,8 @@ export interface OpenDialogActionReturn { | 'sessionBrowser' | 'model' | 'agentConfig' + | 'teamSelection' + | 'teamCreator' | 'permissions'; } diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 5d3a75007d..604b835bc5 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -26,6 +26,7 @@ import { SessionBrowser } from './SessionBrowser.js'; import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js'; import { ModelDialog } from './ModelDialog.js'; import { TeamSelectionDialog } from './TeamSelectionDialog.js'; +import { TeamCreatorWizard } from './TeamCreatorWizard.js'; import { theme } from '../semantic-colors.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; @@ -69,6 +70,14 @@ export const DialogManager = ({ /> ); } + if (uiState.isTeamCreatorActive) { + return ( + uiActions.setIsTeamCreatorActive(false)} + onCancel={() => uiActions.setIsTeamCreatorActive(false)} + /> + ); + } if (uiState.adminSettingsChanged) { return ; } diff --git a/packages/cli/src/ui/components/StatusRow.tsx b/packages/cli/src/ui/components/StatusRow.tsx index 22280cc86d..27f0bcc908 100644 --- a/packages/cli/src/ui/components/StatusRow.tsx +++ b/packages/cli/src/ui/components/StatusRow.tsx @@ -159,7 +159,10 @@ const PROVIDER_COLORS: Record = { */ const ActiveTeamIndicator: React.FC = () => { const config = useConfig(); - const activeTeam = config?.getActiveTeam(); + const activeTeam = + typeof config?.getActiveTeam === 'function' + ? config.getActiveTeam() + : undefined; if (!activeTeam) return null; diff --git a/packages/cli/src/ui/components/TeamCreatorWizard.tsx b/packages/cli/src/ui/components/TeamCreatorWizard.tsx new file mode 100644 index 0000000000..ba1324b680 --- /dev/null +++ b/packages/cli/src/ui/components/TeamCreatorWizard.tsx @@ -0,0 +1,691 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useState, useMemo, useCallback } from 'react'; +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'; +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 { 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'; + +interface TeamCreatorWizardProps { + onComplete: () => void; + onCancel: () => void; +} + +type WizardStep = + | 'identity' + | 'roster' + | 'objective' + | 'confirmation' + | 'success'; + +const EXTERNAL_TEMPLATES: RosterListItem[] = [ + { + name: 'claude-coder', + kind: 'external', + provider: 'claude-code', + description: 'Expert Claude coder focused on direct action.', + logo: '󰈙', + logoColor: '#D7AFFF', // Purple + }, + { + name: 'codex-architect', + kind: 'external', + provider: 'codex', + description: 'Specialized code generation and architectural patterns.', + logo: '󰘦', + logoColor: '#87D7D7', // Cyan + }, + { + name: 'antigravity-creative', + kind: 'external', + provider: 'antigravity', + description: 'Creative problem solving and unconventional solutions.', + logo: '󱜙', + logoColor: '#FFFFAF', // Yellow + }, + { + name: 'gemma-helper', + kind: 'external', + provider: 'gemma', + description: 'Lightweight and efficient general purpose assistant.', + logo: '󱜚', + logoColor: '#87AFFF', // Blue + }, +]; + +interface RosterListItem { + name: string; + kind: 'local' | 'external' | 'meta'; + provider?: string; + description?: string; + sourcePath?: string; + logo?: string; + logoColor?: string; +} + +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(''); + const [displayName, setDisplayName] = useState(''); + const [description, setDescription] = useState(''); + const [instructions, setInstructions] = useState(''); + const [selectedAgents, setSelectedAgents] = useState>(new Set()); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const [createdPath, setCreatedPath] = useState(null); + + const localAgents = useMemo( + () => + config + ?.getAgentRegistry() + .getAllDefinitions() + .filter((a) => a.kind === 'local') || [], + [config], + ); + + const allAvailableAgents = useMemo((): RosterListItem[] => { + const local: RosterListItem[] = localAgents.map((a) => { + let logo = '󰈙'; // Default + let logoColor = theme.text.accent; + + if (a.name === 'codebase-investigator') { + logo = '🔍'; + logoColor = '#87D7D7'; // Cyan + } else if (a.name === 'cli-help') { + logo = '󰞋'; + logoColor = '#FBBC04'; // Yellow + } else if (a.name === 'generalist') { + logo = '󰈙'; + logoColor = '#4285F4'; // Blue + } + + return { + 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, + }; + }); + 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, + })); + 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 handleNext = useCallback(() => { + if (step === 'identity') { + if (!teamName.trim()) { + setError('Slug is 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'); + return; + } + setError(null); + setStep('confirmation'); + } + }, [step, teamName, displayName, instructions]); + + const handleBack = useCallback(() => { + if (step === 'roster') setStep('identity'); + 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 () => { + 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 path = await scaffoldTeam({ + name: teamName, + displayName, + description, + instructions, + agents: agentsToScaffold, + targetDir: config.storage.getProjectTeamsDir(), + }); + setCreatedPath(path); + await config.getTeamRegistry().reload(); + setStep('success'); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setIsSubmitting(false); + } + }, [ + allAvailableAgents, + selectedAgents, + teamName, + displayName, + description, + instructions, + config, + ]); + + // Identity Step Buffers + const nameBuffer = useTextBuffer({ + initialText: teamName, + viewport: { width: 40, height: 1 }, + onChange: setTeamName, + singleLine: true, + }); + const displayNameBuffer = useTextBuffer({ + initialText: displayName, + viewport: { width: 40, height: 1 }, + onChange: setDisplayName, + singleLine: true, + }); + const descBuffer = useTextBuffer({ + initialText: description, + viewport: { width: 80, height: 1 }, + onChange: setDescription, + singleLine: true, + }); + + // Objective Step Buffer + const instructionsBuffer = useTextBuffer({ + initialText: instructions, + viewport: { width: 80, height: 5 }, + 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; + } + 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') { + handleTeamSelect(teamName); + } + onComplete(); + return true; + } + if (key.name === 'tab') { + handleBack(); + return true; + } + return false; + }, + [step, handleBack, onComplete, handleTeamSelect, teamName], + ); + + useKeypress(handleGlobalKeys, { isActive: true, priority: true }); + + useKeypress(handleIdentityKeyPress, { + isActive: step === 'identity', + 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, + }); + + if (step === 'identity') { + return ( + + + Step 1: Team Identity + + + + Slug Name (e.g., coding-team): + + setActiveIdentityField(1)} + /> + + + + Display Name (e.g., The Coding Team): + + setActiveIdentityField(2)} + /> + + + + Short Description: + + + + + + ); + } + + if (step === 'roster') { + const rosterItems: Array> = + allAvailableAgents.map((a) => ({ + key: a.name, + value: a, + })); + rosterItems.push({ + key: 'done', + value: { name: 'done', kind: 'meta' as const }, + }); + + return ( + + + Step 2: Team Roster + + + + Select agents to include in your team. + + + + + items={rosterItems} + onSelect={(item) => { + if (item.name === 'done') handleNext(); + else handleToggleAgent(item.name); + }} + maxItemsToShow={10} + showScrollArrows={true} + renderItem={(item, context) => { + const value = item.value; + if (value.name === 'done') { + return ( + + [ Done - Continue to Objective ] + + ); + } + const isChecked = selectedAgents.has(value.name); + return ( + + + + [{isChecked ? 'x' : ' '}] + + + {' '} + {value.logo && ( + + {value.logo}{' '} + + )} + {value.name} + + {value.kind === 'external' ? ( + + {' '} + [EXTERNAL] + + ) : !value.sourcePath ? ( + + {' '} + [BUILT-IN] + + ) : null} + + {' '} + ({value.kind} + {value.provider ? `: ${value.provider}` : ''}) + + + {value.description && ( + + {' '} + {value.description} + + )} + + ); + }} + /> + + + + ); + } + + if (step === 'objective') { + return ( + + + Step 3: Team Objective + + + + Define what this team does and how it should work. + + {selectedAgents.size > 0 && ( + + Referencing agents: + {Array.from(selectedAgents).map((name, i) => { + const agent = allAvailableAgents.find((a) => a.name === name); + return ( + + {agent?.logo && ( + + {agent.logo}{' '} + + )} + + @{name} + {i < selectedAgents.size - 1 ? ', ' : ''} + + + ); + })} + + )} + + + + + + {error && ( + + Error: {error} + + )} + + + + ); + } + + if (step === 'confirmation') { + return ( + + + Step 4: Confirm Team Creation + + + + Name:{' '} + + {displayName} ({teamName}) + + + + Description: {description} + + + Roster: + + {Array.from(selectedAgents).map((name) => { + const agent = allAvailableAgents.find((a) => a.name === name); + return ( + + - + {agent?.logo && ( + + {agent.logo}{' '} + + )} + {name} + + ); + })} + {selectedAgents.size === 0 && ( + + No agents selected (Gemini CLI expert will be used by default) + + )} + + + {error && ( + + Error: {error} + + )} + + + {isSubmitting ? ( + Creating team... + ) : ( + + )} + + + ); + } + + if (step === 'success') { + return ( + + + Team Created Successfully! + + + + Your new team "{displayName}" is now ready to use. + + {createdPath && ( + + Created at: + {createdPath} + + )} + + + + Would you like to switch to this team now? (y/N) + + + + + Press any other key to finish... + + + + ); + } + + return ; +} diff --git a/packages/cli/src/ui/components/TeamSelectionDialog.test.tsx b/packages/cli/src/ui/components/TeamSelectionDialog.test.tsx index 816352beb8..6693be6953 100644 --- a/packages/cli/src/ui/components/TeamSelectionDialog.test.tsx +++ b/packages/cli/src/ui/components/TeamSelectionDialog.test.tsx @@ -5,76 +5,67 @@ */ import { describe, it, expect, vi } from 'vitest'; +import { renderWithProviders, mockUIActions } from '../../test-utils/render.js'; import { act } from 'react'; import { TeamSelectionDialog } from './TeamSelectionDialog.js'; -import { renderWithProviders } from '../../test-utils/render.js'; -import { waitFor } from '../../test-utils/async.js'; -import { type TeamDefinition } from '@google/gemini-cli-core'; describe('', () => { - const mockTeams: TeamDefinition[] = [ + const mockTeams = [ { - name: 'team-1', + name: 'team-one', displayName: 'Team One', description: 'First team description', - instructions: 'Instructions 1', agents: [], + instructions: '', + configPath: '/test/team1', }, { - name: 'team-2', + name: 'team-two', displayName: 'Team Two', description: 'Second team description', - instructions: 'Instructions 2', agents: [], + instructions: '', + configPath: '/test/team2', }, ]; - const mockOnSelect = vi.fn(); - - beforeEach(() => { - mockOnSelect.mockReset(); - }); - - const renderComponent = async () => - renderWithProviders( - , + it('renders a list of discovered teams and standard options', async () => { + const onSelect = vi.fn(); + const { lastFrame, unmount } = await renderWithProviders( + , ); - it('renders all options correctly', async () => { - const { lastFrame, unmount } = await renderComponent(); - const output = lastFrame(); - - expect(output).toContain('Select an Agent Team'); - expect(output).toContain('Team One'); - expect(output).toContain('First team description'); - expect(output).toContain('Team Two'); - expect(output).toContain('Second team description'); - expect(output).toContain('The Polyglot Team (Curated)'); - expect(output).toContain('No Team'); - expect(output).toContain('Browse Team Marketplace'); - expect(output).toContain('Create Team'); + expect(lastFrame()).toContain('Select an Agent Team'); + expect(lastFrame()).toContain('Team One'); + expect(lastFrame()).toContain('Team Two'); + expect(lastFrame()).toContain('The Polyglot Team'); + expect(lastFrame()).toContain('No Team'); + expect(lastFrame()).toContain('Browse Team Marketplace'); + expect(lastFrame()).toContain('Create Team'); unmount(); }); it('calls onSelect with team name when a team is selected', async () => { - const { stdin, waitUntilReady, unmount } = await renderComponent(); + const onSelect = vi.fn(); + const { stdin, unmount } = await renderWithProviders( + , + ); - // Default selection is index 0 (Team One) await act(async () => { - stdin.write('\r'); // Enter + stdin.write('\r'); // Enter on first item }); - await waitUntilReady(); - await waitFor(() => { - expect(mockOnSelect).toHaveBeenCalledWith('team-1'); - }); + expect(onSelect).toHaveBeenCalledWith('team-one'); unmount(); }); - it('calls onSelect with undefined when "No Team" is selected', async () => { - const { stdin, waitUntilReady, unmount } = await renderComponent(); + it('calls onSelect with undefined when No Team is selected', async () => { + const onSelect = vi.fn(); + const { stdin, waitUntilReady, unmount } = await renderWithProviders( + , + ); - // Navigate to "No Team" (index 3: Team 1, Team 2, Polyglot, No Team) + // Navigate to No Team (index 3) for (let i = 0; i < 3; i++) { await act(async () => { stdin.write('\u001B[B'); // Down @@ -85,37 +76,17 @@ describe('', () => { await act(async () => { stdin.write('\r'); // Enter }); - await waitUntilReady(); - await waitFor(() => { - expect(mockOnSelect).toHaveBeenCalledWith(undefined); - }); + expect(onSelect).toHaveBeenCalledWith(undefined); unmount(); }); - it('does not call onSelect for placeholder options', async () => { - const { stdin, waitUntilReady, unmount } = await renderComponent(); - - // Navigate to "Browse Team Marketplace" (index 4) - for (let i = 0; i < 4; i++) { - await act(async () => { - stdin.write('\u001B[B'); // Down - }); - await waitUntilReady(); - } - - await act(async () => { - stdin.write('\r'); // Enter - }); - await waitUntilReady(); - - expect(mockOnSelect).not.toHaveBeenCalled(); - unmount(); - }); - - it('shows marketplace sub-view when selected', async () => { - const { stdin, lastFrame, waitUntilReady, unmount } = - await renderComponent(); + it('shows marketplace view when selected', async () => { + const onSelect = vi.fn(); + const { lastFrame, stdin, waitUntilReady, unmount } = + await renderWithProviders( + , + ); // Navigate to Marketplace (index 4) for (let i = 0; i < 4; i++) { @@ -131,20 +102,24 @@ describe('', () => { await waitUntilReady(); expect(lastFrame()).toContain('Agent Team Marketplace'); - expect(lastFrame()).toContain('under development'); + expect(lastFrame()).toContain( + 'community marketplace is currently under development', + ); - // Go back + // Go back with escape await act(async () => { - stdin.write('a'); + stdin.write('\u001B'); // Escape }); await waitUntilReady(); expect(lastFrame()).toContain('Select an Agent Team'); unmount(); }); - it('shows create team sub-view when selected', async () => { - const { stdin, lastFrame, waitUntilReady, unmount } = - await renderComponent(); + it('launches team creator wizard when Create Team is selected', async () => { + const onSelect = vi.fn(); + const { stdin, waitUntilReady, unmount } = await renderWithProviders( + , + ); // Navigate to Create Team (index 5) for (let i = 0; i < 5; i++) { @@ -159,15 +134,7 @@ describe('', () => { }); await waitUntilReady(); - expect(lastFrame()).toContain('Create New Agent Team'); - expect(lastFrame()).toContain('Create a directory in .gemini/teams/'); - - // Go back - await act(async () => { - stdin.write('a'); - }); - await waitUntilReady(); - expect(lastFrame()).toContain('Select an Agent Team'); + expect(mockUIActions.setIsTeamCreatorActive).toHaveBeenCalledWith(true); unmount(); }); }); diff --git a/packages/cli/src/ui/components/TeamSelectionDialog.tsx b/packages/cli/src/ui/components/TeamSelectionDialog.tsx index 40fdb04352..39dd83e462 100644 --- a/packages/cli/src/ui/components/TeamSelectionDialog.tsx +++ b/packages/cli/src/ui/components/TeamSelectionDialog.tsx @@ -14,6 +14,7 @@ import { import { theme } from '../semantic-colors.js'; import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js'; import { useKeypress } from '../hooks/useKeypress.js'; +import { useUIActions } from '../contexts/UIActionsContext.js'; interface TeamSelectionDialogProps { teams: TeamDefinition[]; @@ -87,9 +88,8 @@ export function TeamSelectionDialog({ teams: discoveredTeams, onSelect, }: TeamSelectionDialogProps): React.JSX.Element { - const [view, setView] = useState<'select' | 'marketplace' | 'create'>( - 'select', - ); + const uiActions = useUIActions(); + const [view, setView] = useState<'select' | 'marketplace'>('select'); useKeypress( () => { @@ -184,12 +184,13 @@ export function TeamSelectionDialog({ } else if (value === 'marketplace') { setView('marketplace'); } else if (value === 'create') { - setView('create'); + uiActions.setIsTeamCreatorActive(true); + onSelect(undefined); } else { onSelect(value); } }, - [onSelect], + [onSelect, uiActions], ); if (view === 'marketplace') { @@ -229,54 +230,6 @@ export function TeamSelectionDialog({ ); } - if (view === 'create') { - return ( - - - Create New Agent Team - - - - To create a new team, follow these simple steps: - - - 1. Create a directory in .gemini/teams/ (e.g., - .gemini/teams/my-team/) - - - 2. Create a TEAM.md file with name, display_name, and description. - - - 3. (Optional) Add specialized agents to an agents/ sub-directory. - - - - - Tip: You can now specify external agents directly in TEAM.md! - - - - - - Press any key to go back to selection... - - - - - ); - } - return ( { terminalCapabilityManager.enableSupportedModes(); - // Always ensure raw mode is on while we are active. - // Ink handles reference counting for setRawMode(true/false) calls. setRawMode(true); process.stdin.setEncoding('utf8'); // Make data events emit strings diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 88b5899179..92501691fd 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; + setIsTeamCreatorActive: (active: boolean) => void; getPreferredEditor: () => EditorType; clearAccountSuspension: () => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 587a1a0b16..7bf7199889 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -222,6 +222,7 @@ export interface UIState { adminSettingsChanged: boolean; newAgents: AgentDefinition[] | null; isTeamSelectionActive: boolean; + isTeamCreatorActive: boolean; showIsExpandableHint: boolean; hintMode: boolean; hintBuffer: string; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx index ec4aa00677..a201bdfb22 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx @@ -216,6 +216,8 @@ describe('useSlashCommandProcessor', () => { toggleBackgroundTasks: vi.fn(), toggleShortcutsHelp: vi.fn(), setText: vi.fn(), + setIsTeamSelectionActive: vi.fn(), + setIsTeamCreatorActive: vi.fn(), }, new Map(), // extensionsUpdateState true, // isConfigInitialized diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index f55503ad25..89a9387a8d 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -87,6 +87,8 @@ interface SlashCommandProcessorActions { toggleBackgroundTasks: () => void; toggleShortcutsHelp: () => void; setText: (text: string) => void; + setIsTeamSelectionActive: (active: boolean) => void; + setIsTeamCreatorActive: (active: boolean) => void; } /** @@ -500,6 +502,12 @@ export const useSlashCommandProcessor = ( case 'model': actions.openModelDialog(); return { type: 'handled' }; + case 'teamSelection': + actions.setIsTeamSelectionActive(true); + return { type: 'handled' }; + case 'teamCreator': + actions.setIsTeamCreatorActive(true); + return { type: 'handled' }; case 'agentConfig': { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const props = result.props as Record; diff --git a/packages/cli/src/ui/hooks/useFocus.test.tsx b/packages/cli/src/ui/hooks/useFocus.test.tsx index f840a3c9fb..aeae475263 100644 --- a/packages/cli/src/ui/hooks/useFocus.test.tsx +++ b/packages/cli/src/ui/hooks/useFocus.test.tsx @@ -34,9 +34,11 @@ describe('useFocus', () => { pause: vi.fn(), }); stdout = { write: vi.fn() }; - mockedUseStdin.mockReturnValue({ stdin } as unknown as ReturnType< - typeof useStdin - >); + mockedUseStdin.mockReturnValue({ + stdin, + setRawMode: vi.fn(), + isRawModeSupported: true, + } as unknown as ReturnType); mockedUseStdout.mockReturnValue({ stdout } as unknown as ReturnType< typeof useStdout >); diff --git a/packages/core/src/agents/teamRegistry.ts b/packages/core/src/agents/teamRegistry.ts index 30faf3ad42..5649cbb09a 100644 --- a/packages/core/src/agents/teamRegistry.ts +++ b/packages/core/src/agents/teamRegistry.ts @@ -121,10 +121,15 @@ export class TeamRegistry { /** * Sets the current active team. - * @param name The slug (name) of the team to activate. - * @throws Error if the team is not found. + * @param name The slug (name) of the team to activate, or undefined to clear. + * @throws Error if the team is not found and name is provided. */ - setActiveTeam(name: string): void { + setActiveTeam(name: string | undefined): void { + if (name === undefined) { + this.activeTeamName = undefined; + return; + } + if (this.teams.has(name)) { this.activeTeamName = name; } else { diff --git a/packages/core/src/agents/teamScaffolder.test.ts b/packages/core/src/agents/teamScaffolder.test.ts new file mode 100644 index 0000000000..e90895b385 --- /dev/null +++ b/packages/core/src/agents/teamScaffolder.test.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { scaffoldTeam, type ScaffoldTeamOptions } from './teamScaffolder.js'; + +describe('teamScaffolder', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'gemini-team-scaffolder-test-'), + ); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('should scaffold a team with inline external agents', async () => { + const options: ScaffoldTeamOptions = { + name: 'Test Team', + displayName: 'The Test Team', + description: 'A team for testing.', + instructions: 'Do some testing.', + agents: [ + { + kind: 'external', + name: 'claude-coder', + provider: 'claude-code', + description: 'Expert Claude coder.', + }, + ], + targetDir: tempDir, + }; + + const teamPath = await scaffoldTeam(options); + expect(teamPath).toBe(path.join(tempDir, 'test-team')); + + const teamMdPath = path.join(teamPath, 'TEAM.md'); + const content = await fs.readFile(teamMdPath, 'utf-8'); + + expect(content).toContain('name: test-team'); + expect(content).toContain('display_name: The Test Team'); + expect(content).toContain('description: A team for testing.'); + expect(content).toContain('provider: claude-code'); + expect(content).toContain('Do some testing.'); + }); + + it('should scaffold a team with standalone local agents', async () => { + // Create a dummy local agent file + const localAgentPath = path.join(tempDir, 'dummy-agent.md'); + await fs.writeFile( + localAgentPath, + '---\nname: dummy\n---\nPrompt', + 'utf-8', + ); + + const options: ScaffoldTeamOptions = { + name: 'Local Team', + displayName: 'The Local Team', + description: 'A team with local agents.', + instructions: 'Use local agents.', + agents: [ + { + kind: 'local', + name: 'dummy', + sourcePath: localAgentPath, + }, + ], + targetDir: tempDir, + }; + + // Use a subfolder for teams to avoid collision with dummy-agent.md + const teamsDir = path.join(tempDir, 'teams'); + options.targetDir = teamsDir; + + const teamPath = await scaffoldTeam(options); + const agentsDir = path.join(teamPath, 'agents'); + const copiedAgentPath = path.join(agentsDir, 'dummy-agent.md'); + + expect( + await fs + .access(copiedAgentPath) + .then(() => true) + .catch(() => false), + ).toBe(true); + const copiedContent = await fs.readFile(copiedAgentPath, 'utf-8'); + expect(copiedContent).toContain('name: dummy'); + }); +}); diff --git a/packages/core/src/agents/teamScaffolder.ts b/packages/core/src/agents/teamScaffolder.ts new file mode 100644 index 0000000000..6d15c8d515 --- /dev/null +++ b/packages/core/src/agents/teamScaffolder.ts @@ -0,0 +1,107 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { dump } from 'js-yaml'; +import { getErrorMessage } from '../utils/errors.js'; + +export interface ScaffoldTeamAgent { + name: string; + kind: 'local' | 'external'; + provider?: string; + description?: string; + sourcePath?: string; +} + +export interface ScaffoldTeamOptions { + name: string; + displayName: string; + description: string; + instructions: string; + agents: ScaffoldTeamAgent[]; + targetDir: string; +} + +/** + * Scaffolds a new Agent Team directory and its associated files. + * + * @param options Configuration for the team to be created. + * @returns The absolute path to the created team directory. + */ +export async function scaffoldTeam( + options: ScaffoldTeamOptions, +): Promise { + const teamSlug = options.name.toLowerCase().replace(/[^a-z0-9-_]/g, '-'); + const teamDirPath = path.resolve(options.targetDir, teamSlug); + const agentsDirPath = path.join(teamDirPath, 'agents'); + + try { + // 1. Create team directory + await fs.mkdir(teamDirPath, { recursive: true }); + + // 2. Clear agents directory if it exists to ensure a fresh start + try { + await fs.rm(agentsDirPath, { recursive: true, force: true }); + } catch (_e) { + // Ignore if it doesn't exist + } + + // 3. Generate TEAM.md content (no inline agents anymore) + const frontmatter = { + name: teamSlug, + display_name: options.displayName, + description: options.description, + }; + + const teamMdContent = `--- +${dump(frontmatter).trim()} +--- + +${options.instructions.trim()} +`; + + await fs.writeFile( + path.join(teamDirPath, 'TEAM.md'), + teamMdContent, + 'utf-8', + ); + + // 4. Create agents/ sub-directory if there are any agents selected + if (options.agents.length > 0) { + await fs.mkdir(agentsDirPath, { recursive: true }); + + for (const agent of options.agents) { + const destPath = path.join(agentsDirPath, `${agent.name}.md`); + + if (agent.kind === 'external') { + // Generate a new .md file for external agents + const agentFrontmatter = { + kind: 'external', + name: agent.name, + provider: agent.provider, + description: agent.description || `Expert ${agent.name} agent.`, + }; + const agentContent = `--- +${dump(agentFrontmatter).trim()} +--- +`; + await fs.writeFile(destPath, agentContent, 'utf-8'); + } else if (agent.kind === 'local' && agent.sourcePath) { + // Copy existing local agent file + // Use a name-based filename to avoid collisions and keep it clean + await fs.copyFile(agent.sourcePath, destPath); + } + } + } + + return teamDirPath; + } catch (error) { + throw new Error( + `Failed to scaffold team "${options.name}": ${getErrorMessage(error)}`, + ); + } +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index b8b97528f8..b2c77c1e50 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -736,6 +736,7 @@ export interface ConfigParameters { agents?: AgentSettings; }>; enableConseca?: boolean; + activeTeam?: string; billing?: { overageStrategy?: OverageStrategy; }; @@ -894,6 +895,7 @@ export class Config implements McpContext, AgentLoopContext { private initPromise: Promise | undefined; private mcpInitializationPromise: Promise | null = null; readonly storage: Storage; + private readonly _params: ConfigParameters; private readonly fileExclusions: FileExclusions; private readonly eventEmitter?: EventEmitter; private readonly useWriteTodos: boolean; @@ -970,6 +972,7 @@ export class Config implements McpContext, AgentLoopContext { private approvedPlanPath: string | undefined; constructor(params: ConfigParameters) { + this._params = params; this._sessionId = params.sessionId; this.clientName = params.clientName; this.clientVersion = params.clientVersion ?? 'unknown'; @@ -1433,6 +1436,14 @@ export class Config implements McpContext, AgentLoopContext { await this.agentRegistry.initialize(); await this.teamRegistry.initialize(); + if (this._params.activeTeam) { + try { + this.teamRegistry.setActiveTeam(this._params.activeTeam); + } catch (_e) { + // Ignore if team not found (might have been deleted or is project-specific) + } + } + coreEvents.on(CoreEvent.AgentsRefreshed, this.onAgentsRefreshed); this._toolRegistry = await this.createToolRegistry(); @@ -2037,13 +2048,8 @@ export class Config implements McpContext, AgentLoopContext { } setActiveTeam(name: string | undefined): void { - if (name) { - this.teamRegistry.setActiveTeam(name); - } else { - // Logic for clearing active team could be added here if needed, - // but for now TeamRegistry.setActiveTeam expects a string. - // We'll leave it as is to match TeamRegistry signature or update TeamRegistry if null is allowed. - } + this.teamRegistry.setActiveTeam(name); + coreEvents.emitActiveTeamChanged(name); } getAcknowledgedAgentsService(): AcknowledgedAgentsService { diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 10b13a9f31..f6b91b9168 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -120,6 +120,7 @@ export class GeminiClient { coreEvents.on(CoreEvent.ModelChanged, this.handleModelChanged); coreEvents.on(CoreEvent.MemoryChanged, this.handleMemoryChanged); + coreEvents.on(CoreEvent.ActiveTeamChanged, this.handleActiveTeamChanged); } private get config(): Config { @@ -134,6 +135,10 @@ export class GeminiClient { this.updateSystemInstruction(); }; + private handleActiveTeamChanged = () => { + this.updateSystemInstruction(); + }; + clearCurrentSequenceModel(): void { this.currentSequenceModel = null; } @@ -318,6 +323,7 @@ export class GeminiClient { dispose() { coreEvents.off(CoreEvent.ModelChanged, this.handleModelChanged); coreEvents.off(CoreEvent.MemoryChanged, this.handleMemoryChanged); + coreEvents.off(CoreEvent.ActiveTeamChanged, this.handleActiveTeamChanged); } async resumeChat( diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5361397386..bad7e0ad4d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -184,6 +184,7 @@ 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/teamScaffolder.js'; // Export browser session management export { resetBrowserSession } from './agents/browser/browserAgentFactory.js'; diff --git a/packages/core/src/utils/events.ts b/packages/core/src/utils/events.ts index bf3d997da1..a74497c099 100644 --- a/packages/core/src/utils/events.ts +++ b/packages/core/src/utils/events.ts @@ -193,6 +193,7 @@ export enum CoreEvent { EditorSelected = 'editor-selected', SlashCommandConflicts = 'slash-command-conflicts', QuotaChanged = 'quota-changed', + ActiveTeamChanged = 'active-team-changed', TelemetryKeychainAvailability = 'telemetry-keychain-availability', TelemetryTokenStorageType = 'telemetry-token-storage-type', } @@ -226,6 +227,7 @@ export interface CoreEvents extends ExtensionEvents { [CoreEvent.RequestEditorSelection]: never[]; [CoreEvent.EditorSelected]: [EditorSelectedPayload]; [CoreEvent.SlashCommandConflicts]: [SlashCommandConflictsPayload]; + [CoreEvent.ActiveTeamChanged]: [string | undefined]; [CoreEvent.TelemetryKeychainAvailability]: [KeychainAvailabilityEvent]; [CoreEvent.TelemetryTokenStorageType]: [TokenStorageInitializationEvent]; } @@ -403,6 +405,13 @@ export class CoreEventEmitter extends EventEmitter { this.emit(CoreEvent.QuotaChanged, payload); } + /** + * Notifies subscribers that the active team has changed. + */ + emitActiveTeamChanged(teamName: string | undefined): void { + this.emit(CoreEvent.ActiveTeamChanged, teamName); + } + /** * Flushes buffered messages. Call this immediately after primary UI listener * subscribes.