mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-15 14:23:02 -07:00
feat(cli,core): implement interactive Team Creator Wizard and persistence
- Add interactive 5-step TeamCreatorWizard with agent selection and objective editor - Implement /team slash command for team selection and creation - Add colorized Nerd Font logos and scroll support for agent roster selection - Refactor teamScaffolder to generate standalone .md files for all agents - Implement persistent activeTeam setting in workspace configuration - Add ActiveTeamChanged event to force immediate system instruction refresh - Support external editor (Ctrl+X) for team objective instructions - Enhance keyboard navigation with Tab/Shift+Tab field switching in wizard - Fix team selection logic to correctly pivot to creation wizard - Update various UI components and tests for team state management
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
"topicUpdateNarration": true
|
||||
},
|
||||
"general": {
|
||||
"devtools": true
|
||||
"devtools": true,
|
||||
"activeTeam": "test-team"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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<SlashCommandActionReturn> => ({
|
||||
type: 'dialog',
|
||||
dialog: 'teamSelection',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'create',
|
||||
description: 'Create a new agent team interactively',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async (): Promise<SlashCommandActionReturn> => ({
|
||||
type: 'dialog',
|
||||
dialog: 'teamCreator',
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -126,6 +126,8 @@ export interface OpenDialogActionReturn {
|
||||
| 'sessionBrowser'
|
||||
| 'model'
|
||||
| 'agentConfig'
|
||||
| 'teamSelection'
|
||||
| 'teamCreator'
|
||||
| 'permissions';
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<TeamCreatorWizard
|
||||
onComplete={() => uiActions.setIsTeamCreatorActive(false)}
|
||||
onCancel={() => uiActions.setIsTeamCreatorActive(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.adminSettingsChanged) {
|
||||
return <AdminSettingsChangedDialog />;
|
||||
}
|
||||
|
||||
@@ -159,7 +159,10 @@ const PROVIDER_COLORS: Record<string, string> = {
|
||||
*/
|
||||
const ActiveTeamIndicator: React.FC = () => {
|
||||
const config = useConfig();
|
||||
const activeTeam = config?.getActiveTeam();
|
||||
const activeTeam =
|
||||
typeof config?.getActiveTeam === 'function'
|
||||
? config.getActiveTeam()
|
||||
: undefined;
|
||||
|
||||
if (!activeTeam) return null;
|
||||
|
||||
|
||||
@@ -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<WizardStep>('identity');
|
||||
const [teamName, setTeamName] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [instructions, setInstructions] = useState('');
|
||||
const [selectedAgents, setSelectedAgents] = useState<Set<string>>(new Set());
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [createdPath, setCreatedPath] = useState<string | null>(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 (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
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)}
|
||||
/>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={theme.text.secondary}>
|
||||
Display Name (e.g., The Coding Team):
|
||||
</Text>
|
||||
<TextInput
|
||||
buffer={displayNameBuffer}
|
||||
focus={activeIdentityField === 1}
|
||||
onSubmit={() => setActiveIdentityField(2)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={theme.text.secondary}>Short Description:</Text>
|
||||
<TextInput
|
||||
buffer={descBuffer}
|
||||
focus={activeIdentityField === 2}
|
||||
onSubmit={handleNext}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<DialogFooter
|
||||
primaryAction="Enter to continue"
|
||||
navigationActions="Tab or ↑/↓ to switch fields"
|
||||
cancelAction="Esc to cancel"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'roster') {
|
||||
const rosterItems: Array<SelectionListItem<RosterListItem>> =
|
||||
allAvailableAgents.map((a) => ({
|
||||
key: a.name,
|
||||
value: a,
|
||||
}));
|
||||
rosterItems.push({
|
||||
key: 'done',
|
||||
value: { name: 'done', kind: 'meta' as const },
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Step 2: Team Roster
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
Select agents to include in your team.
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<BaseSelectionList<RosterListItem>
|
||||
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 (
|
||||
<Text
|
||||
bold
|
||||
color={
|
||||
context.isSelected
|
||||
? theme.text.accent
|
||||
: theme.text.primary
|
||||
}
|
||||
>
|
||||
[ Done - Continue to Objective ]
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
const isChecked = selectedAgents.has(value.name);
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="row">
|
||||
<Text
|
||||
color={
|
||||
isChecked ? theme.status.success : theme.text.secondary
|
||||
}
|
||||
>
|
||||
[{isChecked ? 'x' : ' '}]
|
||||
</Text>
|
||||
<Text
|
||||
color={
|
||||
context.isSelected
|
||||
? theme.text.accent
|
||||
: theme.text.primary
|
||||
}
|
||||
>
|
||||
{' '}
|
||||
{value.logo && (
|
||||
<Text color={value.logoColor || theme.text.accent}>
|
||||
{value.logo}{' '}
|
||||
</Text>
|
||||
)}
|
||||
{value.name}
|
||||
</Text>
|
||||
{value.kind === 'external' ? (
|
||||
<Text color={theme.text.accent} bold>
|
||||
{' '}
|
||||
[EXTERNAL]
|
||||
</Text>
|
||||
) : !value.sourcePath ? (
|
||||
<Text color={theme.ui.comment} italic>
|
||||
{' '}
|
||||
[BUILT-IN]
|
||||
</Text>
|
||||
) : null}
|
||||
<Text color={theme.text.secondary}>
|
||||
{' '}
|
||||
({value.kind}
|
||||
{value.provider ? `: ${value.provider}` : ''})
|
||||
</Text>
|
||||
</Box>
|
||||
{value.description && (
|
||||
<Text color={theme.text.secondary} italic>
|
||||
{' '}
|
||||
{value.description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<DialogFooter
|
||||
primaryAction="Enter to toggle/confirm"
|
||||
navigationActions="Tab to go back"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'objective') {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Step 3: Team Objective
|
||||
</Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={theme.text.secondary}>
|
||||
Define what this team does and how it should work.
|
||||
</Text>
|
||||
{selectedAgents.size > 0 && (
|
||||
<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);
|
||||
return (
|
||||
<Box key={name} flexDirection="row">
|
||||
{agent?.logo && (
|
||||
<Text color={agent.logoColor || theme.text.accent}>
|
||||
{agent.logo}{' '}
|
||||
</Text>
|
||||
)}
|
||||
<Text color={theme.ui.comment}>
|
||||
@{name}
|
||||
{i < selectedAgents.size - 1 ? ', ' : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
marginTop={1}
|
||||
borderStyle="single"
|
||||
borderColor={theme.ui.comment}
|
||||
paddingX={1}
|
||||
>
|
||||
<TextInput buffer={instructionsBuffer} onSubmit={handleNext} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.status.error}>Error: {error}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<DialogFooter
|
||||
primaryAction="Enter to continue"
|
||||
navigationActions={`Tab to go back · ${formatCommand(Command.OPEN_EXTERNAL_EDITOR)} for full editor`}
|
||||
cancelAction="Esc to cancel"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'confirmation') {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Step 4: Confirm Team Creation
|
||||
</Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={theme.text.secondary}>
|
||||
Name:{' '}
|
||||
<Text color={theme.text.primary}>
|
||||
{displayName} ({teamName})
|
||||
</Text>
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Description: <Text color={theme.text.primary}>{description}</Text>
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>Roster:</Text>
|
||||
</Box>
|
||||
{Array.from(selectedAgents).map((name) => {
|
||||
const agent = allAvailableAgents.find((a) => a.name === name);
|
||||
return (
|
||||
<Box key={name} flexDirection="row">
|
||||
<Text color={theme.text.primary}>- </Text>
|
||||
{agent?.logo && (
|
||||
<Text color={agent.logoColor || theme.text.accent}>
|
||||
{agent.logo}{' '}
|
||||
</Text>
|
||||
)}
|
||||
<Text color={theme.text.primary}>{name}</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{selectedAgents.size === 0 && (
|
||||
<Text color={theme.status.warning} italic>
|
||||
No agents selected (Gemini CLI expert will be used by default)
|
||||
</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>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'success') {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.status.success}
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={theme.status.success}>
|
||||
Team Created Successfully!
|
||||
</Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={theme.text.primary}>
|
||||
Your new team "{displayName}" is now ready to use.
|
||||
</Text>
|
||||
{createdPath && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>Created at: </Text>
|
||||
<Text color={theme.text.primary}>{createdPath}</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>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return <Box />;
|
||||
}
|
||||
@@ -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('<TeamSelectionDialog />', () => {
|
||||
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(
|
||||
<TeamSelectionDialog teams={mockTeams} onSelect={mockOnSelect} />,
|
||||
it('renders a list of discovered teams and standard options', async () => {
|
||||
const onSelect = vi.fn();
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<TeamSelectionDialog teams={mockTeams} onSelect={onSelect} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<TeamSelectionDialog teams={mockTeams} onSelect={onSelect} />,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<TeamSelectionDialog teams={mockTeams} onSelect={onSelect} />,
|
||||
);
|
||||
|
||||
// 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('<TeamSelectionDialog />', () => {
|
||||
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(
|
||||
<TeamSelectionDialog teams={mockTeams} onSelect={onSelect} />,
|
||||
);
|
||||
|
||||
// Navigate to Marketplace (index 4)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
@@ -131,20 +102,24 @@ describe('<TeamSelectionDialog />', () => {
|
||||
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(
|
||||
<TeamSelectionDialog teams={mockTeams} onSelect={onSelect} />,
|
||||
);
|
||||
|
||||
// Navigate to Create Team (index 5)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
@@ -159,15 +134,7 @@ describe('<TeamSelectionDialog />', () => {
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<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"
|
||||
|
||||
@@ -575,11 +575,6 @@ 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;
|
||||
}
|
||||
@@ -859,8 +854,6 @@ export function KeypressProvider({
|
||||
useEffect(() => {
|
||||
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
|
||||
|
||||
@@ -92,6 +92,7 @@ export interface UIActions {
|
||||
handleRestart: () => void;
|
||||
handleNewAgentsSelect: (choice: NewAgentsChoice) => Promise<void>;
|
||||
handleTeamSelect: (teamName: string | undefined) => void;
|
||||
setIsTeamCreatorActive: (active: boolean) => void;
|
||||
getPreferredEditor: () => EditorType;
|
||||
|
||||
clearAccountSuspension: () => void;
|
||||
|
||||
@@ -222,6 +222,7 @@ export interface UIState {
|
||||
adminSettingsChanged: boolean;
|
||||
newAgents: AgentDefinition[] | null;
|
||||
isTeamSelectionActive: boolean;
|
||||
isTeamCreatorActive: boolean;
|
||||
showIsExpandableHint: boolean;
|
||||
hintMode: boolean;
|
||||
hintBuffer: string;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
@@ -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<typeof useStdin>);
|
||||
mockedUseStdout.mockReturnValue({ stdout } as unknown as ReturnType<
|
||||
typeof useStdout
|
||||
>);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> | undefined;
|
||||
private mcpInitializationPromise: Promise<void> | 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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<CoreEvents> {
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user