summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAiden Cline <[email protected]>2026-04-09 01:16:29 -0500
committerGitHub <[email protected]>2026-04-09 02:16:29 -0400
commit489f57974d55d6556dfa5ef7e9b94c06c7238908 (patch)
tree0d3d78e4de4727501b13e3b1e217e24823d42d9f
parent3fc3974cbc5dba91e4df7bd64a6c03ae94cbdf41 (diff)
downloadopencode-489f57974d55d6556dfa5ef7e9b94c06c7238908.tar.gz
opencode-489f57974d55d6556dfa5ef7e9b94c06c7238908.zip
feat: add opencode go upsell modal when limits are hit (#21583)
Co-authored-by: Frank <[email protected]>
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx99
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/index.tsx23
-rw-r--r--packages/opencode/src/session/retry.ts7
3 files changed, 127 insertions, 2 deletions
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx
new file mode 100644
index 000000000..2d200ca3b
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx
@@ -0,0 +1,99 @@
+import { RGBA, TextAttributes } from "@opentui/core"
+import { useKeyboard } from "@opentui/solid"
+import open from "open"
+import { createSignal } from "solid-js"
+import { selectedForeground, useTheme } from "@tui/context/theme"
+import { useDialog, type DialogContext } from "@tui/ui/dialog"
+import { Link } from "@tui/ui/link"
+
+const GO_URL = "https://opencode.ai/go"
+
+export type DialogGoUpsellProps = {
+ onClose?: (dontShowAgain?: boolean) => void
+}
+
+function subscribe(props: DialogGoUpsellProps, dialog: ReturnType<typeof useDialog>) {
+ open(GO_URL).catch(() => {})
+ props.onClose?.()
+ dialog.clear()
+}
+
+function dismiss(props: DialogGoUpsellProps, dialog: ReturnType<typeof useDialog>) {
+ props.onClose?.(true)
+ dialog.clear()
+}
+
+export function DialogGoUpsell(props: DialogGoUpsellProps) {
+ const dialog = useDialog()
+ const { theme } = useTheme()
+ const fg = selectedForeground(theme)
+ const [selected, setSelected] = createSignal(0)
+
+ useKeyboard((evt) => {
+ if (evt.name === "left" || evt.name === "right" || evt.name === "tab") {
+ setSelected((s) => (s === 0 ? 1 : 0))
+ return
+ }
+ if (evt.name !== "return") return
+ if (selected() === 0) subscribe(props, dialog)
+ else dismiss(props, dialog)
+ })
+
+ return (
+ <box paddingLeft={2} paddingRight={2} gap={1}>
+ <box flexDirection="row" justifyContent="space-between">
+ <text attributes={TextAttributes.BOLD} fg={theme.text}>
+ Free limit reached
+ </text>
+ <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
+ esc
+ </text>
+ </box>
+ <box gap={1} paddingBottom={1}>
+ <text fg={theme.textMuted}>
+ Subscribe to OpenCode Go to keep going with reliable access to the best open-source models, starting at
+ $5/month.
+ </text>
+ <box flexDirection="row" gap={1}>
+ <Link href={GO_URL} fg={theme.primary} />
+ </box>
+ </box>
+ <box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
+ <box
+ paddingLeft={3}
+ paddingRight={3}
+ backgroundColor={selected() === 0 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
+ onMouseOver={() => setSelected(0)}
+ onMouseUp={() => subscribe(props, dialog)}
+ >
+ <text fg={selected() === 0 ? fg : theme.text} attributes={selected() === 0 ? TextAttributes.BOLD : undefined}>
+ subscribe
+ </text>
+ </box>
+ <box
+ paddingLeft={3}
+ paddingRight={3}
+ backgroundColor={selected() === 1 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
+ onMouseOver={() => setSelected(1)}
+ onMouseUp={() => dismiss(props, dialog)}
+ >
+ <text
+ fg={selected() === 1 ? fg : theme.textMuted}
+ attributes={selected() === 1 ? TextAttributes.BOLD : undefined}
+ >
+ don't show again
+ </text>
+ </box>
+ </box>
+ </box>
+ )
+}
+
+DialogGoUpsell.show = (dialog: DialogContext) => {
+ return new Promise<boolean>((resolve) => {
+ dialog.replace(
+ () => <DialogGoUpsell onClose={(dontShow) => resolve(dontShow ?? false)} />,
+ () => resolve(false),
+ )
+ })
+}
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index 396d75630..d4ae8db61 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -83,9 +83,15 @@ import { UI } from "@/cli/ui.ts"
import { useTuiConfig } from "../../context/tui-config"
import { getScrollAcceleration } from "../../util/scroll"
import { TuiPluginRuntime } from "../../plugin"
+import { DialogGoUpsell } from "../../component/dialog-go-upsell"
+import { SessionRetry } from "@/session/retry"
addDefaultParsers(parsers.parsers)
+const GO_UPSELL_LAST_SEEN_AT = "go_upsell_last_seen_at"
+const GO_UPSELL_DONT_SHOW = "go_upsell_dont_show"
+const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs
+
const context = createContext<{
width: number
sessionID: string
@@ -218,6 +224,23 @@ export function Session() {
const dialog = useDialog()
const renderer = useRenderer()
+ sdk.event.on("session.status", (evt) => {
+ if (evt.properties.sessionID !== route.sessionID) return
+ if (evt.properties.status.type !== "retry") return
+ if (evt.properties.status.message !== SessionRetry.GO_UPSELL_MESSAGE) return
+ if (dialog.stack.length > 0) return
+
+ const seen = kv.get(GO_UPSELL_LAST_SEEN_AT)
+ if (typeof seen === "number" && Date.now() - seen < GO_UPSELL_WINDOW) return
+
+ if (kv.get(GO_UPSELL_DONT_SHOW)) return
+
+ DialogGoUpsell.show(dialog).then((dontShowAgain) => {
+ if (dontShowAgain) kv.set(GO_UPSELL_DONT_SHOW, true)
+ kv.set(GO_UPSELL_LAST_SEEN_AT, Date.now())
+ })
+ })
+
// Allow exit when in child session (prompt is hidden)
const exit = useExit()
diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts
index 16fec29f3..5ec9a585b 100644
--- a/packages/opencode/src/session/retry.ts
+++ b/packages/opencode/src/session/retry.ts
@@ -6,6 +6,10 @@ import { iife } from "@/util/iife"
export namespace SessionRetry {
export type Err = ReturnType<NamedError["toObject"]>
+ // This exported message is shared with the TUI upsell detector. Matching on a
+ // literal error string kind of sucks, but it is the simplest for now.
+ export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go https://opencode.ai/go"
+
export const RETRY_INITIAL_DELAY = 2000
export const RETRY_BACKOFF_FACTOR = 2
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
@@ -53,8 +57,7 @@ export namespace SessionRetry {
if (MessageV2.ContextOverflowError.isInstance(error)) return undefined
if (MessageV2.APIError.isInstance(error)) {
if (!error.data.isRetryable) return undefined
- if (error.data.responseBody?.includes("FreeUsageLimitError"))
- return `Free usage exceeded, subscribe to Go https://opencode.ai/go`
+ if (error.data.responseBody?.includes("FreeUsageLimitError")) return GO_UPSELL_MESSAGE
return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message
}