From a7c6823d892a846501e53c9d6d68a1b63d0853c8 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 28 Jan 2026 15:40:01 -0800 Subject: [PATCH] cli subscription changes --- cli/src/chat.tsx | 27 +++ cli/src/commands/command-registry.ts | 8 + cli/src/components/bottom-status-line.tsx | 128 +++++++++--- cli/src/components/chat-input-bar.tsx | 6 + cli/src/components/input-mode-banner.tsx | 2 + .../components/subscription-limit-banner.tsx | 190 ++++++++++++++++++ cli/src/components/usage-banner.tsx | 64 +++++- cli/src/data/slash-commands.ts | 6 + cli/src/hooks/use-subscription-query.ts | 102 ++++++++++ cli/src/utils/block-operations.ts | 17 +- cli/src/utils/input-modes.ts | 9 + cli/src/utils/message-block-helpers.ts | 5 +- cli/src/utils/settings.ts | 21 ++ 13 files changed, 554 insertions(+), 31 deletions(-) create mode 100644 cli/src/components/subscription-limit-banner.tsx create mode 100644 cli/src/hooks/use-subscription-query.ts diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 58970c269..9f53a7c61 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -34,6 +34,7 @@ import { useChatState } from './hooks/use-chat-state' import { useChatStreaming } from './hooks/use-chat-streaming' import { useChatUI } from './hooks/use-chat-ui' import { useClaudeQuotaQuery } from './hooks/use-claude-quota-query' +import { useSubscriptionQuery } from './hooks/use-subscription-query' import { useClipboard } from './hooks/use-clipboard' import { useEvent } from './hooks/use-event' import { useGravityAd } from './hooks/use-gravity-ad' @@ -55,6 +56,7 @@ import { getClaudeOAuthStatus } from './utils/claude-oauth' import { showClipboardMessage } from './utils/clipboard' import { readClipboardImage } from './utils/clipboard-image' import { getInputModeConfig } from './utils/input-modes' +import { getAlwaysUseALaCarte } from './utils/settings' import { type ChatKeyboardState, createDefaultChatKeyboardState, @@ -1236,6 +1238,29 @@ export const Chat = ({ refetchInterval: 60 * 1000, // Refetch every 60 seconds }) + // Fetch subscription data + const { data: subscriptionData } = useSubscriptionQuery({ + refetchInterval: 60 * 1000, + }) + + // Auto-show subscription limit banner when rate limit becomes active + const subscriptionLimitShownRef = useRef(false) + useEffect(() => { + const isLimited = subscriptionData?.rateLimit?.limited === true + if (isLimited && !subscriptionLimitShownRef.current) { + subscriptionLimitShownRef.current = true + // Skip showing the banner if user prefers to always fall back to a-la-carte + if (!getAlwaysUseALaCarte()) { + useChatStore.getState().setInputMode('subscriptionLimit') + } + } else if (!isLimited) { + subscriptionLimitShownRef.current = false + if (useChatStore.getState().inputMode === 'subscriptionLimit') { + useChatStore.getState().setInputMode('default') + } + } + }, [subscriptionData?.rateLimit?.limited]) + const inputBoxTitle = useMemo(() => { const segments: string[] = [] @@ -1427,6 +1452,8 @@ export const Chat = ({ isClaudeConnected={isClaudeOAuthActive} isClaudeActive={isClaudeActive} claudeQuota={claudeQuota} + hasSubscription={subscriptionData?.hasSubscription ?? false} + subscriptionRateLimit={subscriptionData?.rateLimit} /> diff --git a/cli/src/commands/command-registry.ts b/cli/src/commands/command-registry.ts index f2f6ca815..9f655cdfe 100644 --- a/cli/src/commands/command-registry.ts +++ b/cli/src/commands/command-registry.ts @@ -380,6 +380,14 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ clearInput(params) }, }), + defineCommand({ + name: 'subscribe', + aliases: ['strong'], + handler: (params) => { + open(WEBSITE_URL + '/strong') + clearInput(params) + }, + }), defineCommand({ name: 'buy-credits', handler: (params) => { diff --git a/cli/src/components/bottom-status-line.tsx b/cli/src/components/bottom-status-line.tsx index a16c93437..c9fe7b4c9 100644 --- a/cli/src/components/bottom-status-line.tsx +++ b/cli/src/components/bottom-status-line.tsx @@ -4,6 +4,7 @@ import { useTheme } from '../hooks/use-theme' import { formatResetTime } from '../utils/time-format' import type { ClaudeQuotaData } from '../hooks/use-claude-quota-query' +import type { SubscriptionRateLimit } from '../hooks/use-subscription-query' interface BottomStatusLineProps { /** Whether Claude OAuth is connected */ @@ -12,46 +13,79 @@ interface BottomStatusLineProps { isClaudeActive: boolean /** Quota data from Anthropic API */ claudeQuota?: ClaudeQuotaData | null + /** Whether the user has an active Codebuff Strong subscription */ + hasSubscription: boolean + /** Rate limit data for the subscription */ + subscriptionRateLimit?: SubscriptionRateLimit | null } /** * Bottom status line component - shows below the input box - * Currently displays Claude subscription status when connected + * Displays Claude subscription status and/or Codebuff Strong status */ export const BottomStatusLine: React.FC = ({ isClaudeConnected, isClaudeActive, claudeQuota, + hasSubscription, + subscriptionRateLimit, }) => { const theme = useTheme() - // Don't render if there's nothing to show - if (!isClaudeConnected) { - return null - } - // Use the more restrictive of the two quotas (5-hour window is usually the limiting factor) - const displayRemaining = claudeQuota + const claudeDisplayRemaining = claudeQuota ? Math.min(claudeQuota.fiveHourRemaining, claudeQuota.sevenDayRemaining) : null - // Check if quota is exhausted (0%) - const isExhausted = displayRemaining !== null && displayRemaining <= 0 + // Check if Claude quota is exhausted (0%) + const isClaudeExhausted = claudeDisplayRemaining !== null && claudeDisplayRemaining <= 0 - // Get the reset time for the limiting quota window - const resetTime = claudeQuota + // Get the reset time for the limiting Claude quota window + const claudeResetTime = claudeQuota ? claudeQuota.fiveHourRemaining <= claudeQuota.sevenDayRemaining ? claudeQuota.fiveHourResetsAt : claudeQuota.sevenDayResetsAt : null - // Determine dot color: red if exhausted, green if active, muted otherwise - const dotColor = isExhausted + // Show Claude when connected and not depleted (takes priority over Strong) + const showClaude = isClaudeConnected && !isClaudeExhausted + // Show Strong when subscribed AND (no Claude connected OR Claude depleted) + const showStrong = hasSubscription && (!isClaudeConnected || isClaudeExhausted) + + // Don't render if there's nothing to show + if (!showClaude && !showStrong && !(isClaudeConnected && isClaudeExhausted)) { + return null + } + + // Determine dot color for Claude: red if exhausted, green if active, muted otherwise + const claudeDotColor = isClaudeExhausted ? theme.error : isClaudeActive ? theme.success : theme.muted + // Subscription remaining percentage (based on weekly) + const subscriptionRemaining = subscriptionRateLimit + ? 100 - subscriptionRateLimit.weeklyPercentUsed + : null + const isSubscriptionLimited = subscriptionRateLimit?.limited === true + + // Get subscription reset time + const subscriptionResetTime = subscriptionRateLimit + ? subscriptionRateLimit.reason === 'block_exhausted' && subscriptionRateLimit.blockResetsAt + ? new Date(subscriptionRateLimit.blockResetsAt) + : subscriptionRateLimit.weeklyResetsAt + ? new Date(subscriptionRateLimit.weeklyResetsAt) + : null + : null + + // Determine dot color for Strong: red if limited, green if has remaining credits, muted otherwise + const strongDotColor = isSubscriptionLimited + ? theme.error + : subscriptionRemaining !== null && subscriptionRemaining > 0 + ? theme.success + : theme.muted + return ( = ({ flexDirection: 'row', justifyContent: 'flex-end', paddingRight: 1, + gap: 2, }} > - - - Claude subscription - {isExhausted && resetTime ? ( - {` · resets in ${formatResetTime(resetTime)}`} - ) : displayRemaining !== null ? ( - - ) : null} - + {/* Show Claude subscription when connected (even when depleted, to show reset time) */} + {isClaudeConnected && !isClaudeExhausted && ( + + + Claude subscription + {claudeDisplayRemaining !== null ? ( + + ) : null} + + )} + + {/* Show Claude as depleted when exhausted */} + {isClaudeConnected && isClaudeExhausted && ( + + + Claude + {claudeResetTime && ( + {` · resets in ${formatResetTime(claudeResetTime)}`} + )} + + )} + + {/* Show Codebuff Strong when subscribed and Claude not healthy */} + {showStrong && ( + + + Codebuff Strong + {isSubscriptionLimited && subscriptionResetTime ? ( + {` · resets in ${formatResetTime(subscriptionResetTime)}`} + ) : subscriptionRemaining !== null ? ( + + ) : null} + + )} ) } diff --git a/cli/src/components/chat-input-bar.tsx b/cli/src/components/chat-input-bar.tsx index 7e0c8c533..4fdbca152 100644 --- a/cli/src/components/chat-input-bar.tsx +++ b/cli/src/components/chat-input-bar.tsx @@ -6,6 +6,7 @@ import { FeedbackContainer } from './feedback-container' import { InputModeBanner } from './input-mode-banner' import { MultilineInput, type MultilineInputHandle } from './multiline-input' import { OutOfCreditsBanner } from './out-of-credits-banner' +import { SubscriptionLimitBanner } from './subscription-limit-banner' import { PublishContainer } from './publish-container' import { SuggestionMenu, type SuggestionItem } from './suggestion-menu' import { useAskUserBridge } from '../hooks/use-ask-user-bridge' @@ -187,6 +188,11 @@ export const ChatInputBar = ({ return } + // Subscription limit mode: replace entire input with subscription limit banner + if (inputMode === 'subscriptionLimit') { + return + } + // Handle input changes with special mode entry detection const handleInputChange = (value: InputValue) => { // Detect entering bash mode: user typed exactly '!' when in default mode diff --git a/cli/src/components/input-mode-banner.tsx b/cli/src/components/input-mode-banner.tsx index e73b74f8a..1a69ff03d 100644 --- a/cli/src/components/input-mode-banner.tsx +++ b/cli/src/components/input-mode-banner.tsx @@ -4,6 +4,7 @@ import { ClaudeConnectBanner } from './claude-connect-banner' import { HelpBanner } from './help-banner' import { PendingAttachmentsBanner } from './pending-attachments-banner' import { ReferralBanner } from './referral-banner' +import { SubscriptionLimitBanner } from './subscription-limit-banner' import { UsageBanner } from './usage-banner' import { useChatStore } from '../state/chat-store' @@ -26,6 +27,7 @@ const BANNER_REGISTRY: Record< referral: () => , help: () => , 'connect:claude': () => , + subscriptionLimit: () => , } /** diff --git a/cli/src/components/subscription-limit-banner.tsx b/cli/src/components/subscription-limit-banner.tsx new file mode 100644 index 000000000..76057ab1c --- /dev/null +++ b/cli/src/components/subscription-limit-banner.tsx @@ -0,0 +1,190 @@ +import open from 'open' +import React from 'react' + +import { Button } from './button' +import { ProgressBar } from './progress-bar' +import { useSubscriptionQuery } from '../hooks/use-subscription-query' +import { useTheme } from '../hooks/use-theme' +import { useUsageQuery } from '../hooks/use-usage-query' +import { WEBSITE_URL } from '../login/constants' +import { useChatStore } from '../state/chat-store' +import { + getAlwaysUseALaCarte, + setAlwaysUseALaCarte, +} from '../utils/settings' +import { formatResetTime } from '../utils/time-format' +import { BORDER_CHARS } from '../utils/ui-constants' + +export const SubscriptionLimitBanner = () => { + const setInputMode = useChatStore((state) => state.setInputMode) + const theme = useTheme() + + const { data: subscriptionData } = useSubscriptionQuery({ + refetchInterval: 15 * 1000, + }) + + const { data: usageData } = useUsageQuery({ + enabled: true, + refetchInterval: 30 * 1000, + }) + + const rateLimit = subscriptionData?.rateLimit + const remainingBalance = usageData?.remainingBalance ?? 0 + const hasAlaCarteCredits = remainingBalance > 0 + + const [alwaysALaCarte, setAlwaysALaCarteState] = React.useState( + () => getAlwaysUseALaCarte(), + ) + + const handleToggleAlwaysALaCarte = () => { + const newValue = !alwaysALaCarte + setAlwaysALaCarteState(newValue) + setAlwaysUseALaCarte(newValue) + if (newValue) { + setInputMode('default') + } + } + + if (!subscriptionData) { + return ( + + Loading subscription data... + + ) + } + + if (!rateLimit?.limited) { + return null + } + + const isWeeklyLimit = rateLimit.reason === 'weekly_limit' + const isBlockExhausted = rateLimit.reason === 'block_exhausted' + + const weeklyRemaining = 100 - rateLimit.weeklyPercentUsed + const weeklyResetsAt = rateLimit.weeklyResetsAt + ? new Date(rateLimit.weeklyResetsAt) + : null + + const blockResetsAt = rateLimit.blockResetsAt + ? new Date(rateLimit.blockResetsAt) + : null + + const handleContinueWithCredits = () => { + setInputMode('default') + } + + const handleBuyCredits = () => { + open(WEBSITE_URL + '/usage') + } + + const handleWait = () => { + setInputMode('default') + } + + const borderColor = isWeeklyLimit ? theme.error : theme.warning + + return ( + + + {isWeeklyLimit ? ( + <> + + 🛑 Weekly limit reached + + + You've used all {rateLimit.weeklyLimit.toLocaleString()} credits for this week. + + {weeklyResetsAt && ( + + Weekly usage resets in {formatResetTime(weeklyResetsAt)} + + )} + + ) : isBlockExhausted ? ( + <> + + ⏱️ Block limit reached + + + You've used all {rateLimit.blockLimit?.toLocaleString()} credits in this 5-hour block. + + {blockResetsAt && ( + + New block starts in {formatResetTime(blockResetsAt)} + + )} + + ) : ( + + Subscription limit reached + + )} + + + Weekly: + + {rateLimit.weeklyPercentUsed}% used + + + {hasAlaCarteCredits && ( + + )} + + + {hasAlaCarteCredits ? ( + <> + + {isWeeklyLimit ? ( + + ) : ( + + )} + + ) : ( + <> + No a-la-carte credits available. + + + + )} + + + + ) +} diff --git a/cli/src/components/usage-banner.tsx b/cli/src/components/usage-banner.tsx index 7283fc657..1235a8cd7 100644 --- a/cli/src/components/usage-banner.tsx +++ b/cli/src/components/usage-banner.tsx @@ -7,6 +7,7 @@ import { Button } from './button' import { ProgressBar } from './progress-bar' import { getActivityQueryData } from '../hooks/use-activity-query' import { useClaudeQuotaQuery } from '../hooks/use-claude-quota-query' +import { useSubscriptionQuery } from '../hooks/use-subscription-query' import { useTheme } from '../hooks/use-theme' import { usageQueryKeys, useUsageQuery } from '../hooks/use-usage-query' import { WEBSITE_URL } from '../login/constants' @@ -53,6 +54,11 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => { refetchInterval: 30 * 1000, // Refresh every 30 seconds when banner is open }) + // Fetch subscription data + const { data: subscriptionData, isLoading: isSubscriptionLoading } = useSubscriptionQuery({ + refetchInterval: 30 * 1000, + }) + const { data: apiData, isLoading, @@ -99,12 +105,68 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => { const adCredits = activeData.balanceBreakdown?.ad const renewalDate = activeData.next_quota_reset ? formatRenewalDate(activeData.next_quota_reset) : null + const hasSubscription = subscriptionData?.hasSubscription === true + const rateLimit = subscriptionData?.rateLimit + const subscriptionInfo = subscriptionData?.subscription + return ( setInputMode('default')} > + {/* Strong subscription section - only show if subscribed */} + {hasSubscription && ( + + + {subscriptionData.displayName ?? 'Strong'} subscription + {subscriptionInfo?.tier ? ` · $${subscriptionInfo.tier}/mo` : ''} + {subscriptionInfo?.billingPeriodEnd + ? ` · Renews ${formatRenewalDate(subscriptionInfo.billingPeriodEnd)}` + : ''} + + {isSubscriptionLoading ? ( + Loading subscription data... + ) : rateLimit ? ( + + {/* Block progress - show if there's an active block */} + {rateLimit.blockLimit != null && rateLimit.blockUsed != null && ( + + + {subscriptionData.displayName ?? 'Strong'}: + + + + {Math.round((rateLimit.blockUsed / rateLimit.blockLimit) * 100)}% used + + {rateLimit.blockResetsAt && ( + + (resets in {formatResetTime(new Date(rateLimit.blockResetsAt))}) + + )} + + )} + {/* Weekly progress */} + + Week: + + + {rateLimit.weeklyPercentUsed}% used · Resets {formatRenewalDate(rateLimit.weeklyResetsAt)} + + + + ) : null} + + )} + {/* Codebuff credits section - structured layout */}