From 030a5ace971816547a0df58966bf0e4c8c2dd495 Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Fri, 21 Nov 2025 08:31:47 -0800 Subject: [PATCH] Fix multiple bugs with auth flow including using the implemented but unused restart support. (#13565) --- packages/cli/index.ts | 15 +- packages/cli/src/gemini.tsx | 33 +-- packages/cli/src/ui/AppContainer.test.tsx | 14 +- packages/cli/src/ui/AppContainer.tsx | 24 ++- packages/cli/src/ui/auth/AuthDialog.test.tsx | 10 +- packages/cli/src/ui/auth/AuthDialog.tsx | 42 ++-- .../cli/src/ui/components/DialogManager.tsx | 7 +- .../cli/src/ui/utils/kittyProtocolDetector.ts | 11 +- packages/cli/src/ui/utils/mouse.ts | 14 +- packages/core/src/code_assist/oauth2.test.ts | 24 ++- packages/core/src/code_assist/oauth2.ts | 199 ++++++++++++------ packages/core/src/index.ts | 1 + packages/core/src/utils/terminal.ts | 50 +++++ 13 files changed, 307 insertions(+), 137 deletions(-) create mode 100644 packages/core/src/utils/terminal.ts diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 8f9a066327..894b5c1fd4 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -8,23 +8,26 @@ import './src/gemini.js'; import { main } from './src/gemini.js'; -import { debugLogger, FatalError } from '@google/gemini-cli-core'; +import { FatalError, writeToStderr } from '@google/gemini-cli-core'; +import { runExitCleanup } from './src/utils/cleanup.js'; // --- Global Entry Point --- -main().catch((error) => { +main().catch(async (error) => { + await runExitCleanup(); + if (error instanceof FatalError) { let errorMessage = error.message; if (!process.env['NO_COLOR']) { errorMessage = `\x1b[31m${errorMessage}\x1b[0m`; } - debugLogger.error(errorMessage); + writeToStderr(errorMessage + '\n'); process.exit(error.exitCode); } - debugLogger.error('An unexpected critical error occurred:'); + writeToStderr('An unexpected critical error occurred:'); if (error instanceof Error) { - debugLogger.error(error.stack); + writeToStderr(error.stack + '\n'); } else { - debugLogger.error(String(error)); + writeToStderr(String(error) + '\n'); } process.exit(1); }); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 15617fa9a8..b4983e9401 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -33,13 +33,11 @@ import { runExitCleanup, } from './utils/cleanup.js'; import { getCliVersion } from './utils/version.js'; -import type { - Config, - ResumedSessionData, - OutputPayload, - ConsoleLogPayload, -} from '@google/gemini-cli-core'; import { + type Config, + type ResumedSessionData, + type OutputPayload, + type ConsoleLogPayload, sessionId, logUserPrompt, AuthType, @@ -53,6 +51,11 @@ import { patchStdio, writeToStdout, writeToStderr, + disableMouseEvents, + enableMouseEvents, + enterAlternateScreen, + disableLineWrapping, + shouldEnterAlternateScreen, } from '@google/gemini-cli-core'; import { initializeApp, @@ -85,9 +88,7 @@ import { deleteSession, listSessions } from './utils/sessions.js'; import { ExtensionManager } from './config/extension-manager.js'; import { createPolicyUpdater } from './config/policy.js'; import { requestConsentNonInteractive } from './config/extensions/consent.js'; -import { disableMouseEvents, enableMouseEvents } from './ui/utils/mouse.js'; import { ScrollProvider } from './ui/contexts/ScrollProvider.js'; -import ansiEscapes from 'ansi-escapes'; import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js'; import { profiler } from './ui/components/DebugProfiler.js'; @@ -176,8 +177,10 @@ export async function startInteractiveUI( // 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 = - isAlternateBufferEnabled(settings) && !config.getScreenReader(); + const useAlternateBuffer = shouldEnterAlternateScreen( + isAlternateBufferEnabled(settings), + config.getScreenReader(), + ); const mouseEventsEnabled = useAlternateBuffer; if (mouseEventsEnabled) { enableMouseEvents(); @@ -481,8 +484,14 @@ export async function main() { // input showing up in the output. process.stdin.setRawMode(true); - if (isAlternateBufferEnabled(settings)) { - writeToStdout(ansiEscapes.enterAlternativeScreen); + if ( + shouldEnterAlternateScreen( + isAlternateBufferEnabled(settings), + config.getScreenReader(), + ) + ) { + enterAlternateScreen(); + disableLineWrapping(); // Ink will cleanup so there is no need for us to manually cleanup. } diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 43a0de7c92..24f3b12885 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -69,6 +69,8 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { stdout: process.stdout, stderr: process.stderr, })), + enableMouseEvents: vi.fn(), + disableMouseEvents: vi.fn(), }; }); import type { LoadedSettings } from '../config/settings.js'; @@ -137,10 +139,6 @@ vi.mock('../utils/events.js'); vi.mock('../utils/handleAutoUpdate.js'); vi.mock('./utils/ConsolePatcher.js'); vi.mock('../utils/cleanup.js'); -vi.mock('./utils/mouse.js', () => ({ - enableMouseEvents: vi.fn(), - disableMouseEvents: vi.fn(), -})); import { useHistory } from './hooks/useHistoryManager.js'; import { useThemeCommand } from './hooks/useThemeCommand.js'; @@ -165,9 +163,13 @@ import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; import { useKeypress, type Key } from './hooks/useKeypress.js'; import { measureElement } from 'ink'; import { useTerminalSize } from './hooks/useTerminalSize.js'; -import { ShellExecutionService, writeToStdout } from '@google/gemini-cli-core'; +import { + ShellExecutionService, + writeToStdout, + enableMouseEvents, + disableMouseEvents, +} from '@google/gemini-cli-core'; import { type ExtensionManager } from '../config/extension-manager.js'; -import { enableMouseEvents, disableMouseEvents } from './utils/mouse.js'; describe('AppContainer State Management', () => { let mockConfig: Config; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 1ae809a96e..ffe7aa51d0 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -52,6 +52,12 @@ import { refreshServerHierarchicalMemory, type ModelChangedPayload, type MemoryChangedPayload, + writeToStdout, + disableMouseEvents, + enterAlternateScreen, + enableMouseEvents, + disableLineWrapping, + shouldEnterAlternateScreen, } from '@google/gemini-cli-core'; import { validateAuthMethod } from '../config/auth.js'; import process from 'node:process'; @@ -92,6 +98,7 @@ import { appEvents, AppEvent } from '../utils/events.js'; import { type UpdateObject } from './utils/updateCheck.js'; import { setUpdateHandler } from '../utils/handleAutoUpdate.js'; import { registerCleanup, runExitCleanup } from '../utils/cleanup.js'; +import { RELAUNCH_EXIT_CODE } from '../utils/processUtils.js'; import { useMessageQueue } from './hooks/useMessageQueue.js'; import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js'; import { useSessionStats } from './contexts/SessionContext.js'; @@ -106,11 +113,9 @@ import { type ExtensionManager } from '../config/extension-manager.js'; import { requestConsentInteractive } from '../config/extensions/consent.js'; import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js'; import { isWorkspaceTrusted } from '../config/trustedFolders.js'; -import { disableMouseEvents, enableMouseEvents } from './utils/mouse.js'; import { useAlternateBuffer } from './hooks/useAlternateBuffer.js'; import { useSettings } from './contexts/SettingsContext.js'; import { enableSupportedProtocol } from './utils/kittyProtocolDetector.js'; -import { writeToStdout } from '@google/gemini-cli-core'; const WARNING_PROMPT_DURATION_MS = 1000; const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000; @@ -372,16 +377,19 @@ export const AppContainer = (props: AppContainerProps) => { setHistoryRemountKey((prev) => prev + 1); }, [setHistoryRemountKey, isAlternateBuffer, stdout]); const handleEditorClose = useCallback(() => { - if (isAlternateBuffer) { + if ( + shouldEnterAlternateScreen(isAlternateBuffer, config.getScreenReader()) + ) { // The editor may have exited alternate buffer mode so we need to // enter it again to be safe. - writeToStdout(ansiEscapes.enterAlternativeScreen); + enterAlternateScreen(); enableMouseEvents(); + disableLineWrapping(); app.rerender(); } enableSupportedProtocol(); refreshStatic(); - }, [refreshStatic, isAlternateBuffer, app]); + }, [refreshStatic, isAlternateBuffer, app, config]); useEffect(() => { coreEvents.on(CoreEvent.ExternalEditorClosed, handleEditorClose); @@ -458,12 +466,12 @@ export const AppContainer = (props: AppContainerProps) => { config.isBrowserLaunchSuppressed() ) { await runExitCleanup(); - debugLogger.log(` + writeToStdout(` ---------------------------------------------------------------- -Logging in with Google... Please restart Gemini CLI to continue. +Logging in with Google... Restarting Gemini CLI to continue. ---------------------------------------------------------------- `); - process.exit(0); + process.exit(RELAUNCH_EXIT_CODE); } } setAuthState(AuthState.Authenticated); diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx index 2b4bcca128..ca9e235ed5 100644 --- a/packages/cli/src/ui/auth/AuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx @@ -25,6 +25,7 @@ import { validateAuthMethodWithSettings } from './useAuth.js'; import { runExitCleanup } from '../../utils/cleanup.js'; import { clearCachedCredentialFile } from '@google/gemini-cli-core'; import { Text } from 'ink'; +import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js'; // Mocks vi.mock('@google/gemini-cli-core', async (importOriginal) => { @@ -229,6 +230,7 @@ describe('AuthDialog', () => { }); it('exits process for Login with Google when browser is suppressed', async () => { + vi.useFakeTimers(); const exitSpy = vi .spyOn(process, 'exit') .mockImplementation(() => undefined as never); @@ -241,14 +243,14 @@ describe('AuthDialog', () => { mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE); + await vi.runAllTimersAsync(); + expect(mockedRunExitCleanup).toHaveBeenCalled(); - expect(logSpy).toHaveBeenCalledWith( - expect.stringContaining('Please restart Gemini CLI'), - ); - expect(exitSpy).toHaveBeenCalledWith(0); + expect(exitSpy).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE); exitSpy.mockRestore(); logSpy.mockRestore(); + vi.useRealTimers(); }); }); diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index ecd51f6ed4..da5b6d7dff 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -5,7 +5,7 @@ */ import type React from 'react'; -import { useCallback } from 'react'; +import { useCallback, useState } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; @@ -17,13 +17,13 @@ import { SettingScope } from '../../config/settings.js'; import { AuthType, clearCachedCredentialFile, - debugLogger, type Config, } from '@google/gemini-cli-core'; import { useKeypress } from '../hooks/useKeypress.js'; import { AuthState } from '../types.js'; import { runExitCleanup } from '../../utils/cleanup.js'; import { validateAuthMethodWithSettings } from './useAuth.js'; +import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js'; interface AuthDialogProps { config: Config; @@ -40,6 +40,7 @@ export function AuthDialog({ authError, onAuthError, }: AuthDialogProps): React.JSX.Element { + const [exiting, setExiting] = useState(false); let items = [ { label: 'Login with Google', @@ -111,6 +112,9 @@ export function AuthDialog({ const onSelect = useCallback( async (authType: AuthType | undefined, scope: LoadableSettingScope) => { + if (exiting) { + return; + } if (authType) { await clearCachedCredentialFile(); @@ -119,15 +123,12 @@ export function AuthDialog({ authType === AuthType.LOGIN_WITH_GOOGLE && config.isBrowserLaunchSuppressed() ) { - runExitCleanup(); - debugLogger.log( - ` ----------------------------------------------------------------- -Logging in with Google... Please restart Gemini CLI to continue. ----------------------------------------------------------------- - `, - ); - process.exit(0); + setExiting(true); + setTimeout(async () => { + await runExitCleanup(); + process.exit(RELAUNCH_EXIT_CODE); + }, 100); + return; } } if (authType === AuthType.USE_GEMINI) { @@ -136,7 +137,7 @@ Logging in with Google... Please restart Gemini CLI to continue. } setAuthState(AuthState.Unauthenticated); }, - [settings, config, setAuthState], + [settings, config, setAuthState, exiting], ); const handleAuthSelect = (authMethod: AuthType) => { @@ -169,6 +170,23 @@ Logging in with Google... Please restart Gemini CLI to continue. { isActive: true }, ); + if (exiting) { + return ( + + + Logging in with Google... Restarting Gemini CLI to continue. + + + ); + } + return ( uiActions.closeSettingsDialog()} - onRestartRequest={() => process.exit(0)} + onRestartRequest={async () => { + await runExitCleanup(); + process.exit(RELAUNCH_EXIT_CODE); + }} availableTerminalHeight={terminalHeight - staticExtraHeight} config={config} /> diff --git a/packages/cli/src/ui/utils/kittyProtocolDetector.ts b/packages/cli/src/ui/utils/kittyProtocolDetector.ts index c15e5a052d..a590eedef4 100644 --- a/packages/cli/src/ui/utils/kittyProtocolDetector.ts +++ b/packages/cli/src/ui/utils/kittyProtocolDetector.ts @@ -97,6 +97,11 @@ export async function detectAndEnableKittyProtocol(): Promise { }); } +import { + enableKittyKeyboardProtocol, + disableKittyKeyboardProtocol, +} from '@google/gemini-cli-core'; + export function isKittyProtocolEnabled(): boolean { return kittyEnabled; } @@ -104,8 +109,7 @@ export function isKittyProtocolEnabled(): boolean { function disableAllProtocols() { try { if (kittyEnabled) { - // use writeSync to avoid race conditions - fs.writeSync(process.stdout.fd, '\x1b[1u'); + enableKittyKeyboardProtocol(); kittyEnabled = true; } } catch { diff --git a/packages/cli/src/ui/utils/mouse.ts b/packages/cli/src/ui/utils/mouse.ts index e5f2cd8241..3485e5a78f 100644 --- a/packages/cli/src/ui/utils/mouse.ts +++ b/packages/cli/src/ui/utils/mouse.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { writeToStdout } from '@google/gemini-cli-core'; +import { enableMouseEvents, disableMouseEvents } from '@google/gemini-cli-core'; import { SGR_MOUSE_REGEX, X11_MOUSE_REGEX, @@ -230,14 +230,4 @@ export function isIncompleteMouseSequence(buffer: string): boolean { return true; } -export function enableMouseEvents() { - // Enable mouse tracking with SGR format - // ?1002h = button event tracking (clicks + drags + scroll wheel) - // ?1006h = SGR extended mouse mode (better coordinate handling) - writeToStdout('\u001b[?1002h\u001b[?1006h'); -} - -export function disableMouseEvents() { - // Disable mouse tracking with SGR format - writeToStdout('\u001b[?1006l\u001b[?1002l'); -} +export { enableMouseEvents, disableMouseEvents }; diff --git a/packages/core/src/code_assist/oauth2.test.ts b/packages/core/src/code_assist/oauth2.test.ts index 697a1a52d9..4a6758105f 100644 --- a/packages/core/src/code_assist/oauth2.test.ts +++ b/packages/core/src/code_assist/oauth2.test.ts @@ -27,6 +27,7 @@ import readline from 'node:readline'; import { FORCE_ENCRYPTED_FILE_ENV_VAR } from '../mcp/token-storage/index.js'; import { GEMINI_DIR } from '../utils/paths.js'; import { debugLogger } from '../utils/debugLogger.js'; +import { writeToStdout } from '../utils/stdio.js'; vi.mock('os', async (importOriginal) => { const os = await importOriginal(); @@ -44,6 +45,19 @@ vi.mock('node:readline'); vi.mock('../utils/browser.js', () => ({ shouldAttemptBrowserLaunch: () => true, })); +vi.mock('../utils/stdio.js', () => ({ + writeToStdout: vi.fn(), + writeToStderr: vi.fn(), + createInkStdio: vi.fn(() => ({ + stdout: process.stdout, + stderr: process.stderr, + })), + enterAlternateScreen: vi.fn(), + exitAlternateScreen: vi.fn(), + enableLineWrapping: vi.fn(), + disableMouseEvents: vi.fn(), + disableKittyKeyboardProtocol: vi.fn(), +})); vi.mock('./oauth-credential-storage.js', () => ({ OAuthCredentialStorage: { @@ -238,13 +252,10 @@ describe('oauth2', () => { const mockReadline = { question: vi.fn((_query, callback) => callback(mockCode)), close: vi.fn(), + on: vi.fn(), }; (readline.createInterface as Mock).mockReturnValue(mockReadline); - const consoleLogSpy = vi - .spyOn(debugLogger, 'log') - .mockImplementation(() => {}); - const client = await getOauthClient( AuthType.LOGIN_WITH_GOOGLE, mockConfigWithNoBrowser, @@ -255,7 +266,7 @@ describe('oauth2', () => { // Verify the auth flow expect(mockGenerateCodeVerifierAsync).toHaveBeenCalled(); expect(mockGenerateAuthUrl).toHaveBeenCalled(); - expect(consoleLogSpy).toHaveBeenCalledWith( + expect(vi.mocked(writeToStdout)).toHaveBeenCalledWith( expect.stringContaining(mockAuthUrl), ); expect(mockReadline.question).toHaveBeenCalledWith( @@ -268,8 +279,6 @@ describe('oauth2', () => { redirect_uri: 'https://codeassist.google.com/authcode', }); expect(mockSetCredentials).toHaveBeenCalledWith(mockTokens); - - consoleLogSpy.mockRestore(); }); describe('in Cloud Shell', () => { @@ -932,6 +941,7 @@ describe('oauth2', () => { const mockReadline = { question: vi.fn((_query, callback) => callback('invalid-code')), close: vi.fn(), + on: vi.fn(), }; (readline.createInterface as Mock).mockReturnValue(mockReadline); diff --git a/packages/core/src/code_assist/oauth2.ts b/packages/core/src/code_assist/oauth2.ts index 4382195adb..b0a4cb4baa 100644 --- a/packages/core/src/code_assist/oauth2.ts +++ b/packages/core/src/code_assist/oauth2.ts @@ -19,7 +19,11 @@ import open from 'open'; import path from 'node:path'; import { promises as fs } from 'node:fs'; import type { Config } from '../config/config.js'; -import { getErrorMessage, FatalAuthenticationError } from '../utils/errors.js'; +import { + getErrorMessage, + FatalAuthenticationError, + FatalCancellationError, +} from '../utils/errors.js'; import { UserAccountManager } from '../utils/userAccountManager.js'; import { AuthType } from '../core/contentGenerator.js'; import readline from 'node:readline'; @@ -27,6 +31,19 @@ import { Storage } from '../config/storage.js'; import { OAuthCredentialStorage } from './oauth-credential-storage.js'; import { FORCE_ENCRYPTED_FILE_ENV_VAR } from '../mcp/token-storage/index.js'; import { debugLogger } from '../utils/debugLogger.js'; +import { + writeToStdout, + createInkStdio, + writeToStderr, +} from '../utils/stdio.js'; +import { + enableLineWrapping, + disableMouseEvents, + disableKittyKeyboardProtocol, + enterAlternateScreen, + exitAlternateScreen, +} from '../utils/terminal.js'; +import { coreEvents, CoreEvent } from '../utils/events.js'; const userAccountManager = new UserAccountManager(); @@ -185,16 +202,34 @@ async function initOauthClient( if (config.isBrowserLaunchSuppressed()) { let success = false; const maxRetries = 2; - for (let i = 0; !success && i < maxRetries; i++) { - success = await authWithUserCode(client); - if (!success) { - debugLogger.error( - '\nFailed to authenticate with user code.', - i === maxRetries - 1 ? '' : 'Retrying...\n', - ); + // Enter alternate buffer + enterAlternateScreen(); + // Clear screen and move cursor to top-left. + writeToStdout('\u001B[2J\u001B[H'); + disableMouseEvents(); + disableKittyKeyboardProtocol(); + enableLineWrapping(); + + try { + for (let i = 0; !success && i < maxRetries; i++) { + success = await authWithUserCode(client); + if (!success) { + writeToStderr( + '\nFailed to authenticate with user code.' + + (i === maxRetries - 1 ? '' : ' Retrying...\n'), + ); + } } + } finally { + exitAlternateScreen(); + // If this was triggered from an active Gemini CLI TUI this event ensures + // the TUI will re-initialize the terminal state just like it will when + // another editor like VIM may have modified the buffer of settings. + coreEvents.emit(CoreEvent.ExternalEditorClosed); } + if (!success) { + writeToStderr('Failed to authenticate with user code.\n'); throw new FatalAuthenticationError( 'Failed to authenticate with user code.', ); @@ -202,11 +237,13 @@ async function initOauthClient( } else { const webLogin = await authWithWeb(client); - debugLogger.log( - `\n\nCode Assist login required.\n` + + coreEvents.emit(CoreEvent.UserFeedback, { + severity: 'info', + message: + `\n\nCode Assist login required.\n` + `Attempting to open authentication page in your browser.\n` + - `Otherwise navigate to:\n\n${webLogin.authUrl}\n\n`, - ); + `Otherwise navigate to:\n\n${webLogin.authUrl}\n\n\n`, + }); try { // Attempt to open the authentication URL in the default browser. // We do not use the `wait` option here because the main script's execution @@ -218,23 +255,28 @@ async function initOauthClient( // in a minimal Docker container), it will emit an unhandled 'error' event, // causing the entire Node.js process to crash. childProcess.on('error', (error) => { - debugLogger.error( - `Failed to open browser with error:`, - getErrorMessage(error), - `\nPlease try running again with NO_BROWSER=true set.`, - ); + coreEvents.emit(CoreEvent.UserFeedback, { + severity: 'error', + message: + `Failed to open browser with error: ${getErrorMessage(error)}\n` + + `Please try running again with NO_BROWSER=true set.`, + }); }); } catch (err) { - debugLogger.error( - `Failed to open browser with error:`, - getErrorMessage(err), - `\nPlease try running again with NO_BROWSER=true set.`, - ); + coreEvents.emit(CoreEvent.UserFeedback, { + severity: 'error', + message: + `Failed to open browser with error: ${getErrorMessage(err)}\n` + + `Please try running again with NO_BROWSER=true set.`, + }); throw new FatalAuthenticationError( `Failed to open browser: ${getErrorMessage(err)}`, ); } - debugLogger.log('Waiting for authentication...'); + coreEvents.emit(CoreEvent.UserFeedback, { + severity: 'info', + message: 'Waiting for authentication...\n', + }); // Add timeout to prevent infinite waiting when browser tab gets stuck const authTimeout = 5 * 60 * 1000; // 5 minutes timeout @@ -250,6 +292,11 @@ async function initOauthClient( }); await Promise.race([webLogin.loginCompletePromise, timeoutPromise]); + + coreEvents.emit(CoreEvent.UserFeedback, { + severity: 'info', + message: 'Authentication succeeded\n', + }); } return client; @@ -266,55 +313,77 @@ export async function getOauthClient( } async function authWithUserCode(client: OAuth2Client): Promise { - const redirectUri = 'https://codeassist.google.com/authcode'; - const codeVerifier = await client.generateCodeVerifierAsync(); - const state = crypto.randomBytes(32).toString('hex'); - const authUrl: string = client.generateAuthUrl({ - redirect_uri: redirectUri, - access_type: 'offline', - scope: OAUTH_SCOPE, - code_challenge_method: CodeChallengeMethod.S256, - code_challenge: codeVerifier.codeChallenge, - state, - }); - debugLogger.log( - 'Please visit the following URL to authorize the application:', - ); - debugLogger.log(''); - debugLogger.log(authUrl); - debugLogger.log(''); - - const code = await new Promise((resolve) => { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - rl.question('Enter the authorization code: ', (code) => { - rl.close(); - resolve(code.trim()); - }); - }); - - if (!code) { - debugLogger.error('Authorization code is required.'); - return false; - } - try { - const { tokens } = await client.getToken({ - code, - codeVerifier: codeVerifier.codeVerifier, + const redirectUri = 'https://codeassist.google.com/authcode'; + const codeVerifier = await client.generateCodeVerifierAsync(); + const state = crypto.randomBytes(32).toString('hex'); + const authUrl: string = client.generateAuthUrl({ redirect_uri: redirectUri, + access_type: 'offline', + scope: OAUTH_SCOPE, + code_challenge_method: CodeChallengeMethod.S256, + code_challenge: codeVerifier.codeChallenge, + state, }); - client.setCredentials(tokens); - } catch (error) { + writeToStdout( + 'Please visit the following URL to authorize the application:\n\n' + + authUrl + + '\n\n', + ); + + const code = await new Promise((resolve, _) => { + const rl = readline.createInterface({ + input: process.stdin, + output: createInkStdio().stdout, + terminal: true, + }); + + rl.question('Enter the authorization code: ', (code) => { + rl.close(); + resolve(code.trim()); + }); + }); + + if (!code) { + writeToStderr('Authorization code is required.\n'); + debugLogger.error('Authorization code is required.'); + return false; + } + + try { + const { tokens } = await client.getToken({ + code, + codeVerifier: codeVerifier.codeVerifier, + redirect_uri: redirectUri, + }); + client.setCredentials(tokens); + } catch (error) { + writeToStderr( + 'Failed to authenticate with authorization code:' + + getErrorMessage(error) + + '\n', + ); + + debugLogger.error( + 'Failed to authenticate with authorization code:', + getErrorMessage(error), + ); + return false; + } + return true; + } catch (err) { + if (err instanceof FatalCancellationError) { + throw err; + } + writeToStderr( + 'Failed to authenticate with user code:' + getErrorMessage(err) + '\n', + ); debugLogger.error( - 'Failed to authenticate with authorization code:', - getErrorMessage(error), + 'Failed to authenticate with user code:', + getErrorMessage(err), ); return false; } - return true; } async function authWithWeb(client: OAuth2Client): Promise { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ad227b8db7..4640729922 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -147,3 +147,4 @@ export * from './hooks/types.js'; // Export stdio utils export * from './utils/stdio.js'; +export * from './utils/terminal.js'; diff --git a/packages/core/src/utils/terminal.ts b/packages/core/src/utils/terminal.ts new file mode 100644 index 0000000000..008919ea49 --- /dev/null +++ b/packages/core/src/utils/terminal.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { writeToStdout } from './stdio.js'; + +export function enableMouseEvents() { + // Enable mouse tracking with SGR format + // ?1002h = button event tracking (clicks + drags + scroll wheel) + // ?1006h = SGR extended mouse mode (better coordinate handling) + writeToStdout('\u001b[?1002h\u001b[?1006h'); +} + +export function disableMouseEvents() { + // Disable mouse tracking with SGR format + writeToStdout('\u001b[?1006l\u001b[?1002l'); +} + +export function enableKittyKeyboardProtocol() { + writeToStdout('\x1b[>1u'); +} + +export function disableKittyKeyboardProtocol() { + writeToStdout('\x1b[