feat(cli,core): expand provider ecosystem with Antigravity and Gemma

- Add Antigravity (Light Blue) and Gemma (Gemini Blue) providers
- Update Gemini CLI brand color to Purple
- Implement personality overlays for Antigravity and Gemma in core
- Ensure brand-accurate labels and colors across Marketplace and Status Bar
- Add unit tests for new provider personality overlays
This commit is contained in:
Taylor Mullen
2026-04-02 09:53:11 -07:00
parent 79cc1eb4f4
commit 1984c9d35b
10 changed files with 480 additions and 41 deletions
@@ -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('<StatusRow />', () => {
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(
<StatusRow
showUiDetails={true}
isNarrow={false}
terminalWidth={100}
hideContextSummary={false}
hideUiDetailsForSuggestions={false}
hasPendingActionRequired={false}
/>,
{
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');
});
});
+46 -4
View File
@@ -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<string, string> = {
'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<string>();
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 (
<Box marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
<Text color={theme.text.accent}>[ Team: {activeTeam.displayName} ]</Text>
<Box marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN} flexDirection="row">
<Text color={theme.text.accent}>[ Team: {activeTeam.displayName} (</Text>
{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 (
<React.Fragment key={p}>
<Text color={color} bold>
{label}
</Text>
{i < sortedProviders.length - 1 && (
<Text color={theme.text.accent}>, </Text>
)}
</React.Fragment>
);
})}
<Text color={theme.text.accent}>) ]</Text>
</Box>
);
};
@@ -49,9 +49,10 @@ describe('<TeamSelectionDialog />', () => {
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('<TeamSelectionDialog />', () => {
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('<TeamSelectionDialog />', () => {
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('<TeamSelectionDialog />', () => {
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();
});
});
@@ -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<string, string> = {
'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 (
<Text color={color} bold>
[{label}]
</Text>
);
};
const MultiModelBadge: React.FC = () => (
<Box marginLeft={1}>
<Text color={theme.text.secondary} italic>
Multi-Model
</Text>
</Box>
);
function getProviderTags(agents: AgentDefinition[]): React.ReactNode {
const providers = new Set<string>();
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 (
<Box flexDirection="row" alignItems="center">
<Box flexDirection="row">
{sortedProviders.map((p, i) => (
<Box key={p} marginLeft={i === 0 ? 0 : 1}>
<ProviderTag provider={p} />
</Box>
))}
</Box>
{isMulti && <MultiModelBadge />}
</Box>
);
}
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 (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold color={theme.text.primary}>
Agent Team Marketplace
</Text>
<Box marginTop={1} flexDirection="column">
<Text color={theme.text.secondary}>
Explore and download community-contributed agent teams.
</Text>
<Box
marginTop={1}
padding={1}
borderStyle="single"
borderColor={theme.ui.comment}
>
<Text color={theme.ui.comment}>
The community marketplace is currently under development. Soon you
will be able to browse hundreds of specialized teams.
</Text>
</Box>
<Box marginTop={1}>
<Text color={theme.text.accent}>
Press any key to go back to selection...
</Text>
</Box>
</Box>
</Box>
);
}
if (view === 'create') {
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold color={theme.text.primary}>
Create New Agent Team
</Text>
<Box marginTop={1} flexDirection="column">
<Text color={theme.text.secondary}>
To create a new team, follow these simple steps:
</Text>
<Text color={theme.text.primary}>
1. Create a directory in .gemini/teams/ (e.g.,
.gemini/teams/my-team/)
</Text>
<Text color={theme.text.primary}>
2. Create a TEAM.md file with name, display_name, and description.
</Text>
<Text color={theme.text.primary}>
3. (Optional) Add specialized agents to an agents/ sub-directory.
</Text>
<Box
marginTop={1}
padding={1}
borderStyle="single"
borderColor={theme.ui.focus}
>
<Text color={theme.ui.focus}>
Tip: You can now specify external agents directly in TEAM.md!
</Text>
</Box>
<Box marginTop={1}>
<Text color={theme.text.accent}>
Press any key to go back to selection...
</Text>
</Box>
</Box>
</Box>
);
}
return (
<Box
borderStyle="round"
@@ -13,6 +13,7 @@ import type { SelectionListItem } from '../../hooks/useSelectionList.js';
export interface DescriptiveRadioSelectItem<T> extends SelectionListItem<T> {
title: string;
description?: string;
titleSuffix?: React.ReactNode;
}
export interface DescriptiveRadioButtonSelectProps<T> {
@@ -61,7 +62,10 @@ export function DescriptiveRadioButtonSelect<T>({
maxItemsToShow={maxItemsToShow}
renderItem={(item, { titleColor }) => (
<Box flexDirection="column" key={item.key}>
<Text color={titleColor}>{item.title}</Text>
<Box flexDirection="row">
<Text color={titleColor}>{item.title}</Text>
{item.titleSuffix && <Box marginLeft={1}>{item.titleSuffix}</Box>}
</Box>
{item.description && (
<Text color={theme.text.secondary}>{item.description}</Text>
)}
@@ -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]);
+1 -1
View File
@@ -211,7 +211,7 @@ const remoteAgentSchema = z.union([
type FrontmatterRemoteAgentDefinition = z.infer<typeof remoteAgentSchema>;
const externalAgentSchema = z
export const externalAgentSchema = z
.object({
kind: z.literal('external'),
name: nameSchema,
@@ -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,
@@ -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:
+36 -3
View File
@@ -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,