feat: add opencode go upsell modal when limits are hit (#21583)
Co-authored-by: Frank <frank@anoma.ly>
This commit is contained in:
@@ -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),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -83,9 +83,15 @@ import { UI } from "@/cli/ui.ts"
|
|||||||
import { useTuiConfig } from "../../context/tui-config"
|
import { useTuiConfig } from "../../context/tui-config"
|
||||||
import { getScrollAcceleration } from "../../util/scroll"
|
import { getScrollAcceleration } from "../../util/scroll"
|
||||||
import { TuiPluginRuntime } from "../../plugin"
|
import { TuiPluginRuntime } from "../../plugin"
|
||||||
|
import { DialogGoUpsell } from "../../component/dialog-go-upsell"
|
||||||
|
import { SessionRetry } from "@/session/retry"
|
||||||
|
|
||||||
addDefaultParsers(parsers.parsers)
|
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<{
|
const context = createContext<{
|
||||||
width: number
|
width: number
|
||||||
sessionID: string
|
sessionID: string
|
||||||
@@ -218,6 +224,23 @@ export function Session() {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const renderer = useRenderer()
|
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)
|
// Allow exit when in child session (prompt is hidden)
|
||||||
const exit = useExit()
|
const exit = useExit()
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import { iife } from "@/util/iife"
|
|||||||
export namespace SessionRetry {
|
export namespace SessionRetry {
|
||||||
export type Err = ReturnType<NamedError["toObject"]>
|
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_INITIAL_DELAY = 2000
|
||||||
export const RETRY_BACKOFF_FACTOR = 2
|
export const RETRY_BACKOFF_FACTOR = 2
|
||||||
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
|
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.ContextOverflowError.isInstance(error)) return undefined
|
||||||
if (MessageV2.APIError.isInstance(error)) {
|
if (MessageV2.APIError.isInstance(error)) {
|
||||||
if (!error.data.isRetryable) return undefined
|
if (!error.data.isRetryable) return undefined
|
||||||
if (error.data.responseBody?.includes("FreeUsageLimitError"))
|
if (error.data.responseBody?.includes("FreeUsageLimitError")) return GO_UPSELL_MESSAGE
|
||||||
return `Free usage exceeded, subscribe to Go https://opencode.ai/go`
|
|
||||||
return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message
|
return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user