Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 14 additions & 6 deletions cli/src/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -168,7 +169,7 @@ export const Chat = ({
})
const hasSubscription = subscriptionData?.hasSubscription ?? false

const { ad } = useGravityAd({ enabled: IS_FREEBUFF || !hasSubscription })
const { ad, adData, recordImpression } = useGravityAd({ enabled: IS_FREEBUFF || !hasSubscription })
const [adsManuallyDisabled, setAdsManuallyDisabled] = useState(false)

const handleDisableAds = useCallback(() => {
Expand Down Expand Up @@ -1445,11 +1446,18 @@ export const Chat = ({
)}

{ad && (IS_FREEBUFF || (!adsManuallyDisabled && getAdsEnabled())) && (
<AdBanner
ad={ad}
onDisableAds={handleDisableAds}
isFreeMode={IS_FREEBUFF || agentMode === 'FREE'}
/>
adData?.variant === 'choice' ? (
<ChoiceAdBanner
ads={adData.ads}
onImpression={recordImpression}
/>
) : (
<AdBanner
ad={ad}
onDisableAds={handleDisableAds}
isFreeMode={IS_FREEBUFF || agentMode === 'FREE'}
/>
)
)}

{reviewMode ? (
Expand Down
2 changes: 1 addition & 1 deletion cli/src/commands/ads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const handleAdsEnable = (): {
return {
postUserMessage: (messages) => [
...messages,
getSystemMessage('Ads enabled. You will see contextual ads above the input and earn credits from impressions.'),
getSystemMessage('Ads enabled. You will see contextual ads above the input.'),
],
}
}
Expand Down
7 changes: 2 additions & 5 deletions cli/src/components/ad-banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,7 @@ export const AdBanner: React.FC<AdBannerProps> = ({ ad, onDisableAds, isFreeMode
{domain}
</text>
)}
<box style={{ flexGrow: 1 }} />
{!IS_FREEBUFF && ad.credits != null && ad.credits > 0 && (
<text style={{ fg: theme.muted }}>+{ad.credits} credits</text>
)}

</box>
</Button>
{/* Info panel: shown when Ad label is clicked, below the ad */}
Expand All @@ -179,7 +176,7 @@ export const AdBanner: React.FC<AdBannerProps> = ({ ad, onDisableAds, isFreeMode
<text style={{ fg: theme.muted, flexShrink: 1 }}>
{IS_FREEBUFF
? 'Ads help keep Freebuff free.'
: 'Ads are optional and earn you credits on each impression. Feel free to hide them anytime.'}
: 'Ads are optional. Feel free to hide them anytime.'}
</text>
<Button
onClick={() => setShowInfoPanel(false)}
Expand Down
146 changes: 146 additions & 0 deletions cli/src/components/choice-ad-banner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { TextAttributes } from '@opentui/core'
import { safeOpen } from '../utils/open-url'
import React, { useState, useMemo, useEffect } from 'react'

import { Button } from './button'
import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
import { useTheme } from '../hooks/use-theme'
import { BORDER_CHARS } from '../utils/ui-constants'

import type { AdResponse } from '../hooks/use-gravity-ad'

interface ChoiceAdBannerProps {
ads: AdResponse[]
onImpression?: (impUrl: string) => void
}

const CARD_HEIGHT = 5 // border-top + 2 lines description + spacer + cta row + border-bottom
const MAX_DESC_LINES = 2
const MIN_CARD_WIDTH = 60 // Minimum width per ad card to remain readable

function truncateToLines(text: string, lineWidth: number, maxLines: number): string {
if (lineWidth <= 0) return text
const maxChars = lineWidth * maxLines
if (text.length <= maxChars) return text
return text.slice(0, maxChars - 1) + '…'
}

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<ChoiceAdBannerProps> = ({ ads, onImpression }) => {
const theme = useTheme()
const { terminalWidth } = useTerminalDimensions()
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)

// Available width for cards (terminal minus left/right margin of 1 each)
const colAvail = terminalWidth - 2

// Only show as many ads as fit with a healthy minimum width; hide the rest
const maxVisible = Math.max(1, Math.floor(colAvail / MIN_CARD_WIDTH))
const visibleAds = useMemo(
() => (ads.length > maxVisible ? ads.slice(0, maxVisible) : ads),
[ads, maxVisible],
)

const widths = useMemo(() => columnWidths(visibleAds.length, colAvail), [visibleAds.length, colAvail])

// Fire impressions only for visible ads
useEffect(() => {
if (onImpression) {
for (const ad of visibleAds) {
onImpression(ad.impUrl)
}
}
}, [visibleAds, onImpression])

const hoverBorderColor = theme.link

return (
<box
style={{
width: '100%',
flexDirection: 'column',
}}
>
{/* Card columns */}
<box
style={{
marginLeft: 1,
marginRight: 1,
flexDirection: 'row',
}}
>
{visibleAds.map((ad, i) => {
const isHovered = hoveredIndex === i
const domain = extractDomain(ad.url)
const ctaText = ad.cta || ad.title || 'Learn more'

return (
<Button
key={ad.impUrl}
onClick={() => {
if (ad.clickUrl) safeOpen(ad.clickUrl)
}}
onMouseOver={() => setHoveredIndex(i)}
onMouseOut={() => setHoveredIndex(null)}
style={{
width: widths[i],
height: CARD_HEIGHT,
borderStyle: 'single',
borderColor: isHovered ? hoverBorderColor : theme.muted,
customBorderChars: BORDER_CHARS,
paddingLeft: 1,
paddingRight: 1,
flexDirection: 'column',

}}
>
<box style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', height: MAX_DESC_LINES, overflow: 'hidden' }}>
<text style={{ fg: theme.muted, flexShrink: 1 }}>
{truncateToLines(ad.adText, widths[i] - 8, MAX_DESC_LINES)}
</text>
<text style={{ fg: theme.muted, flexShrink: 0 }}>{' Ad'}</text>
</box>
<box style={{ flexGrow: 1 }} />
{/* Bottom: CTA + domain */}
<box style={{ flexDirection: 'row', columnGap: 1, alignItems: 'center' }}>
<text
style={{
fg: theme.name === 'light' ? '#ffffff' : theme.background,
bg: isHovered ? theme.link : theme.muted,
attributes: TextAttributes.BOLD,
}}
>
{` ${ctaText} `}
</text>
<text style={{ fg: theme.muted, attributes: TextAttributes.UNDERLINE }}>
{domain}
</text>

</box>
</Button>
)
})}

</box>

</box>
)
}
5 changes: 1 addition & 4 deletions cli/src/components/usage-banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,6 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => {
}

const colorLevel = getBannerColorLevel(activeData.remainingBalance)
const adCredits = activeData.balanceBreakdown?.ad
const renewalDate = activeData.next_quota_reset ? formatRenewalDate(activeData.next_quota_reset) : null

const activeSubscription = subscriptionData?.hasSubscription ? subscriptionData : null
Expand Down Expand Up @@ -152,9 +151,7 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => {
{activeData.remainingBalance?.toLocaleString() ?? '?'} credits
</text>
)}
{adCredits != null && adCredits > 0 && (
<text style={{ fg: theme.muted }}>{`(${adCredits} from ads)`}</text>
)}

{!activeSubscription && renewalDate && (
<>
<text style={{ fg: theme.muted }}>· Renews:</text>
Expand Down
4 changes: 2 additions & 2 deletions cli/src/data/slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,12 @@ const ALL_SLASH_COMMANDS: SlashCommand[] = [
{
id: 'ads:enable',
label: 'ads:enable',
description: 'Enable contextual ads and earn credits',
description: 'Enable contextual ads',
},
{
id: 'ads:disable',
label: 'ads:disable',
description: 'Disable contextual ads and stop earning credits',
description: 'Disable contextual ads',
},
{
id: 'refer-friends',
Expand Down
Loading
Loading