Skip to content
Merged

Ads #401

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
7 changes: 6 additions & 1 deletion bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
},
"dependencies": {
"@codebuff/sdk": "workspace:*",
"@gravity-ai/api": "^0.1.2",
"@opentui/core": "^0.1.63",
"@opentui/react": "^0.1.63",
"@tanstack/react-query": "^5.90.12",
Expand Down Expand Up @@ -639,6 +640,8 @@

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

"@gravity-ai/api": ["@gravity-ai/api@0.1.2", "", { "dependencies": { "axios": "^1.13.2" } }, "sha512-txsAhyzvwB/TNrj5R8DoNqw8afM3JY2ahl7aaeaD5ZsxP+7rxff7C7keGI7+gU2KT3d2Mcw4QB1nHhbTSCJYHw=="],

"@grpc/grpc-js": ["@grpc/grpc-js@1.14.3", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA=="],

"@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "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=="],
Expand Down Expand Up @@ -1503,7 +1506,7 @@

"axe-core": ["axe-core@4.11.0", "", {}, "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ=="],

"axios": ["axios@1.13.1", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-hU4EGxxt+j7TQijx1oYdAjw4xuIp1wRQSsbMFwSthCWeBQur1eF+qJ5iQ5sN3Tw8YRzQNKb8jszgBdMDVqwJcw=="],
"axios": ["axios@1.13.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA=="],

"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],

Expand Down Expand Up @@ -3963,6 +3966,8 @@

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

"nx/axios": ["axios@1.13.1", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-hU4EGxxt+j7TQijx1oYdAjw4xuIp1wRQSsbMFwSthCWeBQur1eF+qJ5iQ5sN3Tw8YRzQNKb8jszgBdMDVqwJcw=="],

"nx/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],

"nx/cli-spinners": ["cli-spinners@2.6.1", "", {}, "sha512-x/5fWmGMnbKQAaNwN+UZlV79qBLM9JFnJuJ03gIi5whrob0xV0ofNVHy9DhwGdsMJQc2OKv0oGmLzvaqvAVv+g=="],
Expand Down
1 change: 1 addition & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
},
"dependencies": {
"@codebuff/sdk": "workspace:*",
"@gravity-ai/api": "^0.1.2",
"@opentui/core": "^0.1.63",
"@opentui/react": "^0.1.63",
"@tanstack/react-query": "^5.90.12",
Expand Down
45 changes: 43 additions & 2 deletions cli/src/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import {
} from 'react'
import { useShallow } from 'zustand/react/shallow'

import { getAdsEnabled } from './commands/ads'
import { routeUserPrompt, addBashMessageToHistory } from './commands/router'
import { AdBanner } from './components/ad-banner'
import { ChatInputBar } from './components/chat-input-bar'
import { LoadPreviousButton } from './components/load-previous-button'
import { MessageWithAgents } from './components/message-with-agents'
Expand All @@ -29,6 +31,7 @@ import {
import { useClipboard } from './hooks/use-clipboard'
import { useConnectionStatus } from './hooks/use-connection-status'
import { useElapsedTime } from './hooks/use-elapsed-time'
import { useGravityAd } from './hooks/use-gravity-ad'
import { useEvent } from './hooks/use-event'
import { useExitHandler } from './hooks/use-exit-handler'
import { useInputHistory } from './hooks/use-input-history'
Expand Down Expand Up @@ -230,6 +233,7 @@ export const Chat = ({

const isConnected = useConnectionStatus(handleReconnection)
const mainAgentTimer = useElapsedTime()
const { ad, reportActivity } = useGravityAd()
const timerStartTime = mainAgentTimer.startTime

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

// Filter slash commands based on current ads state - only show the option that changes state
const filteredSlashCommands = useMemo(() => {
const adsEnabled = getAdsEnabled()
return SLASH_COMMANDS.filter((cmd) => {
if (cmd.id === 'ads:enable') return !adsEnabled
if (cmd.id === 'ads:disable') return adsEnabled
return true
})
}, [inputValue]) // Re-evaluate when input changes (user may have just toggled)

const {
slashContext,
mentionContext,
Expand All @@ -428,7 +442,7 @@ export const Chat = ({
disableAgentSuggestions: forceFileOnlyMentions || inputMode !== 'default',
inputValue: inputMode === 'bash' ? '' : inputValue,
cursorPosition,
slashCommands: SLASH_COMMANDS,
slashCommands: filteredSlashCommands,
localAgents,
fileTree,
currentAgentMode: agentMode,
Expand Down Expand Up @@ -872,6 +886,17 @@ export const Chat = ({
useEffect(() => {
inputValueRef.current = inputValue
}, [inputValue])

// Report activity on input changes for ad rotation (debounced via separate effect)
const lastReportedActivityRef = useRef<number>(0)
useEffect(() => {
const now = Date.now()
// Throttle to max once per second to avoid excessive calls
if (now - lastReportedActivityRef.current > 1000) {
lastReportedActivityRef.current = now
reportActivity()
}
}, [inputValue, reportActivity])
useEffect(() => {
cursorPositionRef.current = cursorPosition
}, [cursorPosition])
Expand Down Expand Up @@ -944,9 +969,11 @@ export const Chat = ({
}, [feedbackMode, askUserState, inputRef])

const handleSubmit = useCallback(async () => {
// Report activity for ad rotation
reportActivity()
const result = await onSubmitPrompt(inputValue, agentMode)
handleCommandResult(result)
}, [onSubmitPrompt, inputValue, agentMode, handleCommandResult])
}, [onSubmitPrompt, inputValue, agentMode, handleCommandResult, reportActivity])

const totalMentionMatches = agentMatches.length + fileMatches.length
const historyNavUpEnabled =
Expand Down Expand Up @@ -1325,8 +1352,20 @@ export const Chat = ({
!feedbackMode &&
(hasStatusIndicatorContent || shouldShowQueuePreview || !isAtBottom)

// Track mouse movement for ad activity (throttled)
const lastMouseActivityRef = useRef<number>(0)
const handleMouseActivity = useCallback(() => {
const now = Date.now()
// Throttle to max once per second
if (now - lastMouseActivityRef.current > 1000) {
lastMouseActivityRef.current = now
reportActivity()
}
}, [reportActivity])

return (
<box
onMouseMove={handleMouseActivity}
style={{
flexDirection: 'column',
gap: 0,
Expand Down Expand Up @@ -1429,6 +1468,8 @@ export const Chat = ({
/>
)}

{ad && getAdsEnabled() && <AdBanner ad={ad} />}

<ChatInputBar
inputValue={inputValue}
cursorPosition={cursorPosition}
Expand Down
39 changes: 39 additions & 0 deletions cli/src/commands/ads.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { saveSettings, loadSettings } from '../utils/settings'
import { getSystemMessage } from '../utils/message-history'
import { logger } from '../utils/logger'

import type { ChatMessage } from '../types/chat'

export const handleAdsEnable = (): {
postUserMessage: (messages: ChatMessage[]) => ChatMessage[]
} => {
logger.info('[gravity] Enabling ads')

saveSettings({ adsEnabled: true })

return {
postUserMessage: (messages) => [
...messages,
getSystemMessage('Ads enabled. You will see contextual ads above the input and earn credits from impressions.'),
],
}
}

export const handleAdsDisable = (): {
postUserMessage: (messages: ChatMessage[]) => ChatMessage[]
} => {
logger.info('[gravity] Disabling ads')
saveSettings({ adsEnabled: false })

return {
postUserMessage: (messages) => [
...messages,
getSystemMessage('Ads disabled.'),
],
}
}

export const getAdsEnabled = (): boolean => {
const settings = loadSettings()
return settings.adsEnabled ?? false
}
19 changes: 19 additions & 0 deletions cli/src/commands/command-registry.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { handleAdsEnable, handleAdsDisable } from './ads'
import { handleHelpCommand } from './help'
import { handleImageCommand } from './image'
import { handleInitializationFlowLocally } from './init'
Expand Down Expand Up @@ -155,6 +156,24 @@ const clearInput = (params: RouterParams) => {
}

export const COMMAND_REGISTRY: CommandDefinition[] = [
defineCommand({
name: 'ads:enable',
handler: (params) => {
const { postUserMessage } = handleAdsEnable()
params.setMessages((prev) => postUserMessage(prev))
params.saveToHistory(params.inputValue.trim())
clearInput(params)
},
}),
defineCommand({
name: 'ads:disable',
handler: (params) => {
const { postUserMessage } = handleAdsDisable()
params.setMessages((prev) => postUserMessage(prev))
params.saveToHistory(params.inputValue.trim())
clearInput(params)
},
}),
defineCommand({
name: 'help',
aliases: ['h', '?'],
Expand Down
119 changes: 119 additions & 0 deletions cli/src/components/ad-banner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import open from 'open'
import React, { useCallback, useEffect, useState } from 'react'

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

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

interface AdBannerProps {
ad: AdResponse
}

const extractDomain = (url: string): string => {
try {
const parsed = new URL(url)
return parsed.hostname.replace(/^www\./, '')
} catch {
return url
}
}

export const AdBanner: React.FC<AdBannerProps> = ({ ad }) => {
useEffect(() => {
logger.info(
{ adText: ad.adText?.substring(0, 50), hasClickUrl: !!ad.clickUrl },
'[gravity] Rendering AdBanner',
)
}, [ad])
const theme = useTheme()
const { separatorWidth, terminalWidth } = useTerminalDimensions()
const [isLinkHovered, setIsLinkHovered] = useState(false)

const handleClick = useCallback(() => {
if (ad.clickUrl) {
open(ad.clickUrl).catch((err) => {
logger.error(err, 'Failed to open ad link')
})
}
}, [ad.clickUrl])

// Use 'url' field for display domain (the actual destination)
const domain = extractDomain(ad.url)
// Use title as CTA
const ctaText = ad.title

// Calculate available width for ad text
// Account for: padding (2), "Ad" label with space (3)
const maxTextWidth = separatorWidth - 5

return (
<box
style={{
width: '100%',
flexDirection: 'column',
}}
>
{/* Horizontal divider line */}
<text style={{ fg: theme.muted }}>{'─'.repeat(terminalWidth)}</text>
{/* Top line: ad text + Ad label */}
<box
style={{
width: '100%',
paddingLeft: 1,
paddingRight: 1,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
}}
>
<text
style={{
fg: theme.foreground,
flexShrink: 1,
maxWidth: maxTextWidth,
}}
>
{ad.adText}
</text>
<text style={{ fg: theme.muted, flexShrink: 0 }}>Ad</text>
</box>
{/* Bottom line: button, domain, credits */}
<box
style={{
width: '100%',
paddingLeft: 1,
paddingRight: 1,
flexDirection: 'row',
flexWrap: 'wrap',
columnGap: 2,
alignItems: 'center',
}}
>
{ctaText && (
<Button
onClick={handleClick}
onMouseOver={() => setIsLinkHovered(true)}
onMouseOut={() => setIsLinkHovered(false)}
>
<text
style={{
fg: theme.name === 'light' ? '#ffffff' : theme.background,
bg: isLinkHovered ? theme.link : theme.muted,
}}
>
{` ${ctaText} `}
</text>
</Button>
)}
{domain && <text style={{ fg: theme.muted }}>{domain}</text>}
<box style={{ flexGrow: 1 }} />
{ad.credits != null && ad.credits > 0 && (
<text style={{ fg: theme.muted }}>+{ad.credits} credits</text>
)}
</box>
</box>
)
}
3 changes: 2 additions & 1 deletion cli/src/components/usage-banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => {
type: 'usage-response'
usage: number
remainingBalance: number | null
balanceBreakdown?: { free: number; paid: number }
balanceBreakdown?: { free: number; paid: number; ad?: number }
next_quota_reset: string | null
}>({
queryKey: usageQueryKeys.current(),
Expand Down Expand Up @@ -83,6 +83,7 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => {
sessionCreditsUsed,
remainingBalance: activeData.remainingBalance,
next_quota_reset: activeData.next_quota_reset,
adCredits: activeData.balanceBreakdown?.ad,
})

return (
Expand Down
10 changes: 10 additions & 0 deletions cli/src/data/slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ const MODE_COMMANDS: SlashCommand[] = AGENT_MODES.map((mode) => ({
}))

export const SLASH_COMMANDS: SlashCommand[] = [
{
id: 'ads:enable',
label: 'ads:enable',
description: 'Enable contextual ads and earn credits',
},
{
id: 'ads:disable',
label: 'ads:disable',
description: 'Disable contextual ads',
},
{
id: 'help',
label: 'help',
Expand Down
Loading
Loading