Skip to content

Commit c80efb1

Browse files
committed
feat(cli): add auto-show credit warnings and refactor banners
UsageBanner now auto-shows when credits are low/critical/depleted. Use BannerWrapper component for consistent UI. Add InputModeBanner component to chat-input-bar for cleaner mode rendering. Implement different auto-hide timeouts (1min manual, 5min auto).
1 parent 4ac8d9a commit c80efb1

File tree

3 files changed

+133
-183
lines changed

3 files changed

+133
-183
lines changed

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,24 @@ import { ReferralBanner } from './referral-banner'
77
import { SuggestionMenu, type SuggestionItem } from './suggestion-menu'
88
import { UsageBanner } from './usage-banner'
99
import { useChatStore } from '../state/chat-store'
10+
11+
const InputModeBanner = ({ inputMode }: { inputMode: InputMode }) => {
12+
switch (inputMode) {
13+
case 'usage':
14+
return <UsageBanner />
15+
case 'referral':
16+
return <ReferralBanner />
17+
default:
18+
return null
19+
}
20+
}
1021
import { getInputModeConfig } from '../utils/input-modes'
1122
import { BORDER_CHARS } from '../utils/ui-constants'
1223

1324
import type { useTheme } from '../hooks/use-theme'
1425
import type { InputValue } from '../state/chat-store'
1526
import type { AgentMode } from '../utils/constants'
27+
import type { InputMode } from '../utils/input-modes'
1628

1729
type Theme = ReturnType<typeof useTheme>
1830

