feat(cli): add /agents slash command to list available agents (#16182)

This commit is contained in:
Adam Weidman
2026-01-08 16:02:44 -05:00
committed by GitHub
parent 41a8809280
commit 7e02ef697d
10 changed files with 293 additions and 0 deletions

View File

@@ -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([]),

View File

@@ -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<SlashCommand | null> = [
aboutCommand,
...(this.config?.isAgentsEnabled() ? [agentsCommand] : []),
authCommand,
bugCommand,
chatCommand,

View File

@@ -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<typeof createMockCommandContext>;
let mockConfig: {
getAgentRegistry: ReturnType<typeof vi.fn>;
};
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),
);
});
});

View File

@@ -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;
},
};

View File

@@ -71,6 +71,30 @@ describe('<HistoryItemDisplay />', () => {
},
);
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(
<HistoryItemDisplay {...baseItem} item={item} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders StatsDisplay for "stats" type', () => {
const item: HistoryItem = {
...baseItem,

View File

@@ -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<HistoryItemDisplayProps> = ({
showDescriptions={itemForDisplay.showDescriptions}
/>
)}
{itemForDisplay.type === 'agents_list' && (
<AgentsStatus
agents={itemForDisplay.agents}
terminalWidth={terminalWidth}
/>
)}
{itemForDisplay.type === 'mcp_status' && (
<McpStatus {...itemForDisplay} serverStatus={getMCPServerStatus} />
)}

View File

@@ -352,6 +352,21 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=true) > should r
50 Line 50"
`;
exports[`<HistoryItemDisplay /> > 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[`<HistoryItemDisplay /> > renders InfoMessage for "info" type with multi-line text (alternateBuffer=false) 1`] = `
"
⚡ Line 1

View File

@@ -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<AgentsStatusProps> = ({
agents,
terminalWidth,
}) => {
const localAgents = agents.filter((a) => a.kind === 'local');
const remoteAgents = agents.filter((a) => a.kind === 'remote');
if (agents.length === 0) {
return (
<Box flexDirection="column">
<Text>No agents available.</Text>
</Box>
);
}
const renderAgentList = (title: string, agentList: AgentDefinitionJson[]) => {
if (agentList.length === 0) return null;
return (
<Box flexDirection="column" marginBottom={1}>
<Text bold color={theme.text.primary}>
{title}
</Text>
<Box height={1} />
{agentList.map((agent) => (
<Box key={agent.name} flexDirection="row">
<Text color={theme.text.primary}>{' '}- </Text>
<Box flexDirection="column">
<Text bold color={theme.text.accent}>
{agent.displayName || agent.name}
{agent.displayName && agent.displayName !== agent.name && (
<Text bold={false}> ({agent.name})</Text>
)}
</Text>
{agent.description && (
<MarkdownDisplay
terminalWidth={terminalWidth}
text={agent.description}
isPending={false}
/>
)}
</Box>
</Box>
))}
</Box>
);
};
return (
<Box flexDirection="column" marginBottom={1}>
{renderAgentList('Local Agents', localAgents)}
{renderAgentList('Remote Agents', remoteAgents)}
</Box>
);
};

View File

@@ -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',

View File

@@ -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';