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:
Taylor Mullen
2026-04-03 16:54:49 -07:00
parent 1984c9d35b
commit a40af4e03e
26 changed files with 1096 additions and 161 deletions
+8 -3
View File
@@ -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');
});
});
+107
View File
@@ -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)}`,
);
}
}
+13 -7
View File
@@ -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 {
+6
View File
@@ -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(
+1
View File
@@ -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';
+9
View File
@@ -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.