mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-15 06:12:50 -07:00
wip
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -67,6 +67,7 @@ export const DialogManager = ({
|
||||
<TeamSelectionDialog
|
||||
teams={config.getTeamRegistry().getAllTeams()}
|
||||
onSelect={uiActions.handleTeamSelect}
|
||||
onRefreshTeams={uiActions.handleRefreshTeams}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<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.
|
||||
*/
|
||||
@@ -180,29 +173,14 @@ const ActiveTeamIndicator: React.FC = () => {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
{sortedProviders.map((p, i) => (
|
||||
<React.Fragment key={p}>
|
||||
<ProviderTag provider={p} />
|
||||
{i < sortedProviders.length - 1 && (
|
||||
<Text color={theme.text.accent}>, </Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
<Text color={theme.text.accent}>) ]</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -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<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 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 (
|
||||
<Text color={color} bold>
|
||||
[{label}]
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export function TeamCreatorWizard({
|
||||
onComplete,
|
||||
onCancel,
|
||||
}: TeamCreatorWizardProps): React.JSX.Element {
|
||||
const config = useConfig();
|
||||
const { handleTeamSelect } = useUIActions();
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const [step, setStep] = useState<WizardStep>('identity');
|
||||
const [teamName, setTeamName] = useState('');
|
||||
@@ -122,7 +92,7 @@ export function TeamCreatorWizard({
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [createdPath, setCreatedPath] = useState<string | null>(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<string>();
|
||||
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
|
||||
</Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={theme.text.secondary}>
|
||||
Slug Name (e.g., coding-team):
|
||||
</Text>
|
||||
<TextInput
|
||||
buffer={nameBuffer}
|
||||
focus={activeIdentityField === 0}
|
||||
onSubmit={() => setActiveIdentityField(1)}
|
||||
/>
|
||||
<Text color={theme.text.secondary}>Unique Team ID (slug):</Text>
|
||||
<Box borderStyle="single" borderColor={theme.ui.comment} paddingX={1}>
|
||||
<TextInput buffer={nameBuffer} placeholder="e.g. frontend-team" />
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={theme.text.secondary}>
|
||||
Display Name (e.g., The Coding Team):
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>Display Name:</Text>
|
||||
</Box>
|
||||
<Box borderStyle="single" borderColor={theme.ui.comment} paddingX={1}>
|
||||
<TextInput
|
||||
buffer={displayNameBuffer}
|
||||
focus={activeIdentityField === 1}
|
||||
onSubmit={() => setActiveIdentityField(2)}
|
||||
placeholder="e.g. Frontend Experts"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={theme.text.secondary}>Short Description:</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>Description (Optional):</Text>
|
||||
</Box>
|
||||
<Box borderStyle="single" borderColor={theme.ui.comment} paddingX={1}>
|
||||
<TextInput
|
||||
buffer={descBuffer}
|
||||
focus={activeIdentityField === 2}
|
||||
buffer={descriptionBuffer}
|
||||
placeholder="Briefly describe the team's purpose"
|
||||
onSubmit={handleNext}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.status.error}>Error: {error}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<DialogFooter
|
||||
primaryAction="Enter to continue"
|
||||
navigationActions="Tab or ↑/↓ to switch fields"
|
||||
cancelAction="Esc to cancel"
|
||||
/>
|
||||
</Box>
|
||||
@@ -469,11 +308,13 @@ export function TeamCreatorWizard({
|
||||
}
|
||||
|
||||
if (step === 'roster') {
|
||||
const rosterItems: Array<SelectionListItem<RosterListItem>> =
|
||||
allAvailableAgents.map((a) => ({
|
||||
const items: SelectionListItem<RosterListItem>[] = allAvailableAgents.map(
|
||||
(a) => ({
|
||||
key: a.name,
|
||||
label: a.name,
|
||||
value: a,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -483,101 +324,65 @@ export function TeamCreatorWizard({
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Box flexDirection="row" justifyContent="space-between">
|
||||
<Text bold color={theme.text.primary}>
|
||||
Step 2: Team Roster
|
||||
</Text>
|
||||
<Box>
|
||||
<Text color={theme.text.secondary}>
|
||||
Selected: {selectedAgents.size}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text bold color={theme.text.primary}>
|
||||
Step 2: Team Roster
|
||||
</Text>
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{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.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} marginBottom={1} flexDirection="column">
|
||||
<Box height={12} width="100%">
|
||||
<BaseSelectionList<RosterListItem>
|
||||
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 (
|
||||
<Box flexDirection="column" paddingLeft={1}>
|
||||
<Box flexDirection="row">
|
||||
<Text
|
||||
color={
|
||||
isChecked ? theme.status.success : theme.text.secondary
|
||||
}
|
||||
>
|
||||
[{isChecked ? 'x' : ' '}]
|
||||
</Text>
|
||||
<Text
|
||||
color={
|
||||
context.isSelected && rosterMode === 'list'
|
||||
? theme.text.accent
|
||||
: theme.text.primary
|
||||
}
|
||||
>
|
||||
{' '}
|
||||
{value.logo && (
|
||||
<Text color={value.logoColor || theme.text.accent}>
|
||||
{value.logo}{' '}
|
||||
</Text>
|
||||
)}
|
||||
{value.name}
|
||||
</Text>
|
||||
{(() => {
|
||||
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 (
|
||||
<Text color={color} bold>
|
||||
{' '}
|
||||
[{label}]
|
||||
</Text>
|
||||
);
|
||||
})()}
|
||||
<Box flexDirection="column" width="100%">
|
||||
<Box flexDirection="row" width="100%">
|
||||
<Box width={2} flexShrink={0}>
|
||||
<Text color={theme.text.accent}>
|
||||
{selectedAgents.has(value.name) ? '☑' : '☐'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="row" flexShrink={0}>
|
||||
<Text
|
||||
color={
|
||||
context.isSelected && rosterMode === 'list'
|
||||
? theme.text.accent
|
||||
: theme.text.primary
|
||||
}
|
||||
>
|
||||
{' '}
|
||||
{value.logo && (
|
||||
<Text color={value.logoColor || theme.text.accent}>
|
||||
{value.logo}{' '}
|
||||
</Text>
|
||||
)}
|
||||
{value.name}
|
||||
</Text>
|
||||
<Box marginLeft={1}>
|
||||
<ProviderTag provider={value.provider || 'gemini'} />
|
||||
</Box>
|
||||
</Box>
|
||||
{value.description && (
|
||||
<Text color={theme.text.secondary} italic wrap="truncate">
|
||||
{' '}
|
||||
{value.description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{value.description && (
|
||||
<Text color={theme.text.secondary} italic wrap="truncate">
|
||||
{' '}
|
||||
{value.description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
isFocused={rosterMode === 'list'}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
paddingX={1}
|
||||
paddingY={0}
|
||||
borderStyle="round"
|
||||
borderColor={
|
||||
rosterMode === 'action' ? theme.text.accent : theme.border.default
|
||||
}
|
||||
justifyContent="center"
|
||||
marginTop={1}
|
||||
>
|
||||
<Text
|
||||
bold
|
||||
@@ -585,16 +390,14 @@ export function TeamCreatorWizard({
|
||||
rosterMode === 'action' ? theme.text.accent : theme.text.primary
|
||||
}
|
||||
>
|
||||
{rosterMode === 'action' ? '> ' : ' '}[ Done - Continue to
|
||||
Objective ]
|
||||
{rosterMode === 'action' ? '> ' : ' '}
|
||||
[ Done - Continue to Objective ]
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<DialogFooter
|
||||
primaryAction={
|
||||
rosterMode === 'list'
|
||||
? 'Enter to toggle agent'
|
||||
: 'Enter to continue'
|
||||
rosterMode === 'list' ? 'Enter to toggle agent' : 'Enter to continue'
|
||||
}
|
||||
navigationActions={
|
||||
rosterMode === 'list' ? 'Tab to continue' : 'Tab to select agents'
|
||||
@@ -624,6 +427,14 @@ export function TeamCreatorWizard({
|
||||
{selectedAgents.size > 0 && (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Box flexDirection="row" flexWrap="wrap">
|
||||
<Text color={theme.text.secondary}>Providers: </Text>
|
||||
{teamProviders.map((p, i) => (
|
||||
<Box key={p} marginLeft={i === 0 ? 0 : 1}>
|
||||
<ProviderTag provider={p} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Box marginTop={1} flexDirection="row" flexWrap="wrap">
|
||||
<Text color={theme.ui.comment}>Referencing agents: </Text>
|
||||
{Array.from(selectedAgents).map((name, i) => {
|
||||
const agent = allAvailableAgents.find((a) => a.name === name);
|
||||
@@ -634,7 +445,9 @@ export function TeamCreatorWizard({
|
||||
{agent.logo}{' '}
|
||||
</Text>
|
||||
)}
|
||||
<Text color={theme.ui.comment}>@{name} </Text>
|
||||
<Text color={theme.ui.comment}>
|
||||
@{name}{' '}
|
||||
</Text>
|
||||
<ProviderTag provider={agent?.provider || 'gemini'} />
|
||||
{i < selectedAgents.size - 1 && (
|
||||
<Text color={theme.ui.comment}>, </Text>
|
||||
@@ -680,7 +493,7 @@ export function TeamCreatorWizard({
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Step 4: Confirm Team Creation
|
||||
Step 4: Confirm Team Configuration
|
||||
</Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={theme.text.secondary}>
|
||||
@@ -707,7 +520,9 @@ export function TeamCreatorWizard({
|
||||
{agent.logo}{' '}
|
||||
</Text>
|
||||
)}
|
||||
<Text color={theme.text.primary}>{name} </Text>
|
||||
<Text color={theme.text.primary}>
|
||||
{name}{' '}
|
||||
</Text>
|
||||
<ProviderTag provider={agent?.provider || 'gemini'} />
|
||||
</Box>
|
||||
);
|
||||
@@ -719,23 +534,23 @@ export function TeamCreatorWizard({
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{isSubmitting && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.accent}>Creating team...</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.status.error}>Error: {error}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginTop={1}>
|
||||
{isSubmitting ? (
|
||||
<Text color={theme.text.accent}>Creating team...</Text>
|
||||
) : (
|
||||
<DialogFooter
|
||||
primaryAction="Enter to create team"
|
||||
navigationActions="Tab to go back"
|
||||
cancelAction="Esc to cancel"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<DialogFooter
|
||||
primaryAction="Enter to create team"
|
||||
cancelAction="Esc to cancel"
|
||||
extraParts={['[B]ack to Objective']}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -750,28 +565,21 @@ export function TeamCreatorWizard({
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={theme.status.success}>
|
||||
Team Created Successfully!
|
||||
Success! Team Created
|
||||
</Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={theme.text.primary}>
|
||||
Your new team "{displayName}" is now ready to use.
|
||||
Team {displayName} has been saved to:
|
||||
</Text>
|
||||
{createdPath && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>Created at: </Text>
|
||||
<Text color={theme.text.primary}>{createdPath}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Text color={theme.text.accent}>{createdPath}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
You can now select this team from the main team selection menu.
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.accent}>
|
||||
Would you like to switch to this team now? (y/N)
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
Press any other key to finish...
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>[Enter] Finish</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -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<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>
|
||||
@@ -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 (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
padding={0}
|
||||
width="100%"
|
||||
height="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>
|
||||
<TeamRegistryView
|
||||
installedTeams={discoveredTeams}
|
||||
onInstall={handleInstallTeam}
|
||||
onClose={() => setView('select')}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'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 (
|
||||
<Text color={color} bold>
|
||||
[{label}]
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
@@ -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<void>;
|
||||
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 (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
paddingY={0}
|
||||
height="100%"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<Text color={theme.text.primary}>
|
||||
Installing Team {team.displayName}...
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
paddingY={0}
|
||||
height="100%"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
>
|
||||
{/* Header Row */}
|
||||
<Box flexDirection="row" justifyContent="space-between" marginBottom={1}>
|
||||
<Box>
|
||||
<Text color={theme.text.secondary}>
|
||||
{'>'} Agent Teams {'>'}{' '}
|
||||
</Text>
|
||||
<Text color={theme.text.primary} bold>
|
||||
{team.displayName}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.text.secondary}>
|
||||
{team.version ? `v${team.version}` : ''} |{' '}
|
||||
</Text>
|
||||
<Text color={theme.status.warning}>⭐ </Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{String(team.stars || 0)} |{' '}
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>@{team.name}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Description */}
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.text.primary}>{team.description}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Author */}
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.text.secondary}>Author: </Text>
|
||||
<Text color={theme.text.primary}>{team.author || 'Community'}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Agents Roster */}
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color={theme.text.primary} bold>
|
||||
Team Roster:
|
||||
</Text>
|
||||
{team.agents.map((agent) => (
|
||||
<Box key={agent.name} marginLeft={2} flexDirection="row">
|
||||
<Box width={15} flexShrink={0}>
|
||||
<Text color={theme.text.primary}>@{agent.name}</Text>
|
||||
</Box>
|
||||
<Box width={18} flexShrink={0} marginX={1}>
|
||||
<ProviderTag provider={agent.provider} />
|
||||
</Box>
|
||||
<Box flexShrink={1}>
|
||||
<Text color={theme.text.secondary} italic>
|
||||
{agent.description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Instructions Summary */}
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color={theme.text.primary} bold>
|
||||
Team Mission:
|
||||
</Text>
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary} wrap="truncate-end">
|
||||
{team.instructions}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Spacer to push installation UI to bottom */}
|
||||
<Box flexGrow={1} />
|
||||
|
||||
{/* Installation UI */}
|
||||
{!isInstalled ? (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.status.warning}
|
||||
paddingX={1}
|
||||
paddingY={0}
|
||||
>
|
||||
<Text color={theme.text.primary}>
|
||||
This team will be installed to your local .gemini/teams/ directory.
|
||||
Agent teams may contain custom instructions and configurations.
|
||||
</Text>
|
||||
<Box marginTop={1} flexDirection="row">
|
||||
<Text color={theme.text.primary}>[{'Enter'}] Install Team</Text>
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>[Esc] Back</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexDirection="row" marginTop={1} justifyContent="center" padding={1}>
|
||||
<Text color={theme.status.success}>Team Already Installed</Text>
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>[Esc] Back</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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<void>;
|
||||
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<RegistryTeam | null>(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 (
|
||||
<Box flexDirection="column" width="100%" marginBottom={1}>
|
||||
<Box flexDirection="row" width="100%" justifyContent="space-between">
|
||||
<Box flexDirection="row" flexShrink={1} minWidth={0}>
|
||||
<Box width={2} flexShrink={0}>
|
||||
<Text
|
||||
color={isActive ? theme.status.success : theme.text.secondary}
|
||||
>
|
||||
{isActive ? '● ' : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexShrink={0}>
|
||||
<Text
|
||||
bold={isActive}
|
||||
color={isActive ? theme.status.success : theme.text.primary}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexShrink={0} marginX={1}>
|
||||
<Text color={theme.text.secondary}>|</Text>
|
||||
</Box>
|
||||
{isInstalled && (
|
||||
<Box marginRight={1} flexShrink={0}>
|
||||
<Text color={theme.status.success}>[Installed]</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box flexShrink={1} minWidth={0}>
|
||||
<Text color={theme.text.secondary} wrap="truncate-end">
|
||||
{item.description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box flexShrink={0} marginLeft={2} width={8} flexDirection="row">
|
||||
<Text color={theme.status.warning}>⭐</Text>
|
||||
<Text
|
||||
color={isActive ? theme.status.success : theme.text.secondary}
|
||||
>
|
||||
{' '}
|
||||
{item.team.stars || 0}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box marginLeft={2} flexDirection="row" alignItems="center">
|
||||
<Text color={theme.text.secondary} italic>
|
||||
{item.team.agents.length} agent
|
||||
{item.team.agents.length === 1 ? '' : 's'}{' '}
|
||||
</Text>
|
||||
<Box marginLeft={1} flexDirection="row">
|
||||
{providers.map((p) => (
|
||||
<Box key={p} marginRight={1}>
|
||||
<ProviderTag provider={p} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
[installedTeams],
|
||||
);
|
||||
|
||||
const header = useMemo(
|
||||
() => (
|
||||
<Box flexDirection="row" justifyContent="space-between" width="100%">
|
||||
<Box flexShrink={1}>
|
||||
<Text color={theme.text.secondary} wrap="truncate">
|
||||
Browse and search Agent Teams from the registry.
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexShrink={0} marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{installedTeams.length > 0 &&
|
||||
`${installedTeams.length} teams installed`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
),
|
||||
[installedTeams.length],
|
||||
);
|
||||
|
||||
const footer = useCallback(
|
||||
({
|
||||
startIndex,
|
||||
endIndex,
|
||||
totalVisible,
|
||||
}: {
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
totalVisible: number;
|
||||
}) => (
|
||||
<Text color={theme.text.secondary}>
|
||||
({startIndex + 1}-{endIndex}) / {totalVisible}
|
||||
</Text>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
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 (
|
||||
<Box padding={1}>
|
||||
<Text color={theme.text.secondary}>Loading Agent Teams...</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box padding={1} flexDirection="column">
|
||||
<Text color={theme.status.error}>Error loading teams:</Text>
|
||||
<Text color={theme.text.secondary}>{error}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
display={selectedTeam ? 'none' : 'flex'}
|
||||
flexDirection="column"
|
||||
width="100%"
|
||||
height="100%"
|
||||
>
|
||||
<SearchableList<TeamListItem>
|
||||
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}
|
||||
/>
|
||||
</Box>
|
||||
{selectedTeam && (
|
||||
<TeamDetailsView
|
||||
team={selectedTeam}
|
||||
onBack={handleBack}
|
||||
onInstall={async () => {
|
||||
await handleInstallAction(selectedTeam);
|
||||
}}
|
||||
isInstalled={installedTeams.some((t) => t.name === selectedTeam.name)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -92,6 +92,7 @@ export interface UIActions {
|
||||
handleRestart: () => void;
|
||||
handleNewAgentsSelect: (choice: NewAgentsChoice) => Promise<void>;
|
||||
handleTeamSelect: (teamName: string | undefined) => void;
|
||||
handleRefreshTeams: () => Promise<void>;
|
||||
setIsTeamCreatorActive: (active: boolean) => void;
|
||||
getPreferredEditor: () => EditorType;
|
||||
|
||||
|
||||
@@ -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<RegistryTeam[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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<NodeJS.Timeout | undefined>(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,
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user