diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index 22b7a47ffc..545168e88d 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -58,6 +58,9 @@ import { CommandKind } from '../ui/commands/types.js'; import { restoreCommand } from '../ui/commands/restoreCommand.js'; vi.mock('../ui/commands/authCommand.js', () => ({ authCommand: {} })); +vi.mock('../ui/commands/agentsCommand.js', () => ({ + agentsCommand: { name: 'agents' }, +})); vi.mock('../ui/commands/bugCommand.js', () => ({ bugCommand: {} })); vi.mock('../ui/commands/chatCommand.js', () => ({ chatCommand: {} })); vi.mock('../ui/commands/clearCommand.js', () => ({ clearCommand: {} })); @@ -104,6 +107,7 @@ describe('BuiltinCommandLoader', () => { getEnableHooksUI: () => false, getExtensionsEnabled: vi.fn().mockReturnValue(true), isSkillsSupportEnabled: vi.fn().mockReturnValue(false), + isAgentsEnabled: vi.fn().mockReturnValue(false), getMcpEnabled: vi.fn().mockReturnValue(true), getSkillManager: vi.fn().mockReturnValue({ getAllSkills: vi.fn().mockReturnValue([]), @@ -189,6 +193,22 @@ describe('BuiltinCommandLoader', () => { const policiesCmd = commands.find((c) => c.name === 'policies'); expect(policiesCmd).toBeDefined(); }); + + it('should include agents command when agents are enabled', async () => { + mockConfig.isAgentsEnabled = vi.fn().mockReturnValue(true); + const loader = new BuiltinCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + const agentsCmd = commands.find((c) => c.name === 'agents'); + expect(agentsCmd).toBeDefined(); + }); + + it('should exclude agents command when agents are disabled', async () => { + mockConfig.isAgentsEnabled = vi.fn().mockReturnValue(false); + const loader = new BuiltinCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + const agentsCmd = commands.find((c) => c.name === 'agents'); + expect(agentsCmd).toBeUndefined(); + }); }); describe('BuiltinCommandLoader profile', () => { @@ -204,6 +224,7 @@ describe('BuiltinCommandLoader profile', () => { getEnableHooksUI: () => false, getExtensionsEnabled: vi.fn().mockReturnValue(true), isSkillsSupportEnabled: vi.fn().mockReturnValue(false), + isAgentsEnabled: vi.fn().mockReturnValue(false), getMcpEnabled: vi.fn().mockReturnValue(true), getSkillManager: vi.fn().mockReturnValue({ getAllSkills: vi.fn().mockReturnValue([]), diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 4320217220..5193b5fe9c 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -14,6 +14,7 @@ import { import type { MessageActionReturn, Config } from '@google/gemini-cli-core'; import { startupProfiler } from '@google/gemini-cli-core'; import { aboutCommand } from '../ui/commands/aboutCommand.js'; +import { agentsCommand } from '../ui/commands/agentsCommand.js'; import { authCommand } from '../ui/commands/authCommand.js'; import { bugCommand } from '../ui/commands/bugCommand.js'; import { chatCommand } from '../ui/commands/chatCommand.js'; @@ -66,6 +67,7 @@ export class BuiltinCommandLoader implements ICommandLoader { const handle = startupProfiler.start('load_builtin_commands'); const allDefinitions: Array = [ aboutCommand, + ...(this.config?.isAgentsEnabled() ? [agentsCommand] : []), authCommand, bugCommand, chatCommand, diff --git a/packages/cli/src/ui/commands/agentsCommand.test.ts b/packages/cli/src/ui/commands/agentsCommand.test.ts new file mode 100644 index 0000000000..1e871bae6a --- /dev/null +++ b/packages/cli/src/ui/commands/agentsCommand.test.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { agentsCommand } from './agentsCommand.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import type { Config } from '@google/gemini-cli-core'; +import { MessageType } from '../types.js'; + +describe('agentsCommand', () => { + let mockContext: ReturnType; + let mockConfig: { + getAgentRegistry: ReturnType; + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockConfig = { + getAgentRegistry: vi.fn().mockReturnValue({ + getAllDefinitions: vi.fn().mockReturnValue([]), + }), + }; + + mockContext = createMockCommandContext({ + services: { + config: mockConfig as unknown as Config, + }, + }); + }); + + it('should show an error if config is not available', async () => { + const contextWithoutConfig = createMockCommandContext({ + services: { + config: null, + }, + }); + + const result = await agentsCommand.action!(contextWithoutConfig, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }); + }); + + it('should show an error if agent registry is not available', async () => { + mockConfig.getAgentRegistry = vi.fn().mockReturnValue(undefined); + + const result = await agentsCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Agent registry not found.', + }); + }); + + it('should call addItem with correct agents list', async () => { + const mockAgents = [ + { + name: 'agent1', + displayName: 'Agent One', + description: 'desc1', + kind: 'local', + }, + { name: 'agent2', description: 'desc2', kind: 'remote' }, + ]; + mockConfig.getAgentRegistry().getAllDefinitions.mockReturnValue(mockAgents); + + await agentsCommand.action!(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.AGENTS_LIST, + agents: mockAgents, + }), + expect.any(Number), + ); + }); +}); diff --git a/packages/cli/src/ui/commands/agentsCommand.ts b/packages/cli/src/ui/commands/agentsCommand.ts new file mode 100644 index 0000000000..516c326662 --- /dev/null +++ b/packages/cli/src/ui/commands/agentsCommand.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SlashCommand, CommandContext } from './types.js'; +import { CommandKind } from './types.js'; +import { MessageType, type HistoryItemAgentsList } from '../types.js'; + +export const agentsCommand: SlashCommand = { + name: 'agents', + description: 'List available local and remote agents', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context: CommandContext) => { + const { config } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }; + } + + const agentRegistry = config.getAgentRegistry(); + if (!agentRegistry) { + return { + type: 'message', + messageType: 'error', + content: 'Agent registry not found.', + }; + } + + const agents = agentRegistry.getAllDefinitions().map((def) => ({ + name: def.name, + displayName: def.displayName, + description: def.description, + kind: def.kind, + })); + + const agentsListItem: HistoryItemAgentsList = { + type: MessageType.AGENTS_LIST, + agents, + }; + + context.ui.addItem(agentsListItem, Date.now()); + + return; + }, +}; diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx index 8488a78dfb..17fd06e6c8 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx @@ -71,6 +71,30 @@ describe('', () => { }, ); + it('renders AgentsStatus for "agents_list" type', () => { + const item: HistoryItem = { + ...baseItem, + type: MessageType.AGENTS_LIST, + agents: [ + { + name: 'local_agent', + displayName: 'Local Agent', + description: ' Local agent description.\n Second line.', + kind: 'local', + }, + { + name: 'remote_agent', + description: 'Remote agent description.', + kind: 'remote', + }, + ], + }; + const { lastFrame } = renderWithProviders( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + it('renders StatsDisplay for "stats" type', () => { const item: HistoryItem = { ...baseItem, diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 5a7f769402..509645eda5 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -29,6 +29,7 @@ import { ExtensionsList } from './views/ExtensionsList.js'; import { getMCPServerStatus } from '@google/gemini-cli-core'; import { ToolsList } from './views/ToolsList.js'; import { SkillsList } from './views/SkillsList.js'; +import { AgentsStatus } from './views/AgentsStatus.js'; import { McpStatus } from './views/McpStatus.js'; import { ChatList } from './views/ChatList.js'; import { HooksList } from './views/HooksList.js'; @@ -160,6 +161,12 @@ export const HistoryItemDisplay: React.FC = ({ showDescriptions={itemForDisplay.showDescriptions} /> )} + {itemForDisplay.type === 'agents_list' && ( + + )} {itemForDisplay.type === 'mcp_status' && ( )} diff --git a/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap index 74b54dbc79..ad04fdb2ba 100644 --- a/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap @@ -352,6 +352,21 @@ exports[` > gemini items (alternateBuffer=true) > should r 50 Line 50" `; +exports[` > renders AgentsStatus for "agents_list" type 1`] = ` +"Local Agents + + - Local Agent (local_agent) + Local agent description. + Second line. + +Remote Agents + + - remote_agent + Remote agent description. + +" +`; + exports[` > renders InfoMessage for "info" type with multi-line text (alternateBuffer=false) 1`] = ` " ℹ ⚡ Line 1 diff --git a/packages/cli/src/ui/components/views/AgentsStatus.tsx b/packages/cli/src/ui/components/views/AgentsStatus.tsx new file mode 100644 index 0000000000..2e6131f7a9 --- /dev/null +++ b/packages/cli/src/ui/components/views/AgentsStatus.tsx @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import type React from 'react'; +import { theme } from '../../semantic-colors.js'; +import type { AgentDefinitionJson } from '../../types.js'; +import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; + +interface AgentsStatusProps { + agents: AgentDefinitionJson[]; + terminalWidth: number; +} + +export const AgentsStatus: React.FC = ({ + agents, + terminalWidth, +}) => { + const localAgents = agents.filter((a) => a.kind === 'local'); + const remoteAgents = agents.filter((a) => a.kind === 'remote'); + + if (agents.length === 0) { + return ( + + No agents available. + + ); + } + + const renderAgentList = (title: string, agentList: AgentDefinitionJson[]) => { + if (agentList.length === 0) return null; + + return ( + + + {title} + + + {agentList.map((agent) => ( + + {' '}- + + + {agent.displayName || agent.name} + {agent.displayName && agent.displayName !== agent.name && ( + ({agent.name}) + )} + + {agent.description && ( + + )} + + + ))} + + ); + }; + + return ( + + {renderAgentList('Local Agents', localAgents)} + {renderAgentList('Remote Agents', remoteAgents)} + + ); +}; diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 7535119a30..096caf862a 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -14,6 +14,7 @@ import type { ToolResultDisplay, RetrieveUserQuotaResponse, SkillDefinition, + AgentDefinition, } from '@google/gemini-cli-core'; import type { PartListUnion } from '@google/genai'; import { type ReactNode } from 'react'; @@ -213,6 +214,16 @@ export type HistoryItemSkillsList = HistoryItemBase & { showDescriptions: boolean; }; +export type AgentDefinitionJson = Pick< + AgentDefinition, + 'name' | 'displayName' | 'description' | 'kind' +>; + +export type HistoryItemAgentsList = HistoryItemBase & { + type: 'agents_list'; + agents: AgentDefinitionJson[]; +}; + // JSON-friendly types for using as a simple data model showing info about an // MCP Server. export interface JsonMcpTool { @@ -292,6 +303,7 @@ export type HistoryItemWithoutId = | HistoryItemExtensionsList | HistoryItemToolsList | HistoryItemSkillsList + | HistoryItemAgentsList | HistoryItemMcpStatus | HistoryItemChatList | HistoryItemHooksList; @@ -315,6 +327,7 @@ export enum MessageType { EXTENSIONS_LIST = 'extensions_list', TOOLS_LIST = 'tools_list', SKILLS_LIST = 'skills_list', + AGENTS_LIST = 'agents_list', MCP_STATUS = 'mcp_status', CHAT_LIST = 'chat_list', HOOKS_LIST = 'hooks_list', diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a15ee2951d..be23fb2d27 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -120,6 +120,9 @@ export * from './resources/resource-registry.js'; // Export prompt logic export * from './prompts/mcp-prompts.js'; +// Export agent definitions +export * from './agents/types.js'; + // Export specific tool logic export * from './tools/read-file.js'; export * from './tools/ls.js';