diff options
| author | Aiden Cline <[email protected]> | 2026-04-09 01:16:29 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-04-09 02:16:29 -0400 |
| commit | 489f57974d55d6556dfa5ef7e9b94c06c7238908 (patch) | |
| tree | 0d3d78e4de4727501b13e3b1e217e24823d42d9f | |
| parent | 3fc3974cbc5dba91e4df7bd64a6c03ae94cbdf41 (diff) | |
| download | opencode-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.tsx | 99 | ||||
| -rw-r--r-- | packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 23 | ||||
| -rw-r--r-- | packages/opencode/src/session/retry.ts | 7 |
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 } |
