Skip to content

Commit 2c423c3

Browse files
authored
Subscription client changes (#424)
Co-authored-by: brandonkachen <brandonchenjiacheng@gmail.com> and Codebuff!
1 parent 3946e0f commit 2c423c3

File tree

62 files changed

+6534
-313
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

62 files changed

+6534
-313
lines changed

.github/workflows/ci.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,13 @@ jobs:
150150
echo "No regular tests found in .agents"
151151
fi
152152
elif [ "${{ matrix.package }}" = "web" ]; then
153-
bun run test --runInBand
153+
# Use bun test directly to pick up bunfig.toml preloads for Request global
154+
TEST_FILES=$(find src -name '*.test.ts' ! -name '*.integration.test.ts' ! -path 'src/__tests__/e2e/*' 2>/dev/null | sort | tr '\n' ' ')
155+
if [ -n "$TEST_FILES" ]; then
156+
bun test $TEST_FILES
157+
else
158+
echo "No tests found in web"
159+
fi
154160
else
155161
# Run all non-integration tests in a single bun test invocation
156162
# This avoids xargs exit code issues with orphaned child processes

bunfig.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@ linkWorkspacePackages = true
77
[test]
88
# Exclude test repositories, integration tests, and Playwright e2e tests from test execution by default
99
exclude = ["evals/test-repos/**", "**/*.integration.test.*", "web/src/__tests__/e2e/**"]
10-
preload = ["./sdk/test/setup-env.ts", "./test/setup-bigquery-mocks.ts"]
10+
preload = ["./sdk/test/setup-env.ts", "./test/setup-bigquery-mocks.ts", "./web/test/setup-globals.ts"]

cli/src/chat.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { useChatState } from './hooks/use-chat-state'
3535
import { useChatStreaming } from './hooks/use-chat-streaming'
3636
import { useChatUI } from './hooks/use-chat-ui'
3737
import { useClaudeQuotaQuery } from './hooks/use-claude-quota-query'
38+
import { useSubscriptionQuery } from './hooks/use-subscription-query'
3839
import { useClipboard } from './hooks/use-clipboard'
3940
import { useEvent } from './hooks/use-event'
4041
import { useGravityAd } from './hooks/use-gravity-ad'
@@ -57,6 +58,7 @@ import { getClaudeOAuthStatus } from './utils/claude-oauth'
5758
import { showClipboardMessage } from './utils/clipboard'
5859
import { readClipboardImage } from './utils/clipboard-image'
5960
import { getInputModeConfig } from './utils/input-modes'
61+
6062
import {
6163
type ChatKeyboardState,
6264
createDefaultChatKeyboardState,
@@ -161,6 +163,11 @@ export const Chat = ({
161163
const { statusMessage } = useClipboard()
162164
const { ad } = useGravityAd()
163165

166+
// Fetch subscription data early - needed for session credits tracking
167+
const { data: subscriptionData } = useSubscriptionQuery({
168+
refetchInterval: 60 * 1000,
169+
})
170+
164171
// Set initial mode from CLI flag on mount
165172
useEffect(() => {
166173
if (initialMode) {
@@ -425,6 +432,7 @@ export const Chat = ({
425432
resumeQueue,
426433
continueChat,
427434
continueChatId,
435+
subscriptionData,
428436
})
429437

430438
sendMessageRef.current = sendMessage
@@ -1278,6 +1286,26 @@ export const Chat = ({
12781286
refetchInterval: 60 * 1000, // Refetch every 60 seconds
12791287
})
12801288

1289+
// Auto-show subscription limit banner when rate limit becomes active
1290+
const subscriptionLimitShownRef = useRef(false)
1291+
const subscriptionRateLimit = subscriptionData?.hasSubscription ? subscriptionData.rateLimit : undefined
1292+
const fallbackToALaCarte = subscriptionData?.fallbackToALaCarte ?? false
1293+
useEffect(() => {
1294+
const isLimited = subscriptionRateLimit?.limited === true
1295+
if (isLimited && !subscriptionLimitShownRef.current) {
1296+
subscriptionLimitShownRef.current = true
1297+
// Skip showing the banner if user prefers to always fall back to a-la-carte
1298+
if (!fallbackToALaCarte) {
1299+
useChatStore.getState().setInputMode('subscriptionLimit')
1300+
}
1301+
} else if (!isLimited) {
1302+
subscriptionLimitShownRef.current = false
1303+
if (useChatStore.getState().inputMode === 'subscriptionLimit') {
1304+
useChatStore.getState().setInputMode('default')
1305+
}
1306+
}
1307+
}, [subscriptionRateLimit?.limited, fallbackToALaCarte])
1308+
12811309
const inputBoxTitle = useMemo(() => {
12821310
const segments: string[] = []
12831311

cli/src/commands/command-registry.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,14 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
382382
clearInput(params)
383383
},
384384
}),
385+
defineCommand({
386+
name: 'subscribe',
387+
aliases: ['strong'],
388+
handler: (params) => {
389+
open(WEBSITE_URL + '/pricing')
390+
clearInput(params)
391+
},
392+
}),
385393
defineCommand({
386394
name: 'buy-credits',
387395
handler: (params) => {

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

Lines changed: 47 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ interface BottomStatusLineProps {
1616

1717
/**
1818
* Bottom status line component - shows below the input box
19-
* Currently displays Claude subscription status when connected
19+
* Displays Claude subscription status and/or Codebuff Strong status
2020
*/
2121
export const BottomStatusLine: React.FC<BottomStatusLineProps> = ({
2222
isClaudeConnected,
@@ -25,28 +25,28 @@ export const BottomStatusLine: React.FC<BottomStatusLineProps> = ({
2525
}) => {
2626
const theme = useTheme()
2727

28-
// Don't render if there's nothing to show
29-
if (!isClaudeConnected) {
30-
return null
31-
}
32-
3328
// Use the more restrictive of the two quotas (5-hour window is usually the limiting factor)
34-
const displayRemaining = claudeQuota
29+
const claudeDisplayRemaining = claudeQuota
3530
? Math.min(claudeQuota.fiveHourRemaining, claudeQuota.sevenDayRemaining)
3631
: null
3732

38-
// Check if quota is exhausted (0%)
39-
const isExhausted = displayRemaining !== null && displayRemaining <= 0
33+
// Check if Claude quota is exhausted (0%)
34+
const isClaudeExhausted = claudeDisplayRemaining !== null && claudeDisplayRemaining <= 0
4035

41-
// Get the reset time for the limiting quota window
42-
const resetTime = claudeQuota
36+
// Get the reset time for the limiting Claude quota window
37+
const claudeResetTime = claudeQuota
4338
? claudeQuota.fiveHourRemaining <= claudeQuota.sevenDayRemaining
4439
? claudeQuota.fiveHourResetsAt
4540
: claudeQuota.sevenDayResetsAt
4641
: null
4742

48-
// Determine dot color: red if exhausted, green if active, muted otherwise
49-
const dotColor = isExhausted
43+
// Only show when Claude is connected
44+
if (!isClaudeConnected) {
45+
return null
46+
}
47+
48+
// Determine dot color for Claude: red if exhausted, green if active, muted otherwise
49+
const claudeDotColor = isClaudeExhausted
5050
? theme.error
5151
: isClaudeActive
5252
? theme.success
@@ -59,23 +59,42 @@ export const BottomStatusLine: React.FC<BottomStatusLineProps> = ({
5959
flexDirection: 'row',
6060
justifyContent: 'flex-end',
6161
paddingRight: 1,
62+
gap: 2,
6263
}}
6364
>
64-
<box
65-
style={{
66-
flexDirection: 'row',
67-
alignItems: 'center',
68-
gap: 0,
69-
}}
70-
>
71-
<text style={{ fg: dotColor }}></text>
72-
<text style={{ fg: theme.muted }}> Claude subscription</text>
73-
{isExhausted && resetTime ? (
74-
<text style={{ fg: theme.muted }}>{` · resets in ${formatResetTime(resetTime)}`}</text>
75-
) : displayRemaining !== null ? (
76-
<BatteryIndicator value={displayRemaining} theme={theme} />
77-
) : null}
78-
</box>
65+
{/* Show Claude subscription when connected and not depleted */}
66+
{!isClaudeExhausted && (
67+
<box
68+
style={{
69+
flexDirection: 'row',
70+
alignItems: 'center',
71+
gap: 0,
72+
}}
73+
>
74+
<text style={{ fg: claudeDotColor }}></text>
75+
<text style={{ fg: theme.muted }}> Claude subscription</text>
76+
{claudeDisplayRemaining !== null ? (
77+
<BatteryIndicator value={claudeDisplayRemaining} theme={theme} />
78+
) : null}
79+
</box>
80+
)}
81+
82+
{/* Show Claude as depleted when exhausted */}
83+
{isClaudeExhausted && (
84+
<box
85+
style={{
86+
flexDirection: 'row',
87+
alignItems: 'center',
88+
gap: 0,
89+
}}
90+
>
91+
<text style={{ fg: theme.error }}></text>
92+
<text style={{ fg: theme.muted }}> Claude</text>
93+
{claudeResetTime && (
94+
<text style={{ fg: theme.muted }}>{` · resets in ${formatResetTime(claudeResetTime)}`}</text>
95+
)}
96+
</box>
97+
)}
7998
</box>
8099
)
81100
}

cli/src/components/input-mode-banner.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ClaudeConnectBanner } from './claude-connect-banner'
44
import { HelpBanner } from './help-banner'
55
import { PendingAttachmentsBanner } from './pending-attachments-banner'
66
import { ReferralBanner } from './referral-banner'
7+
import { SubscriptionLimitBanner } from './subscription-limit-banner'
78
import { UsageBanner } from './usage-banner'
89
import { useChatStore } from '../state/chat-store'
910

@@ -26,6 +27,7 @@ const BANNER_REGISTRY: Record<
2627
referral: () => <ReferralBanner />,
2728
help: () => <HelpBanner />,
2829
'connect:claude': () => <ClaudeConnectBanner />,
30+
subscriptionLimit: () => <SubscriptionLimitBanner />,
2931
}
3032

3133
/**

cli/src/components/message-footer.tsx

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
1+
import { SUBSCRIPTION_DISPLAY_NAME } from '@codebuff/common/constants/subscription-plans'
12
import { pluralize } from '@codebuff/common/util/string'
23
import { TextAttributes } from '@opentui/core'
34
import React, { useCallback, useMemo } from 'react'
45

56
import { CopyButton } from './copy-button'
67
import { ElapsedTimer } from './elapsed-timer'
78
import { FeedbackIconButton } from './feedback-icon-button'
9+
import { useSubscriptionQuery } from '../hooks/use-subscription-query'
10+
import {
11+
getBlockPercentRemaining,
12+
isCoveredBySubscription,
13+
} from '../utils/subscription'
814
import { useTheme } from '../hooks/use-theme'
915
import {
1016
useFeedbackStore,
@@ -157,19 +163,7 @@ export const MessageFooter: React.FC<MessageFooterProps> = ({
157163
if (typeof credits === 'number' && credits > 0) {
158164
footerItems.push({
159165
key: 'credits',
160-
node: (
161-
<text
162-
attributes={TextAttributes.DIM}
163-
style={{
164-
wrapMode: 'none',
165-
fg: theme.secondary,
166-
marginTop: 0,
167-
marginBottom: 0,
168-
}}
169-
>
170-
{pluralize(credits, 'credit')}
171-
</text>
172-
),
166+
node: <CreditsOrSubscriptionIndicator credits={credits} />,
173167
})
174168
}
175169
if (shouldRenderFeedbackButton) {
@@ -222,3 +216,42 @@ export const MessageFooter: React.FC<MessageFooterProps> = ({
222216
</box>
223217
)
224218
}
219+
220+
const CreditsOrSubscriptionIndicator: React.FC<{ credits: number }> = ({ credits }) => {
221+
const theme = useTheme()
222+
const { data: subscriptionData } = useSubscriptionQuery({
223+
refetchInterval: false,
224+
refetchOnActivity: false,
225+
pauseWhenIdle: false,
226+
})
227+
228+
const blockPercentRemaining = useMemo(
229+
() => getBlockPercentRemaining(subscriptionData),
230+
[subscriptionData],
231+
)
232+
233+
const showSubscriptionIndicator = isCoveredBySubscription(subscriptionData)
234+
235+
if (showSubscriptionIndicator) {
236+
const label = (blockPercentRemaining ?? 0) < 20
237+
? `✓ ${SUBSCRIPTION_DISPLAY_NAME} (${blockPercentRemaining}% left)`
238+
: `✓ ${SUBSCRIPTION_DISPLAY_NAME}`
239+
return (
240+
<text
241+
attributes={TextAttributes.DIM}
242+
style={{ wrapMode: 'none', fg: theme.success, marginTop: 0, marginBottom: 0 }}
243+
>
244+
{label}
245+
</text>
246+
)
247+
}
248+
249+
return (
250+
<text
251+
attributes={TextAttributes.DIM}
252+
style={{ wrapMode: 'none', fg: theme.secondary, marginTop: 0, marginBottom: 0 }}
253+
>
254+
{pluralize(credits, 'credit')}
255+
</text>
256+
)
257+
}

cli/src/components/progress-bar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export const ProgressBar: React.FC<ProgressBarProps> = ({
7272
<box style={{ flexDirection: 'row', alignItems: 'center', gap: 0 }}>
7373
{label && <text style={{ fg: theme.muted }}>{label} </text>}
7474
<text style={{ fg: barColor }}>{filled}</text>
75-
<text style={{ fg: theme.muted }}>{empty}</text>
75+
{emptyWidth > 0 && <text style={{ fg: theme.muted }}>{empty}</text>}
7676
{showPercentage && (
7777
<text style={{ fg: textColor }}> {Math.round(clampedValue)}%</text>
7878
)}

0 commit comments

Comments
 (0)