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,