Skip to content

Commit 489f579

Browse files
rekram1-nodefwang
andauthored
feat: add opencode go upsell modal when limits are hit (#21583)
Co-authored-by: Frank <frank@anoma.ly>
1 parent 3fc3974 commit 489f579

File tree

3 files changed

+127
-2
lines changed

3 files changed

+127
-2
lines changed
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { RGBA, TextAttributes } from "@opentui/core"
2+
import { useKeyboard } from "@opentui/solid"
3+
import open from "open"
4+
import { createSignal } from "solid-js"
5+
import { selectedForeground, useTheme } from "@tui/context/theme"
6+
import { useDialog, type DialogContext } from "@tui/ui/dialog"
7+
import { Link } from "@tui/ui/link"
8+
9+
const GO_URL = "https://opencode.ai/go"
10+
11+
export type DialogGoUpsellProps = {
12+
onClose?: (dontShowAgain?: boolean) => void
13+
}
14+
15+
function subscribe(props: DialogGoUpsellProps, dialog: ReturnType<typeof useDialog>) {
16+
open(GO_URL).catch(() => {})
17+
props.onClose?.()
18+
dialog.clear()
19+
}
20+
21+
function dismiss(props: DialogGoUpsellProps, dialog: ReturnType<typeof useDialog>) {
22+
props.onClose?.(true)
23+
dialog.clear()
24+
}
25+
26+
export function DialogGoUpsell(props: DialogGoUpsellProps) {
27+
const dialog = useDialog()
28+
const { theme } = useTheme()
29+
const fg = selectedForeground(theme)
30+
const [selected, setSelected] = createSignal(0)
31+
32+
useKeyboard((evt) => {
33+
if (evt.name === "left" || evt.name === "right" || evt.name === "tab") {
34+
setSelected((s) => (s === 0 ? 1 : 0))
35+
return
36+
}
37+
if (evt.name !== "return") return
38+
if (selected() === 0) subscribe(props, dialog)
39+
else dismiss(props, dialog)
40+
})
41+
42+
return (
43+
<box paddingLeft={2} paddingRight={2} gap={1}>
44+
<box flexDirection="row" justifyContent="space-between">
45+
<text attributes={TextAttributes.BOLD} fg={theme.text}>
46+
Free limit reached
47+
</text>
48+
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
49+
esc
50+
</text>
51+
</box>
52+
<box gap={1} paddingBottom={1}>
53+
<text fg={theme.textMuted}>
54+
Subscribe to OpenCode Go to keep going with reliable access to the best open-source models, starting at
55+
$5/month.
56+
</text>
57+
<box flexDirection="row" gap={1}>
58+
<Link href={GO_URL} fg={theme.primary} />
59+
</box>
60+
</box>
61+
<box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
62+
<box
63+
paddingLeft={3}
64+
paddingRight={3}
65+
backgroundColor={selected() === 0 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
66+
onMouseOver={() => setSelected(0)}
67+
onMouseUp={() => subscribe(props, dialog)}
68+
>
69+
<text fg={selected() === 0 ? fg : theme.text} attributes={selected() === 0 ? TextAttributes.BOLD : undefined}>
70+
subscribe
71+
</text>
72+
</box>
73+
<box
74+
paddingLeft={3}
75+
paddingRight={3}
76+
backgroundColor={selected() === 1 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
77+
onMouseOver={() => setSelected(1)}
78+
onMouseUp={() => dismiss(props, dialog)}
79+
>
80+
<text
81+
fg={selected() === 1 ? fg : theme.textMuted}
82+
attributes={selected() === 1 ? TextAttributes.BOLD : undefined}
83+
>
84+
don't show again
85+
</text>
86+
</box>
87+
</box>
88+
</box>
89+
)
90+
}
91+
92+
DialogGoUpsell.show = (dialog: DialogContext) => {
93+
return new Promise<boolean>((resolve) => {
94+
dialog.replace(
95+
() => <DialogGoUpsell onClose={(dontShow) => resolve(dontShow ?? false)} />,
96+
() => resolve(false),
97+
)
98+
})
99+
}

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,15 @@ import { UI } from "@/cli/ui.ts"
8383
import { useTuiConfig } from "../../context/tui-config"
8484
import { getScrollAcceleration } from "../../util/scroll"
8585
import { TuiPluginRuntime } from "../../plugin"
86+
import { DialogGoUpsell } from "../../component/dialog-go-upsell"
87+
import { SessionRetry } from "@/session/retry"
8688

8789
addDefaultParsers(parsers.parsers)
8890

91+
const GO_UPSELL_LAST_SEEN_AT = "go_upsell_last_seen_at"
92+
const GO_UPSELL_DONT_SHOW = "go_upsell_dont_show"
93+
const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs
94+
8995
const context = createContext<{
9096
width: number
9197
sessionID: string
@@ -218,6 +224,23 @@ export function Session() {
218224
const dialog = useDialog()
219225
const renderer = useRenderer()
220226

227+
sdk.event.on("session.status", (evt) => {
228+
if (evt.properties.sessionID !== route.sessionID) return
229+
if (evt.properties.status.type !== "retry") return
230+
if (evt.properties.status.message !== SessionRetry.GO_UPSELL_MESSAGE) return
231+
if (dialog.stack.length > 0) return
232+
233+
const seen = kv.get(GO_UPSELL_LAST_SEEN_AT)
234+
if (typeof seen === "number" && Date.now() - seen < GO_UPSELL_WINDOW) return
235+
236+
if (kv.get(GO_UPSELL_DONT_SHOW)) return
237+
238+
DialogGoUpsell.show(dialog).then((dontShowAgain) => {
239+
if (dontShowAgain) kv.set(GO_UPSELL_DONT_SHOW, true)
240+
kv.set(GO_UPSELL_LAST_SEEN_AT, Date.now())
241+
})
242+
})
243+
221244
// Allow exit when in child session (prompt is hidden)
222245
const exit = useExit()
223246

packages/opencode/src/session/retry.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import { iife } from "@/util/iife"
66
export namespace SessionRetry {
77
export type Err = ReturnType<NamedError["toObject"]>
88

9+
// This exported message is shared with the TUI upsell detector. Matching on a
10+
// literal error string kind of sucks, but it is the simplest for now.
11+
export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go https://opencode.ai/go"
12+
913
export const RETRY_INITIAL_DELAY = 2000
1014
export const RETRY_BACKOFF_FACTOR = 2
1115
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
@@ -53,8 +57,7 @@ export namespace SessionRetry {
5357
if (MessageV2.ContextOverflowError.isInstance(error)) return undefined
5458
if (MessageV2.APIError.isInstance(error)) {
5559
if (!error.data.isRetryable) return undefined
56-
if (error.data.responseBody?.includes("FreeUsageLimitError"))
57-
return `Free usage exceeded, subscribe to Go https://opencode.ai/go`
60+
if (error.data.responseBody?.includes("FreeUsageLimitError")) return GO_UPSELL_MESSAGE
5861
return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message
5962
}
6063

0 commit comments

Comments
 (0)