Skip to content

Commit c2274f2

Browse files
committed
feat: show helpful usage stats when the user is below certain thresholds
1 parent 6c58d85 commit c2274f2

File tree

18 files changed

+512
-133
lines changed

18 files changed

+512
-133
lines changed

cli/src/chat.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { useSuggestionMenuHandlers } from './hooks/use-suggestion-menu-handlers'
3939
import { useTerminalDimensions } from './hooks/use-terminal-dimensions'
4040
import { useTheme } from './hooks/use-theme'
4141
import { useTimeout } from './hooks/use-timeout'
42+
import { useUsageMonitor } from './hooks/use-usage-monitor'
4243

4344
import { useChatStore } from './state/chat-store'
4445
import { getInputModeConfig } from './utils/input-modes'
@@ -118,6 +119,9 @@ export const Chat = ({
118119
// Subscribe to ask_user bridge to trigger form display
119120
useAskUserBridge()
120121

122+
// Monitor usage data and auto-show banner when thresholds are crossed
123+
useUsageMonitor()
124+
121125
const {
122126
inputValue,
123127
cursorPosition,

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

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,16 @@ import type { InputMode } from '../utils/input-modes'
2020

2121
type Theme = ReturnType<typeof useTheme>
2222

23-
const InputModeBanner = ({ inputMode }: { inputMode: InputMode }) => {
23+
const InputModeBanner = ({
24+
inputMode,
25+
usageBannerShowTime,
26+
}: {
27+
inputMode: InputMode
28+
usageBannerShowTime: number
29+
}) => {
2430
switch (inputMode) {
2531
case 'usage':
26-
return <UsageBanner />
32+
return <UsageBanner showTime={usageBannerShowTime} />
2733
case 'referral':
2834
return <ReferralBanner />
2935
default:
@@ -104,6 +110,16 @@ export const ChatInputBar = ({
104110
const inputMode = useChatStore((state) => state.inputMode)
105111
const setInputMode = useChatStore((state) => state.setInputMode)
106112

113+
const [usageBannerShowTime, setUsageBannerShowTime] = React.useState(
114+
() => Date.now(),
115+
)
116+
117+
React.useEffect(() => {
118+
if (inputMode === 'usage') {
119+
setUsageBannerShowTime(Date.now())
120+
}
121+
}, [inputMode])
122+
107123
const modeConfig = getInputModeConfig(inputMode)
108124
const askUserState = useChatStore((state) => state.askUserState)
109125
const updateAskUserAnswer = useChatStore((state) => state.updateAskUserAnswer)
@@ -336,7 +352,10 @@ export const ChatInputBar = ({
336352
</box>
337353
</box>
338354
</box>
339-
<InputModeBanner inputMode={inputMode} />
355+
<InputModeBanner
356+
inputMode={inputMode}
357+
usageBannerShowTime={usageBannerShowTime}
358+
/>
340359
</>
341360
)
342361
}

cli/src/components/usage-banner.tsx

Lines changed: 23 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,41 @@
1-
import type { UserState } from '@codebuff/common/old-constants'
2-
import { useQuery } from '@tanstack/react-query'
3-
import React, { useEffect, useRef, useState } from 'react'
1+
import { useQuery, useQueryClient } from '@tanstack/react-query'
2+
import React, { useEffect } from 'react'
43

54
import { BannerWrapper } from './banner-wrapper'
65
import { useTheme } from '../hooks/use-theme'
76
import { usageQueryKeys, useUsageQuery } from '../hooks/use-usage-query'
87
import { useChatStore } from '../state/chat-store'
9-
import { getAuthToken } from '../utils/auth'
108
import {
119
getBannerColorLevel,
1210
generateUsageBannerText,
1311
generateLoadingBannerText,
14-
shouldAutoShowBanner,
1512
} from '../utils/usage-banner-state'
1613

1714
const MANUAL_SHOW_TIMEOUT = 60 * 1000 // 1 minute
18-
const AUTO_SHOW_TIMEOUT = 5 * 60 * 1000 // 5 minutes
15+
const USAGE_POLL_INTERVAL = 30 * 1000 // 30 seconds
1916

20-
export const UsageBanner = () => {
17+
export const UsageBanner = ({ showTime }: { showTime: number }) => {
2118
const theme = useTheme()
19+
const queryClient = useQueryClient()
2220
const sessionCreditsUsed = useChatStore((state) => state.sessionCreditsUsed)
23-
const isChainInProgress = useChatStore((state) => state.isChainInProgress)
2421
const setInputMode = useChatStore((state) => state.setInputMode)
2522

26-
const [isAutoShown, setIsAutoShown] = useState(false)
27-
const lastWarnedStateRef = useRef<UserState | null>(null)
23+
const {
24+
data: apiData,
25+
isLoading,
26+
isFetching,
27+
} = useUsageQuery({
28+
enabled: true,
29+
})
2830

29-
const { data: apiData, isLoading, isFetching } = useUsageQuery({ enabled: true })
31+
// Manual polling using setInterval - TanStack Query's refetchInterval doesn't work
32+
// reliably in terminal environments even with focusManager configuration
33+
useEffect(() => {
34+
const interval = setInterval(() => {
35+
queryClient.invalidateQueries({ queryKey: usageQueryKeys.current() })
36+
}, USAGE_POLL_INTERVAL)
37+
return () => clearInterval(interval)
38+
}, [queryClient])
3039

3140
const { data: cachedUsageData } = useQuery<{
3241
type: 'usage-response'
@@ -39,34 +48,13 @@ export const UsageBanner = () => {
3948
enabled: false,
4049
})
4150

42-
// Credit warning monitoring logic
43-
useEffect(() => {
44-
const authToken = getAuthToken()
45-
const decision = shouldAutoShowBanner(
46-
isChainInProgress,
47-
!!authToken,
48-
cachedUsageData?.remainingBalance ?? null,
49-
lastWarnedStateRef.current,
50-
)
51-
52-
if (decision.newWarningState !== lastWarnedStateRef.current) {
53-
lastWarnedStateRef.current = decision.newWarningState
54-
}
55-
56-
if (decision.shouldShow) {
57-
setIsAutoShown(true)
58-
}
59-
}, [isChainInProgress, cachedUsageData])
60-
61-
// Auto-hide effect
51+
// Auto-hide after timeout
6252
useEffect(() => {
63-
const timeout = isAutoShown ? AUTO_SHOW_TIMEOUT : MANUAL_SHOW_TIMEOUT
6453
const timer = setTimeout(() => {
6554
setInputMode('default')
66-
setIsAutoShown(false)
67-
}, timeout)
55+
}, MANUAL_SHOW_TIMEOUT)
6856
return () => clearTimeout(timer)
69-
}, [isAutoShown, setInputMode])
57+
}, [showTime, setInputMode])
7058

7159
const activeData = apiData || cachedUsageData
7260
const isLoadingData = isLoading || isFetching

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

Lines changed: 78 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import {
22
MAX_RETRIES_PER_MESSAGE,
33
RETRY_BACKOFF_BASE_DELAY_MS,
44
RETRY_BACKOFF_MAX_DELAY_MS,
5+
isPaymentRequiredError,
6+
ErrorCodes,
57
} from '@codebuff/sdk'
68
import { useQueryClient } from '@tanstack/react-query'
79
import { has, isEqual } from 'lodash'
@@ -1769,15 +1771,53 @@ export const useSendMessage = ({
17691771
})
17701772

17711773
if (!runState.output || runState.output.type === 'error') {
1772-
logger.warn(
1773-
{
1774-
errorMessage:
1775-
runState.output?.type === 'error'
1776-
? runState.output.message
1777-
: 'No output from agent run',
1778-
},
1779-
'Agent run failed',
1780-
)
1774+
const errorOutput = runState.output?.type === 'error' ? runState.output : null
1775+
const errorMessage = errorOutput?.message ?? 'No output from agent run'
1776+
1777+
logger.warn({ errorMessage, errorCode: errorOutput?.errorCode }, 'Agent run failed')
1778+
1779+
// Check if this is an out-of-credits error using the error code
1780+
const isOutOfCredits = errorOutput?.errorCode === ErrorCodes.PAYMENT_REQUIRED
1781+
1782+
if (isOutOfCredits) {
1783+
const appUrl = process.env.NEXT_PUBLIC_CODEBUFF_APP_URL || 'https://codebuff.com'
1784+
const paymentErrorMessage =
1785+
errorOutput?.message ??
1786+
`Out of credits. Please add credits at ${appUrl}/usage`
1787+
applyMessageUpdate((prev) =>
1788+
prev.map((msg) => {
1789+
if (msg.id !== aiMessageId) return msg
1790+
return {
1791+
...msg,
1792+
content: paymentErrorMessage,
1793+
blocks: undefined, // Clear blocks so content renders
1794+
isComplete: true,
1795+
}
1796+
}),
1797+
)
1798+
// Show the usage banner so user can see their balance and renewal date
1799+
useChatStore.getState().setInputMode('usage')
1800+
// Refresh usage data to show current state
1801+
queryClient.invalidateQueries({ queryKey: usageQueryKeys.current() })
1802+
} else {
1803+
// Generic error - display the error message directly from SDK
1804+
applyMessageUpdate((prev) =>
1805+
prev.map((msg) => {
1806+
if (msg.id !== aiMessageId) return msg
1807+
return {
1808+
...msg,
1809+
content: `**Error:** ${errorMessage}`,
1810+
blocks: undefined, // Clear blocks so content renders
1811+
isComplete: true,
1812+
}
1813+
}),
1814+
)
1815+
}
1816+
1817+
setStreamStatus('idle')
1818+
setCanProcessQueue(true)
1819+
updateChainInProgress(false)
1820+
timerController.stop('error')
17811821
return
17821822
}
17831823

@@ -1835,6 +1875,35 @@ export const useSendMessage = ({
18351875

18361876
const errorMessage =
18371877
error instanceof Error ? error.message : 'Unknown error occurred'
1878+
1879+
// Handle payment required (out of credits) specially
1880+
if (isPaymentRequiredError(error)) {
1881+
const appUrl = process.env.NEXT_PUBLIC_CODEBUFF_APP_URL || 'https://codebuff.com'
1882+
const paymentErrorMessage =
1883+
error instanceof Error && error.message
1884+
? error.message
1885+
: `Out of credits. Please add credits at ${appUrl}/usage`
1886+
1887+
applyMessageUpdate((prev) =>
1888+
prev.map((msg) => {
1889+
if (msg.id !== aiMessageId) {
1890+
return msg
1891+
}
1892+
return {
1893+
...msg,
1894+
content: paymentErrorMessage,
1895+
blocks: undefined, // Clear blocks so content renders
1896+
isComplete: true,
1897+
}
1898+
}),
1899+
)
1900+
// Show the usage banner so user can see their balance and renewal date
1901+
useChatStore.getState().setInputMode('usage')
1902+
// Refresh usage data to show current state
1903+
queryClient.invalidateQueries({ queryKey: usageQueryKeys.current() })
1904+
return
1905+
}
1906+
18381907
applyMessageUpdate((prev) =>
18391908
prev.map((msg) => {
18401909
if (msg.id !== aiMessageId) {

cli/src/hooks/use-usage-monitor.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { useEffect, useRef } from 'react'
2+
3+
import { getAuthToken } from '../utils/auth'
4+
import { useChatStore } from '../state/chat-store'
5+
import { useUsageQuery } from './use-usage-query'
6+
import { shouldAutoShowBanner } from '../utils/usage-banner-state'
7+
8+
/**
9+
* Hook that monitors usage data and auto-shows the usage banner
10+
* when credit thresholds are crossed.
11+
*
12+
* This should be placed in a component that's always mounted (like Chat)
13+
* so monitoring happens continuously, not just when the banner is visible.
14+
*/
15+
export function useUsageMonitor() {
16+
const isChainInProgress = useChatStore((state) => state.isChainInProgress)
17+
const sessionCreditsUsed = useChatStore((state) => state.sessionCreditsUsed)
18+
const setInputMode = useChatStore((state) => state.setInputMode)
19+
const lastWarnedThresholdRef = useRef<number | null>(null)
20+
21+
// Query usage data - this will refetch when invalidated after message completion
22+
const { data: usageData } = useUsageQuery({ enabled: true })
23+
24+
useEffect(() => {
25+
// Only show after user has sent at least one message (to avoid overwhelming on app start)
26+
if (sessionCreditsUsed === 0) {
27+
return
28+
}
29+
30+
const authToken = getAuthToken()
31+
const remainingBalance = usageData?.remainingBalance ?? null
32+
33+
const decision = shouldAutoShowBanner(
34+
isChainInProgress,
35+
!!authToken,
36+
remainingBalance,
37+
lastWarnedThresholdRef.current,
38+
)
39+
40+
// Update the last warned threshold
41+
if (decision.newWarningThreshold !== lastWarnedThresholdRef.current) {
42+
lastWarnedThresholdRef.current = decision.newWarningThreshold
43+
}
44+
45+
// Show the usage banner if we should
46+
if (decision.shouldShow) {
47+
setInputMode('usage')
48+
}
49+
}, [isChainInProgress, usageData, sessionCreditsUsed, setInputMode])
50+
}

cli/src/hooks/use-usage-query.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,26 +65,35 @@ export async function fetchUsageData({
6565
export interface UseUsageQueryDeps {
6666
logger?: Logger
6767
enabled?: boolean
68+
refetchInterval?: number | false
69+
refetchIntervalInBackground?: boolean
6870
}
6971

7072
/**
7173
* Hook to fetch usage data from the API
7274
* Returns TanStack Query result directly - no store synchronization needed
7375
*/
7476
export function useUsageQuery(deps: UseUsageQueryDeps = {}) {
75-
const { logger = defaultLogger, enabled = true } = deps
77+
const {
78+
logger = defaultLogger,
79+
enabled = true,
80+
refetchInterval = false,
81+
refetchIntervalInBackground = false,
82+
} = deps
7683
const authToken = getAuthToken()
7784

7885
return useQuery({
7986
queryKey: usageQueryKeys.current(),
8087
queryFn: () => fetchUsageData({ authToken: authToken!, logger }),
8188
enabled: enabled && !!authToken,
82-
staleTime: 30 * 1000, // 30 seconds - usage data changes as user makes requests
89+
staleTime: 0, // Always consider data stale for immediate refetching
8390
gcTime: 5 * 60 * 1000, // 5 minutes
8491
retry: false, // Don't retry failed usage queries
85-
refetchOnMount: false, // Don't auto-refetch on mount
92+
refetchOnMount: 'always', // Always refetch on mount to get fresh data when banner opens
8693
refetchOnWindowFocus: false, // CLI doesn't have window focus
8794
refetchOnReconnect: false, // Don't auto-refetch on reconnect
95+
refetchInterval, // Poll at specified interval (when banner is visible)
96+
refetchIntervalInBackground, // Required for terminal environments without browser visibility API
8897
})
8998
}
9099

cli/src/index.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { createRoot } from '@opentui/react'
88
import { API_KEY_ENV_VAR } from '@codebuff/common/old-constants'
99
import { getProjectFileTree } from '@codebuff/common/project-file-tree'
1010
import { validateAgents } from '@codebuff/sdk'
11-
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
11+
import { QueryClient, QueryClientProvider, focusManager } from '@tanstack/react-query'
1212
import { Command } from 'commander'
1313
import React from 'react'
1414

@@ -50,6 +50,15 @@ function loadPackageVersion(): string {
5050
return 'dev'
5151
}
5252

53+
// Configure TanStack Query's focusManager for terminal environments
54+
// This is required because there's no browser visibility API in terminal apps
55+
// Without this, refetchInterval won't work because TanStack Query thinks the app is "unfocused"
56+
focusManager.setEventListener(() => {
57+
// No-op: no event listeners in CLI environment (no window focus/visibility events)
58+
return () => {}
59+
})
60+
focusManager.setFocused(true)
61+
5362
function createQueryClient(): QueryClient {
5463
return new QueryClient({
5564
defaultOptions: {

0 commit comments

Comments
 (0)