diff --git a/esbuild.config.js b/esbuild.config.js index 49d158ec36..f0d55e3ca6 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -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(), diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 2985e20358..04a370d7e9 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -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 ( - - - - - - - - - - - - - - - - - - - - ); - }; - - 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'] ? ( - - - - ) : ( - - ), - { - 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. diff --git a/packages/cli/src/interactiveCli.tsx b/packages/cli/src/interactiveCli.tsx new file mode 100644 index 0000000000..a27cdbbb78 --- /dev/null +++ b/packages/cli/src/interactiveCli.tsx @@ -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 ( + + + + + + + + + + + + + + + + + + + + ); + }; + + 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'] ? ( + + + + ) : ( + + ), + { + 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`); + }); + } +} diff --git a/scripts/build_binary.js b/scripts/build_binary.js index d4aa578925..7d0fd815c1 100644 --- a/scripts/build_binary.js +++ b/scripts/build_binary.js @@ -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) {