feat: add channels support

This commit is contained in:
Jack Wotherspoon
2026-03-20 15:52:29 -04:00
parent 52250c162d
commit 0b94546ee4
14 changed files with 290 additions and 0 deletions
+8
View File
@@ -98,6 +98,7 @@ export interface CliArgs {
rawOutput: boolean | undefined;
acceptRawOutputRisk: boolean | undefined;
isCommand: boolean | undefined;
channels: string[] | undefined;
}
/**
@@ -299,6 +300,12 @@ export async function parseArguments(
.option('accept-raw-output-risk', {
type: 'boolean',
description: 'Suppress the security warning when using --raw-output.',
})
.option('channels', {
type: 'string',
array: true,
description: 'Enable channel message delivery from named MCP servers',
coerce: coerceCommaSeparated,
}),
)
// Register MCP subcommands
@@ -930,6 +937,7 @@ export async function loadCliConfig(
};
},
enableConseca: settings.security?.enableConseca,
channels: argv.channels,
});
}
+1
View File
@@ -513,6 +513,7 @@ describe('gemini.tsx main function kitty protocol', () => {
rawOutput: undefined,
acceptRawOutputRisk: undefined,
isCommand: undefined,
channels: undefined,
});
await act(async () => {
+7
View File
@@ -259,6 +259,13 @@ export async function main() {
});
}
if (argv.channels && argv.channels.length > 0) {
coreEvents.emitFeedback(
'info',
`Channels enabled: ${argv.channels.join(', ')}`,
);
}
// Check for invalid input combinations early to prevent crashes
if (argv.promptInteractive && !process.stdin.isTTY) {
writeToStderr(
+21
View File
@@ -67,6 +67,7 @@ import {
refreshServerHierarchicalMemory,
flattenMemory,
type MemoryChangedPayload,
type ChannelMessagePayload,
writeToStdout,
disableMouseEvents,
enterAlternateScreen,
@@ -1220,6 +1221,26 @@ Logging in with Google... Restarting Gemini CLI to continue.
isMcpReady,
});
// Listen for external channel messages from MCP servers declaring
// experimental['gemini/channel'] and inject them into the message queue.
const channelsEnabled = config.getChannels().length > 0;
useEffect(() => {
if (!channelsEnabled) return;
const handler = (payload: ChannelMessagePayload) => {
const meta = payload.metadata ?? {};
const user = meta['user'] ?? payload.sender;
const chatId = meta['chat_id'] ?? '';
const msgId = meta['message_id'] ?? '';
const imagePath = meta['image_path'] ?? '';
const formatted = `<channel source="${payload.channelName}" chat_id="${chatId}" message_id="${msgId}" user="${user}"${imagePath ? ` image_path="${imagePath}"` : ''}>\n${payload.content}\n</channel>`;
addMessage(formatted);
};
coreEvents.on(CoreEvent.ChannelMessage, handler);
return () => {
coreEvents.off(CoreEvent.ChannelMessage, handler);
};
}, [channelsEnabled, addMessage]);
cancelHandlerRef.current = useCallback(
(shouldRestorePrompt: boolean = true) => {
const pendingHistoryItems = [
@@ -30,6 +30,7 @@ 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 { ChannelsList } from './views/ChannelsList.js';
import { McpStatus } from './views/McpStatus.js';
import { ChatList } from './views/ChatList.js';
import { ModelMessage } from './messages/ModelMessage.js';
@@ -227,6 +228,9 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
terminalWidth={terminalWidth}
/>
)}
{itemForDisplay.type === 'channels_list' && (
<ChannelsList channels={itemForDisplay.channels} />
)}
{itemForDisplay.type === 'mcp_status' && (
<McpStatus {...itemForDisplay} serverStatus={getMCPServerStatus} />
)}
@@ -0,0 +1,40 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { ChannelsList } from './ChannelsList.js';
import { type ChannelInfo } from '../../types.js';
import { renderWithProviders } from '../../../test-utils/render.js';
const mockChannels: ChannelInfo[] = [
{
name: 'telegram',
displayName: 'Telegram',
supportsReply: true,
},
{
name: 'minimal-channel',
supportsReply: false,
},
];
describe('<ChannelsList />', () => {
it('renders correctly with active channels', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
<ChannelsList channels={mockChannels} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly with no active channels', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
<ChannelsList channels={[]} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -0,0 +1,56 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import type { ChannelInfo } from '../../types.js';
interface ChannelsListProps {
channels: readonly ChannelInfo[];
}
export const ChannelsList: React.FC<ChannelsListProps> = ({ channels }) => {
if (channels.length === 0) {
return (
<Box flexDirection="column" marginBottom={1}>
<Text color={theme.text.primary}>No active channels.</Text>
<Text color="gray">
{
"MCP servers must declare `experimental['gemini/channel']` in their capabilities to act as channels."
}
</Text>
</Box>
);
}
return (
<Box flexDirection="column" marginBottom={1}>
<Text bold color={theme.text.primary}>
Active channels:
</Text>
<Box height={1} />
<Box flexDirection="column">
{channels.map((channel) => (
<Box key={channel.name} flexDirection="row">
<Text color={theme.text.primary}>{' '}- </Text>
<Box flexDirection="column">
<Text bold color={theme.text.accent}>
{channel.displayName || channel.name} ({channel.name})
</Text>
<Text color="gray">
Direction:{' '}
<Text color={channel.supportsReply ? 'green' : 'gray'}>
{channel.supportsReply ? 'two-way' : 'one-way'}
</Text>
</Text>
</Box>
</Box>
))}
</Box>
</Box>
);
};
@@ -0,0 +1,17 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ChannelsList /> > renders correctly with active channels 1`] = `
"Active channels:
- Telegram (telegram)
Direction: two-way
- minimal-channel (minimal-channel)
Direction: one-way
"
`;
exports[`<ChannelsList /> > renders correctly with no active channels 1`] = `
"No active channels.
MCP servers must declare \`experimental['gemini/channel']\` in their capabilities to act as channels.
"
`;
+13
View File
@@ -295,6 +295,17 @@ export type AgentDefinitionJson = Pick<
'name' | 'displayName' | 'description' | 'kind'
>;
export interface ChannelInfo {
name: string;
displayName?: string;
supportsReply: boolean;
}
export type HistoryItemChannelsList = HistoryItemBase & {
type: 'channels_list';
channels: ChannelInfo[];
};
export type HistoryItemAgentsList = HistoryItemBase & {
type: 'agents_list';
agents: AgentDefinitionJson[];
@@ -377,6 +388,7 @@ export type HistoryItemWithoutId =
| HistoryItemToolsList
| HistoryItemSkillsList
| HistoryItemAgentsList
| HistoryItemChannelsList
| HistoryItemMcpStatus
| HistoryItemChatList
| HistoryItemThinking
@@ -402,6 +414,7 @@ export enum MessageType {
TOOLS_LIST = 'tools_list',
SKILLS_LIST = 'skills_list',
AGENTS_LIST = 'agents_list',
CHANNELS_LIST = 'channels_list',
MCP_STATUS = 'mcp_status',
CHAT_LIST = 'chat_list',
HINT = 'hint',