Skip to content

Commit b2333d7

Browse files
committed
feat(cli): streamlined out-of-credits UX with celebratory restoration flow
- Replace input box with dedicated OutOfCreditsBanner when out of credits - Show friendly yellow warning with session/balance stats - Press Enter to open buy credits page, Escape/Ctrl+C to dismiss - Poll for credit updates every 5 seconds while banner is shown - Auto-detect when credits are restored with shimmer animation celebration - Show 'Credits acquired!' with sparkles and 'Press Enter to continue' - Add outOfCredits input mode with appropriate keyboard handling
1 parent aba2e54 commit b2333d7

File tree

7 files changed

+212
-2
lines changed

7 files changed

+212
-2
lines changed

cli/src/chat.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { RECONNECTION_MESSAGE_DURATION_MS } from '@codebuff/sdk'
2+
import open from 'open'
23
import { useQueryClient } from '@tanstack/react-query'
34
import {
45
useCallback,
@@ -14,6 +15,7 @@ import { getAdsEnabled } from './commands/ads'
1415
import { routeUserPrompt, addBashMessageToHistory } from './commands/router'
1516
import { AdBanner } from './components/ad-banner'
1617
import { ChatInputBar } from './components/chat-input-bar'
18+
import { areCreditsRestored } from './components/out-of-credits-banner'
1719
import { LoadPreviousButton } from './components/load-previous-button'
1820
import { MessageWithAgents } from './components/message-with-agents'
1921
import { PendingBashMessage } from './components/pending-bash-message'
@@ -47,6 +49,7 @@ import { useTerminalLayout } from './hooks/use-terminal-layout'
4749
import { useTheme } from './hooks/use-theme'
4850
import { useTimeout } from './hooks/use-timeout'
4951
import { useUsageMonitor } from './hooks/use-usage-monitor'
52+
import { WEBSITE_URL } from './login/constants'
5053
import { getProjectRoot } from './project-files'
5154
import { useChatStore } from './state/chat-store'
5255
import { useFeedbackStore } from './state/feedback-store'
@@ -1228,6 +1231,15 @@ export const Chat = ({
12281231
}
12291232
})
12301233
},
1234+
onOpenBuyCredits: () => {
1235+
// If credits have been restored, just return to default mode
1236+
if (areCreditsRestored()) {
1237+
setInputMode('default')
1238+
return
1239+
}
1240+
// Otherwise open the buy credits page
1241+
open(WEBSITE_URL + '/usage')
1242+
},
12311243
}),
12321244
[
12331245
setInputMode,

cli/src/components/chat-input-bar.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { MultipleChoiceForm } from './ask-user'
55
import { FeedbackContainer } from './feedback-container'
66
import { InputModeBanner } from './input-mode-banner'
77
import { MultilineInput, type MultilineInputHandle } from './multiline-input'
8+
import { OutOfCreditsBanner } from './out-of-credits-banner'
89
import { PublishContainer } from './publish-container'
910
import { SuggestionMenu, type SuggestionItem } from './suggestion-menu'
1011
import { useAskUserBridge } from '../hooks/use-ask-user-bridge'
@@ -176,6 +177,11 @@ export const ChatInputBar = ({
176177
)
177178
}
178179

180+
// Out of credits mode: replace entire input with out-of-credits banner
181+
if (inputMode === 'outOfCredits') {
182+
return <OutOfCreditsBanner />
183+
}
184+
179185
// Handle input changes with special mode entry detection
180186
const handleInputChange = (value: InputValue) => {
181187
// Detect entering bash mode: user typed exactly '!' when in default mode
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { useQuery, useQueryClient } from '@tanstack/react-query'
2+
import React, { useEffect, useState } from 'react'
3+
4+
import { usageQueryKeys, useUsageQuery } from '../hooks/use-usage-query'
5+
import { useChatStore } from '../state/chat-store'
6+
import { useTheme } from '../hooks/use-theme'
7+
import { BORDER_CHARS } from '../utils/ui-constants'
8+
import { ShimmerText } from './shimmer-text'
9+
10+
const CREDIT_POLL_INTERVAL = 5 * 1000 // Poll every 5 seconds
11+
12+
// Track credits restored state globally so keyboard handler can access it
13+
let creditsRestoredGlobal = false
14+
15+
export const areCreditsRestored = () => creditsRestoredGlobal
16+
17+
export const OutOfCreditsBanner = () => {
18+
const sessionCreditsUsed = useChatStore((state) => state.sessionCreditsUsed)
19+
const queryClient = useQueryClient()
20+
const [creditsRestored, setCreditsRestored] = useState(false)
21+
22+
const { data: apiData } = useUsageQuery({
23+
enabled: true,
24+
})
25+
26+
const { data: cachedUsageData } = useQuery<{
27+
type: 'usage-response'
28+
usage: number
29+
remainingBalance: number | null
30+
balanceBreakdown?: { free: number; paid: number; ad?: number }
31+
next_quota_reset: string | null
32+
}>({
33+
queryKey: usageQueryKeys.current(),
34+
enabled: false,
35+
})
36+
37+
const theme = useTheme()
38+
const activeData = apiData || cachedUsageData
39+
const remainingBalance = activeData?.remainingBalance ?? 0
40+
41+
// Poll for credit updates
42+
useEffect(() => {
43+
const interval = setInterval(() => {
44+
queryClient.invalidateQueries({ queryKey: usageQueryKeys.current() })
45+
}, CREDIT_POLL_INTERVAL)
46+
return () => clearInterval(interval)
47+
}, [queryClient])
48+
49+
// Track if we've confirmed the zero-balance state to avoid false positives from stale cache
50+
const [confirmedZeroBalance, setConfirmedZeroBalance] = useState(false)
51+
52+
// Reset global flag when component mounts (handles re-entry to out-of-credits mode)
53+
useEffect(() => {
54+
creditsRestoredGlobal = false
55+
}, [])
56+
57+
// Confirm zero balance on first fetch to avoid race condition with cached data
58+
useEffect(() => {
59+
if (apiData && !confirmedZeroBalance) {
60+
if ((apiData.remainingBalance ?? 0) <= 0) {
61+
setConfirmedZeroBalance(true)
62+
}
63+
}
64+
}, [apiData, confirmedZeroBalance])
65+
66+
// Check if credits have been restored - show celebratory message
67+
useEffect(() => {
68+
// Only check for restoration after we've confirmed zero balance
69+
if (!confirmedZeroBalance || remainingBalance <= 0 || creditsRestored) {
70+
return
71+
}
72+
73+
// Credits restored! Show the success state
74+
setCreditsRestored(true)
75+
creditsRestoredGlobal = true
76+
}, [remainingBalance, creditsRestored, confirmedZeroBalance])
77+
78+
// Build stats text
79+
const statsText = activeData
80+
? `Session: ${sessionCreditsUsed.toLocaleString()} credits used · Balance: ${remainingBalance.toLocaleString()} credits`
81+
: `Session: ${sessionCreditsUsed.toLocaleString()} credits used`
82+
83+
// Show celebratory success state when credits are restored
84+
if (creditsRestored) {
85+
return (
86+
<box
87+
style={{
88+
width: '100%',
89+
borderStyle: 'single',
90+
borderColor: theme.success,
91+
customBorderChars: BORDER_CHARS,
92+
paddingLeft: 1,
93+
paddingRight: 1,
94+
paddingTop: 0,
95+
paddingBottom: 0,
96+
flexDirection: 'column',
97+
gap: 0,
98+
}}
99+
>
100+
<box
101+
style={{
102+
flexDirection: 'column',
103+
justifyContent: 'center',
104+
minHeight: 3,
105+
gap: 0,
106+
}}
107+
>
108+
<text style={{ fg: theme.success }}>
109+
<ShimmerText
110+
text="✨ Credits acquired! ✨"
111+
primaryColor={theme.success}
112+
interval={120}
113+
/>
114+
</text>
115+
<text style={{ fg: theme.muted }}>
116+
Balance: {remainingBalance.toLocaleString()} credits
117+
</text>
118+
<text style={{ fg: theme.foreground }}>
119+
Press Enter to continue
120+
</text>
121+
</box>
122+
</box>
123+
)
124+
}
125+
126+
return (
127+
<box
128+
style={{
129+
width: '100%',
130+
borderStyle: 'single',
131+
borderColor: theme.warning,
132+
customBorderChars: BORDER_CHARS,
133+
paddingLeft: 1,
134+
paddingRight: 1,
135+
paddingTop: 0,
136+
paddingBottom: 0,
137+
flexDirection: 'column',
138+
gap: 0,
139+
}}
140+
>
141+
<box
142+
style={{
143+
flexDirection: 'column',
144+
justifyContent: 'center',
145+
minHeight: 3,
146+
gap: 0,
147+
}}
148+
>
149+
<text style={{ fg: theme.warning }}>
150+
Out of credits
151+
</text>
152+
<text style={{ fg: theme.muted }}>
153+
{statsText}
154+
</text>
155+
<text style={{ fg: theme.foreground }}>
156+
Press Enter to buy more credits
157+
</text>
158+
</box>
159+
</box>
160+
)
161+
}

cli/src/hooks/helpers/send-message.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ export const handleRunCompletion = (params: {
216216

217217
if (isOutOfCreditsError(output)) {
218218
updater.setError(OUT_OF_CREDITS_MESSAGE)
219-
useChatStore.getState().setInputMode('usage')
219+
useChatStore.getState().setInputMode('outOfCredits')
220220
queryClient.invalidateQueries({ queryKey: usageQueryKeys.current() })
221221
finalizeAfterError()
222222
return
@@ -299,7 +299,7 @@ export const handleRunError = (params: {
299299

300300
if (isOutOfCreditsError(error)) {
301301
updater.setError(OUT_OF_CREDITS_MESSAGE)
302-
useChatStore.getState().setInputMode('usage')
302+
useChatStore.getState().setInputMode('outOfCredits')
303303
queryClient.invalidateQueries({ queryKey: usageQueryKeys.current() })
304304
return
305305
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ export type ChatKeyboardHandlers = {
7171
onPasteImage: () => void
7272
onPasteImagePath: (imagePath: string) => void
7373
onPasteText: (text: string) => void
74+
75+
// Out of credits handler
76+
onOpenBuyCredits: () => void
7477
}
7578

7679
/**
@@ -223,6 +226,9 @@ function dispatchAction(
223226
}
224227
return true
225228
}
229+
case 'open-buy-credits':
230+
handlers.onOpenBuyCredits()
231+
return true
226232
case 'none':
227233
return false
228234
}

cli/src/utils/input-modes.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type InputMode =
1111
| 'usage'
1212
| 'image'
1313
| 'help'
14+
| 'outOfCredits'
1415

1516
// Theme color keys that are valid color values (must match ChatTheme keys)
1617
export type ThemeColorKey =
@@ -96,6 +97,14 @@ export const INPUT_MODE_CONFIGS: Record<InputMode, InputModeConfig> = {
9697
showAgentModeToggle: true,
9798
disableSlashSuggestions: false,
9899
},
100+
outOfCredits: {
101+
icon: null,
102+
color: 'warning',
103+
placeholder: '',
104+
widthAdjustment: 0,
105+
showAgentModeToggle: false,
106+
disableSlashSuggestions: true,
107+
},
99108
}
100109

101110
export function getInputModeConfig(mode: InputMode): InputModeConfig {

cli/src/utils/keyboard-actions.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,9 @@ export type ChatKeyboardAction =
9696
// Paste action (dispatcher checks clipboard content to route to image or text handler)
9797
| { type: 'paste' }
9898

99+
// Out of credits action
100+
| { type: 'open-buy-credits' }
101+
99102
// No action needed
100103
| { type: 'none' }
101104

@@ -124,6 +127,19 @@ export function resolveChatKeyboardAction(
124127
!key.shift &&
125128
!hasModifier(key)
126129

130+
// Priority 0: Out of credits mode - Enter opens buy credits page
131+
if (state.inputMode === 'outOfCredits') {
132+
if (isEnter) {
133+
return { type: 'open-buy-credits' }
134+
}
135+
// Allow Escape or Ctrl+C to exit out-of-credits mode (return to normal input)
136+
if (isEscape || isCtrlC) {
137+
return { type: 'exit-input-mode' }
138+
}
139+
// Block most other inputs in this mode
140+
return { type: 'none' }
141+
}
142+
127143
// Priority 1: Feedback mode handlers
128144
if (state.feedbackMode) {
129145
if (isEscape) {

0 commit comments

Comments
 (0)