Skip to content

Commit b9fe123

Browse files
jahoomaGravityclaude
authored
Choice ad placement (#498)
Co-authored-by: Gravity <Gravity@Leos-MacBook-Pro.attlocal.net> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a887872 commit b9fe123

File tree

10 files changed

+394
-183
lines changed

10 files changed

+394
-183
lines changed

cli/src/chat.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { useShallow } from 'zustand/react/shallow'
1414
import { getAdsEnabled, handleAdsDisable } from './commands/ads'
1515
import { routeUserPrompt, addBashMessageToHistory } from './commands/router'
1616
import { AdBanner } from './components/ad-banner'
17+
import { ChoiceAdBanner } from './components/choice-ad-banner'
1718
import { ChatInputBar } from './components/chat-input-bar'
1819
import { LoadPreviousButton } from './components/load-previous-button'
1920
import { ReviewScreen } from './components/review-screen'
@@ -168,7 +169,7 @@ export const Chat = ({
168169
})
169170
const hasSubscription = subscriptionData?.hasSubscription ?? false
170171

171-
const { ad } = useGravityAd({ enabled: IS_FREEBUFF || !hasSubscription })
172+
const { ad, adData, recordImpression } = useGravityAd({ enabled: IS_FREEBUFF || !hasSubscription })
172173
const [adsManuallyDisabled, setAdsManuallyDisabled] = useState(false)
173174

174175
const handleDisableAds = useCallback(() => {
@@ -1445,11 +1446,18 @@ export const Chat = ({
14451446
)}
14461447

14471448
{ad && (IS_FREEBUFF || (!adsManuallyDisabled && getAdsEnabled())) && (
1448-
<AdBanner
1449-
ad={ad}
1450-
onDisableAds={handleDisableAds}
1451-
isFreeMode={IS_FREEBUFF || agentMode === 'FREE'}
1452-
/>
1449+
adData?.variant === 'choice' ? (
1450+
<ChoiceAdBanner
1451+
ads={adData.ads}
1452+
onImpression={recordImpression}
1453+
/>
1454+
) : (
1455+
<AdBanner
1456+
ad={ad}
1457+
onDisableAds={handleDisableAds}
1458+
isFreeMode={IS_FREEBUFF || agentMode === 'FREE'}
1459+
/>
1460+
)
14531461
)}
14541462

14551463
{reviewMode ? (

cli/src/commands/ads.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export const handleAdsEnable = (): {
1616
return {
1717
postUserMessage: (messages) => [
1818
...messages,
19-
getSystemMessage('Ads enabled. You will see contextual ads above the input and earn credits from impressions.'),
19+
getSystemMessage('Ads enabled. You will see contextual ads above the input.'),
2020
],
2121
}
2222
}

cli/src/components/ad-banner.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -150,10 +150,7 @@ export const AdBanner: React.FC<AdBannerProps> = ({ ad, onDisableAds, isFreeMode
150150
{domain}
151151
</text>
152152
)}
153-
<box style={{ flexGrow: 1 }} />
154-
{!IS_FREEBUFF && ad.credits != null && ad.credits > 0 && (
155-
<text style={{ fg: theme.muted }}>+{ad.credits} credits</text>
156-
)}
153+
157154
</box>
158155
</Button>
159156
{/* Info panel: shown when Ad label is clicked, below the ad */}
@@ -179,7 +176,7 @@ export const AdBanner: React.FC<AdBannerProps> = ({ ad, onDisableAds, isFreeMode
179176
<text style={{ fg: theme.muted, flexShrink: 1 }}>
180177
{IS_FREEBUFF
181178
? 'Ads help keep Freebuff free.'
182-
: 'Ads are optional and earn you credits on each impression. Feel free to hide them anytime.'}
179+
: 'Ads are optional. Feel free to hide them anytime.'}
183180
</text>
184181
<Button
185182
onClick={() => setShowInfoPanel(false)}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { TextAttributes } from '@opentui/core'
2+
import { safeOpen } from '../utils/open-url'
3+
import React, { useState, useMemo, useEffect } from 'react'
4+
5+
import { Button } from './button'
6+
import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
7+
import { useTheme } from '../hooks/use-theme'
8+
import { BORDER_CHARS } from '../utils/ui-constants'
9+
10+
import type { AdResponse } from '../hooks/use-gravity-ad'
11+
12+
interface ChoiceAdBannerProps {
13+
ads: AdResponse[]
14+
onImpression?: (impUrl: string) => void
15+
}
16+
17+
const CARD_HEIGHT = 5 // border-top + 2 lines description + spacer + cta row + border-bottom
18+
const MAX_DESC_LINES = 2
19+
const MIN_CARD_WIDTH = 60 // Minimum width per ad card to remain readable
20+
21+
function truncateToLines(text: string, lineWidth: number, maxLines: number): string {
22+
if (lineWidth <= 0) return text
23+
const maxChars = lineWidth * maxLines
24+
if (text.length <= maxChars) return text
25+
return text.slice(0, maxChars - 1) + '…'
26+
}
27+
28+
const extractDomain = (url: string): string => {
29+
try {
30+
const parsed = new URL(url)
31+
return parsed.hostname.replace(/^www\./, '')
32+
} catch {
33+
return url
34+
}
35+
}
36+
37+
/**
38+
* Calculate evenly distributed column widths that sum exactly to availableWidth.
39+
* Distributes remainder pixels across the first N columns so there's no gap.
40+
*/
41+
function columnWidths(count: number, availableWidth: number): number[] {
42+
const base = Math.floor(availableWidth / count)
43+
const remainder = availableWidth - base * count
44+
return Array.from({ length: count }, (_, i) => base + (i < remainder ? 1 : 0))
45+
}
46+
47+
export const ChoiceAdBanner: React.FC<ChoiceAdBannerProps> = ({ ads, onImpression }) => {
48+
const theme = useTheme()
49+
const { terminalWidth } = useTerminalDimensions()
50+
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
51+
52+
// Available width for cards (terminal minus left/right margin of 1 each)
53+
const colAvail = terminalWidth - 2
54+
55+
// Only show as many ads as fit with a healthy minimum width; hide the rest
56+
const maxVisible = Math.max(1, Math.floor(colAvail / MIN_CARD_WIDTH))
57+
const visibleAds = useMemo(
58+
() => (ads.length > maxVisible ? ads.slice(0, maxVisible) : ads),
59+
[ads, maxVisible],
60+
)
61+
62+
const widths = useMemo(() => columnWidths(visibleAds.length, colAvail), [visibleAds.length, colAvail])
63+
64+
// Fire impressions only for visible ads
65+
useEffect(() => {
66+
if (onImpression) {
67+
for (const ad of visibleAds) {
68+
onImpression(ad.impUrl)
69+
}
70+
}
71+
}, [visibleAds, onImpression])
72+
73+
const hoverBorderColor = theme.link
74+
75+
return (
76+
<box
77+
style={{
78+
width: '100%',
79+
flexDirection: 'column',
80+
}}
81+
>
82+
{/* Card columns */}
83+
<box
84+
style={{
85+
marginLeft: 1,
86+
marginRight: 1,
87+
flexDirection: 'row',
88+
}}
89+
>
90+
{visibleAds.map((ad, i) => {
91+
const isHovered = hoveredIndex === i
92+
const domain = extractDomain(ad.url)
93+
const ctaText = ad.cta || ad.title || 'Learn more'
94+
95+
return (
96+
<Button
97+
key={ad.impUrl}
98+
onClick={() => {
99+
if (ad.clickUrl) safeOpen(ad.clickUrl)
100+
}}
101+
onMouseOver={() => setHoveredIndex(i)}
102+
onMouseOut={() => setHoveredIndex(null)}
103+
style={{
104+
width: widths[i],
105+
height: CARD_HEIGHT,
106+
borderStyle: 'single',
107+
borderColor: isHovered ? hoverBorderColor : theme.muted,
108+
customBorderChars: BORDER_CHARS,
109+
paddingLeft: 1,
110+
paddingRight: 1,
111+
flexDirection: 'column',
112+
113+
}}
114+
>
115+
<box style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', height: MAX_DESC_LINES, overflow: 'hidden' }}>
116+
<text style={{ fg: theme.muted, flexShrink: 1 }}>
117+
{truncateToLines(ad.adText, widths[i] - 8, MAX_DESC_LINES)}
118+
</text>
119+
<text style={{ fg: theme.muted, flexShrink: 0 }}>{' Ad'}</text>
120+
</box>
121+
<box style={{ flexGrow: 1 }} />
122+
{/* Bottom: CTA + domain */}
123+
<box style={{ flexDirection: 'row', columnGap: 1, alignItems: 'center' }}>
124+
<text
125+
style={{
126+
fg: theme.name === 'light' ? '#ffffff' : theme.background,
127+
bg: isHovered ? theme.link : theme.muted,
128+
attributes: TextAttributes.BOLD,
129+
}}
130+
>
131+
{` ${ctaText} `}
132+
</text>
133+
<text style={{ fg: theme.muted, attributes: TextAttributes.UNDERLINE }}>
134+
{domain}
135+
</text>
136+
137+
</box>
138+
</Button>
139+
)
140+
})}
141+
142+
</box>
143+
144+
</box>
145+
)
146+
}

cli/src/components/usage-banner.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,6 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => {
110110
}
111111

112112
const colorLevel = getBannerColorLevel(activeData.remainingBalance)
113-
const adCredits = activeData.balanceBreakdown?.ad
114113
const renewalDate = activeData.next_quota_reset ? formatRenewalDate(activeData.next_quota_reset) : null
115114

116115
const activeSubscription = subscriptionData?.hasSubscription ? subscriptionData : null
@@ -152,9 +151,7 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => {
152151
{activeData.remainingBalance?.toLocaleString() ?? '?'} credits
153152
</text>
154153
)}
155-
{adCredits != null && adCredits > 0 && (
156-
<text style={{ fg: theme.muted }}>{`(${adCredits} from ads)`}</text>
157-
)}
154+
158155
{!activeSubscription && renewalDate && (
159156
<>
160157
<text style={{ fg: theme.muted }}>· Renews:</text>

cli/src/data/slash-commands.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,12 @@ const ALL_SLASH_COMMANDS: SlashCommand[] = [
8383
{
8484
id: 'ads:enable',
8585
label: 'ads:enable',
86-
description: 'Enable contextual ads and earn credits',
86+
description: 'Enable contextual ads',
8787
},
8888
{
8989
id: 'ads:disable',
9090
label: 'ads:disable',
91-
description: 'Disable contextual ads and stop earning credits',
91+
description: 'Disable contextual ads',
9292
},
9393
{
9494
id: 'refer-friends',

0 commit comments

Comments
 (0)