Skip to content

Commit 7859d05

Browse files
authored
Ads (#401)
1 parent ed58b61 commit 7859d05

File tree

25 files changed

+3945
-7
lines changed

25 files changed

+3945
-7
lines changed

bun.lock

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
},
4545
"dependencies": {
4646
"@codebuff/sdk": "workspace:*",
47+
"@gravity-ai/api": "^0.1.2",
4748
"@opentui/core": "^0.1.63",
4849
"@opentui/react": "^0.1.63",
4950
"@tanstack/react-query": "^5.90.12",
@@ -639,6 +640,8 @@
639640

640641
"@google-cloud/promisify": ["@google-cloud/[email protected]", "", {}, "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g=="],
641642

643+
"@gravity-ai/api": ["@gravity-ai/[email protected]", "", { "dependencies": { "axios": "^1.13.2" } }, "sha512-txsAhyzvwB/TNrj5R8DoNqw8afM3JY2ahl7aaeaD5ZsxP+7rxff7C7keGI7+gU2KT3d2Mcw4QB1nHhbTSCJYHw=="],
644+
642645
"@grpc/grpc-js": ["@grpc/[email protected]", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA=="],
643646

644647
"@grpc/proto-loader": ["@grpc/[email protected]", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="],
@@ -1503,7 +1506,7 @@
15031506

15041507
"axe-core": ["[email protected]", "", {}, "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ=="],
15051508

1506-
"axios": ["[email protected].1", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-hU4EGxxt+j7TQijx1oYdAjw4xuIp1wRQSsbMFwSthCWeBQur1eF+qJ5iQ5sN3Tw8YRzQNKb8jszgBdMDVqwJcw=="],
1509+
"axios": ["[email protected].2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA=="],
15071510

15081511
"axobject-query": ["[email protected]", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
15091512

@@ -3963,6 +3966,8 @@
39633966

39643967
"nextjs-linkedin-insight-tag/typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g=="],
39653968

3969+
"nx/axios": ["[email protected]", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-hU4EGxxt+j7TQijx1oYdAjw4xuIp1wRQSsbMFwSthCWeBQur1eF+qJ5iQ5sN3Tw8YRzQNKb8jszgBdMDVqwJcw=="],
3970+
39663971
"nx/chalk": ["[email protected]", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
39673972

39683973
"nx/cli-spinners": ["[email protected]", "", {}, "sha512-x/5fWmGMnbKQAaNwN+UZlV79qBLM9JFnJuJ03gIi5whrob0xV0ofNVHy9DhwGdsMJQc2OKv0oGmLzvaqvAVv+g=="],

cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
},
3030
"dependencies": {
3131
"@codebuff/sdk": "workspace:*",
32+
"@gravity-ai/api": "^0.1.2",
3233
"@opentui/core": "^0.1.63",
3334
"@opentui/react": "^0.1.63",
3435
"@tanstack/react-query": "^5.90.12",

cli/src/chat.tsx

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import {
1010
} from 'react'
1111
import { useShallow } from 'zustand/react/shallow'
1212

13+
import { getAdsEnabled } from './commands/ads'
1314
import { routeUserPrompt, addBashMessageToHistory } from './commands/router'
15+
import { AdBanner } from './components/ad-banner'
1416
import { ChatInputBar } from './components/chat-input-bar'
1517
import { LoadPreviousButton } from './components/load-previous-button'
1618
import { MessageWithAgents } from './components/message-with-agents'
@@ -29,6 +31,7 @@ import {
2931
import { useClipboard } from './hooks/use-clipboard'
3032
import { useConnectionStatus } from './hooks/use-connection-status'
3133
import { useElapsedTime } from './hooks/use-elapsed-time'
34+
import { useGravityAd } from './hooks/use-gravity-ad'
3235
import { useEvent } from './hooks/use-event'
3336
import { useExitHandler } from './hooks/use-exit-handler'
3437
import { useInputHistory } from './hooks/use-input-history'
@@ -230,6 +233,7 @@ export const Chat = ({
230233

231234
const isConnected = useConnectionStatus(handleReconnection)
232235
const mainAgentTimer = useElapsedTime()
236+
const { ad, reportActivity } = useGravityAd()
233237
const timerStartTime = mainAgentTimer.startTime
234238

235239
// Set initial mode from CLI flag on mount
@@ -415,6 +419,16 @@ export const Chat = ({
415419
const setInputMode = useChatStore((state) => state.setInputMode)
416420
const askUserState = useChatStore((state) => state.askUserState)
417421

422+
// Filter slash commands based on current ads state - only show the option that changes state
423+
const filteredSlashCommands = useMemo(() => {
424+
const adsEnabled = getAdsEnabled()
425+
return SLASH_COMMANDS.filter((cmd) => {
426+
if (cmd.id === 'ads:enable') return !adsEnabled
427+
if (cmd.id === 'ads:disable') return adsEnabled
428+
return true
429+
})
430+
}, [inputValue]) // Re-evaluate when input changes (user may have just toggled)
431+
418432
const {
419433
slashContext,
420434
mentionContext,
@@ -428,7 +442,7 @@ export const Chat = ({
428442
disableAgentSuggestions: forceFileOnlyMentions || inputMode !== 'default',
429443
inputValue: inputMode === 'bash' ? '' : inputValue,
430444
cursorPosition,
431-
slashCommands: SLASH_COMMANDS,
445+
slashCommands: filteredSlashCommands,
432446
localAgents,
433447
fileTree,
434448
currentAgentMode: agentMode,
@@ -872,6 +886,17 @@ export const Chat = ({
872886
useEffect(() => {
873887
inputValueRef.current = inputValue
874888
}, [inputValue])
889+
890+
// Report activity on input changes for ad rotation (debounced via separate effect)
891+
const lastReportedActivityRef = useRef<number>(0)
892+
useEffect(() => {
893+
const now = Date.now()
894+
// Throttle to max once per second to avoid excessive calls
895+
if (now - lastReportedActivityRef.current > 1000) {
896+
lastReportedActivityRef.current = now
897+
reportActivity()
898+
}
899+
}, [inputValue, reportActivity])
875900
useEffect(() => {
876901
cursorPositionRef.current = cursorPosition
877902
}, [cursorPosition])
@@ -944,9 +969,11 @@ export const Chat = ({
944969
}, [feedbackMode, askUserState, inputRef])
945970

946971
const handleSubmit = useCallback(async () => {
972+
// Report activity for ad rotation
973+
reportActivity()
947974
const result = await onSubmitPrompt(inputValue, agentMode)
948975
handleCommandResult(result)
949-
}, [onSubmitPrompt, inputValue, agentMode, handleCommandResult])
976+
}, [onSubmitPrompt, inputValue, agentMode, handleCommandResult, reportActivity])
950977

951978
const totalMentionMatches = agentMatches.length + fileMatches.length
952979
const historyNavUpEnabled =
@@ -1325,8 +1352,20 @@ export const Chat = ({
13251352
!feedbackMode &&
13261353
(hasStatusIndicatorContent || shouldShowQueuePreview || !isAtBottom)
13271354

1355+
// Track mouse movement for ad activity (throttled)
1356+
const lastMouseActivityRef = useRef<number>(0)
1357+
const handleMouseActivity = useCallback(() => {
1358+
const now = Date.now()
1359+
// Throttle to max once per second
1360+
if (now - lastMouseActivityRef.current > 1000) {
1361+
lastMouseActivityRef.current = now
1362+
reportActivity()
1363+
}
1364+
}, [reportActivity])
1365+
13281366
return (
13291367
<box
1368+
onMouseMove={handleMouseActivity}
13301369
style={{
13311370
flexDirection: 'column',
13321371
gap: 0,
@@ -1429,6 +1468,8 @@ export const Chat = ({
14291468
/>
14301469
)}
14311470

1471+
{ad && getAdsEnabled() && <AdBanner ad={ad} />}
1472+
14321473
<ChatInputBar
14331474
inputValue={inputValue}
14341475
cursorPosition={cursorPosition}

cli/src/commands/ads.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { saveSettings, loadSettings } from '../utils/settings'
2+
import { getSystemMessage } from '../utils/message-history'
3+
import { logger } from '../utils/logger'
4+
5+
import type { ChatMessage } from '../types/chat'
6+
7+
export const handleAdsEnable = (): {
8+
postUserMessage: (messages: ChatMessage[]) => ChatMessage[]
9+
} => {
10+
logger.info('[gravity] Enabling ads')
11+
12+
saveSettings({ adsEnabled: true })
13+
14+
return {
15+
postUserMessage: (messages) => [
16+
...messages,
17+
getSystemMessage('Ads enabled. You will see contextual ads above the input and earn credits from impressions.'),
18+
],
19+
}
20+
}
21+
22+
export const handleAdsDisable = (): {
23+
postUserMessage: (messages: ChatMessage[]) => ChatMessage[]
24+
} => {
25+
logger.info('[gravity] Disabling ads')
26+
saveSettings({ adsEnabled: false })
27+
28+
return {
29+
postUserMessage: (messages) => [
30+
...messages,
31+
getSystemMessage('Ads disabled.'),
32+
],
33+
}
34+
}
35+
36+
export const getAdsEnabled = (): boolean => {
37+
const settings = loadSettings()
38+
return settings.adsEnabled ?? false
39+
}

cli/src/commands/command-registry.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { handleAdsEnable, handleAdsDisable } from './ads'
12
import { handleHelpCommand } from './help'
23
import { handleImageCommand } from './image'
34
import { handleInitializationFlowLocally } from './init'
@@ -155,6 +156,24 @@ const clearInput = (params: RouterParams) => {
155156
}
156157

157158
export const COMMAND_REGISTRY: CommandDefinition[] = [
159+
defineCommand({
160+
name: 'ads:enable',
161+
handler: (params) => {
162+
const { postUserMessage } = handleAdsEnable()
163+
params.setMessages((prev) => postUserMessage(prev))
164+
params.saveToHistory(params.inputValue.trim())
165+
clearInput(params)
166+
},
167+
}),
168+
defineCommand({
169+
name: 'ads:disable',
170+
handler: (params) => {
171+
const { postUserMessage } = handleAdsDisable()
172+
params.setMessages((prev) => postUserMessage(prev))
173+
params.saveToHistory(params.inputValue.trim())
174+
clearInput(params)
175+
},
176+
}),
158177
defineCommand({
159178
name: 'help',
160179
aliases: ['h', '?'],

cli/src/components/ad-banner.tsx

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import open from 'open'
2+
import React, { useCallback, useEffect, useState } from 'react'
3+
4+
import { Button } from './button'
5+
import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
6+
import { useTheme } from '../hooks/use-theme'
7+
import { logger } from '../utils/logger'
8+
9+
import type { AdResponse } from '../hooks/use-gravity-ad'
10+
11+
interface AdBannerProps {
12+
ad: AdResponse
13+
}
14+
15+
const extractDomain = (url: string): string => {
16+
try {
17+
const parsed = new URL(url)
18+
return parsed.hostname.replace(/^www\./, '')
19+
} catch {
20+
return url
21+
}
22+
}
23+
24+
export const AdBanner: React.FC<AdBannerProps> = ({ ad }) => {
25+
useEffect(() => {
26+
logger.info(
27+
{ adText: ad.adText?.substring(0, 50), hasClickUrl: !!ad.clickUrl },
28+
'[gravity] Rendering AdBanner',
29+
)
30+
}, [ad])
31+
const theme = useTheme()
32+
const { separatorWidth, terminalWidth } = useTerminalDimensions()
33+
const [isLinkHovered, setIsLinkHovered] = useState(false)
34+
35+
const handleClick = useCallback(() => {
36+
if (ad.clickUrl) {
37+
open(ad.clickUrl).catch((err) => {
38+
logger.error(err, 'Failed to open ad link')
39+
})
40+
}
41+
}, [ad.clickUrl])
42+
43+
// Use 'url' field for display domain (the actual destination)
44+
const domain = extractDomain(ad.url)
45+
// Use title as CTA
46+
const ctaText = ad.title
47+
48+
// Calculate available width for ad text
49+
// Account for: padding (2), "Ad" label with space (3)
50+
const maxTextWidth = separatorWidth - 5
51+
52+
return (
53+
<box
54+
style={{
55+
width: '100%',
56+
flexDirection: 'column',
57+
}}
58+
>
59+
{/* Horizontal divider line */}
60+
<text style={{ fg: theme.muted }}>{'─'.repeat(terminalWidth)}</text>
61+
{/* Top line: ad text + Ad label */}
62+
<box
63+
style={{
64+
width: '100%',
65+
paddingLeft: 1,
66+
paddingRight: 1,
67+
flexDirection: 'row',
68+
justifyContent: 'space-between',
69+
alignItems: 'flex-start',
70+
}}
71+
>
72+
<text
73+
style={{
74+
fg: theme.foreground,
75+
flexShrink: 1,
76+
maxWidth: maxTextWidth,
77+
}}
78+
>
79+
{ad.adText}
80+
</text>
81+
<text style={{ fg: theme.muted, flexShrink: 0 }}>Ad</text>
82+
</box>
83+
{/* Bottom line: button, domain, credits */}
84+
<box
85+
style={{
86+
width: '100%',
87+
paddingLeft: 1,
88+
paddingRight: 1,
89+
flexDirection: 'row',
90+
flexWrap: 'wrap',
91+
columnGap: 2,
92+
alignItems: 'center',
93+
}}
94+
>
95+
{ctaText && (
96+
<Button
97+
onClick={handleClick}
98+
onMouseOver={() => setIsLinkHovered(true)}
99+
onMouseOut={() => setIsLinkHovered(false)}
100+
>
101+
<text
102+
style={{
103+
fg: theme.name === 'light' ? '#ffffff' : theme.background,
104+
bg: isLinkHovered ? theme.link : theme.muted,
105+
}}
106+
>
107+
{` ${ctaText} `}
108+
</text>
109+
</Button>
110+
)}
111+
{domain && <text style={{ fg: theme.muted }}>{domain}</text>}
112+
<box style={{ flexGrow: 1 }} />
113+
{ad.credits != null && ad.credits > 0 && (
114+
<text style={{ fg: theme.muted }}>+{ad.credits} credits</text>
115+
)}
116+
</box>
117+
</box>
118+
)
119+
}

cli/src/components/usage-banner.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => {
4343
type: 'usage-response'
4444
usage: number
4545
remainingBalance: number | null
46-
balanceBreakdown?: { free: number; paid: number }
46+
balanceBreakdown?: { free: number; paid: number; ad?: number }
4747
next_quota_reset: string | null
4848
}>({
4949
queryKey: usageQueryKeys.current(),
@@ -83,6 +83,7 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => {
8383
sessionCreditsUsed,
8484
remainingBalance: activeData.remainingBalance,
8585
next_quota_reset: activeData.next_quota_reset,
86+
adCredits: activeData.balanceBreakdown?.ad,
8687
})
8788

8889
return (

cli/src/data/slash-commands.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,16 @@ const MODE_COMMANDS: SlashCommand[] = AGENT_MODES.map((mode) => ({
1515
}))
1616

1717
export const SLASH_COMMANDS: SlashCommand[] = [
18+
{
19+
id: 'ads:enable',
20+
label: 'ads:enable',
21+
description: 'Enable contextual ads and earn credits',
22+
},
23+
{
24+
id: 'ads:disable',
25+
label: 'ads:disable',
26+
description: 'Disable contextual ads',
27+
},
1828
{
1929
id: 'help',
2030
label: 'help',

0 commit comments

Comments
 (0)