@@ -88,6 +100,7 @@ export const ChatInputBar = ({
88100
}: ChatInputBarProps) => {
89101
const inputMode = useChatStore((state) => state.inputMode)
90102
const setInputMode = useChatStore((state) => state.setInputMode)
103+
91104
const modeConfig = getInputModeConfig(inputMode)
92105
if (feedbackMode) {
93106
return (
@@ -218,8 +231,7 @@ export const ChatInputBar = ({
218231
</box>
219232
</box>
220233
</box>
221-
<UsageBanner />
222-
<ReferralBanner />
234+
<InputModeBanner inputMode={inputMode} />
223235
</>
224236
)
225237
}
Lines changed: 14 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,31 @@
1-
import React, { useMemo } from 'react'
1+
import React from 'react'
22

3-
import { Button } from './button'
4-
import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
3+
import { BannerWrapper } from './banner-wrapper'
54
import { useTheme } from '../hooks/use-theme'
65
import { useUserDetailsQuery } from '../hooks/use-user-details-query'
76
import { useChatStore } from '../state/chat-store'
8-
import { BORDER_CHARS } from '../utils/ui-constants'
97

108
export const ReferralBanner = () => {
11-
const { terminalWidth } = useTerminalDimensions()
129
const theme = useTheme()
13-
const inputMode = useChatStore((state) => state.inputMode)
1410
const setInputMode = useChatStore((state) => state.setInputMode)
15-
const isReferralMode = inputMode === 'referral'
16-
const [isCloseHovered, setIsCloseHovered] = React.useState(false)
1711

18-
// Fetch referral link when in referral mode
1912
const { data: userDetails, isLoading, isError } = useUserDetailsQuery({
2013
fields: ['referral_link'] as const,
21-
enabled: isReferralMode,
14+
enabled: true,
2215
})
23-
const referralLink = userDetails?.referral_link ?? null
24-
25-
// Memoize the banner text
26-
const text = useMemo(() => {
27-
if (isLoading) return 'Loading your referral link...'
28-
29-
if (isError) {
30-
return 'Failed to load your referral link. Please try again later.'
31-
}
3216

33-
if (!referralLink) {
34-
return 'Your referral link is not available yet'
35-
}
36-
37-
return `Share this link with friends:\n${referralLink}`
38-
}, [referralLink, isLoading, isError])
39-
40-
if (!isReferralMode) return null
17+
const referralLink = userDetails?.referral_link ?? null
18+
let text = ''
19+
if (isLoading) text = 'Loading your referral link...'
20+
else if (isError) text = 'Failed to load your referral link. Please try again later.'
21+
else if (!referralLink) text = 'Your referral link is not available yet'
22+
else text = `Share this link with friends:\n${referralLink}`
4123

4224
return (
43-
<box
44-
key={terminalWidth}
45-
style={{
46-
width: '100%',
47-
borderStyle: 'single',
48-
borderColor: theme.warning,
49-
flexDirection: 'row',
50-
justifyContent: 'space-between',
51-
paddingLeft: 1,
52-
paddingRight: 1,
53-
marginTop: 0,
54-
marginBottom: 0,
55-
}}
56-
border={['bottom', 'left', 'right']}
57-
customBorderChars={BORDER_CHARS}
58-
>
59-
<text
60-
style={{
61-
fg: theme.warning,
62-
wrapMode: 'word',
63-
flexShrink: 1,
64-
marginRight: 3,
65-
}}
66-
>
67-
{text}
68-
</text>
69-
<Button
70-
onClick={() => setInputMode('default')}
71-
onMouseOver={() => setIsCloseHovered(true)}
72-
onMouseOut={() => setIsCloseHovered(false)}
73-
>
74-
<text style={{ fg: isCloseHovered ? theme.error : theme.muted }}>x</text>
75-
</Button>
76-
</box>
25+
<BannerWrapper
26+
color={theme.warning}
27+
text={text}
28+
onClose={() => setInputMode('default')}
29+
/>
7730
)
7831
}
Lines changed: 105 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -1,141 +1,126 @@
1-
import React, { useEffect, useMemo } from 'react'
1+
import { UserState, getUserState } from '@codebuff/common/old-constants'
2+
import { useQuery } from '@tanstack/react-query'
3+
import React, { useEffect, useRef, useState } from 'react'
24

3-
import { Button } from './button'
4-
import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
5+
import { BannerWrapper } from './banner-wrapper'
56
import { useTheme } from '../hooks/use-theme'
6-
import { useUsageQuery } from '../hooks/use-usage-query'
7+
import { usageQueryKeys, useUsageQuery } from '../hooks/use-usage-query'
78
import { useChatStore } from '../state/chat-store'
8-
import { BORDER_CHARS } from '../utils/ui-constants'
9+
import { getAuthToken } from '../utils/auth'
910

10-
// Credit level thresholds for banner color
1111
const HIGH_CREDITS_THRESHOLD = 1000
1212
const MEDIUM_CREDITS_THRESHOLD = 100
1313

14+
const MANUAL_SHOW_TIMEOUT = 60 * 1000 // 1 minute
15+
const AUTO_SHOW_TIMEOUT = 5 * 60 * 1000 // 5 minutes
16+
1417
export const UsageBanner = () => {
15-
const { terminalWidth } = useTerminalDimensions()
1618
const theme = useTheme()
17-
const isUsageVisible = useChatStore((state) => state.isUsageVisible)
1819
const sessionCreditsUsed = useChatStore((state) => state.sessionCreditsUsed)
19-
const setIsUsageVisible = useChatStore((state) => state.setIsUsageVisible)
20-
const [isCloseHovered, setIsCloseHovered] = React.useState(false)
21-
22-
// Fetch usage data when banner is visible
23-
const { data: apiData } = useUsageQuery({ enabled: isUsageVisible })
24-
25-
// Transform API data to usage data format
26-
const usageData = apiData
27-
? {
28-
sessionUsage: sessionCreditsUsed,
29-
remainingBalance: apiData.remainingBalance,
30-
nextQuotaReset: apiData.next_quota_reset,
31-
}
32-
: null
33-
34-
// Auto-hide banner after 60 seconds
20+
const isChainInProgress = useChatStore((state) => state.isChainInProgress)
21+
const setInputMode = useChatStore((state) => state.setInputMode)
22+
23+
const [isAutoShown, setIsAutoShown] = useState(false)
24+
const lastWarnedStateRef = useRef<UserState | null>(null)
25+
26+
const { data: apiData } = useUsageQuery({ enabled: true })
27+
28+
const { data: cachedUsageData } = useQuery<{
29+
type: 'usage-response'
30+
usage: number
31+
remainingBalance: number | null
32+
balanceBreakdown?: { free: number; paid: number }
33+
next_quota_reset: string | null
34+
}>({
35+
queryKey: usageQueryKeys.current(),
36+
enabled: false,
37+
})
38+
39+
// Credit warning monitoring logic
3540
useEffect(() => {
36-
if (isUsageVisible) {
37-
const timer = setTimeout(() => {
38-
setIsUsageVisible(false)
39-
}, 60000)
40-
return () => clearTimeout(timer)
41-
} else {
42-
// Reset hover state when banner closes
43-
setIsCloseHovered(false)
44-
}
45-
return undefined
46-
}, [isUsageVisible, setIsUsageVisible])
41+
if (isChainInProgress) return
42+
const authToken = getAuthToken()
43+
if (!authToken) return
44+
if (!cachedUsageData || cachedUsageData.remainingBalance === null) return
4745

48-
// Memoize the banner text computation
49-
const text = useMemo(() => {
50-
if (!usageData) return ''
46+
const credits = cachedUsageData.remainingBalance
47+
const userState = getUserState(true, credits)
5148

52-
let result = `Session usage: ${usageData.sessionUsage.toLocaleString()}`
53-
54-
if (usageData.remainingBalance !== null) {
55-
result += `. Credits remaining: ${usageData.remainingBalance.toLocaleString()}`
49+
if (userState === UserState.GOOD_STANDING) {
50+
lastWarnedStateRef.current = null
51+
return
5652
}
5753

58-
if (usageData.nextQuotaReset) {
59-
const resetDate = new Date(usageData.nextQuotaReset)
60-
const today = new Date()
61-
const isToday = resetDate.toDateString() === today.toDateString()
62-
63-
// Format date without slashes to prevent mid-date line breaks
64-
const dateDisplay = isToday
65-
? resetDate.toLocaleString('en-US', {
66-
month: 'short',
67-
day: 'numeric',
68-
year: 'numeric',
69-
hour: 'numeric',
70-
minute: '2-digit',
71-
})
72-
: resetDate.toLocaleDateString('en-US', {
73-
month: 'short',
74-
day: 'numeric',
75-
year: 'numeric',
76-
})
77-
78-
result += `. Free credits renew ${dateDisplay}`
54+
if (
55+
lastWarnedStateRef.current !== userState &&
56+
(userState === UserState.ATTENTION_NEEDED ||
57+
userState === UserState.CRITICAL ||
58+
userState === UserState.DEPLETED)
59+
) {
60+
lastWarnedStateRef.current = userState
61+
setIsAutoShown(true)
7962
}
63+
}, [isChainInProgress, cachedUsageData])
8064

81-
return result
82-
}, [usageData])
83-
84-
const bannerColor = useMemo(() => {
85-
// Default color
86-
if (!usageData || usageData.remainingBalance === null) {
87-
return theme.warning
88-
}
89-
90-
const balance = usageData.remainingBalance
91-
92-
if (balance >= HIGH_CREDITS_THRESHOLD) {
93-
return theme.success
94-
}
95-
96-
if (balance >= MEDIUM_CREDITS_THRESHOLD) {
97-
return theme.warning
98-
}
99-
100-
return theme.error
101-
}, [usageData, theme])
102-
103-
if (!isUsageVisible || !usageData) return null
65+
// Auto-hide effect
66+
useEffect(() => {
67+
const timeout = isAutoShown ? AUTO_SHOW_TIMEOUT : MANUAL_SHOW_TIMEOUT
68+
const timer = setTimeout(() => {
69+
setInputMode('default')
70+
setIsAutoShown(false)
71+
}, timeout)
72+
return () => clearTimeout(timer)
73+
}, [isAutoShown, setInputMode])
74+
75+
const activeData = apiData || cachedUsageData
76+
if (!activeData) return null
77+
78+
const balance = activeData.remainingBalance
79+
let color = theme.warning
80+
81+
if (balance === null) {
82+
color = theme.warning
83+
} else if (balance >= HIGH_CREDITS_THRESHOLD) {
84+
color = theme.success
85+
} else if (balance >= MEDIUM_CREDITS_THRESHOLD) {
86+
color = theme.warning
87+
} else {
88+
color = theme.error
89+
}
90+
91+
let text = `Session usage: ${sessionCreditsUsed.toLocaleString()}`
92+
93+
if (activeData.remainingBalance !== null) {
94+
text += `. Credits remaining: ${activeData.remainingBalance.toLocaleString()}`
95+
}
96+
97+
if (activeData.next_quota_reset) {
98+
const resetDate = new Date(activeData.next_quota_reset)
99+
const today = new Date()
100+
const isToday = resetDate.toDateString() === today.toDateString()
101+
102+
const dateDisplay = isToday
103+
? resetDate.toLocaleString('en-US', {
104+
month: 'short',
105+
day: 'numeric',
106+
year: 'numeric',
107+
hour: 'numeric',
108+
minute: '2-digit',
109+
})
110+
: resetDate.toLocaleDateString('en-US', {
111+
month: 'short',
112+
day: 'numeric',
113+
year: 'numeric',
114+
})
115+
116+
text += `. Free credits renew ${dateDisplay}`
117+
}
104118

105119
return (
106-
<box
107-
key={terminalWidth}
108-
style={{
109-
width: '100%',
110-
borderStyle: 'single',
111-
borderColor: bannerColor,
112-
flexDirection: 'row',
113-
justifyContent: 'space-between',
114-
paddingLeft: 1,
115-
paddingRight: 1,
116-
marginTop: 0,
117-
marginBottom: 0,
118-
}}
119-
border={['bottom', 'left', 'right']}
120-
customBorderChars={BORDER_CHARS}
121-
>
122-
<text
123-
style={{
124-
fg: bannerColor,
125-
wrapMode: 'word',
126-
flexShrink: 1,
127-
marginRight: 3,
128-
}}
129-
>
130-
{text}
131-
</text>
132-
<Button
133-
onClick={() => setIsUsageVisible(false)}
134-
onMouseOver={() => setIsCloseHovered(true)}
135-
onMouseOut={() => setIsCloseHovered(false)}
136-
>
137-
<text style={{ fg: isCloseHovered ? theme.error : theme.muted }}>x</text>
138-
</Button>
139-
</box>
120+
<BannerWrapper
121+
color={color}
122+
text={text}
123+
onClose={() => setInputMode('default')}
124+
/>
140125
)
141126
}

0 commit comments

Comments
 (0)