diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx
index 97eb0a0a3d..30509a44cf 100644
--- a/cli/src/chat.tsx
+++ b/cli/src/chat.tsx
@@ -14,6 +14,7 @@ import { useShallow } from 'zustand/react/shallow'
import { getAdsEnabled, handleAdsDisable } from './commands/ads'
import { routeUserPrompt, addBashMessageToHistory } from './commands/router'
import { AdBanner } from './components/ad-banner'
+import { ChoiceAdBanner } from './components/choice-ad-banner'
import { ChatInputBar } from './components/chat-input-bar'
import { LoadPreviousButton } from './components/load-previous-button'
import { ReviewScreen } from './components/review-screen'
@@ -168,7 +169,7 @@ export const Chat = ({
})
const hasSubscription = subscriptionData?.hasSubscription ?? false
- const { ad } = useGravityAd({ enabled: IS_FREEBUFF || !hasSubscription })
+ const { ad, adData } = useGravityAd({ enabled: IS_FREEBUFF || !hasSubscription })
const [adsManuallyDisabled, setAdsManuallyDisabled] = useState(false)
const handleDisableAds = useCallback(() => {
@@ -1445,11 +1446,19 @@ export const Chat = ({
)}
{ad && (IS_FREEBUFF || (!adsManuallyDisabled && getAdsEnabled())) && (
-
+ adData?.variant === 'choice' ? (
+
+ ) : (
+
+ )
)}
{reviewMode ? (
diff --git a/cli/src/components/choice-ad-banner.tsx b/cli/src/components/choice-ad-banner.tsx
new file mode 100644
index 0000000000..f3dcb239d3
--- /dev/null
+++ b/cli/src/components/choice-ad-banner.tsx
@@ -0,0 +1,242 @@
+import { TextAttributes } from '@opentui/core'
+import { safeOpen } from '../utils/open-url'
+import React, { useState, useMemo } from 'react'
+
+import { Button } from './button'
+import { Clickable } from './clickable'
+import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
+import { useTheme } from '../hooks/use-theme'
+import { IS_FREEBUFF } from '../utils/constants'
+
+import type { AdResponse } from '../hooks/use-gravity-ad'
+
+interface ChoiceAdBannerProps {
+ ads: AdResponse[]
+ onDisableAds: () => void
+ isFreeMode: boolean
+}
+
+const CARD_HEIGHT = 5 // border-top + description + spacer + cta row + border-bottom
+
+const extractDomain = (url: string): string => {
+ try {
+ const parsed = new URL(url)
+ return parsed.hostname.replace(/^www\./, '')
+ } catch {
+ return url
+ }
+}
+
+/**
+ * Calculate evenly distributed column widths that sum exactly to availableWidth.
+ * Distributes remainder pixels across the first N columns so there's no gap.
+ */
+function columnWidths(count: number, availableWidth: number): number[] {
+ const base = Math.floor(availableWidth / count)
+ const remainder = availableWidth - base * count
+ return Array.from({ length: count }, (_, i) => base + (i < remainder ? 1 : 0))
+}
+
+export const ChoiceAdBanner: React.FC = ({ ads, onDisableAds, isFreeMode }) => {
+ const theme = useTheme()
+ const { separatorWidth, terminalWidth } = useTerminalDimensions()
+ const [hoveredIndex, setHoveredIndex] = useState(null)
+ const [showInfoPanel, setShowInfoPanel] = useState(false)
+ const [isAdLabelHovered, setIsAdLabelHovered] = useState(false)
+ const [isHideHovered, setIsHideHovered] = useState(false)
+ const [isCloseHovered, setIsCloseHovered] = useState(false)
+
+ // Available width for cards (terminal minus left/right margin of 1 each)
+ const colAvail = terminalWidth - 2
+ const widths = useMemo(() => columnWidths(ads.length, colAvail), [ads.length, colAvail])
+
+ // Sum of all credits across choice ads
+ const totalCredits = ads.reduce((sum, ad) => sum + (ad.credits ?? 0), 0)
+
+ // Hover colors
+ const hoverBorderColor = theme.link
+ const hoverBgColor = theme.name === 'light' ? '#e8f0fe' : '#1a2332'
+
+ return (
+
+ {/* Horizontal divider line */}
+ {'โ'.repeat(terminalWidth)}
+
+ {/* Header: "Sponsored picks" + credits + Ad label */}
+
+ Sponsored picks
+
+ {!IS_FREEBUFF && totalCredits > 0 && (
+ +{totalCredits} credits
+ )}
+ {!IS_FREEBUFF ? (
+ setShowInfoPanel(true)}
+ onMouseOver={() => setIsAdLabelHovered(true)}
+ onMouseOut={() => setIsAdLabelHovered(false)}
+ >
+
+ {isAdLabelHovered && !showInfoPanel ? 'Ad ?' : ' Ad'}
+
+
+ ) : (
+ {' Ad'}
+ )}
+
+
+
+ {/* Card columns */}
+
+ {ads.map((ad, i) => {
+ const isHovered = hoveredIndex === i
+ const domain = extractDomain(ad.url)
+ const ctaText = ad.cta || ad.title || 'Learn more'
+
+ return (
+
+ )
+ })}
+
+
+ {/* Info panel: shown when Ad label is clicked */}
+ {showInfoPanel && (
+
+ {' ' + 'โ'.repeat(separatorWidth - 2)}
+
+
+ {IS_FREEBUFF
+ ? 'Ads help keep Freebuff free.'
+ : 'Ads are optional and earn you credits on each impression. Feel free to hide them anytime.'}
+
+
+
+
+ {isFreeMode && !IS_FREEBUFF ? (
+
+ Ads are required in Free mode.
+
+ ) : (
+ <>
+
+ ยท
+
+ Use /ads:enable to show again
+
+ >
+ )}
+
+
+ )}
+
+ )
+}
diff --git a/cli/src/hooks/use-gravity-ad.ts b/cli/src/hooks/use-gravity-ad.ts
index ee825baf56..36e8c6c3f1 100644
--- a/cli/src/hooks/use-gravity-ad.ts
+++ b/cli/src/hooks/use-gravity-ad.ts
@@ -27,8 +27,15 @@ export type AdResponse = {
credits?: number // Set after impression is recorded (in cents)
}
+export type AdVariant = 'banner' | 'choice'
+
+export type AdData =
+ | { variant: 'banner'; ad: AdResponse }
+ | { variant: 'choice'; ads: AdResponse[] }
+
export type GravityAdState = {
ad: AdResponse | null
+ adData: AdData | null
isLoading: boolean
}
@@ -36,6 +43,9 @@ export type GravityAdState = {
type GravityController = {
cache: AdResponse[]
cacheIndex: number
+ choiceCache: AdResponse[][] // Cache of choice ad sets (each entry is 4 ads)
+ choiceCacheIndex: number
+ variant: AdVariant | null // Assigned variant from backend
impressionsFired: Set
adsShownSinceActivity: number
tickInFlight: boolean
@@ -57,6 +67,23 @@ function nextFromCache(ctrl: GravityController): AdResponse | null {
return ad
}
+// Pure helper: add a choice ad set to the choice cache
+function addToChoiceCache(ctrl: GravityController, ads: AdResponse[]): void {
+ // Deduplicate by checking if any set has the same first impUrl
+ const key = ads[0]?.impUrl
+ if (key && ctrl.choiceCache.some((set) => set[0]?.impUrl === key)) return
+ if (ctrl.choiceCache.length >= MAX_AD_CACHE_SIZE) ctrl.choiceCache.shift()
+ ctrl.choiceCache.push(ads)
+}
+
+// Pure helper: get the next cached choice ad set
+function nextFromChoiceCache(ctrl: GravityController): AdResponse[] | null {
+ if (ctrl.choiceCache.length === 0) return null
+ const set = ctrl.choiceCache[ctrl.choiceCacheIndex % ctrl.choiceCache.length]!
+ ctrl.choiceCacheIndex = (ctrl.choiceCacheIndex + 1) % ctrl.choiceCache.length
+ return set
+}
+
/**
* Hook for fetching and rotating Gravity ads.
*
@@ -71,6 +98,7 @@ function nextFromCache(ctrl: GravityController): AdResponse | null {
export const useGravityAd = (options?: { enabled?: boolean }): GravityAdState => {
const enabled = options?.enabled ?? true
const [ad, setAd] = useState(null)
+ const [adData, setAdData] = useState(null)
const [isLoading, setIsLoading] = useState(false)
// Check if terminal height is too small to show ads
@@ -94,6 +122,9 @@ export const useGravityAd = (options?: { enabled?: boolean }): GravityAdState =>
const ctrlRef = useRef({
cache: [],
cacheIndex: 0,
+ choiceCache: [],
+ choiceCacheIndex: 0,
+ variant: null,
impressionsFired: new Set(),
adsShownSinceActivity: 0,
tickInFlight: false,
@@ -145,6 +176,22 @@ export const useGravityAd = (options?: { enabled?: boolean }): GravityAdState =>
? { ...cur, credits: data.creditsGranted }
: cur,
)
+ // Also update credits in adData for choice ads
+ setAdData((cur) => {
+ if (!cur) return cur
+ if (cur.variant === 'choice') {
+ return {
+ ...cur,
+ ads: cur.ads.map((a) =>
+ a.impUrl === impUrl ? { ...a, credits: data.creditsGranted } : a,
+ ),
+ }
+ }
+ if (cur.variant === 'banner' && cur.ad.impUrl === impUrl) {
+ return { ...cur, ad: { ...cur.ad, credits: data.creditsGranted } }
+ }
+ return cur
+ })
}
})
.catch((err) => {
@@ -152,14 +199,29 @@ export const useGravityAd = (options?: { enabled?: boolean }): GravityAdState =>
})
}
- // Show an ad and fire impression
+ // Show a single banner ad and fire impression
const showAd = (next: AdResponse): void => {
setAd(next)
+ setAdData({ variant: 'banner', ad: next })
recordImpressionOnce(next.impUrl)
}
+ // Show a choice ad set and fire impressions for all
+ const showChoiceAds = (ads: AdResponse[]): void => {
+ setAd(ads[0] ?? null) // Keep backwards compat for ad field
+ setAdData({ variant: 'choice', ads })
+ for (const choiceAd of ads) {
+ recordImpressionOnce(choiceAd.impUrl)
+ }
+ }
+
+ type FetchAdResult =
+ | { variant: 'banner'; ad: AdResponse }
+ | { variant: 'choice'; ads: AdResponse[] }
+ | null
+
// Fetch an ad via web API
- const fetchAd = async (): Promise => {
+ const fetchAd = async (): Promise => {
// Don't fetch ads when they should be hidden
if (shouldHideAdsRef.current) return null
if (!getAdsEnabled()) return null
@@ -223,7 +285,17 @@ export const useGravityAd = (options?: { enabled?: boolean }): GravityAdState =>
}
const data = await response.json()
- return data.ad as AdResponse | null
+ const variant = data.variant ?? 'banner'
+
+ if (variant === 'choice' && Array.isArray(data.ads) && data.ads.length > 0) {
+ return { variant: 'choice', ads: data.ads as AdResponse[] }
+ }
+
+ if (data.ad) {
+ return { variant: 'banner', ad: data.ad as AdResponse }
+ }
+
+ return null
} catch (err) {
logger.error({ err }, '[gravity] Failed to fetch ad')
return null
@@ -245,21 +317,34 @@ export const useGravityAd = (options?: { enabled?: boolean }): GravityAdState =>
ctrl.adsShownSinceActivity < MAX_ADS_AFTER_ACTIVITY &&
isUserActive(ACTIVITY_THRESHOLD_MS)
- let next: AdResponse | null = null
-
- if (canFetchNew) {
- next = await fetchAd()
- if (next) addToCache(ctrl, next)
- }
-
- // Fall back to cached ads if no new ad
- if (!next) {
- next = nextFromCache(ctrl)
- }
-
- if (next) {
- ctrl.adsShownSinceActivity += 1
- showAd(next)
+ const result = canFetchNew ? await fetchAd() : null
+
+ if (result) {
+ ctrl.variant = result.variant
+ if (result.variant === 'choice') {
+ addToChoiceCache(ctrl, result.ads)
+ ctrl.adsShownSinceActivity += 1
+ showChoiceAds(result.ads)
+ } else {
+ addToCache(ctrl, result.ad)
+ ctrl.adsShownSinceActivity += 1
+ showAd(result.ad)
+ }
+ } else {
+ // Fall back to cached ads
+ if (ctrl.variant === 'choice') {
+ const cachedSet = nextFromChoiceCache(ctrl)
+ if (cachedSet) {
+ ctrl.adsShownSinceActivity += 1
+ showChoiceAds(cachedSet)
+ }
+ } else {
+ const next = nextFromCache(ctrl)
+ if (next) {
+ ctrl.adsShownSinceActivity += 1
+ showAd(next)
+ }
+ }
}
} finally {
ctrl.tickInFlight = false
@@ -283,11 +368,18 @@ export const useGravityAd = (options?: { enabled?: boolean }): GravityAdState =>
// Fetch first ad immediately
void (async () => {
- const firstAd = await fetchAd()
- if (firstAd) {
- addToCache(ctrlRef.current, firstAd)
- showAd(firstAd)
- ctrlRef.current.adsShownSinceActivity = 1
+ const result = await fetchAd()
+ if (result) {
+ const ctrl = ctrlRef.current
+ ctrl.variant = result.variant
+ if (result.variant === 'choice') {
+ addToChoiceCache(ctrl, result.ads)
+ showChoiceAds(result.ads)
+ } else {
+ addToCache(ctrl, result.ad)
+ showAd(result.ad)
+ }
+ ctrl.adsShownSinceActivity = 1
}
setIsLoading(false)
})()
@@ -303,7 +395,12 @@ export const useGravityAd = (options?: { enabled?: boolean }): GravityAdState =>
}, [hasUserMessaged, shouldHideAds])
// Don't return ad when ads should be hidden
- return { ad: hasUserMessaged && !shouldHideAds ? ad : null, isLoading }
+ const visible = hasUserMessaged && !shouldHideAds
+ return {
+ ad: visible ? ad : null,
+ adData: visible ? adData : null,
+ isLoading,
+ }
}
type AdMessage = { role: 'user' | 'assistant'; content: string }
diff --git a/web/src/app/api/v1/ads/_post.ts b/web/src/app/api/v1/ads/_post.ts
index 1e8cc407e1..9a07f58c36 100644
--- a/web/src/app/api/v1/ads/_post.ts
+++ b/web/src/app/api/v1/ads/_post.ts
@@ -1,3 +1,5 @@
+import { createHash } from 'crypto'
+
import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events'
import { buildArray } from '@codebuff/common/util/array'
import { getErrorObject } from '@codebuff/common/util/error'
@@ -18,6 +20,26 @@ import type { NextRequest } from 'next/server'
const DEFAULT_PAYOUT = 0.04
+// A/B test: 50% of users see the "choice" ad variant (4 ads as bullet points)
+type AdVariant = 'banner' | 'choice'
+
+const CHOICE_AD_PLACEMENT_IDS = [
+ 'choice-ad-1',
+ 'choice-ad-2',
+ 'choice-ad-3',
+ 'choice-ad-4',
+]
+
+/**
+ * Deterministically assign a user to an ad variant based on their userId.
+ * Uses a hash so the assignment is stable across requests.
+ */
+function getAdVariant(userId: string): AdVariant {
+ const hash = createHash('sha256').update(`ad-variant:${userId}`).digest()
+ // Use first byte: even = banner, odd = choice (50/50 split)
+ return hash[0] % 2 === 0 ? 'banner' : 'choice'
+}
+
const messageSchema = z.object({
role: z.string(),
content: z.string(),
@@ -143,13 +165,23 @@ export async function postAds(params: {
}
: undefined
+ // Determine A/B test variant for this user
+ const variant = getAdVariant(userId)
+
+ // Build placements based on variant
+ const placements =
+ variant === 'choice'
+ ? CHOICE_AD_PLACEMENT_IDS.map((id) => ({
+ placement: 'below_response',
+ placement_id: id,
+ }))
+ : [{ placement: 'below_response', placement_id: 'code-assist-ad' }]
+
try {
const requestBody = {
messages: filteredMessages,
sessionId: sessionId ?? userId,
- placements: [
- { placement: 'below_response', placement_id: 'code-assist-ad' },
- ],
+ placements,
testAd: serverEnv.CB_ENVIRONMENT !== 'prod',
relevancy: 0.3,
...(device ? { device } : {}),
@@ -174,7 +206,7 @@ export async function postAds(params: {
{ request: requestBody, status: response.status },
'[ads] No ad available from Gravity API',
)
- return NextResponse.json({ ad: null }, { status: 200 })
+ return NextResponse.json({ ad: null, variant }, { status: 200 })
}
// Check response.ok BEFORE parsing JSON to handle HTML error pages gracefully
@@ -196,7 +228,7 @@ export async function postAds(params: {
{ request: requestBody, response: errorBody, status: response.status },
'[ads] Gravity API returned error',
)
- return NextResponse.json({ ad: null }, { status: 200 })
+ return NextResponse.json({ ad: null, variant }, { status: 200 })
}
// Now safe to parse JSON body since response.ok is true
@@ -207,16 +239,72 @@ export async function postAds(params: {
{ request: requestBody, response: ads, status: response.status },
'[ads] No ads returned from Gravity API',
)
- return NextResponse.json({ ad: null }, { status: 200 })
+ return NextResponse.json({ ad: null, variant }, { status: 200 })
}
- const ad = ads[0]
+ // Store all returned ads in the database
+ for (const ad of ads) {
+ const payout = ad.payout || DEFAULT_PAYOUT
+ try {
+ await db.insert(schema.adImpression).values({
+ user_id: userId,
+ ad_text: ad.adText,
+ title: ad.title,
+ cta: ad.cta,
+ url: ad.url,
+ favicon: ad.favicon,
+ click_url: ad.clickUrl,
+ imp_url: ad.impUrl,
+ payout: String(payout),
+ credits_granted: 0,
+ })
+ } catch (error) {
+ logger.warn(
+ {
+ userId,
+ impUrl: ad.impUrl,
+ status: response.status,
+ error:
+ error instanceof Error
+ ? { name: error.name, message: error.message }
+ : error,
+ },
+ '[ads] Failed to create ad_impression record (likely duplicate)',
+ )
+ }
+ }
+
+ // Strip payout from all ads before returning to client
+ const sanitizeAd = (ad: Record) => {
+ const { payout: _payout, ...rest } = ad
+ return rest
+ }
+
+ if (variant === 'choice') {
+ // Return all ads for the choice variant (up to 4)
+ const sanitizedAds = ads.map(sanitizeAd)
+ logger.info(
+ {
+ variant,
+ adCount: sanitizedAds.length,
+ request: requestBody,
+ status: response.status,
+ },
+ '[ads] Fetched choice ads from Gravity API',
+ )
+
+ return NextResponse.json({ ads: sanitizedAds, variant })
+ }
+
+ // Banner variant: return single ad (existing behavior)
+ const ad = ads[0]
const payout = ad.payout || DEFAULT_PAYOUT
logger.info(
{
ad,
+ variant,
request: requestBody,
status: response.status,
payout: {
@@ -229,41 +317,7 @@ export async function postAds(params: {
'[ads] Fetched ad from Gravity API',
)
- // Insert ad_impression row to database (served_at = now)
- // This stores the trusted ad data server-side so we don't have to trust the client later
- try {
- await db.insert(schema.adImpression).values({
- user_id: userId,
- ad_text: ad.adText,
- title: ad.title,
- cta: ad.cta,
- url: ad.url,
- favicon: ad.favicon,
- click_url: ad.clickUrl,
- imp_url: ad.impUrl,
- payout: String(payout),
- credits_granted: 0, // Will be updated when impression is fired
- })
- } catch (error) {
- // If insert fails (e.g., duplicate impUrl), log but continue
- // The ad can still be shown, it just won't be tracked
- logger.warn(
- {
- userId,
- impUrl: ad.impUrl,
- status: response.status,
- error:
- error instanceof Error
- ? { name: error.name, message: error.message }
- : error,
- },
- '[ads] Failed to create ad_impression record (likely duplicate)',
- )
- }
-
- // Return ad to client without payout (credits will come from impression endpoint)
- const { payout: _payout, ...adWithoutPayout } = ad
- return NextResponse.json({ ad: adWithoutPayout })
+ return NextResponse.json({ ad: sanitizeAd(ad), variant })
} catch (error) {
logger.error(
{
@@ -278,7 +332,7 @@ export async function postAds(params: {
'[ads] Failed to fetch ad from Gravity API',
)
return NextResponse.json(
- { ad: null, error: getErrorObject(error) },
+ { ad: null, variant, error: getErrorObject(error) },
{ status: 500 },
)
}