Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
75a228f
Initial backend impl
jahooma Jan 28, 2026
00af124
Review fixes
jahooma Jan 28, 2026
8e31469
Plans to tiered subscription. Don't store plan name/tier in db
jahooma Jan 28, 2026
66463e9
Extract getUserByStripeCustomerId helper
jahooma Jan 28, 2026
b807cfa
migrateUnusedCredits: remove filter on free/referral
jahooma Jan 28, 2026
8976298
Add .env.example for stripe price id
jahooma Jan 28, 2026
ed2a1d9
Remove subscription_count. Add more stripe status enums
jahooma Jan 28, 2026
31db66e
cleanup
jahooma Jan 28, 2026
458616a
Generate migration
jahooma Jan 28, 2026
c39155b
More reviewer improvments
jahooma Jan 28, 2026
cba210d
Update migrateUnusedCredits query
jahooma Jan 28, 2026
40a0b2e
Rename Flex to Strong
jahooma Jan 28, 2026
76f71c4
Add subscription tiers. Extract util getStripeId
jahooma Jan 28, 2026
9184aa2
Web routes to cancel, change tier, create subscription, or get subscr…
jahooma Jan 28, 2026
3f81504
Web subscription UI
jahooma Jan 28, 2026
5e9b314
Fix billing test to mock subscription endpoint
jahooma Jan 28, 2026
a7c6823
cli subscription changes
jahooma Jan 28, 2026
71c4d1d
Merge branch 'main' into subscription-client
jahooma Jan 29, 2026
9d79443
Fix type error
jahooma Jan 29, 2026
77c296c
Update usage multiplier
jahooma Jan 29, 2026
6770873
Merge branch 'main' into subscription-client
jahooma Jan 30, 2026
a5589d8
Handle subscription scheduled webhook events
jahooma Jan 30, 2026
e29b8cd
Simplify subscription plan to use on manage subscription button
jahooma Jan 30, 2026
3d5b4d1
Makeover for subscription panel
jahooma Jan 30, 2026
20f680c
Tweak subscription section design
jahooma Jan 30, 2026
66edcaa
Merge branch 'main' into subscription-client
jahooma Jan 30, 2026
13e8fc0
Create a credit block when you send a message
jahooma Jan 30, 2026
b9c5a92
fix 401 getting subscription
jahooma Jan 30, 2026
2a0015b
Set auth token at app startup
jahooma Jan 30, 2026
05b0321
Improve 5 hour limit banner
jahooma Jan 31, 2026
6e58594
Don't create a new block if the previous one's 5 hours is not up
jahooma Jan 31, 2026
f23f122
Show the scheduled tier in subscription panel
jahooma Jan 31, 2026
919a856
Fix: when cancelling a downgrade, scheduled_tier was not being cleared
jahooma Jan 31, 2026
07ba6f5
fix test
jahooma Feb 2, 2026
12794da
Remove bottom status bar for Strong subscription. Include subscriptio…
jahooma Feb 2, 2026
a124b3e
Improve usage banner a lot
jahooma Feb 2, 2026
12e7c01
Update /usage and subscription banner labels/ui
jahooma Feb 2, 2026
7120b0e
Revert thinking code changes
jahooma Feb 2, 2026
0a72e18
Refactor to pull out Subscription types
jahooma Feb 2, 2026
c9b56fc
Use generated updated_at for subscription table
jahooma Feb 2, 2026
a52d403
Improve stripe "phases" docs
jahooma Feb 2, 2026
fba5e79
Let you change setting for pause/spend credits for when subscription …
jahooma Feb 2, 2026
2d9cbea
Refactor so only one ensureSubscriberBlockGrant function is injected
jahooma Feb 2, 2026
631838c
Tweaks for usage banner
jahooma Feb 2, 2026
fadcc88
Clean up time formatting utils
jahooma Feb 2, 2026
f68ac73
Fetch authenticated billing portal link!
jahooma Feb 2, 2026
aedb14c
Update the pricing to advertize codebuff strong
jahooma Feb 2, 2026
e67902b
Update Codebuff strong screen
jahooma Feb 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions cli/src/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand Down Expand Up @@ -1245,6 +1247,30 @@ 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)
const subscriptionRateLimit = subscriptionData?.hasSubscription ? subscriptionData.rateLimit : undefined
useEffect(() => {
const isLimited = subscriptionRateLimit?.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')
}
}
}, [subscriptionRateLimit?.limited])

const inputBoxTitle = useMemo(() => {
const segments: string[] = []

Expand Down
8 changes: 8 additions & 0 deletions cli/src/commands/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
75 changes: 47 additions & 28 deletions cli/src/components/bottom-status-line.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ interface BottomStatusLineProps {

/**
* 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<BottomStatusLineProps> = ({
isClaudeConnected,
Expand All @@ -25,28 +25,28 @@ export const BottomStatusLine: React.FC<BottomStatusLineProps> = ({
}) => {
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
// Only show when Claude is connected
if (!isClaudeConnected) {
return null
}

// Determine dot color for Claude: red if exhausted, green if active, muted otherwise
const claudeDotColor = isClaudeExhausted
? theme.error
: isClaudeActive
? theme.success
Expand All @@ -59,23 +59,42 @@ export const BottomStatusLine: React.FC<BottomStatusLineProps> = ({
flexDirection: 'row',
justifyContent: 'flex-end',
paddingRight: 1,
gap: 2,
}}
>
<box
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 0,
}}
>
<text style={{ fg: dotColor }}>●</text>
<text style={{ fg: theme.muted }}> Claude subscription</text>
{isExhausted && resetTime ? (
<text style={{ fg: theme.muted }}>{` · resets in ${formatResetTime(resetTime)}`}</text>
) : displayRemaining !== null ? (
<BatteryIndicator value={displayRemaining} theme={theme} />
) : null}
</box>
{/* Show Claude subscription when connected and not depleted */}
{!isClaudeExhausted && (
<box
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 0,
}}
>
<text style={{ fg: claudeDotColor }}>●</text>
<text style={{ fg: theme.muted }}> Claude subscription</text>
{claudeDisplayRemaining !== null ? (
<BatteryIndicator value={claudeDisplayRemaining} theme={theme} />
) : null}
</box>
)}

