diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 0ae9ef35989..1350ca496b1 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -48,6 +48,7 @@ import { profileCommand } from '../ui/commands/profileCommand.js'; import { quitCommand } from '../ui/commands/quitCommand.js'; import { restoreCommand } from '../ui/commands/restoreCommand.js'; import { resumeCommand } from '../ui/commands/resumeCommand.js'; +import { remoteControlCommand } from '../ui/commands/remoteControlCommand.js'; import { statsCommand } from '../ui/commands/statsCommand.js'; import { themeCommand } from '../ui/commands/themeCommand.js'; import { toolsCommand } from '../ui/commands/toolsCommand.js'; @@ -150,6 +151,7 @@ export class BuiltinCommandLoader implements ICommandLoader { privacyCommand, ...(isDevelopment ? [profileCommand] : []), quitCommand, + remoteControlCommand, restoreCommand(this.config), resumeCommand, statsCommand, diff --git a/packages/cli/src/services/RemoteControlService.ts b/packages/cli/src/services/RemoteControlService.ts new file mode 100644 index 00000000000..312c1b98c26 --- /dev/null +++ b/packages/cli/src/services/RemoteControlService.ts @@ -0,0 +1,203 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import WebSocket from 'ws'; +import { + ShellExecutionService, + coreEvents, + CoreEvent, + type OutputPayload, + type ConsoleLogPayload, +} from '@google/gemini-cli-core'; +import { remoteControlEmitter, REMOTE_INPUT_EVENT } from './remoteControlEmitter.js'; + +interface RemoteCommand { + type: 'exec' | 'stdin' | 'resize' | 'input'; + command?: string; + input?: string; + pid?: number; + cols?: number; + rows?: number; +} + +export class RemoteControlService { + private ws: WebSocket | null = null; + private url: string; + private connected = false; + private activePids = new Set(); + + constructor(url: string) { + this.url = url; + } + + public connect(): Promise { + return new Promise((resolve, reject) => { + try { + this.ws = new WebSocket(this.url); + + this.ws.on('open', () => { + this.connected = true; + this.subscribeToCoreEvents(); + resolve(); + }); + + this.ws.on('message', (data: WebSocket.RawData) => { + this.handleMessage(data); + }); + + this.ws.on('close', () => { + this.connected = false; + this.unsubscribeFromCoreEvents(); + }); + + this.ws.on('error', (err) => { + if (!this.connected) { + reject(err); + } + // If already connected, error will likely be followed by close + }); + } catch (err) { + reject(err); + } + }); + } + + public disconnect(): void { + if (this.ws) { + this.ws.close(); + this.ws = null; + } + this.connected = false; + this.unsubscribeFromCoreEvents(); + } + + public isConnected(): boolean { + return this.connected; + } + + private subscribeToCoreEvents() { + coreEvents.on(CoreEvent.Output, this.handleOutput); + coreEvents.on(CoreEvent.ConsoleLog, this.handleConsoleLog); + } + + private unsubscribeFromCoreEvents() { + coreEvents.off(CoreEvent.Output, this.handleOutput); + coreEvents.off(CoreEvent.ConsoleLog, this.handleConsoleLog); + } + + private handleOutput = (payload: OutputPayload) => { + // Forward global output to remote + this.sendMessage({ + type: 'output', + stream: payload.isStderr ? 'stderr' : 'stdout', + chunk: payload.chunk.toString(), + }); + }; + + private handleConsoleLog = (payload: ConsoleLogPayload) => { + // Forward console logs to remote + this.sendMessage({ + type: 'log', + level: payload.type, + content: payload.content, + }); + }; + + private handleMessage(data: WebSocket.RawData) { + try { + const message = JSON.parse(data.toString()) as RemoteCommand; + switch (message.type) { + case 'exec': + if (message.command) { + this.executeCommand(message.command); + } + break; + case 'stdin': + if (message.pid && message.input) { + ShellExecutionService.writeToPty(message.pid, message.input); + } + break; + case 'input': + if (message.input) { + remoteControlEmitter.emit(REMOTE_INPUT_EVENT, { input: message.input }); + } + break; + case 'resize': + if (message.pid && message.cols && message.rows) { + ShellExecutionService.resizePty( + message.pid, + message.cols, + message.rows, + ); + } + break; + default: + console.warn('Unknown remote command type:', message.type); + } + } catch (e) { + console.error('Failed to parse remote message:', e); + } + } + + private async executeCommand(command: string) { + const cwd = process.cwd(); + const abortController = new AbortController(); + + try { + const { pid, result } = await ShellExecutionService.execute( + command, + cwd, + (event) => { + if (pid) { + this.sendMessage({ + type: 'shell_event', + pid, + event + }); + } + }, + abortController.signal, + true, // Use PTY if available + { + sanitizationConfig: { + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + enableEnvironmentVariableRedaction: false, + }, + } + ); + + if (pid) { + this.activePids.add(pid); + } + + const res = await result; + + if (pid) { + this.activePids.delete(pid); + } + + this.sendMessage({ + type: 'exec_result', + pid, + exitCode: res.exitCode, + output: res.output + }); + + } catch (e) { + this.sendMessage({ + type: 'exec_error', + error: e instanceof Error ? e.message : String(e) + }); + } + } + + private sendMessage(message: any) { + if (this.ws && this.connected) { + this.ws.send(JSON.stringify(message)); + } + } +} diff --git a/packages/cli/src/services/remoteControlEmitter.ts b/packages/cli/src/services/remoteControlEmitter.ts new file mode 100644 index 00000000000..ac38ea7929d --- /dev/null +++ b/packages/cli/src/services/remoteControlEmitter.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EventEmitter } from 'events'; + +export const remoteControlEmitter = new EventEmitter(); +export const REMOTE_INPUT_EVENT = 'remote-input'; + +export interface RemoteInputPayload { + input: string; +} diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 84b51e5f2de..63e1d95ef87 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -142,6 +142,11 @@ import { NewAgentsChoice } from './components/NewAgentsNotification.js'; import { isSlashCommand } from './utils/commandUtils.js'; import { useTerminalTheme } from './hooks/useTerminalTheme.js'; import { isITerm2 } from './utils/terminalUtils.js'; +import { + remoteControlEmitter, + REMOTE_INPUT_EVENT, + type RemoteInputPayload, +} from '../services/remoteControlEmitter.js'; function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) { return pendingHistoryItems.some((item) => { @@ -1106,6 +1111,16 @@ Logging in with Google... Restarting Gemini CLI to continue. ], ); + useEffect(() => { + const handleRemoteInput = (payload: RemoteInputPayload) => { + handleFinalSubmit(payload.input); + }; + remoteControlEmitter.on(REMOTE_INPUT_EVENT, handleRemoteInput); + return () => { + remoteControlEmitter.off(REMOTE_INPUT_EVENT, handleRemoteInput); + }; + }, [handleFinalSubmit]); + const handleClearScreen = useCallback(() => { historyManager.clearItems(); clearConsoleMessagesState(); diff --git a/packages/cli/src/ui/commands/remoteControlCommand.test.ts b/packages/cli/src/ui/commands/remoteControlCommand.test.ts new file mode 100644 index 00000000000..8d25c493cef --- /dev/null +++ b/packages/cli/src/ui/commands/remoteControlCommand.test.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Hoist the mock factory +vi.mock('../../services/RemoteControlService.js', () => { + const RemoteControlService = vi.fn(); + RemoteControlService.prototype.connect = vi.fn().mockResolvedValue(undefined); + RemoteControlService.prototype.disconnect = vi.fn(); + RemoteControlService.prototype.isConnected = vi.fn().mockReturnValue(false); + return { RemoteControlService }; +}); + +import { remoteControlCommand } from './remoteControlCommand.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { MessageType } from '../types.js'; +import { RemoteControlService } from '../../services/RemoteControlService.js'; + +describe('remoteControlCommand', () => { + let mockContext: any; + const originalEnv = process.env; + + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...originalEnv }; + mockContext = createMockCommandContext({ + ui: { + addItem: vi.fn(), + }, + } as any); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should start a remote control session', async () => { + process.env['GEMINI_REMOTE_CONTROL_URL'] = 'ws://test-url'; + + if (!remoteControlCommand.action) { + throw new Error('Command has no action'); + } + + // @ts-ignore + await remoteControlCommand.action(mockContext, ''); + + expect(RemoteControlService).toHaveBeenCalledWith(expect.stringMatching(/^ws:\/\/test-url\?token=/)); + expect(RemoteControlService.prototype.connect).toHaveBeenCalled(); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: expect.stringContaining('Connected to remote control service'), + }), + ); + }); +}); diff --git a/packages/cli/src/ui/commands/remoteControlCommand.ts b/packages/cli/src/ui/commands/remoteControlCommand.ts new file mode 100644 index 00000000000..ec19c0d749b --- /dev/null +++ b/packages/cli/src/ui/commands/remoteControlCommand.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { RemoteControlService } from '../../services/RemoteControlService.js'; +import { CommandKind, type SlashCommand } from './types.js'; +import { MessageType } from '../types.js'; +import { randomBytes } from 'crypto'; + +let service: RemoteControlService | null = null; + +export const remoteControlCommand: SlashCommand = { + name: 'remote-control', + description: 'Start a remote control session via Google Chat', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context) => { + if (service && service.isConnected()) { + context.ui.addItem({ + type: MessageType.INFO, + text: 'Remote control session is already active.', + }); + return; + } + + const token = randomBytes(16).toString('hex'); + const baseUrl = process.env['GEMINI_REMOTE_CONTROL_URL'] || 'ws://localhost:8080'; + const url = `${baseUrl}?token=${token}`; + + context.ui.addItem({ + type: MessageType.INFO, + text: `Connecting to remote control service at ${baseUrl}...`, + }); + context.ui.addItem({ + type: MessageType.INFO, + text: `Session Token: ${token}`, + }); + + service = new RemoteControlService(url); + + try { + await service.connect(); + context.ui.addItem({ + type: MessageType.INFO, + text: 'Connected to remote control service. You can now manage your session via Google Chat.', + }); + context.ui.addItem({ + type: MessageType.INFO, + text: 'Press Ctrl+C to stop the CLI (which will also stop the remote session).', + }); + } catch (err) { + context.ui.addItem({ + type: MessageType.ERROR, + text: `Failed to connect: ${err instanceof Error ? err.message : String(err)}`, + }); + service = null; + } + }, +};