Skip to content

Commit 4afccd1

Browse files
committed
Improve status bar and /usage UI
1 parent 88440f1 commit 4afccd1

File tree

6 files changed

+102
-170
lines changed

6 files changed

+102
-170
lines changed

cli/src/components/bottom-status-line.tsx

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import React from 'react'
22

33
import { useTheme } from '../hooks/use-theme'
44

5+
import { formatResetTime } from '../utils/time-format'
6+
57
import type { ClaudeQuotaData } from '../hooks/use-claude-quota-query'
6-
import type { ChatTheme } from '../types/theme-system'
78

89
interface BottomStatusLineProps {
910
/** Whether Claude OAuth is connected */
@@ -14,23 +15,6 @@ interface BottomStatusLineProps {
1415
claudeQuota?: ClaudeQuotaData | null
1516
}
1617

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-
3418
/**
3519
* Bottom status line component - shows below the input box
3620
* Currently displays Claude subscription status when connected
@@ -52,6 +36,23 @@ export const BottomStatusLine: React.FC<BottomStatusLineProps> = ({
5236
? Math.min(claudeQuota.fiveHourRemaining, claudeQuota.sevenDayRemaining)
5337
: null
5438

39+
// Check if quota is exhausted (0%)
40+
const isExhausted = displayRemaining !== null && displayRemaining <= 0
41+
42+
// Get the reset time for the limiting quota window
43+
const resetTime = claudeQuota
44+
? claudeQuota.fiveHourRemaining <= claudeQuota.sevenDayRemaining
45+
? claudeQuota.fiveHourResetsAt
46+
: claudeQuota.sevenDayResetsAt
47+
: null
48+
49+
// Determine dot color: red if exhausted, green if active, muted otherwise
50+
const dotColor = isExhausted
51+
? theme.error
52+
: isClaudeActive
53+
? theme.success
54+
: theme.muted
55+
5556
return (
5657
<box
5758
style={{
@@ -68,13 +69,13 @@ export const BottomStatusLine: React.FC<BottomStatusLineProps> = ({
6869
gap: 0,
6970
}}
7071
>
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-
)}
72+
<text style={{ fg: dotColor }}></text>
73+
<text style={{ fg: theme.muted }}> Claude subscription</text>
74+
{isExhausted && resetTime ? (
75+
<text style={{ fg: theme.muted }}>{` · resets in ${formatResetTime(resetTime)}`}</text>
76+
) : displayRemaining !== null ? (
77+
<text style={{ fg: theme.foreground }}>{` ${Math.round(displayRemaining)}%`}</text>
78+
) : null}
7879
</box>
7980
</box>
8081
)

cli/src/components/progress-bar.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,16 @@ interface ProgressBarProps {
1818
*/
1919
const getProgressColor = (
2020
value: number,
21-
theme: { primary: string; muted: string; warning: string; error: string },
21+
theme: {
22+
primary: string
23+
foreground: string
24+
warning: string
25+
error: string
26+
},
2227
): string => {
2328
if (value <= 10) return theme.error
2429
if (value <= 25) return theme.warning
25-
return theme.muted // Use muted for normal levels
30+
return theme.foreground
2631
}
2732

2833
/**
@@ -63,9 +68,7 @@ export const ProgressBar: React.FC<ProgressBarProps> = ({
6368

6469
return (
6570
<box style={{ flexDirection: 'row', alignItems: 'center', gap: 0 }}>
66-
{label && (
67-
<text style={{ fg: theme.muted }}>{label} </text>
68-
)}
71+
{label && <text style={{ fg: theme.muted }}>{label} </text>}
6972
<text style={{ fg: barColor }}>{filled}</text>
7073
<text style={{ fg: theme.muted }}>{empty}</text>
7174
{showPercentage && (

cli/src/components/usage-banner.tsx

Lines changed: 48 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -10,33 +10,34 @@ import { usageQueryKeys, useUsageQuery } from '../hooks/use-usage-query'
1010
import { useChatStore } from '../state/chat-store'
1111
import {
1212
getBannerColorLevel,
13-
generateUsageBannerText,
1413
generateLoadingBannerText,
1514
} from '../utils/usage-banner-state'
1615
import { WEBSITE_URL } from '../login/constants'
1716
import { useTheme } from '../hooks/use-theme'
1817
import { isClaudeOAuthValid } from '@codebuff/sdk'
1918

19+
import { formatResetTime } from '../utils/time-format'
20+
2021
const MANUAL_SHOW_TIMEOUT = 60 * 1000 // 1 minute
2122
const USAGE_POLL_INTERVAL = 30 * 1000 // 30 seconds
2223

2324
/**
24-
* Format time until reset in human-readable form
25+
* Format the renewal date for display
2526
*/
26-
const formatResetTime = (resetDate: Date | null): string => {
27-
if (!resetDate) return ''
28-
const now = new Date()
29-
const diffMs = resetDate.getTime() - now.getTime()
30-
if (diffMs <= 0) return 'now'
31-
32-
const diffMins = Math.floor(diffMs / (1000 * 60))
33-
const diffHours = Math.floor(diffMins / 60)
34-
const remainingMins = diffMins % 60
35-
36-
if (diffHours > 0) {
37-
return `${diffHours}h ${remainingMins}m`
38-
}
39-
return `${diffMins}m`
27+
const formatRenewalDate = (dateStr: string | null): string => {
28+
if (!dateStr) return ''
29+
const resetDate = new Date(dateStr)
30+
const today = new Date()
31+
const isToday = resetDate.toDateString() === today.toDateString()
32+
return isToday
33+
? resetDate.toLocaleString('en-US', {
34+
hour: 'numeric',
35+
minute: '2-digit',
36+
})
37+
: resetDate.toLocaleDateString('en-US', {
38+
month: 'short',
39+
day: 'numeric',
40+
})
4041
}
4142

4243
export const UsageBanner = ({ showTime }: { showTime: number }) => {
@@ -106,36 +107,54 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => {
106107
}
107108

108109
const colorLevel = getBannerColorLevel(activeData.remainingBalance)
109-
110-
// Show loading indicator if refreshing data
111-
const text = isLoadingData
112-
? generateLoadingBannerText(sessionCreditsUsed)
113-
: generateUsageBannerText({
114-
sessionCreditsUsed,
115-
remainingBalance: activeData.remainingBalance,
116-
next_quota_reset: activeData.next_quota_reset,
117-
adCredits: activeData.balanceBreakdown?.ad,
118-
})
110+
const adCredits = activeData.balanceBreakdown?.ad
111+
const renewalDate = activeData.next_quota_reset ? formatRenewalDate(activeData.next_quota_reset) : null
119112

120113
return (
121114
<BottomBanner
122115
borderColorKey={isLoadingData ? 'muted' : colorLevel}
123116
onClose={() => setInputMode('default')}
124117
>
125118
<box style={{ flexDirection: 'column', gap: 0 }}>
126-
{/* Codebuff credits section */}
119+
{/* Codebuff credits section - structured layout */}
127120
<Button
128121
onClick={() => {
129122
open(WEBSITE_URL + '/usage')
130123
}}
131124
>
132-
<text style={{ fg: theme.foreground }}>{text}</text>
125+
<box style={{ flexDirection: 'column', gap: 0 }}>
126+
{/* Main stats row */}
127+
<box style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 1 }}>
128+
<text style={{ fg: theme.muted }}>Session:</text>
129+
<text style={{ fg: theme.foreground }}>{sessionCreditsUsed.toLocaleString()}</text>
130+
<text style={{ fg: theme.muted }}>·</text>
131+
<text style={{ fg: theme.muted }}>Remaining:</text>
132+
{isLoadingData ? (
133+
<text style={{ fg: theme.muted }}>...</text>
134+
) : (
135+
<text style={{ fg: theme.foreground }}>
136+
{activeData.remainingBalance?.toLocaleString() ?? '?'}
137+
</text>
138+
)}
139+
{adCredits != null && adCredits > 0 && (
140+
<text style={{ fg: theme.muted }}>{`(${adCredits} from ads)`}</text>
141+
)}
142+
{renewalDate && (
143+
<>
144+
<text style={{ fg: theme.muted }}>· Renews:</text>
145+
<text style={{ fg: theme.foreground }}>{renewalDate}</text>
146+
</>
147+
)}
148+
</box>
149+
{/* See more link */}
150+
<text style={{ fg: theme.muted }}>↗ See more on codebuff.com</text>
151+
</box>
133152
</Button>
134153

135154
{/* Claude subscription section - only show if connected */}
136155
{isClaudeConnected && (
137156
<box style={{ flexDirection: 'column', marginTop: 1 }}>
138-
<text style={{ fg: theme.primary }}>Claude subscription</text>
157+
<text style={{ fg: theme.muted }}>Claude subscription</text>
139158
{isClaudeLoading ? (
140159
<text style={{ fg: theme.muted }}>Loading quota...</text>
141160
) : claudeQuota ? (

cli/src/utils/__tests__/usage-banner-state.test.ts

Lines changed: 0 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { describe, test, expect } from 'bun:test'
33
import {
44
getBannerColorLevel,
55
getThresholdInfo,
6-
generateUsageBannerText,
76
generateLoadingBannerText,
87
shouldAutoShowBanner,
98
} from '../usage-banner-state'
@@ -111,55 +110,6 @@ describe('usage-banner-state', () => {
111110
})
112111
})
113112

114-
describe('generateUsageBannerText', () => {
115-
test('always shows session usage', () => {
116-
const text = generateUsageBannerText({
117-
sessionCreditsUsed: 250,
118-
remainingBalance: null,
119-
next_quota_reset: null,
120-
})
121-
expect(text).toContain('250')
122-
})
123-
124-
test('shows remaining balance when available', () => {
125-
const text = generateUsageBannerText({
126-
sessionCreditsUsed: 100,
127-
remainingBalance: 500,
128-
next_quota_reset: null,
129-
})
130-
expect(text).toContain('500')
131-
})
132-
133-
test('omits balance when not available', () => {
134-
const text = generateUsageBannerText({
135-
sessionCreditsUsed: 100,
136-
remainingBalance: null,
137-
next_quota_reset: null,
138-
})
139-
expect(text).not.toContain('remaining')
140-
})
141-
142-
test('shows renewal date when available', () => {
143-
const text = generateUsageBannerText({
144-
sessionCreditsUsed: 100,
145-
remainingBalance: 500,
146-
next_quota_reset: '2025-03-15T00:00:00.000Z',
147-
today: new Date('2025-03-01'),
148-
})
149-
expect(text).toContain('Mar')
150-
expect(text).toContain('15')
151-
})
152-
153-
test('omits renewal date when not available', () => {
154-
const text = generateUsageBannerText({
155-
sessionCreditsUsed: 100,
156-
remainingBalance: 500,
157-
next_quota_reset: null,
158-
})
159-
expect(text.toLowerCase()).not.toContain('renew')
160-
})
161-
})
162-
163113
describe('shouldAutoShowBanner', () => {
164114
describe('when banner should NOT auto-show', () => {
165115
test('during active AI response chain', () => {

cli/src/utils/time-format.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Format time until reset in human-readable form
3+
* @param resetDate - The date when the quota/resource resets
4+
* @returns Human-readable string like "2h 30m" or "45m"
5+
*/
6+
export const formatResetTime = (resetDate: Date | null): string => {
7+
if (!resetDate) return ''
8+
const now = new Date()
9+
const diffMs = resetDate.getTime() - now.getTime()
10+
if (diffMs <= 0) return 'now'
11+
12+
const diffMins = Math.floor(diffMs / (1000 * 60))
13+
const diffHours = Math.floor(diffMins / 60)
14+
const remainingMins = diffMins % 60
15+
16+
if (diffHours > 0) {
17+
return `${diffHours}h ${remainingMins}m`
18+
}
19+
return `${diffMins}m`
20+
}

cli/src/utils/usage-banner-state.ts

Lines changed: 0 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -62,74 +62,13 @@ export function getBannerColorLevel(balance: number | null): BannerColorLevel {
6262
return getThresholdInfo(balance).colorLevel
6363
}
6464

65-
export interface UsageBannerTextOptions {
66-
sessionCreditsUsed: number
67-
remainingBalance: number | null
68-
next_quota_reset: string | null
69-
/** Ad impression credits earned */
70-
adCredits?: number
71-
/** For testing purposes, allows overriding "today" */
72-
today?: Date
73-
}
74-
7565
/**
7666
* Generates loading text for the usage banner while data is being fetched.
7767
*/
7868
export function generateLoadingBannerText(sessionCreditsUsed: number): string {
7969
return `Session usage: ${sessionCreditsUsed.toLocaleString()}. Loading credit balance...`
8070
}
8171

82-
/**
83-
* Generates the text content for the usage banner.
84-
*/
85-
export function generateUsageBannerText(
86-
options: UsageBannerTextOptions,
87-
): string {
88-
const {
89-
sessionCreditsUsed,
90-
remainingBalance,
91-
next_quota_reset,
92-
adCredits,
93-
today = new Date(),
94-
} = options
95-
96-
let text = `Session usage: ${sessionCreditsUsed.toLocaleString()}`
97-
98-
if (remainingBalance !== null) {
99-
text += `. Credits remaining: ${remainingBalance.toLocaleString()}`
100-
}
101-
102-
// Show ad credits earned if any
103-
if (adCredits && adCredits > 0) {
104-
text += ` (${adCredits.toLocaleString()} from ads)`
105-
}
106-
107-
if (next_quota_reset) {
108-
const resetDate = new Date(next_quota_reset)
109-
const isToday = resetDate.toDateString() === today.toDateString()
110-
111-
const dateDisplay = isToday
112-
? resetDate.toLocaleString('en-US', {
113-
month: 'short',
114-
day: 'numeric',
115-
year: 'numeric',
116-
hour: 'numeric',
117-
minute: '2-digit',
118-
})
119-
: resetDate.toLocaleDateString('en-US', {
120-
month: 'short',
121-
day: 'numeric',
122-
year: 'numeric',
123-
})
124-
125-
text += `. Free credits renew ${dateDisplay}`
126-
}
127-
128-
text += `. See more`
129-
130-
return text
131-
}
132-
13372
/**
13473
* Gets the threshold tier for a given balance.
13574
* Returns null if balance is above all thresholds.

0 commit comments

Comments
 (0)