tui: go plan payg msg (#26248)

This commit is contained in:
Aiden Cline
2026-05-07 16:53:24 -05:00
committed by GitHub
parent 22e64cac67
commit f5d0371efe
10 changed files with 285 additions and 83 deletions

View File

@@ -8,32 +8,36 @@ import { GoLogo } from "./logo"
import { BgPulse, type BgPulseMask } from "./bg-pulse"
import { useBindings } from "../keymap"
const GO_URL = "https://opencode.ai/go"
const PAD_X = 3
const PAD_TOP_OUTER = 1
export type DialogGoUpsellProps = {
export type DialogRetryActionProps = {
title: string
message: string
label: string
link?: string
onClose?: (dontShowAgain?: boolean) => void
}
function subscribe(props: DialogGoUpsellProps, dialog: ReturnType<typeof useDialog>) {
open(GO_URL).catch(() => {})
function runAction(props: DialogRetryActionProps, dialog: ReturnType<typeof useDialog>) {
if (props.link) open(props.link).catch(() => {})
props.onClose?.()
dialog.clear()
}
function dismiss(props: DialogGoUpsellProps, dialog: ReturnType<typeof useDialog>) {
function dismiss(props: DialogRetryActionProps, dialog: ReturnType<typeof useDialog>) {
props.onClose?.(true)
dialog.clear()
}
export function DialogGoUpsell(props: DialogGoUpsellProps) {
export function DialogRetryAction(props: DialogRetryActionProps) {
const dialog = useDialog()
const { theme } = useTheme()
const fg = selectedForeground(theme)
const [selected, setSelected] = createSignal<"dismiss" | "subscribe">("subscribe")
const [selected, setSelected] = createSignal<"dismiss" | "action">("action")
const [center, setCenter] = createSignal<{ x: number; y: number } | undefined>()
const [masks, setMasks] = createSignal<BgPulseMask[]>([])
const showGoTreatment = () => props.link === "https://opencode.ai/go"
let content: BoxRenderable | undefined
let logoBox: BoxRenderable | undefined
let headingBox: BoxRenderable | undefined
@@ -41,11 +45,13 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) {
let buttonsBox: BoxRenderable | undefined
const sync = () => {
if (!content || !logoBox) return
setCenter({
x: logoBox.x - content.x + logoBox.width / 2,
y: logoBox.y - content.y + logoBox.height / 2 + PAD_TOP_OUTER,
})
if (!content) return
if (logoBox) {
setCenter({
x: logoBox.x - content.x + logoBox.width / 2,
y: logoBox.y - content.y + logoBox.height / 2 + PAD_TOP_OUTER,
})
}
const next: BgPulseMask[] = []
const baseY = PAD_TOP_OUTER
for (const b of [headingBox, descBox, buttonsBox]) {
@@ -75,20 +81,20 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) {
bindings: [
{
key: "left",
cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")),
cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
},
{
key: "right",
cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")),
cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
},
{
key: "tab",
cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")),
cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
},
{
key: "return",
cmd: () => {
if (selected() === "subscribe") subscribe(props, dialog)
if (selected() === "action") runAction(props, dialog)
else dismiss(props, dialog)
},
},
@@ -97,33 +103,34 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) {
return (
<box ref={(item: BoxRenderable) => (content = item)}>
<box position="absolute" top={-PAD_TOP_OUTER} left={0} right={0} bottom={0} zIndex={0}>
<BgPulse centerX={center()?.x} centerY={center()?.y} masks={masks()} />
</box>
<box paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
{showGoTreatment() ? (
<box position="absolute" top={-PAD_TOP_OUTER} left={0} right={0} bottom={0} zIndex={0}>
<BgPulse centerX={center()?.x} centerY={center()?.y} masks={masks()} />
</box>
) : null}
<box paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1} zIndex={1}>
<box ref={(item: BoxRenderable) => (headingBox = item)} flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Free limit reached
{props.title}
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box ref={(item: BoxRenderable) => (descBox = item)} gap={0}>
<box flexDirection="row">
<text fg={theme.textMuted}>Subscribe to </text>
<text attributes={TextAttributes.BOLD} fg={theme.textMuted}>
OpenCode Go
</text>
<text fg={theme.textMuted}> for reliable access to the</text>
</box>
<text fg={theme.textMuted}>best open-source models, starting at $5/month.</text>
<text fg={theme.textMuted}>{props.message}</text>
</box>
<box alignItems="center" gap={1} paddingBottom={1}>
<box ref={(item: BoxRenderable) => (logoBox = item)}>
<GoLogo />
</box>
<Link href={GO_URL} fg={theme.primary} />
<box gap={1} paddingBottom={1}>
{showGoTreatment() ? (
<box ref={(item: BoxRenderable) => (logoBox = item)} alignItems="center">
<GoLogo />
</box>
) : null}
{props.link ? (
<box width="100%" flexDirection="row" justifyContent="center">
<Link href={props.link} fg={theme.primary} wrapMode="none" />
</box>
) : null}
</box>
<box ref={(item: BoxRenderable) => (buttonsBox = item)} flexDirection="row" justifyContent="space-between">
<box
@@ -143,15 +150,15 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) {
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={selected() === "subscribe" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
onMouseOver={() => setSelected("subscribe")}
onMouseUp={() => subscribe(props, dialog)}
backgroundColor={selected() === "action" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
onMouseOver={() => setSelected("action")}
onMouseUp={() => runAction(props, dialog)}
>
<text
fg={selected() === "subscribe" ? fg : theme.text}
attributes={selected() === "subscribe" ? TextAttributes.BOLD : undefined}
fg={selected() === "action" ? fg : theme.text}
attributes={selected() === "action" ? TextAttributes.BOLD : undefined}
>
subscribe
{props.label}
</text>
</box>
</box>
@@ -160,10 +167,13 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) {
)
}
DialogGoUpsell.show = (dialog: DialogContext) => {
DialogRetryAction.show = (
dialog: DialogContext,
props: Pick<DialogRetryActionProps, "title" | "message" | "label" | "link">,
) => {
return new Promise<boolean>((resolve) => {
dialog.replace(
() => <DialogGoUpsell onClose={(dontShow) => resolve(dontShow ?? false)} />,
() => <DialogRetryAction {...props} onClose={(dontShow) => resolve(dontShow ?? false)} />,
() => resolve(false),
)
})

View File

@@ -85,7 +85,7 @@ import { UI } from "@/cli/ui.ts"
import { useTuiConfig } from "../../context/tui-config"
import { getScrollAcceleration } from "../../util/scroll"
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { DialogGoUpsell } from "../../component/dialog-go-upsell"
import { DialogRetryAction } from "../../component/dialog-retry-action"
import { SessionRetry } from "@/session/retry"
import { getRevertDiffFiles } from "../../util/revert-diff"
import { useCommandPalette } from "../../context/command-palette"
@@ -260,7 +260,7 @@ export function Session() {
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 (!evt.properties.status.action) return
if (dialog.stack.length > 0) return
const seen = kv.get(GO_UPSELL_LAST_SEEN_AT)
@@ -268,7 +268,7 @@ export function Session() {
if (kv.get(GO_UPSELL_DONT_SHOW)) return
void DialogGoUpsell.show(dialog).then((dontShowAgain) => {
void DialogRetryAction.show(dialog, evt.properties.status.action).then((dontShowAgain) => {
if (dontShowAgain) kv.set(GO_UPSELL_DONT_SHOW, true)
kv.set(GO_UPSELL_LAST_SEEN_AT, Date.now())
})

View File

@@ -6,6 +6,8 @@ export interface LinkProps {
href: string
children?: JSX.Element | string
fg?: RGBA
width?: number | "auto" | `${number}%`
wrapMode?: "word" | "none"
}
/**
@@ -18,6 +20,8 @@ export function Link(props: LinkProps) {
return (
<text
fg={props.fg}
width={props.width}
wrapMode={props.wrapMode}
onMouseUp={() => {
open(props.href).catch(() => {})
}}

View File

@@ -717,6 +717,7 @@ export const layer: Layer.Layer<
type: "retry",
attempt: info.attempt,
message: info.message,
action: info.action,
next: info.next,
})
},

View File

@@ -5,9 +5,19 @@ import { iife } from "@/util/iife"
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 GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go"
export const PAYG_UPSELL_MESSAGE = "Go usage exceeded, enable PAYG"
export const GO_UPSELL_URL = "https://opencode.ai/go"
export type Retryable = {
message: string
action?: {
title: string
message: string
label: string
link?: string
}
}
export const RETRY_INITIAL_DELAY = 2000
export const RETRY_BACKOFF_FACTOR = 2
@@ -59,8 +69,49 @@ export function retryable(error: Err) {
// 5xx errors are transient server failures and should always be retried,
// even when the provider SDK doesn't explicitly mark them as retryable.
if (!error.data.isRetryable && !(status !== undefined && status >= 500)) return undefined
if (error.data.responseBody?.includes("FreeUsageLimitError")) return GO_UPSELL_MESSAGE
return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message
if (error.data.responseBody?.includes("FreeUsageLimitError")) {
return {
message: GO_UPSELL_MESSAGE,
action: {
title: "Free limit reached",
message:
"Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.",
label: "subscribe",
link: GO_UPSELL_URL,
},
}
}
if (error.data.responseBody?.includes("GoUsageLimitError")) {
const body = parseJSON(error.data.responseBody)
const workspace = str(body?.metadata?.workspace)
const limit = str(body?.metadata?.limit)
const resetAt = num(body?.metadata?.resetAt)
const resetIn = iife(() => {
if (resetAt === undefined) return ""
const seconds = Math.max(0, Math.ceil(resetAt))
const days = Math.floor(seconds / 86_400)
const hours = Math.floor((seconds % 86_400) / 3_600)
const minutes = Math.ceil((seconds % 3_600) / 60)
const unit = (value: number, name: string) => `${value} ${name}${value === 1 ? "" : "s"}`
if (days > 0) return hours > 0 ? `${unit(days, "day")} ${unit(hours, "hour")}` : unit(days, "day")
if (hours > 0) return minutes > 0 ? `${unit(hours, "hour")} ${unit(minutes, "minute")}` : unit(hours, "hour")
return minutes > 0 ? unit(minutes, "minute") : "less than a minute"
})
return {
message: PAYG_UPSELL_MESSAGE,
action: {
title: "Go limit reached",
message:
limit && resetIn
? `You hit your ${limit} limit. It will reset in ${resetIn}. You can also enable pay-as-you-go.`
: "Enable pay-as-you-go to keep using Go models after your subscription quota is used.",
label: "enable PAYG",
...(workspace ? { link: `https://opencode.ai/workspace/${workspace}/go` } : {}),
},
}
}
return { message: error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message }
}
// Check for rate limit patterns in plain text error messages
@@ -72,50 +123,66 @@ export function retryable(error: Err) {
lower.includes("rate limit") ||
lower.includes("too many requests")
) {
return msg
return { message: msg }
}
}
const json = iife(() => {
try {
if (typeof error.data?.message === "string") {
const parsed = JSON.parse(error.data.message)
return parsed
}
return JSON.parse(error.data.message)
} catch {
return undefined
}
})
const json = parseJSON(error.data?.message)
if (!json || typeof json !== "object") return undefined
const code = typeof json.code === "string" ? json.code : ""
if (json.type === "error" && json.error?.type === "too_many_requests") {
return "Too Many Requests"
return { message: "Too Many Requests" }
}
if (code.includes("exhausted") || code.includes("unavailable")) {
return "Provider is overloaded"
return { message: "Provider is overloaded" }
}
if (json.type === "error" && typeof json.error?.code === "string" && json.error.code.includes("rate_limit")) {
return "Rate Limited"
return { message: "Rate Limited" }
}
return undefined
}
function str(value: unknown) {
if (value === undefined || value === null) return ""
return String(value)
}
function num(value: unknown) {
const parsed = Number.parseFloat(str(value))
if (Number.isNaN(parsed)) return undefined
return parsed
}
function parseJSON(value: unknown) {
return iife(() => {
try {
if (typeof value !== "string") return undefined
return JSON.parse(value)
} catch {
return undefined
}
})
}
export function policy(opts: {
parse: (error: unknown) => Err
set: (input: { attempt: number; message: string; next: number }) => Effect.Effect<void>
set: (input: { attempt: number; message: string; action?: Retryable["action"]; next: number }) => Effect.Effect<void>
}) {
return Schedule.fromStepWithMetadata(
Effect.succeed((meta: Schedule.InputMetadata<unknown>) => {
const error = opts.parse(meta.input)
const message = retryable(error)
if (!message) return Cause.done(meta.attempt)
const retry = retryable(error)
if (!retry) return Cause.done(meta.attempt)
return Effect.gen(function* () {
const wait = delay(meta.attempt, MessageV2.APIError.isInstance(error) ? error : undefined)
const now = yield* Clock.currentTimeMillis
yield* opts.set({ attempt: meta.attempt, message, next: now + wait })
yield* opts.set({
attempt: meta.attempt,
message: retry.message,
action: retry.action,
next: now + wait,
})
return [meta.attempt, Duration.millis(wait)] as [number, Duration.Duration]
})
}),

View File

@@ -15,6 +15,14 @@ export const Info = Schema.Union([
type: Schema.Literal("retry"),
attempt: NonNegativeInt,
message: Schema.String,
action: Schema.optional(
Schema.Struct({
title: Schema.String,
message: Schema.String,
label: Schema.String,
link: Schema.optional(Schema.String),
}),
),
next: NonNegativeInt,
}),
Schema.Struct({