Skip to content

Commit 10ffeff

Browse files
committed
feat(cli): Add comprehensive error handling and crash recovery system
- Add ErrorScreen component with styled error display, stack traces, and Discord link - Add crash error state management to chat store - Integrate error screen into Chat component - Extend keyboard handlers to dismiss errors (C/ESC) or exit (R) - Add process-level error handlers for uncaught exceptions and promise rejections - Suppress Opentui console errors by intercepting stderr/stdout - Disable Opentui's built-in console overlay in favor of custom error screen
1 parent 521232e commit 10ffeff

File tree

5 files changed

+395
-2
lines changed

5 files changed

+395
-2
lines changed

cli/src/chat.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useShallow } from 'zustand/react/shallow'
44

55
import { routeUserPrompt } from './commands/router'
66
import { AgentModeToggle } from './components/agent-mode-toggle'
7+
import { ErrorScreen } from './components/error-screen'
78
import { LoginModal } from './components/login-modal'
89
import { MessageRenderer } from './components/message-renderer'
910
import {
@@ -119,6 +120,8 @@ export const Chat = ({
119120
setHasReceivedPlanResponse,
120121
lastMessageMode,
121122
setLastMessageMode,
123+
crashError,
124+
setCrashError,
122125
resetChatStore,
123126
} = useChatStore(
124127
useShallow((store) => ({
@@ -151,6 +154,8 @@ export const Chat = ({
151154
setHasReceivedPlanResponse: store.setHasReceivedPlanResponse,
152155
lastMessageMode: store.lastMessageMode,
153156
setLastMessageMode: store.setLastMessageMode,
157+
crashError: store.crashError,
158+
setCrashError: store.setCrashError,
154159
resetChatStore: store.reset,
155160
})),
156161
)
@@ -284,6 +289,10 @@ export const Chat = ({
284289
setInputValue,
285290
})
286291

292+
const handleDismissCrashError = useCallback(() => {
293+
setCrashError(null)
294+
}, [setCrashError])
295+
287296
const [scrollIndicatorHovered, setScrollIndicatorHovered] = useState(false)
288297

289298
const {
@@ -486,6 +495,9 @@ export const Chat = ({
486495
onCtrlC: handleCtrlC,
487496
historyNavUpEnabled,
488497
historyNavDownEnabled,
498+
hasCrashError: crashError !== null,
499+
onDismissCrashError: handleDismissCrashError,
500+
onRestartApp: resetChatStore,
489501
})
490502

491503
const { tree: messageTree, topLevelMessages } = useMemo(
@@ -849,6 +861,15 @@ export const Chat = ({
849861
hasInvalidCredentials={hasInvalidCredentials}
850862
/>
851863
)}
864+
865+
{/* Error Screen Overlay - show when a crash error occurs */}
866+
{crashError && (
867+
<ErrorScreen
868+
error={crashError}
869+
onDismiss={handleDismissCrashError}
870+
onRestart={resetChatStore}
871+
/>
872+
)}
852873
</box>
853874
)
854875
}
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import { TextAttributes } from '@opentui/core'
2+
import React, { useCallback } from 'react'
3+
4+
import { useTheme } from '../hooks/use-theme'
5+
import { BORDER_CHARS } from '../utils/ui-constants'
6+
7+
import type { CrashError } from '../state/chat-store'
8+
9+
interface ErrorScreenProps {
10+
error: CrashError
11+
onDismiss: () => void
12+
onRestart?: () => void
13+
}
14+
15+
export const ErrorScreen = ({ error, onDismiss, onRestart }: ErrorScreenProps) => {
16+
const theme = useTheme()
17+
18+
// Clear the screen when error screen mounts
19+
React.useEffect(() => {
20+
// Clear the terminal to remove any console output
21+
process.stdout.write('\x1b[2J\x1b[3J\x1b[H')
22+
}, [])
23+
24+
const handleDismiss = useCallback(
25+
(e?: any) => {
26+
if (e && e.stopPropagation) {
27+
e.stopPropagation()
28+
}
29+
onDismiss()
30+
},
31+
[onDismiss],
32+
)
33+
34+
const handleRestart = useCallback(
35+
(e?: any) => {
36+
if (e && e.stopPropagation) {
37+
e.stopPropagation()
38+
}
39+
// For a proper restart after an error, exit the process
40+
// The user's shell or process manager can restart it
41+
process.exit(1)
42+
},
43+
[onRestart],
44+
)
45+
46+
const errorTime = new Date(error.timestamp).toLocaleTimeString()
47+
48+
return (
49+
<box
50+
style={{
51+
position: 'absolute',
52+
left: 0,
53+
top: 0,
54+
width: '100%',
55+
height: '100%',
56+
backgroundColor: '#000000',
57+
flexDirection: 'column',
58+
justifyContent: 'center',
59+
alignItems: 'center',
60+
zIndex: 999999, // Extremely high z-index to ensure we're always on top
61+
}}
62+
>
63+
<box
64+
style={{
65+
width: '80%',
66+
maxWidth: 100,
67+
borderStyle: 'double',
68+
borderColor: theme.error,
69+
customBorderChars: BORDER_CHARS,
70+
backgroundColor: theme.background,
71+
flexDirection: 'column',
72+
gap: 1,
73+
paddingLeft: 2,
74+
paddingRight: 2,
75+
paddingTop: 1,
76+
paddingBottom: 1,
77+
}}
78+
>
79+
<box style={{ flexDirection: 'column', gap: 0 }}>
80+
<text style={{ wrapMode: 'word' }}>
81+
<span fg={theme.error} attributes={TextAttributes.BOLD}>
82+
⚠ Application Error
83+
</span>
84+
</text>
85+
<text style={{ wrapMode: 'word' }}>
86+
<span fg={theme.muted}>at {errorTime}</span>
87+
</text>
88+
</box>
89+
90+
<box
91+
style={{
92+
flexDirection: 'column',
93+
gap: 1,
94+
paddingLeft: 1,
95+
paddingRight: 1,
96+
paddingTop: 1,
97+
paddingBottom: 1,
98+
backgroundColor: theme.secondary,
99+
}}
100+
>
101+
<text>
102+
<span fg={theme.foreground}>
103+
This error has been shared with the team.{'\n'}
104+
Feel free to join the discussion at:{'\n'}
105+
</span>
106+
<span fg={theme.info} attributes={TextAttributes.UNDERLINE}>
107+
https://codebuff.com/discord
108+
</span>
109+
</text>
110+
</box>
111+
112+
<box
113+
style={{
114+
flexDirection: 'column',
115+
gap: 0,
116+
backgroundColor: theme.secondary,
117+
paddingLeft: 1,
118+
paddingRight: 1,
119+
paddingTop: 0,
120+
paddingBottom: 0,
121+
}}
122+
>
123+
<text style={{ wrapMode: 'word' }}>
124+
<span fg={theme.foreground}>{error.message}</span>
125+
</text>
126+
</box>
127+
128+
{error.stack && (
129+
<box
130+
style={{
131+
flexDirection: 'column',
132+
gap: 0,
133+
maxHeight: 10,
134+
}}
135+
>
136+
<text style={{ wrapMode: 'word' }}>
137+
<span fg={theme.muted} attributes={TextAttributes.ITALIC}>
138+
Stack trace:
139+
</span>
140+
</text>
141+
<scrollbox
142+
scrollX={false}
143+
scrollbarOptions={{ visible: false }}
144+
style={{
145+
flexGrow: 1,
146+
rootOptions: {
147+
flexGrow: 1,
148+
padding: 0,
149+
gap: 0,
150+
flexDirection: 'column',
151+
},
152+
wrapperOptions: {
153+
flexGrow: 1,
154+
border: false,
155+
},
156+
contentOptions: {
157+
flexDirection: 'column',
158+
gap: 0,
159+
},
160+
}}
161+
>
162+
<text style={{ wrapMode: 'word' }}>
163+
<span fg={theme.muted}>{error.stack}</span>
164+
</text>
165+
</scrollbox>
166+
</box>
167+
)}
168+
169+
<box
170+
style={{
171+
flexDirection: 'row',
172+
gap: 2,
173+
justifyContent: 'center',
174+
}}
175+
>
176+
<box
177+
style={{
178+
backgroundColor: theme.secondary,
179+
paddingLeft: 2,
180+
paddingRight: 2,
181+
borderStyle: 'single',
182+
borderColor: theme.muted,
183+
}}
184+
onMouseDown={handleDismiss}
185+
>
186+
<text>
187+
<span fg={theme.foreground} attributes={TextAttributes.BOLD}>
188+
[C] Continue
189+
</span>
190+
</text>
191+
</box>
192+
{onRestart && (
193+
<box
194+
style={{
195+
backgroundColor: theme.info,
196+
paddingLeft: 2,
197+
paddingRight: 2,
198+
borderStyle: 'single',
199+
borderColor: theme.info,
200+
}}
201+
onMouseDown={handleRestart}
202+
>
203+
<text>
204+
<span fg={theme.background} attributes={TextAttributes.BOLD}>
205+
[R] Exit
206+
</span>
207+
</text>
208+
</box>
209+
)}
210+
</box>
211+
<box
212+
style={{
213+
flexDirection: 'column',
214+
gap: 0,
215+
alignItems: 'center',
216+
}}
217+
>
218+
<text style={{ wrapMode: 'word' }}>
219+
<span fg={theme.muted} attributes={TextAttributes.ITALIC}>
220+
Press ESC/C to continue or R to exit
221+
</span>
222+
</text>
223+
</box>
224+
</box>
225+
</box>
226+
)
227+
}

cli/src/hooks/use-keyboard-handlers.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ interface KeyboardHandlersConfig {
1818
onCtrlC: () => boolean
1919
historyNavUpEnabled: boolean
2020
historyNavDownEnabled: boolean
21+
hasCrashError: boolean
22+
onDismissCrashError: () => void
23+
onRestartApp?: () => void
2124
}
2225

2326
export const useKeyboardHandlers = ({
@@ -35,13 +38,43 @@ export const useKeyboardHandlers = ({
3538
onCtrlC,
3639
historyNavUpEnabled,
3740
historyNavDownEnabled,
41+
hasCrashError,
42+
onDismissCrashError,
43+
onRestartApp,
3844
}: KeyboardHandlersConfig) => {
3945
useKeyboard(
4046
useCallback(
4147
(key) => {
4248
const isEscape = key.name === 'escape'
4349
const isCtrlC = key.ctrl && key.name === 'c'
4450

51+
// Handle keyboard shortcuts when crash error is visible
52+
if (hasCrashError) {
53+
// ESC or C key to continue
54+
if (isEscape || (!key.ctrl && key.name === 'c')) {
55+
if (
56+
'preventDefault' in key &&
57+
typeof key.preventDefault === 'function'
58+
) {
59+
key.preventDefault()
60+
}
61+
onDismissCrashError()
62+
return
63+
}
64+
65+
// R key to restart
66+
if (!key.ctrl && key.name === 'r' && onRestartApp) {
67+
if (
68+
'preventDefault' in key &&
69+
typeof key.preventDefault === 'function'
70+
) {
71+
key.preventDefault()
72+
}
73+
onRestartApp()
74+
return
75+
}
76+
}
77+
4578
if ((isEscape || isCtrlC) && (isStreaming || isWaitingForResponse)) {
4679
if (
4780
'preventDefault' in key &&
@@ -68,7 +101,15 @@ export const useKeyboardHandlers = ({
68101
}
69102
}
70103
},
71-
[isStreaming, isWaitingForResponse, abortControllerRef, onCtrlC],
104+
[
105+
isStreaming,
106+
isWaitingForResponse,
107+
abortControllerRef,
108+
onCtrlC,
109+
hasCrashError,
110+
onDismissCrashError,
111+
onRestartApp,
112+
],
72113
),
73114
)
74115

0 commit comments

Comments
 (0)