Files
gemini-cli/packages/cli/src/interactiveCli.tsx
Sandy Tao c83d368e2c feat(cli): add A2A HTTP listener for external message injection in Forever Mode
Embed a JSON-RPC 2.0 HTTP server that bridges A2A protocol messages into
the interactive session. Starts automatically in Forever Mode, binds to
127.0.0.1 on a configurable port (sisyphusMode.a2aPort), and writes a
port discovery file to ~/.gemini/sessions/.

Supported methods: message/send (blocking), tasks/get, responses/poll,
and GET /.well-known/agent-card.json.

- Add ExternalMessage and A2AListenerStarted app events
- Track streaming state transitions to capture agent responses
- Display A2A port in StatusDisplay when active
2026-03-17 14:34:01 -07:00

237 lines
7.5 KiB
TypeScript

/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { render } from 'ink';
import { basename } from 'node:path';
import { AppContainer } from './ui/AppContainer.js';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
import { registerCleanup, setupTtyCheck } from './utils/cleanup.js';
import { startExternalListener } from './external-listener.js';
import { appEvents, AppEvent } from './utils/events.js';
import {
type StartupWarning,
type Config,
type ResumedSessionData,
coreEvents,
createWorkingStdio,
disableMouseEvents,
enableMouseEvents,
disableLineWrapping,
enableLineWrapping,
shouldEnterAlternateScreen,
recordSlowRender,
writeToStdout,
getVersion,
debugLogger,
} from '@google/gemini-cli-core';
import type { InitializationResult } from './core/initializer.js';
import type { LoadedSettings } from './config/settings.js';
import { checkForUpdates } from './ui/utils/updateCheck.js';
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
import { SettingsContext } from './ui/contexts/SettingsContext.js';
import { MouseProvider } from './ui/contexts/MouseContext.js';
import { StreamingState } from './ui/types.js';
import { computeTerminalTitle } from './utils/windowTitle.js';
import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
import { KeyMatchersProvider } from './ui/hooks/useKeyMatchers.js';
import { loadKeyMatchers } from './ui/key/keyMatchers.js';
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
import { TerminalProvider } from './ui/contexts/TerminalContext.js';
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
import { OverflowProvider } from './ui/contexts/OverflowContext.js';
import { profiler } from './ui/components/DebugProfiler.js';
const SLOW_RENDER_MS = 200;
export async function startInteractiveUI(
config: Config,
settings: LoadedSettings,
startupWarnings: StartupWarning[],
workspaceRoot: string = process.cwd(),
resumedSessionData: ResumedSessionData | undefined,
initializationResult: InitializationResult,
) {
// Never enter Ink alternate buffer mode when screen reader mode is enabled
// as there is no benefit of alternate buffer mode when using a screen reader
// and the Ink alternate buffer mode requires line wrapping harmful to
// screen readers.
const useAlternateBuffer = shouldEnterAlternateScreen(
isAlternateBufferEnabled(config),
config.getScreenReader(),
);
const mouseEventsEnabled = useAlternateBuffer;
if (mouseEventsEnabled) {
enableMouseEvents();
registerCleanup(() => {
disableMouseEvents();
});
}
const { matchers, errors } = await loadKeyMatchers();
errors.forEach((error) => {
coreEvents.emitFeedback('warning', error);
});
const version = await getVersion();
setWindowTitle(basename(workspaceRoot), settings);
const consolePatcher = new ConsolePatcher({
onNewMessage: (msg) => {
coreEvents.emitConsoleLog(msg.type, msg.content);
},
debugMode: config.getDebugMode(),
});
consolePatcher.patch();
registerCleanup(consolePatcher.cleanup);
const { stdout: inkStdout, stderr: inkStderr } = createWorkingStdio();
const isShpool = !!process.env['SHPOOL_SESSION_NAME'];
// Create wrapper component to use hooks inside render
const AppWrapper = () => {
useKittyKeyboardProtocol();
return (
<SettingsContext.Provider value={settings}>
<KeyMatchersProvider value={matchers}>
<KeypressProvider
config={config}
debugKeystrokeLogging={
settings.merged.general.debugKeystrokeLogging
}
>
<MouseProvider
mouseEventsEnabled={mouseEventsEnabled}
debugKeystrokeLogging={
settings.merged.general.debugKeystrokeLogging
}
>
<TerminalProvider>
<ScrollProvider>
<OverflowProvider>
<SessionStatsProvider>
<VimModeProvider>
<AppContainer
config={config}
startupWarnings={startupWarnings}
version={version}
resumedSessionData={resumedSessionData}
initializationResult={initializationResult}
/>
</VimModeProvider>
</SessionStatsProvider>
</OverflowProvider>
</ScrollProvider>
</TerminalProvider>
</MouseProvider>
</KeypressProvider>
</KeyMatchersProvider>
</SettingsContext.Provider>
);
};
if (isShpool) {
// Wait a moment for shpool to stabilize terminal size and state.
await new Promise((resolve) => setTimeout(resolve, 100));
}
const instance = render(
process.env['DEBUG'] ? (
<React.StrictMode>
<AppWrapper />
</React.StrictMode>
) : (
<AppWrapper />
),
{
stdout: inkStdout,
stderr: inkStderr,
stdin: process.stdin,
exitOnCtrlC: false,
isScreenReaderEnabled: config.getScreenReader(),
onRender: ({ renderTime }: { renderTime: number }) => {
if (renderTime > SLOW_RENDER_MS) {
recordSlowRender(config, renderTime);
}
profiler.reportFrameRendered();
},
patchConsole: false,
alternateBuffer: useAlternateBuffer,
incrementalRendering:
settings.merged.ui.incrementalRendering !== false &&
useAlternateBuffer &&
!isShpool,
},
);
if (useAlternateBuffer) {
disableLineWrapping();
registerCleanup(() => {
enableLineWrapping();
});
}
checkForUpdates(settings)
.then((info) => {
handleAutoUpdate(info, settings, config.getProjectRoot());
})
.catch((err) => {
// Silently ignore update check errors.
if (config.getDebugMode()) {
debugLogger.warn('Update check failed:', err);
}
});
registerCleanup(() => instance.unmount());
// Auto-start A2A HTTP listener in Forever Mode
if (config.getIsForeverMode()) {
const sisyphusMode = config.getSisyphusMode();
const a2aPort = sisyphusMode.a2aPort ?? 0;
try {
const listener = await startExternalListener({ port: a2aPort });
registerCleanup(listener.cleanup);
appEvents.emit(AppEvent.A2AListenerStarted, listener.port);
coreEvents.emitFeedback(
'info',
`A2A endpoint listening on port ${listener.port}`,
);
} catch (err) {
coreEvents.emitFeedback(
'warning',
`Failed to start A2A listener: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
registerCleanup(setupTtyCheck());
}
function setWindowTitle(title: string, settings: LoadedSettings) {
if (!settings.merged.ui.hideWindowTitle) {
// Initial state before React loop starts
const windowTitle = computeTerminalTitle({
streamingState: StreamingState.Idle,
isConfirming: false,
isSilentWorking: false,
folderName: title,
showThoughts: !!settings.merged.ui.showStatusInTitle,
useDynamicTitle: settings.merged.ui.dynamicWindowTitle,
});
writeToStdout(`\x1b]0;${windowTitle}\x07`);
process.on('exit', () => {
writeToStdout(`\x1b]0;\x07`);
});
}
}