{/* Show Claude as depleted when exhausted */}
{isClaudeExhausted && (
<box
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 0,
}}
>
<text style={{ fg: theme.error }}>●</text>
<text style={{ fg: theme.muted }}> Claude</text>
{claudeResetTime && (
<text style={{ fg: theme.muted }}>{` · resets in ${formatResetTime(claudeResetTime)}`}</text>
)}
</box>
)}
</box>
)
}
Expand Down
6 changes: 6 additions & 0 deletions cli/src/components/chat-input-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -187,6 +188,11 @@ export const ChatInputBar = ({
return <OutOfCreditsBanner />
}

// Subscription limit mode: replace entire input with subscription limit banner
if (inputMode === 'subscriptionLimit') {
return <SubscriptionLimitBanner />
}

// Handle input changes with special mode entry detection
const handleInputChange = (value: InputValue) => {
// Detect entering bash mode: user typed exactly '!' when in default mode
Expand Down
2 changes: 2 additions & 0 deletions cli/src/components/input-mode-banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -26,6 +27,7 @@ const BANNER_REGISTRY: Record<
referral: () => <ReferralBanner />,
help: () => <HelpBanner />,
'connect:claude': () => <ClaudeConnectBanner />,
subscriptionLimit: () => <SubscriptionLimitBanner />,
}

/**
Expand Down
59 changes: 46 additions & 13 deletions cli/src/components/message-footer.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { SUBSCRIPTION_DISPLAY_NAME } from '@codebuff/common/constants/subscription-plans'
import { pluralize } from '@codebuff/common/util/string'
import { TextAttributes } from '@opentui/core'
import React, { useCallback, useMemo } from 'react'

import { CopyButton } from './copy-button'
import { ElapsedTimer } from './elapsed-timer'
import { FeedbackIconButton } from './feedback-icon-button'
import { useSubscriptionQuery } from '../hooks/use-subscription-query'
import { useTheme } from '../hooks/use-theme'
import {
useFeedbackStore,
Expand Down Expand Up @@ -157,19 +159,7 @@ export const MessageFooter: React.FC<MessageFooterProps> = ({
if (typeof credits === 'number' && credits > 0) {
footerItems.push({
key: 'credits',
node: (
<text
attributes={TextAttributes.DIM}
style={{
wrapMode: 'none',
fg: theme.secondary,
marginTop: 0,
marginBottom: 0,
}}
>
{pluralize(credits, 'credit')}
</text>
),
node: <CreditsOrSubscriptionIndicator credits={credits} />,
})
}
if (shouldRenderFeedbackButton) {
Expand Down Expand Up @@ -222,3 +212,46 @@ export const MessageFooter: React.FC<MessageFooterProps> = ({
</box>
)
}

const CreditsOrSubscriptionIndicator: React.FC<{ credits: number }> = ({ credits }) => {
const theme = useTheme()
const { data: subscriptionData } = useSubscriptionQuery({
refetchInterval: false,
refetchOnActivity: false,
pauseWhenIdle: false,
})

const activeSubscription = subscriptionData?.hasSubscription ? subscriptionData : null
const rateLimit = activeSubscription?.rateLimit

const blockPercentRemaining = useMemo(() => {
if (!rateLimit?.blockLimit || rateLimit.blockUsed == null) return null
return Math.round(((rateLimit.blockLimit - rateLimit.blockUsed) / rateLimit.blockLimit) * 100)
}, [rateLimit])

const showSubscriptionIndicator =
activeSubscription && !rateLimit?.limited && blockPercentRemaining != null && blockPercentRemaining > 0

if (showSubscriptionIndicator) {
const label = blockPercentRemaining < 20
? `✓ ${SUBSCRIPTION_DISPLAY_NAME} (${blockPercentRemaining}% left)`
: `✓ ${SUBSCRIPTION_DISPLAY_NAME}`
return (
<text
attributes={TextAttributes.DIM}
style={{ wrapMode: 'none', fg: theme.success, marginTop: 0, marginBottom: 0 }}
>
{label}
</text>
)
}

return (
<text
attributes={TextAttributes.DIM}
style={{ wrapMode: 'none', fg: theme.secondary, marginTop: 0, marginBottom: 0 }}
>
{pluralize(credits, 'credit')}
</text>
)
}
2 changes: 1 addition & 1 deletion cli/src/components/progress-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export const ProgressBar: React.FC<ProgressBarProps> = ({
<box style={{ flexDirection: 'row', alignItems: 'center', gap: 0 }}>
{label && <text style={{ fg: theme.muted }}>{label} </text>}
<text style={{ fg: barColor }}>{filled}</text>
<text style={{ fg: theme.muted }}>{empty}</text>
{emptyWidth > 0 && <text style={{ fg: theme.muted }}>{empty}</text>}
{showPercentage && (
<text style={{ fg: textColor }}> {Math.round(clampedValue)}%</text>
)}
Expand Down
Loading
Loading