perf(cli): enable code splitting and deferred UI loading (#22117)

This commit is contained in:
Sehoon Shon
2026-03-12 16:37:03 -04:00
committed by GitHub
parent c2691f44b6
commit b6beab9480
4 changed files with 302 additions and 230 deletions

View File

@@ -82,11 +82,14 @@ const commonAliases = {
const cliConfig = {
...baseConfig,
banner: {
js: `const require = (await import('node:module')).createRequire(import.meta.url); globalThis.__filename = (await import('node:url')).fileURLToPath(import.meta.url); globalThis.__dirname = (await import('node:path')).dirname(globalThis.__filename);`,
js: `const require = (await import('node:module')).createRequire(import.meta.url); const __chunk_filename = (await import('node:url')).fileURLToPath(import.meta.url); const __chunk_dirname = (await import('node:path')).dirname(__chunk_filename);`,
},
entryPoints: ['packages/cli/index.ts'],
outfile: 'bundle/gemini.js',
entryPoints: { gemini: 'packages/cli/index.ts' },
outdir: 'bundle',
splitting: true,
define: {
__filename: '__chunk_filename',
__dirname: '__chunk_dirname',
'process.env.CLI_VERSION': JSON.stringify(pkg.version),
'process.env.GEMINI_SANDBOX_IMAGE_DEFAULT': JSON.stringify(
pkg.config?.sandboxImageUri,
@@ -103,11 +106,13 @@ const cliConfig = {
const a2aServerConfig = {
...baseConfig,
banner: {
js: `const require = (await import('node:module')).createRequire(import.meta.url); globalThis.__filename = (await import('node:url')).fileURLToPath(import.meta.url); globalThis.__dirname = (await import('node:path')).dirname(globalThis.__filename);`,
js: `const require = (await import('node:module')).createRequire(import.meta.url); const __chunk_filename = (await import('node:url')).fileURLToPath(import.meta.url); const __chunk_dirname = (await import('node:path')).dirname(__chunk_filename);`,
},
entryPoints: ['packages/a2a-server/src/http/server.ts'],
outfile: 'packages/a2a-server/dist/a2a-server.mjs',
define: {
__filename: '__chunk_filename',
__dirname: '__chunk_dirname',
'process.env.CLI_VERSION': JSON.stringify(pkg.version),
},
plugins: createWasmPlugins(),

View File

@@ -4,13 +4,38 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { render } from 'ink';
import { AppContainer } from './ui/AppContainer.js';
import {
type StartupWarning,
WarningPriority,
type Config,
type ResumedSessionData,
type OutputPayload,
type ConsoleLogPayload,
type UserFeedbackPayload,
sessionId,
logUserPrompt,
AuthType,
UserPromptEvent,
coreEvents,
CoreEvent,
getOauthClient,
patchStdio,
writeToStdout,
writeToStderr,
shouldEnterAlternateScreen,
startupProfiler,
ExitCodes,
SessionStartSource,
SessionEndReason,
ValidationCancelledError,
ValidationRequiredError,
type AdminControlsSettings,
debugLogger,
} from '@google/gemini-cli-core';
import { loadCliConfig, parseArguments } from './config/config.js';
import * as cliConfig from './config/config.js';
import { readStdin } from './utils/readStdin.js';
import { basename } from 'node:path';
import { createHash } from 'node:crypto';
import v8 from 'node:v8';
import os from 'node:os';
@@ -37,47 +62,11 @@ import {
runExitCleanup,
registerTelemetryConfig,
setupSignalHandlers,
setupTtyCheck,
} from './utils/cleanup.js';
import {
cleanupToolOutputFiles,
cleanupExpiredSessions,
} from './utils/sessionCleanup.js';
import {
type StartupWarning,
WarningPriority,
type Config,
type ResumedSessionData,
type OutputPayload,
type ConsoleLogPayload,
type UserFeedbackPayload,
sessionId,
logUserPrompt,
AuthType,
getOauthClient,
UserPromptEvent,
debugLogger,
recordSlowRender,
coreEvents,
CoreEvent,
createWorkingStdio,
patchStdio,
writeToStdout,
writeToStderr,
disableMouseEvents,
enableMouseEvents,
disableLineWrapping,
enableLineWrapping,
shouldEnterAlternateScreen,
startupProfiler,
ExitCodes,
SessionStartSource,
SessionEndReason,
getVersion,
ValidationCancelledError,
ValidationRequiredError,
type AdminControlsSettings,
} from '@google/gemini-cli-core';
import {
initializeApp,
type InitializationResult,
@@ -85,21 +74,9 @@ import {
import { validateAuthMethod } from './config/auth.js';
import { runAcpClient } from './acp/acpClient.js';
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
import { checkForUpdates } from './ui/utils/updateCheck.js';
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
import { appEvents, AppEvent } from './utils/events.js';
import { SessionError, SessionSelector } from './utils/sessionUtils.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 {
relaunchAppInChildProcess,
relaunchOnExitCode,
@@ -107,19 +84,13 @@ import {
import { loadSandboxConfig } from './config/sandboxConfig.js';
import { deleteSession, listSessions } from './utils/sessions.js';
import { createPolicyUpdater } from './config/policy.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 { setupTerminalAndTheme } from './utils/terminalTheme.js';
import { profiler } from './ui/components/DebugProfiler.js';
import { runDeferredCommand } from './deferred.js';
import { cleanupBackgroundLogs } from './utils/logCleanup.js';
import { SlashCommandConflictHandler } from './services/SlashCommandConflictHandler.js';
const SLOW_RENDER_MS = 200;
export function validateDnsResolutionOrder(
order: string | undefined,
): DnsResolutionOrder {
@@ -198,147 +169,16 @@ export async function startInteractiveUI(
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(),
// Dynamically import the heavy UI module so React/Ink are only parsed when needed
const { startInteractiveUI: doStartUI } = await import('./interactiveCli.js');
await doStartUI(
config,
settings,
startupWarnings,
workspaceRoot,
resumedSessionData,
initializationResult,
);
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.
// shpool is a persistence tool that restores terminal state by replaying it.
// This delay gives shpool time to finish its restoration replay and send
// the actual terminal size (often via an immediate SIGWINCH) before we
// render the first TUI frame. Without this, the first frame may be
// garbled or rendered at an incorrect size, which disabling incremental
// rendering alone cannot fix for the initial frame.
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());
registerCleanup(setupTtyCheck());
}
export async function main() {
@@ -845,25 +685,6 @@ export async function main() {
}
}
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`);
});
}
}
export function initializeOutputListenersAndFlush() {
// If there are no listeners for output, make sure we flush so output is not
// lost.

View File

@@ -0,0 +1,214 @@
/**
* @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 {
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());
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`);
});
}
}

View File

@@ -228,23 +228,35 @@ const packageJson = JSON.parse(
// Helper to calc hash
const sha256 = (content) => createHash('sha256').update(content).digest('hex');
// Read Main Bundle
const geminiBundlePath = join(root, 'bundle/gemini.js');
const geminiContent = readFileSync(geminiBundlePath);
const geminiHash = sha256(geminiContent);
const assets = {
'gemini.mjs': geminiBundlePath, // Use .js source but map to .mjs for runtime ESM
'manifest.json': 'bundle/manifest.json',
};
const manifest = {
main: 'gemini.mjs',
mainHash: geminiHash,
mainHash: '',
version: packageJson.version,
files: [],
};
// Add all javascript chunks from the bundle directory
const jsFiles = globSync('*.js', { cwd: bundleDir });
for (const jsFile of jsFiles) {
const fsPath = join(bundleDir, jsFile);
const content = readFileSync(fsPath);
const hash = sha256(content);
// Node SEA requires the main entry point to be explicitly mapped
if (jsFile === 'gemini.js') {
assets['gemini.mjs'] = fsPath;
manifest.mainHash = hash;
} else {
// Other chunks need to be mapped exactly as they are named so dynamic imports find them
assets[jsFile] = fsPath;
manifest.files.push({ key: jsFile, path: jsFile, hash: hash });
}
}
// Helper to recursively find files from STAGING
function addAssetsFromDir(baseDir, runtimePrefix) {
const fullDir = join(stagingDir, baseDir);
@@ -346,6 +358,22 @@ const targetBinaryPath = join(targetDir, binaryName);
console.log(`Copying node binary from ${nodeBinary} to ${targetBinaryPath}...`);
copyFileSync(nodeBinary, targetBinaryPath);
if (platform === 'darwin') {
console.log(`Thinning universal binary for ${arch}...`);
try {
// Attempt to thin the binary. Will fail safely if it's not a fat binary.
runCommand('lipo', [
targetBinaryPath,
'-thin',
arch,
'-output',
targetBinaryPath,
]);
} catch (e) {
console.log(`Skipping lipo thinning: ${e.message}`);
}
}
// Remove existing signature using helper
removeSignature(targetBinaryPath);
@@ -357,9 +385,7 @@ if (existsSync(bundleDir)) {
// Clean up source JS files from output (we only want embedded)
const filesToRemove = [
'gemini.js',
'gemini.mjs',
'gemini.js.map',
'gemini.mjs.map',
'gemini-sea.cjs',
'sea-launch.cjs',
@@ -373,6 +399,12 @@ filesToRemove.forEach((f) => {
if (existsSync(p)) rmSync(p, { recursive: true, force: true });
});
// Remove all chunk and entry .js/.js.map files
const jsFilesToRemove = globSync('*.{js,js.map}', { cwd: targetDir });
for (const f of jsFilesToRemove) {
rmSync(join(targetDir, f));
}
// Remove .sb files from targetDir
const sbFilesToRemove = globSync('sandbox-macos-*.sb', { cwd: targetDir });
for (const f of sbFilesToRemove) {