Skip to content

Commit 60b840e

Browse files
committed
Bottom status bar when using claude subscription
1 parent e7936c4 commit 60b840e

File tree

3 files changed

+232
-0
lines changed

3 files changed

+232
-0
lines changed

cli/src/chat.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { getAdsEnabled } from './commands/ads'
1515
import { routeUserPrompt, addBashMessageToHistory } from './commands/router'
1616
import { AdBanner } from './components/ad-banner'
1717
import { ChatInputBar } from './components/chat-input-bar'
18+
import { BottomStatusLine } from './components/bottom-status-line'
1819
import { areCreditsRestored } from './components/out-of-credits-banner'
1920
import { LoadPreviousButton } from './components/load-previous-button'
2021
import { MessageWithAgents } from './components/message-with-agents'
@@ -26,6 +27,7 @@ import { useAgentValidation } from './hooks/use-agent-validation'
2627
import { useAskUserBridge } from './hooks/use-ask-user-bridge'
2728
import { authQueryKeys } from './hooks/use-auth-query'
2829
import { useChatInput } from './hooks/use-chat-input'
30+
import { useClaudeQuotaQuery } from './hooks/use-claude-quota-query'
2931
import {
3032
useChatKeyboard,
3133
type ChatKeyboardHandlers,
@@ -73,6 +75,7 @@ import {
7375
getStatusIndicatorState,
7476
type AuthStatus,
7577
} from './utils/status-indicator-state'
78+
import { getClaudeOAuthStatus } from './utils/claude-oauth'
7679
import { createPasteHandler } from './utils/strings'
7780
import { computeInputLayoutMetrics } from './utils/text-layout'
7881
import { createMarkdownPalette } from './utils/theme-system'
@@ -1360,6 +1363,20 @@ export const Chat = ({
13601363
isAskUserActive: askUserState !== null,
13611364
})
13621365
const hasStatusIndicatorContent = statusIndicatorState.kind !== 'idle'
1366+
1367+
// Check if Claude OAuth is active for the current agent mode
1368+
const isClaudeOAuthActive = useMemo(() => {
1369+
const status = getClaudeOAuthStatus()
1370+
// When connected, Claude OAuth is active for Claude models
1371+
return status.connected
1372+
}, [inputMode]) // Re-check when input mode changes (e.g., after /connect:claude)
1373+
1374+
// Fetch Claude quota when OAuth is active
1375+
const { data: claudeQuota } = useClaudeQuotaQuery({
1376+
enabled: isClaudeOAuthActive,
1377+
refetchInterval: 60 * 1000, // Refetch every 60 seconds
1378+
})
1379+
13631380
const inputBoxTitle = useMemo(() => {
13641381
const segments: string[] = []
13651382

@@ -1380,6 +1397,9 @@ export const Chat = ({
13801397
!feedbackMode &&
13811398
(hasStatusIndicatorContent || shouldShowQueuePreview || !isAtBottom)
13821399

1400+
// Determine if Claude is actively streaming/waiting
1401+
const isClaudeActive = isStreaming || isWaitingForResponse
1402+
13831403
// Track mouse movement for ad activity (throttled)
13841404
const lastMouseActivityRef = useRef<number>(0)
13851405
const handleMouseActivity = useCallback(() => {
@@ -1541,6 +1561,12 @@ export const Chat = ({
15411561
cwd: getProjectRoot() ?? process.cwd(),
15421562
})}
15431563
/>
1564+
1565+
<BottomStatusLine
1566+
isClaudeConnected={isClaudeOAuthActive}
1567+
isClaudeActive={isClaudeActive}
1568+
claudeQuota={claudeQuota}
1569+
/>
15441570
</box>
15451571
</box>
15461572
)
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import React from 'react'
2+
3+
import { useTheme } from '../hooks/use-theme'
4+
5+
import type { ClaudeQuotaData } from '../hooks/use-claude-quota-query'
6+
import type { ChatTheme } from '../types/theme-system'
7+
8+
interface BottomStatusLineProps {
9+
/** Whether Claude OAuth is connected */
10+
isClaudeConnected: boolean
11+
/** Whether Claude is actively being used (streaming/waiting) */
12+
isClaudeActive: boolean
13+
/** Quota data from Anthropic API */
14+
claudeQuota?: ClaudeQuotaData | null
15+
}
16+
17+
/**
18+
* Format remaining quota for display
19+
*/
20+
const formatQuota = (remaining: number): string => {
21+
const rounded = Math.round(remaining)
22+
return `${rounded}%`
23+
}
24+
25+
/**
26+
* Get color for quota percentage - only highlight when approaching limit
27+
*/
28+
const getQuotaColor = (remaining: number, theme: ChatTheme): string => {
29+
if (remaining <= 10) return theme.error
30+
if (remaining <= 25) return theme.warning
31+
return theme.muted // Use muted for normal levels - doesn't need to be salient
32+
}
33+
34+
/**
35+
* Bottom status line component - shows below the input box
36+
* Currently displays Claude subscription status when connected
37+
*/
38+
export const BottomStatusLine: React.FC<BottomStatusLineProps> = ({
39+
isClaudeConnected,
40+
isClaudeActive,
41+
claudeQuota,
42+
}) => {
43+
const theme = useTheme()
44+
45+
// Don't render if there's nothing to show
46+
if (!isClaudeConnected) {
47+
return null
48+
}
49+
50+
// Use the more restrictive of the two quotas (5-hour window is usually the limiting factor)
51+
const displayRemaining = claudeQuota
52+
? Math.min(claudeQuota.fiveHourRemaining, claudeQuota.sevenDayRemaining)
53+
: null
54+
55+
return (
56+
<box
57+
style={{
58+
width: '100%',
59+
flexDirection: 'row',
60+
justifyContent: 'flex-end',
61+
paddingRight: 1,
62+
}}
63+
>
64+
<box
65+
style={{
66+
flexDirection: 'row',
67+
alignItems: 'center',
68+
gap: 0,
69+
}}
70+
>
71+
<text style={{ fg: isClaudeActive ? theme.success : theme.muted }}></text>
72+
<text style={{ fg: isClaudeActive ? theme.primary : theme.muted }}> Claude subscription</text>
73+
{displayRemaining !== null && (
74+
<text style={{ fg: getQuotaColor(displayRemaining, theme) }}>
75+
{' '}{formatQuota(displayRemaining)}
76+
</text>
77+
)}
78+
</box>
79+
</box>
80+
)
81+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { useQuery } from '@tanstack/react-query'
2+
3+
import { getClaudeOAuthCredentials, isClaudeOAuthValid } from '@codebuff/sdk'
4+
5+
import { logger as defaultLogger } from '../utils/logger'
6+
7+
import type { Logger } from '@codebuff/common/types/contracts/logger'
8+
9+
// Query keys for type-safe cache management
10+
export const claudeQuotaQueryKeys = {
11+
all: ['claude-quota'] as const,
12+
current: () => [...claudeQuotaQueryKeys.all, 'current'] as const,
13+
}
14+
15+
/**
16+
* Response from Anthropic OAuth usage endpoint
17+
*/
18+
export interface ClaudeQuotaWindow {
19+
utilization: number // Percentage used (0-100)
20+
resets_at: string | null // ISO timestamp when quota resets
21+
}
22+
23+
export interface ClaudeQuotaResponse {
24+
five_hour: ClaudeQuotaWindow | null
25+
seven_day: ClaudeQuotaWindow | null
26+
seven_day_oauth_apps: ClaudeQuotaWindow | null
27+
seven_day_opus: ClaudeQuotaWindow | null
28+
}
29+
30+
/**
31+
* Parsed quota data for display
32+
*/
33+
export interface ClaudeQuotaData {
34+
/** Remaining percentage for the 5-hour window (0-100) */
35+
fiveHourRemaining: number
36+
/** When the 5-hour quota resets */
37+
fiveHourResetsAt: Date | null
38+
/** Remaining percentage for the 7-day window (0-100) */
39+
sevenDayRemaining: number
40+
/** When the 7-day quota resets */
41+
sevenDayResetsAt: Date | null
42+
}
43+
44+
/**
45+
* Fetches Claude OAuth usage data from Anthropic API
46+
*/
47+
export async function fetchClaudeQuota(
48+
accessToken: string,
49+
logger: Logger = defaultLogger,
50+
): Promise<ClaudeQuotaData> {
51+
const response = await fetch('https://api.anthropic.com/api/oauth/usage', {
52+
method: 'GET',
53+
headers: {
54+
Authorization: `Bearer ${accessToken}`,
55+
Accept: 'application/json',
56+
'Content-Type': 'application/json',
57+
// Required beta headers for OAuth endpoints (same as model requests)
58+
'anthropic-version': '2023-06-01',
59+
'anthropic-beta': 'oauth-2025-04-20,claude-code-20250219',
60+
},
61+
})
62+
63+
if (!response.ok) {
64+
logger.debug(
65+
{ status: response.status },
66+
'Failed to fetch Claude quota data',
67+
)
68+
throw new Error(`Failed to fetch Claude quota: ${response.status}`)
69+
}
70+
71+
const data = (await response.json()) as ClaudeQuotaResponse
72+
73+
// Parse the response into a more usable format
74+
const fiveHour = data.five_hour
75+
const sevenDay = data.seven_day
76+
77+
return {
78+
fiveHourRemaining: fiveHour ? Math.max(0, 100 - fiveHour.utilization) : 100,
79+
fiveHourResetsAt: fiveHour?.resets_at ? new Date(fiveHour.resets_at) : null,
80+
sevenDayRemaining: sevenDay ? Math.max(0, 100 - sevenDay.utilization) : 100,
81+
sevenDayResetsAt: sevenDay?.resets_at ? new Date(sevenDay.resets_at) : null,
82+
}
83+
}
84+
85+
export interface UseClaudeQuotaQueryDeps {
86+
logger?: Logger
87+
enabled?: boolean
88+
/** Refetch interval in milliseconds (default: 60 seconds) */
89+
refetchInterval?: number | false
90+
}
91+
92+
/**
93+
* Hook to fetch Claude OAuth quota data from Anthropic API
94+
* Only fetches when Claude OAuth is connected and valid
95+
*/
96+
export function useClaudeQuotaQuery(deps: UseClaudeQuotaQueryDeps = {}) {
97+
const {
98+
logger = defaultLogger,
99+
enabled = true,
100+
refetchInterval = 60 * 1000, // Default: refetch every 60 seconds
101+
} = deps
102+
103+
const isConnected = isClaudeOAuthValid()
104+
105+
return useQuery({
106+
queryKey: claudeQuotaQueryKeys.current(),
107+
queryFn: () => {
108+
// Get credentials inside queryFn to avoid stale closures
109+
const credentials = getClaudeOAuthCredentials()
110+
if (!credentials?.accessToken) {
111+
throw new Error('No Claude OAuth credentials')
112+
}
113+
return fetchClaudeQuota(credentials.accessToken, logger)
114+
},
115+
enabled: enabled && isConnected,
116+
staleTime: 30 * 1000, // Consider data stale after 30 seconds
117+
gcTime: 5 * 60 * 1000, // 5 minutes
118+
retry: 1, // Only retry once on failure
119+
refetchOnMount: true,
120+
refetchOnWindowFocus: false, // CLI doesn't have window focus
121+
refetchOnReconnect: false,
122+
refetchInterval,
123+
refetchIntervalInBackground: true, // Required for terminal environments
124+
})
125+
}

0 commit comments

Comments
 (0)