diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 2ae7d72d32..06c69bf784 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -181,6 +181,7 @@ Stack trace: ${reason.stack}` : '' }`; + debugLogger.error(errorMessage); appEvents.emit(AppEvent.LogError, errorMessage); if (!unhandledRejectionOccurred) { unhandledRejectionOccurred = true; diff --git a/packages/cli/src/ui/components/shared/ErrorBoundary.test.tsx b/packages/cli/src/ui/components/shared/ErrorBoundary.test.tsx index 77371206bd..8576943e75 100644 --- a/packages/cli/src/ui/components/shared/ErrorBoundary.test.tsx +++ b/packages/cli/src/ui/components/shared/ErrorBoundary.test.tsx @@ -15,6 +15,11 @@ const Thrower = ({ message }: { message: string }) => { throw new Error(message); }; +// A child that throws a non-Error value (string). +const StringThrower = ({ message }: { message: string }) => { + throw message; +}; + describe('ErrorBoundary', () => { // React logs caught render errors to console.error; silence it so the test // output stays clean (the boundary catching the error is the point). @@ -103,4 +108,20 @@ describe('ErrorBoundary', () => { rerender(tree); expect(lastFrame()).toContain('recovered'); }); + + it('normalizes a non-Error thrown value to an Error instance', () => { + const onError = vi.fn(); + const { lastFrame } = render( + + + , + ); + // The fallback renders the stringified value. + expect(lastFrame()).toContain('string error'); + // onError receives a proper Error instance, not the raw string. + expect(onError).toHaveBeenCalledTimes(1); + const [error] = onError.mock.calls[0]; + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('string error'); + }); }); diff --git a/packages/cli/src/ui/components/shared/ErrorBoundary.tsx b/packages/cli/src/ui/components/shared/ErrorBoundary.tsx index 37e508ba9b..dec8a68157 100644 --- a/packages/cli/src/ui/components/shared/ErrorBoundary.tsx +++ b/packages/cli/src/ui/components/shared/ErrorBoundary.tsx @@ -9,6 +9,13 @@ import { Box, Text } from 'ink'; import { theme } from '../../semantic-colors.js'; import { sanitizeTerminalText } from '../../utils/textUtils.js'; +function normalizeError(error: unknown): Error { + if (error instanceof Error) { + return error; + } + return new Error(String(error)); +} + interface ErrorBoundaryProps { children: ReactNode; /** @@ -38,12 +45,12 @@ export class ErrorBoundary extends Component< > { override state: ErrorBoundaryState = { error: null }; - static getDerivedStateFromError(error: Error): ErrorBoundaryState { - return { error }; + static getDerivedStateFromError(error: unknown): ErrorBoundaryState { + return { error: normalizeError(error) }; } - override componentDidCatch(error: Error, info: ErrorInfo): void { - this.props.onError?.(error, info); + override componentDidCatch(error: unknown, info: ErrorInfo): void { + this.props.onError?.(normalizeError(error), info); } private readonly reset = () => { diff --git a/packages/cli/src/ui/startInteractiveUI.tsx b/packages/cli/src/ui/startInteractiveUI.tsx index ffd1a4f143..1c06c586a1 100644 --- a/packages/cli/src/ui/startInteractiveUI.tsx +++ b/packages/cli/src/ui/startInteractiveUI.tsx @@ -33,7 +33,8 @@ import { } from './utils/kittyProtocolDetector.js'; import { installTerminalRedrawOptimizer } from './utils/terminalRedrawOptimizer.js'; import { installSynchronizedOutput } from './utils/synchronizedOutput.js'; -import { registerCleanup } from '../utils/cleanup.js'; +import { ErrorBoundary } from './components/shared/ErrorBoundary.js'; +import { registerCleanup, runExitCleanup } from '../utils/cleanup.js'; import { stopAndGetCapturedInput } from '../utils/earlyInputCapture.js'; import { profileCheckpoint } from '../utils/startupProfiler.js'; import { writeStderrLine } from '../utils/stdioHelpers.js'; @@ -195,13 +196,29 @@ export async function startInteractiveUI( // coordinates even though these listeners are owned and cleaned up. process.stdout.setMaxListeners(0); } + const appTree = ( + { + debugLogger.error( + `[FATAL_RENDER_ERROR] ${error.message}\n${info.componentStack ?? ''}\n${error.stack ?? ''}`, + ); + // The fallback replaces AppWrapper, unmounting KeypressProvider and + // Ctrl+C handling. Schedule a graceful exit so the session does not + // hang (e.g. under the Kitty keyboard protocol where Ctrl+C is a + // keypress, not SIGINT). + setTimeout(() => { + void runExitCleanup().then(() => process.exit(1)); + }, 5000); + }} + > + + + ); const instance = render( process.env['DEBUG'] ? ( - - - + {appTree} ) : ( - + appTree ), { exitOnCtrlC: false,