diff --git a/packages/cli/src/ui/components/StatusRow.test.tsx b/packages/cli/src/ui/components/StatusRow.test.tsx index b80dbacabe..7ae1232e73 100644 --- a/packages/cli/src/ui/components/StatusRow.test.tsx +++ b/packages/cli/src/ui/components/StatusRow.test.tsx @@ -12,7 +12,7 @@ import { type UIState } from '../contexts/UIStateContext.js'; import { type TextBuffer } from '../components/shared/text-buffer.js'; import { type SessionStatsState } from '../contexts/SessionContext.js'; import { type ThoughtSummary } from '../types.js'; -import { ApprovalMode } from '@google/gemini-cli-core'; +import { ApprovalMode, makeFakeConfig } from '@google/gemini-cli-core'; vi.mock('../hooks/useComposerStatus.js', () => ({ useComposerStatus: vi.fn(), @@ -142,4 +142,52 @@ describe('', () => { await waitUntilReady(); expect(lastFrame()).toContain('Tip: Test Tip'); }); + + it('renders ActiveTeamIndicator with provider letters', async () => { + (useComposerStatus as Mock).mockReturnValue({ + isInteractiveShellWaiting: false, + showLoadingIndicator: false, + showTips: false, + showWit: false, + modeContentObj: null, + showMinimalContext: false, + }); + + const mockTeam = { + name: 'poly-team', + displayName: 'Polyglot', + agents: [ + { kind: 'local', name: 'g' }, + { kind: 'external', name: 'c', provider: 'claude-code' }, + { kind: 'external', name: 'x', provider: 'codex' }, + ], + }; + + const config = makeFakeConfig(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + config.getActiveTeam = () => mockTeam as any; + + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + { + width: 100, + uiState: defaultUiState, + config, + }, + ); + + await waitUntilReady(); + const output = lastFrame(); + expect(output).toContain('[ Team: Polyglot'); + expect(output).toContain('Claude Cod'); + expect(output).toContain('Codex'); + expect(output).toContain('Gemini CLI'); + }); }); diff --git a/packages/cli/src/ui/components/StatusRow.tsx b/packages/cli/src/ui/components/StatusRow.tsx index ad168464fe..22280cc86d 100644 --- a/packages/cli/src/ui/components/StatusRow.tsx +++ b/packages/cli/src/ui/components/StatusRow.tsx @@ -4,8 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type React from 'react'; -import { useCallback, useRef, useState } from 'react'; +import React, { useCallback, useRef, useState } from 'react'; import { Box, Text, ResizeObserver, type DOMElement } from 'ink'; import { isUserVisibleHook, @@ -147,6 +146,14 @@ 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. */ @@ -156,9 +163,44 @@ const ActiveTeamIndicator: React.FC = () => { if (!activeTeam) return null; + const providers = new Set(); + for (const agent of activeTeam.agents) { + if (agent.kind === 'external') { + providers.add(agent.provider); + } else { + providers.add('gemini'); + } + } + + const sortedProviders = Array.from(providers).sort(); + return ( - - [ Team: {activeTeam.displayName} ] + + [ 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 && ( + , + )} + + ); + })} + ) ] ); }; diff --git a/packages/cli/src/ui/components/TeamSelectionDialog.test.tsx b/packages/cli/src/ui/components/TeamSelectionDialog.test.tsx index f338d731f4..816352beb8 100644 --- a/packages/cli/src/ui/components/TeamSelectionDialog.test.tsx +++ b/packages/cli/src/ui/components/TeamSelectionDialog.test.tsx @@ -49,9 +49,10 @@ describe('', () => { 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 Marketplace (Coming Soon)'); - expect(output).toContain('Create Team (Coming Soon)'); + expect(output).toContain('Browse Team Marketplace'); + expect(output).toContain('Create Team'); unmount(); }); @@ -73,15 +74,13 @@ describe('', () => { it('calls onSelect with undefined when "No Team" is selected', async () => { const { stdin, waitUntilReady, unmount } = await renderComponent(); - // Navigate to "No Team" (index 2) - await act(async () => { - stdin.write('\u001B[B'); // Down - }); - await waitUntilReady(); - await act(async () => { - stdin.write('\u001B[B'); // Down - }); - await waitUntilReady(); + // Navigate to "No Team" (index 3: Team 1, Team 2, Polyglot, No Team) + for (let i = 0; i < 3; i++) { + await act(async () => { + stdin.write('\u001B[B'); // Down + }); + await waitUntilReady(); + } await act(async () => { stdin.write('\r'); // Enter @@ -97,8 +96,8 @@ describe('', () => { it('does not call onSelect for placeholder options', async () => { const { stdin, waitUntilReady, unmount } = await renderComponent(); - // Navigate to "Browse Marketplace" (index 3) - for (let i = 0; i < 3; i++) { + // Navigate to "Browse Team Marketplace" (index 4) + for (let i = 0; i < 4; i++) { await act(async () => { stdin.write('\u001B[B'); // Down }); @@ -113,4 +112,62 @@ describe('', () => { expect(mockOnSelect).not.toHaveBeenCalled(); unmount(); }); + + it('shows marketplace sub-view when selected', async () => { + const { stdin, lastFrame, waitUntilReady, unmount } = + await renderComponent(); + + // Navigate to 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(lastFrame()).toContain('Agent Team Marketplace'); + expect(lastFrame()).toContain('under development'); + + // Go back + await act(async () => { + stdin.write('a'); + }); + 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(); + + // Navigate to Create Team (index 5) + for (let i = 0; i < 5; i++) { + await act(async () => { + stdin.write('\u001B[B'); // Down + }); + await waitUntilReady(); + } + + await act(async () => { + stdin.write('\r'); // Enter + }); + 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'); + unmount(); + }); }); diff --git a/packages/cli/src/ui/components/TeamSelectionDialog.tsx b/packages/cli/src/ui/components/TeamSelectionDialog.tsx index 32e7025dc7..40fdb04352 100644 --- a/packages/cli/src/ui/components/TeamSelectionDialog.tsx +++ b/packages/cli/src/ui/components/TeamSelectionDialog.tsx @@ -5,60 +5,186 @@ */ import type React from 'react'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { Box, Text } from 'ink'; -import { type TeamDefinition } from '@google/gemini-cli-core'; +import { + type TeamDefinition, + type AgentDefinition, +} from '@google/gemini-cli-core'; import { theme } from '../semantic-colors.js'; import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js'; +import { useKeypress } from '../hooks/useKeypress.js'; interface TeamSelectionDialogProps { teams: TeamDefinition[]; onSelect: (teamName: string | undefined) => void; } +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 = () => ( + + + Multi-Model + + +); + +function getProviderTags(agents: AgentDefinition[]): React.ReactNode { + const providers = new Set(); + for (const agent of agents) { + if (agent.kind === 'external') { + providers.add(agent.provider); + } else { + providers.add('gemini'); + } + } + + const sortedProviders = Array.from(providers).sort(); + const isMulti = providers.size > 1; + + return ( + + + {sortedProviders.map((p, i) => ( + + + + ))} + + {isMulti && } + + ); +} + export function TeamSelectionDialog({ - teams, + teams: discoveredTeams, onSelect, }: TeamSelectionDialogProps): React.JSX.Element { + const [view, setView] = useState<'select' | 'marketplace' | 'create'>( + 'select', + ); + + useKeypress( + () => { + setView('select'); + return true; + }, + { isActive: view !== 'select' }, + ); + const options = useMemo(() => { - const list = teams.map((team) => ({ + const list = discoveredTeams.map((team) => ({ value: team.name, - title: team.displayName, + title: team.displayName || team.name, description: team.description, key: team.name, + titleSuffix: getProviderTags(team.agents), })); + // Curated "Marketplace" Teams (Hardcoded MVP) + const polyglotTeam: TeamDefinition = { + name: 'curated-polyglot', + displayName: 'The Polyglot Team', + description: + 'Advanced multi-model orchestration with Gemini, Claude Code, and Codex.', + instructions: 'Orchestrate across multiple specialized models.', + agents: [ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + { + kind: 'local', + name: 'gemini-expert', + description: 'Gemini Expert', + inputConfig: { type: 'object', properties: {}, required: [] }, + } as unknown as AgentDefinition, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + { + kind: 'external', + name: 'claude-coder', + provider: 'claude-code', + description: 'Claude Coder', + inputConfig: { type: 'object', properties: {}, required: [] }, + } as unknown as AgentDefinition, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + { + kind: 'external', + name: 'codex-architect', + provider: 'codex', + description: 'Codex Architect', + inputConfig: { type: 'object', properties: {}, required: [] }, + } as unknown as AgentDefinition, + ], + }; + + list.push({ + value: polyglotTeam.name, + title: `${polyglotTeam.displayName} (Curated)`, + description: polyglotTeam.description, + key: polyglotTeam.name, + titleSuffix: getProviderTags(polyglotTeam.agents), + }); + list.push({ value: 'none', title: 'No Team', description: 'Continue with standard Gemini CLI experience', key: 'none', + titleSuffix: undefined, }); list.push({ value: 'marketplace', - title: 'Browse Marketplace (Coming Soon)', + title: 'Browse Team Marketplace', description: 'Discover and install teams from the community', key: 'marketplace', + titleSuffix: undefined, }); list.push({ value: 'create', - title: 'Create Team (Coming Soon)', + title: 'Create Team', description: 'Define your own agent team and orchestration instructions', key: 'create', + titleSuffix: undefined, }); return list; - }, [teams]); + }, [discoveredTeams]); const handleSelect = useCallback( (value: string) => { if (value === 'none') { onSelect(undefined); - } else if (value === 'marketplace' || value === 'create') { - // No-op for coming soon features - return; + } else if (value === 'marketplace') { + setView('marketplace'); + } else if (value === 'create') { + setView('create'); } else { onSelect(value); } @@ -66,6 +192,91 @@ export function TeamSelectionDialog({ [onSelect], ); + 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... + + + + + ); + } + + 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 ( extends SelectionListItem { title: string; description?: string; + titleSuffix?: React.ReactNode; } export interface DescriptiveRadioButtonSelectProps { @@ -61,7 +62,10 @@ export function DescriptiveRadioButtonSelect({ maxItemsToShow={maxItemsToShow} renderItem={(item, { titleColor }) => ( - {item.title} + + {item.title} + {item.titleSuffix && {item.titleSuffix}} + {item.description && ( {item.description} )} diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 3189172792..15b378ba22 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -575,6 +575,11 @@ function* emitKeys( } else if ((match = /^(\d+)?(?:;(\d+))?([A-Za-z])$/.exec(cmd))) { code += match[3]; modifier = parseInt(match[2] ?? match[1] ?? '1', 10) - 1; + + // Skip focus-in/out events from useFocus hook + if (code === '[I' || code === '[O') { + continue; + } } else { code += cmd; } @@ -854,10 +859,9 @@ export function KeypressProvider({ useEffect(() => { terminalCapabilityManager.enableSupportedModes(); - const wasRaw = stdin.isRaw; - if (wasRaw === false) { - setRawMode(true); - } + // 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 @@ -882,9 +886,7 @@ export function KeypressProvider({ stdin.on('data', dataListener); return () => { stdin.removeListener('data', dataListener); - if (wasRaw === false) { - setRawMode(false); - } + setRawMode(false); }; }, [stdin, setRawMode, config, debugKeystrokeLogging, broadcast]); diff --git a/packages/core/src/agents/agentLoader.ts b/packages/core/src/agents/agentLoader.ts index ba19f82b22..24a645a4fe 100644 --- a/packages/core/src/agents/agentLoader.ts +++ b/packages/core/src/agents/agentLoader.ts @@ -211,7 +211,7 @@ const remoteAgentSchema = z.union([ type FrontmatterRemoteAgentDefinition = z.infer; -const externalAgentSchema = z +export const externalAgentSchema = z .object({ kind: z.literal('external'), name: nameSchema, diff --git a/packages/core/src/agents/external-invocation.test.ts b/packages/core/src/agents/external-invocation.test.ts index 011860f291..8207015f7b 100644 --- a/packages/core/src/agents/external-invocation.test.ts +++ b/packages/core/src/agents/external-invocation.test.ts @@ -76,6 +76,30 @@ describe('ExternalAgentInvocation', () => { ); }); + it('should support Antigravity provider', () => { + const antigravityDef: ExternalAgentDefinition = { + ...externalDef, + provider: 'antigravity', + }; + const polyfilled = polyfillExternalAgent(antigravityDef); + + expect(polyfilled.promptConfig.systemPrompt).toContain( + 'Antigravity Personality Overlay', + ); + }); + + it('should support Gemma provider', () => { + const gemmaDef: ExternalAgentDefinition = { + ...externalDef, + provider: 'gemma', + }; + const polyfilled = polyfillExternalAgent(gemmaDef); + + expect(polyfilled.promptConfig.systemPrompt).toContain( + 'Gemma Personality Overlay', + ); + }); + it('should include styleInstructions from providerConfig', () => { const customDef: ExternalAgentDefinition = { ...externalDef, diff --git a/packages/core/src/agents/external-invocation.ts b/packages/core/src/agents/external-invocation.ts index 2291b204b8..cbb4a9a3c0 100644 --- a/packages/core/src/agents/external-invocation.ts +++ b/packages/core/src/agents/external-invocation.ts @@ -101,6 +101,24 @@ You are acting as the "Codex" agent, a specialized code generation model. - Focus on generating high-quality, idiomatic, and correct code for the requested task. - Prioritize structural integrity and idiomatic patterns for the target language. - Provide clear, well-documented code snippets. +${styleInstructions}`.trim(); + + case 'antigravity': + return ` +# Antigravity Personality Overlay +You are acting as the "Antigravity" agent. +- Focus on creative problem solving and thinking "outside the box". +- Adopt a playful but highly competent engineering persona. +- Encourage unconventional but effective solutions. +${styleInstructions}`.trim(); + + case 'gemma': + return ` +# Gemma Personality Overlay +You are acting as the "Gemma" agent, an open, lightweight, and capable model. +- Focus on accessibility and efficiency. +- Provide helpful, clear, and easy-to-understand explanations. +- Maintain a friendly and supportive persona. ${styleInstructions}`.trim(); default: diff --git a/packages/core/src/agents/teamLoader.ts b/packages/core/src/agents/teamLoader.ts index dfedb720e4..f0cf5b43ba 100644 --- a/packages/core/src/agents/teamLoader.ts +++ b/packages/core/src/agents/teamLoader.ts @@ -11,7 +11,12 @@ import * as path from 'node:path'; import * as crypto from 'node:crypto'; import { z } from 'zod'; import { type TeamDefinition } from './types.js'; -import { AgentLoadError, loadAgentsFromDirectory } from './agentLoader.js'; +import { + AgentLoadError, + loadAgentsFromDirectory, + externalAgentSchema, + markdownToAgentDefinition, +} from './agentLoader.js'; import { FRONTMATTER_REGEX } from '../skills/skillLoader.js'; import { getErrorMessage } from '../utils/errors.js'; @@ -32,6 +37,7 @@ const teamSchema = z name: nameSchema, display_name: z.string().min(1), description: z.string().min(1), + agents: z.array(externalAgentSchema).optional(), }) .strict(); @@ -121,18 +127,45 @@ export async function loadTeamsFromDirectory( ); } - const { name, display_name, description } = parsedFrontmatter.data; + const { + name, + display_name, + description, + agents: inlineAgentsRaw, + } = parsedFrontmatter.data; // Load agents from agents/ subfolder const agentsResult = await loadAgentsFromDirectory(agentsDirPath); result.errors.push(...agentsResult.errors); + const allAgents = [...agentsResult.agents]; + + // Add inline agents + if (inlineAgentsRaw) { + for (const inline of inlineAgentsRaw) { + try { + const agent = markdownToAgentDefinition(inline, { + hash, + filePath: teamMdPath, + }); + allAgents.push(agent); + } catch (error) { + result.errors.push( + new AgentLoadError( + teamMdPath, + `Error loading inline agent "${inline.name}": ${getErrorMessage(error)}`, + ), + ); + } + } + } + result.teams.push({ name, displayName: display_name, description, instructions, - agents: agentsResult.agents, + agents: allAgents, metadata: { hash, filePath: teamMdPath,