Compare commits

...

5 Commits

20 changed files with 1491 additions and 24 deletions
+1
View File
@@ -110,6 +110,7 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
const text = [input.message, body].filter((value) => value.length > 0).join("\n")
const clientScoped = input.status === undefined || (input.status >= 400 && input.status < 500)
if (codes.includes("freeusagelimiterror")) return new QuotaExceededError({ ...details, classification: "free-tier" })
if (
clientScoped &&
(codes.includes("context_length_exceeded") ||
+4 -1
View File
@@ -55,7 +55,10 @@ export class RateLimitError extends Schema.TaggedError<RateLimitError>("AI.Error
export class QuotaExceededError extends Schema.TaggedError<QuotaExceededError>("AI.Error.QuotaExceeded")(
"QuotaExceeded",
ReasonFields,
{
...ReasonFields,
classification: Schema.optional(Schema.Literal("free-tier")),
},
) {}
export class ContentPolicyError extends Schema.TaggedError<ContentPolicyError>("AI.Error.ContentPolicy")(
+19
View File
@@ -29,6 +29,25 @@ const expectAIError = (error: unknown) => {
const largeProviderMessage = `Upstream request failed: ${"validation failed; ".repeat(1_000)}`
describe("RequestExecutor", () => {
it.effect("preserves free-tier exhaustion on HTTP 429 responses", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expect(error.reason).toMatchObject({
_tag: "QuotaExceeded",
classification: "free-tier",
http: { status: 429, headers: { "retry-after": "60" } },
})
}).pipe(
Effect.provide(
fixedResponse(JSON.stringify({ error: { type: "FreeUsageLimitError", message: "Rate limit exceeded" } }), {
status: 429,
headers: { "content-type": "application/json", "retry-after": "60" },
}),
),
),
)
it.effect("preserves externally captured HTTP errors without inventing response context", () =>
Effect.sync(() => {
const cause = new Error("upstream request failed")
+13
View File
@@ -3,6 +3,19 @@ import { isContextOverflow } from "../src/index.js"
import { classifyProviderFailure } from "../src/provider-error.js"
describe("provider error classification", () => {
test("distinguishes free-tier exhaustion from transient rate limits", () => {
const rawBody = JSON.stringify({ error: { type: "FreeUsageLimitError", message: "Rate limit exceeded" } })
const reason = classifyProviderFailure({ message: "Rate limit exceeded", status: 429, rawBody })
expect(reason).toMatchObject({ _tag: "QuotaExceeded", classification: "free-tier", body: rawBody })
expect(reason.message).toBe("Rate limit exceeded")
expect(classifyProviderFailure({ message: rawBody })).toMatchObject({
_tag: "QuotaExceeded",
classification: "free-tier",
})
expect(classifyProviderFailure({ message: "Rate limit exceeded", status: 429 })._tag).toBe("RateLimit")
expect(classifyProviderFailure({ message: "FreeUsageLimitError", status: 429 })._tag).toBe("RateLimit")
})
test("classifies provider token limit messages as context overflow", () => {
const messages = [
"tokens in request more than max tokens allowed",
@@ -0,0 +1,131 @@
import { expect, test, type Page } from "@playwright/test"
import { session, sessionID, setupTimeline } from "../performance/timeline-stability/fixture"
test.use({ serviceWorkers: "block" })
const title = "Free limit reached"
const preference = "opencode.global.dat:go-upsell"
const message = "Free usage exceeded, subscribe to Go: https://opencode.ai/go"
async function fail(
page: Page,
timeline: Awaited<ReturnType<typeof setupTimeline>>,
sequence: number,
input: { sessionID?: string; type?: string } = {},
) {
const error = { type: input.type ?? "provider.free-tier-limit", message: `${message} (${sequence})` }
const id = input.sessionID ?? sessionID
// Execution failures are server-wide events and deliberately have no location.
await timeline.send({
id: `evt_free_limit_${sequence}`,
created: Date.now(),
type: "session.execution.failed",
durable: { aggregateID: id, seq: sequence, version: 1 },
data: { sessionID: id, error },
})
await expect
.poll(() =>
page.evaluate(
(message) =>
Object.keys(localStorage)
.filter((key) => key.includes("notification"))
.some((key) =>
JSON.parse(localStorage.getItem(key) ?? "{}").list?.some(
(notification: { error?: { message?: string } }) => notification.error?.message === message,
),
),
error.message,
),
)
.toBe(true)
}
test("free limit opens only for the current session and continues to Go connection", async ({
page,
context,
}, info) => {
const other = "ses_free_limit_other"
const timeline = await setupTimeline(page, { sessions: [session(), session({ id: other })], reducedMotion: true })
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
const dialog = page.getByRole("dialog", { name: title })
await fail(page, timeline, 1, { sessionID: other })
await expect(dialog).toHaveCount(0)
await fail(page, timeline, 2, { type: "provider.error" })
await expect(dialog).toHaveCount(0)
await fail(page, timeline, 3)
await expect(dialog).toBeVisible()
await expect(dialog).toContainText(
"Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.",
)
await expect(dialog.getByRole("button", { name: "Subscribe", exact: true })).toBeEnabled()
await page.screenshot({ path: info.outputPath("free-limit-go.png") })
await info.attach("free-limit-go", { path: info.outputPath("free-limit-go.png"), contentType: "image/png" })
await fail(page, timeline, 4)
await expect(page.getByRole("dialog")).toHaveCount(1)
await context.route("https://opencode.ai/go", (route) => route.fulfill({ body: "Go signup destination" }))
const popup = context.waitForEvent("page")
await dialog.getByRole("button", { name: "Subscribe", exact: true }).click()
const signup = await popup
await expect(signup).toHaveURL("https://opencode.ai/go")
await signup.close()
const connection = page.getByRole("dialog")
await expect(connection.getByRole("textbox", { name: "API key" })).toBeEditable()
await expect(connection).toContainText("opencode-go")
await page.keyboard.press("Escape")
await expect(connection).toHaveCount(0)
await fail(page, timeline, 5)
await expect(dialog).toHaveCount(0)
})
test("free limit respects the existing 24-hour suppression window", async ({ page }) => {
const now = Date.now()
await page.clock.setFixedTime(now)
await page.addInitScript(
({ preference, now }) => {
localStorage.setItem(preference, JSON.stringify({ go_upsell_last_seen_at: now - 23 * 60 * 60 * 1000 }))
},
{ preference, now },
)
const timeline = await setupTimeline(page)
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
await fail(page, timeline, 1)
await expect(page.getByRole("dialog")).toHaveCount(0)
await page.clock.setFixedTime(now + 2 * 60 * 60 * 1000)
await fail(page, timeline, 2)
await expect(page.getByRole("dialog", { name: title })).toBeVisible()
})
test("do not show again persists beyond the free limit suppression window", async ({ page }) => {
const timeline = await setupTimeline(page)
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
const dialog = page.getByRole("dialog", { name: title })
await fail(page, timeline, 1)
await dialog.getByRole("button", { name: "Don't show again" }).click()
await expect(dialog).toHaveCount(0)
await expect
.poll(() => page.evaluate((key) => JSON.parse(localStorage.getItem(key) ?? "{}").go_upsell_dont_show, preference))
.toBeGreaterThan(0)
await page.clock.setFixedTime(Date.now() + 25 * 60 * 60 * 1000)
await fail(page, timeline, 2)
await expect(page.getByRole("dialog")).toHaveCount(0)
})
test("free limit uses localized copy on mobile", async ({ page }, info) => {
const timeline = await setupTimeline(page, {
viewport: { width: 390, height: 844 },
locale: "de",
reducedMotion: true,
})
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
await fail(page, timeline, 1)
const dialog = page.getByRole("dialog", { name: "Kostenloses Limit erreicht" })
await expect(dialog).toBeVisible()
await expect(dialog.getByRole("button", { name: "Abonnieren", exact: true })).toBeInViewport()
await page.screenshot({ path: info.outputPath("free-limit-go-mobile.png") })
await info.attach("free-limit-go-mobile", {
path: info.outputPath("free-limit-go-mobile.png"),
contentType: "image/png",
})
})
@@ -1,4 +1,5 @@
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
import { Persist, persisted } from "@/runtime/persistence/storage"
import type { SessionStatus } from "@opencode-ai/client/promise"
import { onCleanup } from "solid-js"
@@ -42,6 +43,7 @@ function goUpsellKeys(status: SessionStatus) {
export function useUsageExceededDialogs() {
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const dialog = useDialog()
const { params } = useSessionLayout()
const { t, locale } = useI18n()
@@ -54,6 +56,41 @@ export function useUsageExceededDialogs() {
[GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: null,
})
const showFreeTier = () => {
if (dialog.active) return
const seen = goUpsellState[GO_UPSELL_FREE_TIER_LAST_SEEN_AT]
if (seen && Date.now() - seen < GO_UPSELL_WINDOW) return
if (goUpsellState[GO_UPSELL_FREE_TIER_DONT_SHOW]) return
void dialog.show(() => (
<DialogUsageExceeded
title={t("dialog.usageExceeded.freeTier.title")}
description={t("dialog.usageExceeded.freeTier.description")}
actionLabel={t("dialog.usageExceeded.freeTier.actionLabel")}
link="https://opencode.ai/go"
onClose={(dontShowAgain) => {
setGoUpsellState(GO_UPSELL_FREE_TIER_LAST_SEEN_AT, Date.now())
if (dontShowAgain) setGoUpsellState(GO_UPSELL_FREE_TIER_DONT_SHOW, Date.now())
else {
void import("@/providers/connect/dialog").then((x) => {
const controller = x.useProviderConnectController()
controller.select("opencode-go")
void dialog.show(() => <x.DialogConnectProvider controller={controller} />)
})
}
}}
/>
))
}
onCleanup(
serverSDK.event.on("session.execution.failed", (evt) => {
if (evt.data.sessionID !== params.id) return
if (typeof evt.data.error === "string" || evt.data.error.type !== "provider.free-tier-limit") return
showFreeTier()
}),
)
onCleanup(
sdk().event.on("session.status", (evt) => {
if (evt.data.sessionID !== params.id) return
@@ -64,32 +101,13 @@ export function useUsageExceededDialogs() {
const keys = goUpsellKeys(evt.data.status)
if (!keys) return
if (action.reason === "free_tier_limit") return showFreeTier()
const seen = goUpsellState[keys.lastSeenAt]
if (seen && Date.now() - seen < GO_UPSELL_WINDOW) return
if (goUpsellState[keys.dontShow]) return
if (action.reason === "free_tier_limit") {
dialog.show(() => (
<DialogUsageExceeded
title={isEnglish() ? action.title : t("dialog.usageExceeded.freeTier.title")}
description={isEnglish() ? action.message : t("dialog.usageExceeded.freeTier.description")}
actionLabel={isEnglish() ? action.label : t("dialog.usageExceeded.freeTier.actionLabel")}
link={action.link}
onClose={(dontShowAgain) => {
setGoUpsellState(keys.lastSeenAt, Date.now())
if (dontShowAgain) setGoUpsellState(keys.dontShow, Date.now())
else {
void import("@/providers/connect/dialog").then((x) => {
const controller = x.useProviderConnectController()
controller.select("opencode-go")
void dialog.show(() => <x.DialogConnectProvider controller={controller} />)
})
}
}}
/>
))
} else if (action.reason === "account_rate_limit") {
if (action.reason === "account_rate_limit") {
dialog.show(() => (
<DialogUsageExceeded
title={isEnglish() ? action.title : t("dialog.usageExceeded.accountRateLimit.title")}
@@ -27,6 +27,7 @@ export interface Decision {
}
export function isRetryable(error: AIError) {
if (error.reason._tag === "QuotaExceeded" && error.reason.classification === "free-tier") return false
const override = error.reason.http?.headers["x-should-retry"]
if (override === "true") return true
if (override === "false") return false
@@ -14,6 +14,12 @@ export function toSessionError(cause: unknown): SessionError.Error {
case "Authentication":
return providerError("provider.auth", cause.reason)
case "QuotaExceeded":
if (cause.reason.classification === "free-tier")
return providerError(
"provider.free-tier-limit",
cause.reason,
"Free usage exceeded, subscribe to Go: https://opencode.ai/go",
)
return providerError("provider.quota", cause.reason)
case "ContentPolicy":
return providerError("provider.content-filter", cause.reason)
@@ -58,7 +64,7 @@ export function toSessionError(cause: unknown): SessionError.Error {
return { type: "unknown", message: cause instanceof Error ? cause.message : String(cause) }
}
function providerError(type: string, reason: AIError["reason"]): SessionError.Error {
function providerError(type: string, reason: AIError["reason"], message = reason.message): SessionError.Error {
const status = reason.http?.status
return { type, message: reason.message, ...(status === undefined ? {} : { status }) }
return { type, message, ...(status === undefined ? {} : { status }) }
}
+22
View File
@@ -901,6 +901,28 @@ it.effect("preserves complete HTTP context on AI SDK call errors", () =>
}),
)
it.effect("stops retrying free-tier limits from AI SDK providers", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
apiCallError({
message: "Rate limit exceeded",
statusCode: 429,
responseHeaders: { "retry-after": "60" },
responseBody: JSON.stringify({
type: "error",
error: { type: "FreeUsageLimitError", message: "Rate limit exceeded" },
}),
}),
)
expect(SessionRunnerRetry.isRetryable(error)).toBeFalse()
expect(toSessionError(error)).toEqual({
type: "provider.free-tier-limit",
message: "Free usage exceeded, subscribe to Go: https://opencode.ai/go",
status: 429,
})
}),
)
it.effect("classifies retryable AI SDK failures with retry-after details", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
+34
View File
@@ -23,10 +23,33 @@ import { Provider } from "@opencode-ai/core/provider"
import { Tool } from "@opencode-ai/schema/tool"
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
import { classifyProviderFailure } from "@opencode-ai/ai/provider-error"
const llm = (reason: AIError["reason"]) => new AIError({ reason })
describe("toSessionError", () => {
test("stops retrying free-tier limits and prompts for OpenCode Go", () => {
const error = llm(
classifyProviderFailure({
message: "Rate limit exceeded",
status: 429,
rawBody: JSON.stringify({ error: { type: "FreeUsageLimitError", message: "Rate limit exceeded" } }),
http: new HttpContext({
url: "https://opencode.ai/zen/v1/chat/completions",
status: 429,
headers: { "retry-after": "60" },
}),
}),
)
expect(SessionRunnerRetry.isRetryable(error)).toBe(false)
expect(toSessionError(error)).toEqual({
type: "provider.free-tier-limit",
message: "Free usage exceeded, subscribe to Go: https://opencode.ai/go",
status: 429,
})
})
test("maps every AI error reason to the open wire type", () => {
expect(toSessionError(llm(new RateLimitError({ message: "rate", retryAfterMs: 123 })))).toEqual({
type: "provider.rate-limit",
@@ -225,5 +248,16 @@ describe("toSessionError", () => {
llm(new InvalidRequestError({ message: "retry", http: http({ "x-should-retry": "true" }) })),
),
).toBeTrue()
expect(
SessionRunnerRetry.isRetryable(
llm(
new QuotaExceededError({
message: "Free limit reached",
classification: "free-tier",
http: http({ "x-should-retry": "true" }),
}),
),
),
).toBeFalse()
})
})
+33
View File
@@ -17,6 +17,7 @@ import { OpenAIChat } from "@opencode-ai/ai/protocols/openai-chat"
import { AnthropicMessages, OpenAIResponses } from "@opencode-ai/ai/protocols"
import { compileRequest } from "@opencode-ai/ai/route/client"
import { TestLLM } from "@opencode-ai/ai/testing"
import { classifyProviderFailure } from "@opencode-ai/ai/provider-error"
import { Catalog } from "@opencode-ai/core/catalog"
import { Database } from "@opencode-ai/core/database/database"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
@@ -4355,6 +4356,38 @@ describe("SessionRunnerLLM", () => {
])
})
scenario("settles free-tier limits once with a Go signup prompt", function* (s) {
const failure = new AIError({
reason: classifyProviderFailure({
message: "Rate limit exceeded",
status: 429,
rawBody: JSON.stringify({ error: { type: "FreeUsageLimitError", message: "Rate limit exceeded" } }),
retryAfterMs: 60_000,
}),
})
yield* s.llm.push(Stream.fail(failure))
expect(yield* s.runPrompt("Use the free model").pipe(Effect.flip)).toBe(failure)
expect(s.requests).toHaveLength(1)
const events = yield* recordedEventTypes(sessionID)
expect(events).not.toContain("session.retry.scheduled.1")
expect(events.filter((type) => type === "session.step.failed.1")).toHaveLength(1)
yield* replaySessionProjection(sessionID)
expect(yield* s.context).toMatchObject([
Expected.user("Use the free model"),
Expected.assistant(
{
finish: "error",
error: {
type: "provider.free-tier-limit",
message: "Free usage exceeded, subscribe to Go: https://opencode.ai/go",
},
},
[],
),
])
})
scenario("bounds jittered exponential backoff for eligible pre-output failures", function* (s) {
yield* s.admit("Retry transport")
yield* s.llm.push(Stream.fail(providerUnavailable()))
@@ -0,0 +1,419 @@
import { OptimizedBuffer, RGBA, TextAttributes } from "@opentui/core"
import { go } from "../logo"
const PERIOD = 4600
const RINGS = 3
const WIDTH = 3.8
const TAIL = 9.5
const AMP = 0.55
const TAIL_AMP = 0.16
const BREATH_AMP = 0.05
const BREATH_SPEED = 0.0008
// Offset so the bg ring emits from the estimated GO center when the logo shimmer peaks.
const PHASE_OFFSET = 0.29
const LOGO_GAP = 1
const LOGO_TOP_BIAS = -1
const LOGO_LEFT_WIDTH = go.left[0]?.length ?? 0
const LOGO_LINES = go.left.map((line, index) => line + " ".repeat(LOGO_GAP) + go.right[index])
const LOGO_WIDTH = LOGO_LINES[0]?.length ?? 0
const LOGO_HEIGHT = LOGO_LINES.length
const SPACE = " ".codePointAt(0)!
const TOP_HALF = "▀".codePointAt(0)!
const FULL_BLOCK = "█".codePointAt(0)!
const RING_SCALE = 1 / RINGS
const TAIL_SCALE = 1 / TAIL
const LOGO_REACH = Math.hypot(LOGO_WIDTH, LOGO_HEIGHT * 2) + 3
const enum LogoCellKind {
Background,
Top,
ShadowTop,
Solid,
Char,
}
type LogoTemplateCell = {
x: number
y: number
kind: LogoCellKind
charCode: number
attributes: number
topDist: number
bottomDist: number
}
const LOGO_TEMPLATE: LogoTemplateCell[] = LOGO_LINES.flatMap((line, y) =>
Array.from(line)
.map((char, x) => {
if (char === " ") return undefined
const kind =
char === "_"
? LogoCellKind.Background
: char === "^"
? LogoCellKind.Top
: char === "~"
? LogoCellKind.ShadowTop
: char === "█"
? LogoCellKind.Solid
: LogoCellKind.Char
return {
x,
y,
kind,
charCode: char.codePointAt(0) ?? SPACE,
attributes: x > LOGO_LEFT_WIDTH ? TextAttributes.BOLD : 0,
topDist: Math.hypot(x + 0.5 - LOGO_WIDTH / 2, y * 2 - LOGO_HEIGHT),
bottomDist: Math.hypot(x + 0.5 - LOGO_WIDTH / 2, y * 2 + 1 - LOGO_HEIGHT),
}
})
.filter((cell): cell is LogoTemplateCell => !!cell),
)
export type Rgb = [number, number, number]
export type GoUpsellArtRenderOptions = {
deltaTime?: number
rgb?: boolean
cache?: boolean
}
const CACHE_FRAME_COUNT = Math.round(PERIOD / (1000 / 30))
export function toRgb(color: RGBA): Rgb {
const [r, g, b] = color.toInts()
return [r, g, b]
}
function clamp(n: number) {
return Math.max(0, Math.min(1, n))
}
function writeRgb(buffer: Uint16Array, offset: number, r: number, g: number, b: number, a = 255) {
buffer[offset] = r
buffer[offset + 1] = g
buffer[offset + 2] = b
buffer[offset + 3] = a
}
function mixChannel(base: number, overlay: number, alpha: number) {
return Math.round(base + (overlay - base) * clamp(alpha))
}
function writeLogoTint(
buffer: Uint16Array,
offset: number,
base: Rgb,
primary: Rgb,
primaryMix: number,
peakMix: number,
) {
const p = clamp(primaryMix)
const q = clamp(peakMix)
const r = mixChannel(mixChannel(base[0], primary[0], p), 255, q)
const g = mixChannel(mixChannel(base[1], primary[1], p), 255, q)
const b = mixChannel(mixChannel(base[2], primary[2], p), 255, q)
writeRgb(buffer, offset, r, g, b)
}
function sameRgb(a: Rgb, b: Rgb) {
return a[0] === b[0] && a[1] === b[1] && a[2] === b[2]
}
export class GoUpsellArtPainter {
private panelRgb: Rgb = [0, 0, 0]
private primaryRgb: Rgb = [255, 255, 255]
private logoBaseRgb: Rgb = [180, 180, 180]
private elapsed = 0
private distances = new Float32Array(0)
private edgeFalloff = new Float32Array(0)
private geometryWidth = 0
private geometryHeight = 0
private reach = 1
private logoX = 0
private logoY = 0
private logoIndexes = new Int32Array(0)
private logoRgb: boolean | undefined
private pulsePeak = 0
private pulsePrimary = 0
private frameCache: Array<{ fg: Uint16Array; bg: Uint16Array }> = []
private paintedPhase: number | undefined
setBackgroundPanel(value: RGBA | Rgb | undefined) {
if (!value) return false
const next = value instanceof RGBA ? toRgb(value) : value
if (sameRgb(this.panelRgb, next)) return false
this.panelRgb = next
this.invalidateCache()
return true
}
setLogoBase(value: RGBA | Rgb | undefined) {
if (!value) return false
const next = value instanceof RGBA ? toRgb(value) : value
if (sameRgb(this.logoBaseRgb, next)) return false
this.logoBaseRgb = next
this.invalidateCache()
return true
}
setPrimary(value: RGBA | Rgb | undefined) {
if (!value) return false
const next = value instanceof RGBA ? toRgb(value) : value
if (sameRgb(this.primaryRgb, next)) return false
this.primaryRgb = next
this.invalidateCache()
return true
}
render(frameBuffer: OptimizedBuffer, options: GoUpsellArtRenderOptions = {}) {
const rgb = options.rgb === true
this.elapsed = (this.elapsed + (options.deltaTime ?? 0)) % PERIOD
this.rebuildGeometry(frameBuffer, rgb)
if (options.cache !== false) {
this.drawCached(frameBuffer, rgb)
return
}
this.paintedPhase = undefined
this.drawBackground(frameBuffer, this.elapsed)
this.drawLogo(frameBuffer, this.elapsed, rgb)
}
private invalidateCache() {
this.paintedPhase = undefined
this.frameCache = []
}
private rebuildGeometry(frameBuffer: OptimizedBuffer, rgb: boolean) {
const width = frameBuffer.width
const height = frameBuffer.height
const geometryChanged = width !== this.geometryWidth || height !== this.geometryHeight
const logoTemplateChanged = this.logoRgb !== rgb
if (!geometryChanged && !logoTemplateChanged) return
if (geometryChanged) {
this.geometryWidth = width
this.geometryHeight = height
this.logoX = Math.max(0, Math.floor((width - LOGO_WIDTH) / 2))
this.logoY = Math.max(
0,
Math.min(Math.max(0, height - LOGO_HEIGHT), Math.round((height - LOGO_HEIGHT) / 2) + LOGO_TOP_BIAS),
)
const centerX = this.logoX + LOGO_WIDTH / 2
const centerY = this.logoY + LOGO_HEIGHT / 2
this.reach = Math.hypot(Math.max(centerX, width - centerX), Math.max(centerY, height - centerY) * 2) + TAIL
this.distances = new Float32Array(width * height)
this.edgeFalloff = new Float32Array(width * height)
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const index = y * width + x
const dist = Math.hypot(x + 0.5 - centerX, (y + 0.5 - centerY) * 2)
this.distances[index] = dist
this.edgeFalloff[index] = Math.max(0, 1 - (dist / (this.reach * 0.85)) ** 2)
}
}
}
this.logoRgb = rgb
this.invalidateCache()
this.rebuildCellTemplate(frameBuffer, rgb)
}
private drawCached(frameBuffer: OptimizedBuffer, rgb: boolean) {
// Returning to an exact phase can land just below its boundary after floating-point addition.
const phase = Math.floor((this.elapsed / PERIOD) * CACHE_FRAME_COUNT + 1e-9) % CACHE_FRAME_COUNT
if (phase === this.paintedPhase) return
const frame = this.frameCache[phase]
if (frame) {
frameBuffer.buffers.fg.set(frame.fg)
frameBuffer.buffers.bg.set(frame.bg)
this.paintedPhase = phase
return
}
const t = (phase / CACHE_FRAME_COUNT) * PERIOD
this.drawBackground(frameBuffer, t)
this.drawLogo(frameBuffer, t, rgb)
this.frameCache[phase] = {
fg: new Uint16Array(frameBuffer.buffers.fg),
bg: new Uint16Array(frameBuffer.buffers.bg),
}
this.paintedPhase = phase
}
private rebuildCellTemplate(frameBuffer: OptimizedBuffer, rgb: boolean) {
const buffers = frameBuffer.buffers
buffers.char.fill(SPACE)
buffers.attributes.fill(0)
if (this.geometryWidth < LOGO_WIDTH || this.geometryHeight < LOGO_HEIGHT) {
this.logoIndexes = new Int32Array(0)
return
}
this.logoIndexes = new Int32Array(LOGO_TEMPLATE.length)
for (let i = 0; i < LOGO_TEMPLATE.length; i++) {
const cell = LOGO_TEMPLATE[i]
const index = (this.logoY + cell.y) * this.geometryWidth + this.logoX + cell.x
this.logoIndexes[i] = index
buffers.attributes[index] = cell.attributes
buffers.char[index] =
cell.kind === LogoCellKind.Background
? SPACE
: cell.kind === LogoCellKind.Top || cell.kind === LogoCellKind.ShadowTop
? TOP_HALF
: cell.kind === LogoCellKind.Solid
? rgb
? TOP_HALF
: FULL_BLOCK
: cell.charCode
}
}
private drawBackground(frameBuffer: OptimizedBuffer, t: number) {
const buffers = frameBuffer.buffers
const fg = buffers.fg
const bg = buffers.bg
const distances = this.distances
const edgeFalloff = this.edgeFalloff
const baseR = this.panelRgb[0]
const baseG = this.panelRgb[1]
const baseB = this.panelRgb[2]
const deltaR = this.primaryRgb[0] - baseR
const deltaG = this.primaryRgb[1] - baseG
const deltaB = this.primaryRgb[2] - baseB
const breath = (0.5 + 0.5 * Math.sin(t * BREATH_SPEED)) * BREATH_AMP
const phase0 = (t / PERIOD - PHASE_OFFSET + 1) % 1
const phase1 = (t / PERIOD + 1 / RINGS - PHASE_OFFSET + 1) % 1
const phase2 = (t / PERIOD + 2 / RINGS - PHASE_OFFSET + 1) % 1
const envelope0 = Math.sin(phase0 * Math.PI)
const envelope1 = Math.sin(phase1 * Math.PI)
const envelope2 = Math.sin(phase2 * Math.PI)
const eased0 = envelope0 * envelope0 * (3 - 2 * envelope0)
const eased1 = envelope1 * envelope1 * (3 - 2 * envelope1)
const eased2 = envelope2 * envelope2 * (3 - 2 * envelope2)
const head0 = phase0 * this.reach
const head1 = phase1 * this.reach
const head2 = phase2 * this.reach
for (let index = 0; index < distances.length; index++) {
const dist = distances[index]
const delta0 = dist - head0
const abs0 = delta0 < 0 ? -delta0 : delta0
const crest0 = abs0 < WIDTH ? 0.5 + 0.5 * Math.cos((delta0 / WIDTH) * Math.PI) : 0
const tail0 = delta0 < 0 && delta0 > -TAIL ? (1 + delta0 * TAIL_SCALE) ** 2.3 : 0
const delta1 = dist - head1
const abs1 = delta1 < 0 ? -delta1 : delta1
const crest1 = abs1 < WIDTH ? 0.5 + 0.5 * Math.cos((delta1 / WIDTH) * Math.PI) : 0
const tail1 = delta1 < 0 && delta1 > -TAIL ? (1 + delta1 * TAIL_SCALE) ** 2.3 : 0
const delta2 = dist - head2
const abs2 = delta2 < 0 ? -delta2 : delta2
const crest2 = abs2 < WIDTH ? 0.5 + 0.5 * Math.cos((delta2 / WIDTH) * Math.PI) : 0
const tail2 = delta2 < 0 && delta2 > -TAIL ? (1 + delta2 * TAIL_SCALE) ** 2.3 : 0
const level =
(crest0 * AMP + tail0 * TAIL_AMP) * eased0 +
(crest1 * AMP + tail1 * TAIL_AMP) * eased1 +
(crest2 * AMP + tail2 * TAIL_AMP) * eased2
const rawStrength = (level * RING_SCALE + breath) * edgeFalloff[index]
const strength = (rawStrength > 1 ? 1 : rawStrength) * 0.7
const offset = index * 4
const r = Math.round(baseR + deltaR * strength)
const g = Math.round(baseG + deltaG * strength)
const b = Math.round(baseB + deltaB * strength)
bg[offset] = fg[offset] = r
bg[offset + 1] = fg[offset + 1] = g
bg[offset + 2] = fg[offset + 2] = b
bg[offset + 3] = fg[offset + 3] = 255
}
}
private setLogoPulse(dist: number, head0: number, eased0: number, head1: number, eased1: number) {
let peak = 0.04
let primary = 0
const delta0 = dist - head0
const core0 = Math.exp(-(Math.abs(delta0 / 1.2) ** 1.8))
const soft0 = Math.exp(-(Math.abs(delta0 / 7) ** 1.6))
const tail0 = delta0 < 0 && delta0 > -7 ? (1 + delta0 / 7) ** 2.6 : 0
peak += core0 * 0.65 * eased0
primary += (soft0 * 0.16 + tail0 * 0.22) * eased0
const delta1 = dist - head1
const core1 = Math.exp(-(Math.abs(delta1 / 1.2) ** 1.8))
const soft1 = Math.exp(-(Math.abs(delta1 / 7) ** 1.6))
const tail1 = delta1 < 0 && delta1 > -7 ? (1 + delta1 / 7) ** 2.6 : 0
peak += core1 * 0.65 * eased1
primary += (soft1 * 0.16 + tail1 * 0.22) * eased1
this.pulsePeak = peak > 1 ? 1 : peak
this.pulsePrimary = primary > 1 ? 1 : primary
}
private drawLogo(frameBuffer: OptimizedBuffer, t: number, rgb: boolean) {
if (this.logoIndexes.length === 0) return
const buffers = frameBuffer.buffers
const fg = buffers.fg
const bg = buffers.bg
const shadow: Rgb = [
mixChannel(this.panelRgb[0], this.logoBaseRgb[0], 0.25),
mixChannel(this.panelRgb[1], this.logoBaseRgb[1], 0.25),
mixChannel(this.panelRgb[2], this.logoBaseRgb[2], 0.25),
]
const phase0 = (t / PERIOD) % 1
const phase1 = (t / PERIOD + 0.5) % 1
const envelope0 = Math.sin(phase0 * Math.PI)
const envelope1 = Math.sin(phase1 * Math.PI)
const eased0 = envelope0 * envelope0 * (3 - 2 * envelope0)
const eased1 = envelope1 * envelope1 * (3 - 2 * envelope1)
const head0 = phase0 * LOGO_REACH
const head1 = phase1 * LOGO_REACH
for (let i = 0; i < LOGO_TEMPLATE.length; i++) {
const cell = LOGO_TEMPLATE[i]
const index = this.logoIndexes[i]
const offset = index * 4
this.setLogoPulse(cell.topDist, head0, eased0, head1, eased1)
const topPeak = this.pulsePeak
const topPrimary = this.pulsePrimary
this.setLogoPulse(cell.bottomDist, head0, eased0, head1, eased1)
const bottomPeak = this.pulsePeak
const bottomPrimary = this.pulsePrimary
if (cell.kind === LogoCellKind.Background) {
writeLogoTint(bg, offset, shadow, this.primaryRgb, 0, Math.max(topPeak, bottomPeak) * 0.18)
continue
}
if (cell.kind === LogoCellKind.Top) {
writeLogoTint(fg, offset, this.logoBaseRgb, this.primaryRgb, topPrimary, topPeak)
writeLogoTint(bg, offset, shadow, this.primaryRgb, 0, bottomPeak * 0.18)
continue
}
if (cell.kind === LogoCellKind.ShadowTop) {
writeLogoTint(fg, offset, shadow, this.primaryRgb, 0, topPeak * 0.18)
continue
}
if (cell.kind === LogoCellKind.Solid && rgb) {
writeLogoTint(fg, offset, this.logoBaseRgb, this.primaryRgb, topPrimary, topPeak)
writeLogoTint(bg, offset, this.logoBaseRgb, this.primaryRgb, bottomPrimary, bottomPeak)
continue
}
writeLogoTint(
fg,
offset,
this.logoBaseRgb,
this.primaryRgb,
(topPrimary + bottomPrimary) / 2,
(topPeak + bottomPeak) / 2,
)
}
}
}
+118
View File
@@ -0,0 +1,118 @@
import {
FrameBufferRenderable,
RGBA,
SystemClock,
type OptimizedBuffer,
type RenderContext,
type Renderable,
type RenderableOptions,
type TimerHandle,
} from "@opentui/core"
import { extend } from "@opentui/solid"
import { useConfig } from "../config"
import { GoUpsellArtPainter } from "./bg-pulse-render"
type GoUpsellArtOptions = RenderableOptions<FrameBufferRenderable> & {
backgroundPanel?: RGBA
primary?: RGBA
logoBase?: RGBA
animated?: boolean
}
class GoUpsellArtRenderable extends FrameBufferRenderable {
private painter = new GoUpsellArtPainter()
private clock = new SystemClock()
private timer: TimerHandle | undefined
private deltaTime = 0
constructor(ctx: RenderContext, options: GoUpsellArtOptions = {}) {
const width = typeof options.width === "number" ? options.width : 1
const height = typeof options.height === "number" ? options.height : 1
super(ctx, {
...options,
width,
height,
live: false,
respectAlpha: false,
})
if (options.width !== undefined && typeof options.width !== "number") this.width = options.width
if (options.height !== undefined && typeof options.height !== "number") this.height = options.height
this.painter.setBackgroundPanel(options.backgroundPanel)
this.painter.setPrimary(options.primary)
this.painter.setLogoBase(options.logoBase)
this.animated = options.animated ?? false
}
set backgroundPanel(value: RGBA | undefined) {
if (this.painter.setBackgroundPanel(value)) this.requestRender()
}
set logoBase(value: RGBA | undefined) {
if (this.painter.setLogoBase(value)) this.requestRender()
}
set primary(value: RGBA | undefined) {
if (this.painter.setPrimary(value)) this.requestRender()
}
set animated(value: boolean) {
if (this.timer !== undefined) this.clock.clearInterval(this.timer)
this.timer = undefined
this.deltaTime = 0
if (!value) return
// Unrelated renderer frames must not advance the animation's clock.
let previous = this.clock.now()
this.timer = this.clock.setInterval(() => {
const now = this.clock.now()
const deltaTime = now - previous
previous = now
if (!this.visible || this.isDestroyed) return
for (let node: Renderable | null = this.parent; node; node = node.parent) {
if (!node.visible || node.isDestroyed) return
}
this.deltaTime += deltaTime
this.requestRender()
}, 1000 / 30)
}
protected override renderSelf(buffer: OptimizedBuffer): void {
if (!this.visible || this.isDestroyed) return
this.painter.render(this.frameBuffer, {
deltaTime: this.deltaTime,
rgb: this._ctx.capabilities?.rgb === true,
})
this.deltaTime = 0
super.renderSelf(buffer)
}
protected override destroySelf(): void {
this.animated = false
super.destroySelf()
}
}
declare module "@opentui/solid" {
interface OpenTUIComponents {
go_upsell_art: typeof GoUpsellArtRenderable
}
}
extend({ go_upsell_art: GoUpsellArtRenderable })
export function BgPulse(props: { backgroundPanel: RGBA; primary: RGBA; logoBase: RGBA }) {
const config = useConfig().data
return (
<go_upsell_art
width="100%"
height="100%"
backgroundPanel={props.backgroundPanel}
primary={props.primary}
logoBase={props.logoBase}
animated={config.animations ?? true}
/>
)
}
+91
View File
@@ -0,0 +1,91 @@
import { RGBA, TextAttributes } from "@opentui/core"
import { createMemo, For } from "solid-js"
import { createStore } from "solid-js/store"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { tint } from "../theme/color"
import { useDialog } from "../ui/dialog"
import { Link } from "../ui/link"
import { BgPulse } from "./bg-pulse"
export function DialogGo(props: { onSubscribe: () => void; onDismiss: () => void }) {
const dialog = useDialog()
const theme = useTheme("elevated")
const [store, setStore] = createStore({ selected: "subscribe" as "dismiss" | "subscribe" })
const textBg = createMemo(() => {
const color = theme.background.default.toInts()
return RGBA.fromInts(color[0], color[1], color[2], 186)
})
const select = (key: "dismiss" | "subscribe") => {
if (key === "subscribe") props.onSubscribe()
if (key === "dismiss") props.onDismiss()
dialog.clear()
}
Keymap.createLayer(() => ({
mode: "modal",
commands: [
{
bind: "left,right,tab,shift+tab",
title: "Switch Go signup option",
group: "Dialog",
run: () => setStore("selected", store.selected === "subscribe" ? "dismiss" : "subscribe"),
},
{ bind: "return", title: "Confirm Go signup option", group: "Dialog", run: () => select(store.selected) },
],
}))
return (
<box>
<box position="absolute" top={-1} left={0} right={0} bottom={0} zIndex={0}>
<BgPulse
backgroundPanel={theme.background.default}
primary={theme.background.action.primary.focused}
logoBase={tint(theme.background.default, theme.text.default, 0.62)}
/>
</box>
<box zIndex={1} paddingLeft={3} paddingRight={3} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default} bg={textBg()}>
Free limit reached
</text>
<text fg={theme.text.subdued} bg={textBg()} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<text fg={theme.text.subdued} bg={textBg()}>
Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.
</text>
<box alignItems="center" justifyContent="flex-end" height={7} paddingBottom={1}>
<Link
href="https://opencode.ai/go"
fg={theme.background.action.primary.focused}
bg={textBg()}
wrapMode="none"
/>
</box>
<box flexDirection="row" justifyContent="space-between">
<For each={["dismiss", "subscribe"] as const}>
{(key) => (
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={store.selected === key ? theme.background.action.primary.focused : undefined}
onMouseOver={() => setStore("selected", key)}
onMouseUp={() => select(key)}
>
<text
fg={store.selected === key ? theme.text.action.primary.focused : theme.text.subdued}
bg={store.selected === key ? undefined : textBg()}
attributes={store.selected === key ? TextAttributes.BOLD : undefined}
>
{key === "subscribe" ? "Subscribe" : "Don't show again"}
</text>
</box>
)}
</For>
</box>
</box>
</box>
)
}
@@ -0,0 +1,43 @@
import { onCleanup, type Accessor } from "solid-js"
import open from "open"
import { useData } from "../../context/data"
import { useStorage } from "../../context/storage"
import { useDialog } from "../../ui/dialog"
import { DialogGo } from "../../component/dialog-go"
import { useToast } from "../../ui/toast"
export function useGoUpsell(sessionID: Accessor<string>) {
const data = useData()
const dialog = useDialog()
const toast = useToast()
const [state, update] = useStorage().store("go-upsell", {
initial: { lastSeenAt: 0, dontShowAgain: false },
})
onCleanup(
data.on("session.execution.failed", (event) => {
if (event.data.sessionID !== sessionID()) return
if (event.data.error.type !== "provider.free-tier-limit") return
if (dialog.stack.length > 0 || state.dontShowAgain) return
if (state.lastSeenAt && Date.now() - state.lastSeenAt < 86_400_000) return
let dontShowAgain = false
dialog.replace(
() => (
<DialogGo
onSubscribe={() => void open("https://opencode.ai/go").catch(toast.error)}
onDismiss={() => {
dontShowAgain = true
}}
/>
),
() => {
void update((draft) => {
draft.lastSeenAt = Date.now()
if (dontShowAgain) draft.dontShowAgain = true
}).catch(toast.error)
},
)
}),
)
}
@@ -114,6 +114,7 @@ import { SessionLocationMissing } from "./location-missing"
import { isRecord } from "../../util/record"
import { createHistoryPrepend } from "./history"
import { useSessionTerminals } from "../../context/session-terminals"
import { useGoUpsell } from "./go-upsell"
addDefaultParsers(parsers.parsers)
@@ -170,6 +171,7 @@ export function Session(props: {
await writeFile(file, content)
}
const route = useRouteData("session")
useGoUpsell(() => route.sessionID)
const sessionID = route.sessionID
const { navigate } = useRoute()
const data = useData()
@@ -0,0 +1,133 @@
import { OptimizedBuffer, RGBA } from "@opentui/core"
import { afterEach, expect, test } from "bun:test"
import { GoUpsellArtPainter } from "../../src/component/bg-pulse-render"
const buffers: OptimizedBuffer[] = []
afterEach(() => {
buffers.splice(0).forEach((buffer) => buffer.destroy())
})
function buffer(width = 40, height = 12) {
const result = OptimizedBuffer.create(width, height, "unicode", { respectAlpha: false })
buffers.push(result)
return result
}
function painter() {
const result = new GoUpsellArtPainter()
result.setBackgroundPanel(RGBA.fromHex("#161616"))
result.setPrimary(RGBA.fromHex("#f5a742"))
result.setLogoBase(RGBA.fromHex("#b0b0b0"))
return result
}
function snapshot(buffer: OptimizedBuffer) {
return {
char: buffer.buffers.char.slice(),
attributes: buffer.buffers.attributes.slice(),
fg: buffer.buffers.fg.slice(),
bg: buffer.buffers.bg.slice(),
}
}
test("pulse changes colors across frames without changing the logo cells", () => {
const art = painter()
const frame = buffer()
art.render(frame, { rgb: true, cache: false })
const first = snapshot(frame)
art.render(frame, { rgb: true, cache: false, deltaTime: 1150 })
const next = snapshot(frame)
expect(next.char).toEqual(first.char)
expect(next.attributes).toEqual(first.attributes)
expect(next.fg).not.toEqual(first.fg)
expect(next.bg).not.toEqual(first.bg)
expect(next.char.some((char) => char !== 32)).toBeTrue()
art.render(frame, { rgb: true, cache: false })
expect(snapshot(frame)).toEqual(next)
})
test.each([false, true])("all 138 cold and warm phases match uncached frames with rgb=%s", (rgb) => {
const cached = painter()
const frame = buffer()
const expected = buffer()
let previous = 0
for (let cycle = 0; cycle < 2; cycle++) {
for (let phase = 0; phase < 138; phase++) {
const time = cycle * 4600 + (phase / 138) * 4600
cached.render(frame, { rgb, deltaTime: time - previous })
painter().render(expected, { rgb, deltaTime: (phase / 138) * 4600, cache: false })
expect(snapshot(frame)).toEqual(snapshot(expected))
cached.render(frame, { rgb, deltaTime: 2300 })
cached.render(frame, { rgb, deltaTime: 2300 })
expect(snapshot(frame)).toEqual(snapshot(expected))
previous = time
}
}
})
test("repeated phases do not touch buffer cells", () => {
const art = painter()
const frame = buffer()
art.render(frame, { rgb: true })
// Any redraw or cache copy would overwrite these sentinel channels.
frame.buffers.fg[0] ^= 255
frame.buffers.bg[0] ^= 255
const marked = snapshot(frame)
art.render(frame, { rgb: true })
art.render(frame, { rgb: true, deltaTime: 10 })
expect(snapshot(frame)).toEqual(marked)
})
test("an uncached render cannot leave the repeated-phase guard stale", () => {
const art = painter()
const frame = buffer()
art.render(frame, { rgb: true })
const first = snapshot(frame)
art.render(frame, { rgb: true, cache: false, deltaTime: 20 })
expect(snapshot(frame)).not.toEqual(first)
art.render(frame, { rgb: true })
expect(snapshot(frame)).toEqual(first)
})
test.each(["setBackgroundPanel", "setPrimary", "setLogoBase"] as const)("%s invalidates cached colors", (setter) => {
const cached = painter()
const direct = painter()
const frame = buffer()
const expected = buffer()
cached.render(frame, { rgb: true })
const before = snapshot(frame)
const color = RGBA.fromHex("#2060a0")
expect(cached[setter](color)).toBeTrue()
expect(cached[setter](RGBA.clone(color))).toBeFalse()
direct[setter](color)
cached.render(frame, { rgb: true })
direct.render(expected, { rgb: true, cache: false })
expect(snapshot(frame)).not.toEqual(before)
expect(snapshot(frame)).toEqual(snapshot(expected))
})
test("resize and terminal color capability invalidate cached geometry", () => {
const cached = painter()
const frame = buffer()
cached.render(frame, { rgb: true })
for (const [width, height, rgb] of [
[40, 12, false],
[60, 16, false],
[5, 2, true],
[40, 12, true],
] as const) {
frame.resize(width, height)
const expected = buffer(width, height)
painter().render(expected, { rgb, cache: false })
cached.render(frame, { rgb })
expect(snapshot(frame)).toEqual(snapshot(expected))
cached.render(frame, { rgb })
expect(snapshot(frame)).toEqual(snapshot(expected))
}
})
@@ -0,0 +1,111 @@
/** @jsxImportSource @opentui/solid */
import { BoxRenderable, RGBA } from "@opentui/core"
import { testRender } from "@opentui/solid"
import { expect, spyOn, test } from "bun:test"
import { createSignal, Show } from "solid-js"
import { BgPulse } from "../../src/component/bg-pulse"
import { ConfigProvider, useConfig } from "../../src/config"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
test("animation owns its timer without changing renderer FPS and stops when hidden, disabled or unmounted", async () => {
const settings = { animations: false }
const [visible, setVisible] = createSignal(true)
const [mounted, setMounted] = createSignal(true)
const [color, setColor] = createSignal(RGBA.fromHex("#f5a742"))
let config!: ReturnType<typeof useConfig>
let box!: BoxRenderable
function Art() {
config = useConfig()
return (
<box ref={box} visible={visible()} width="100%" height="100%">
<Show when={mounted()}>
<BgPulse backgroundPanel={RGBA.fromHex("#161616")} primary={color()} logoBase={RGBA.fromHex("#b0b0b0")} />
</Show>
</box>
)
}
const app = await testRender(
() => (
<ConfigProvider
config={createTuiResolvedConfig(settings)}
service={{
get: async () => settings,
update: async (update) => {
update(settings)
return settings
},
}}
>
<Art />
</ConfigProvider>
),
{ width: 40, height: 12, targetFps: 60, maxFps: 60 },
)
try {
await app.renderOnce()
const art = box.getChildren()[0]
const requests = spyOn(art, "requestRender")
const still = app.captureSpans()
await Bun.sleep(80)
expect(requests).not.toHaveBeenCalled()
await app.renderOnce()
expect(app.captureSpans()).toEqual(still)
setColor(RGBA.fromHex("#2060a0"))
await app.renderOnce()
expect(app.captureSpans()).not.toEqual(still)
const recolored = app.captureSpans()
await app.renderOnce()
expect(app.captureSpans()).toEqual(recolored)
await config.update((draft) => {
draft.animations = true
})
requests.mockClear()
await Bun.sleep(80)
expect(requests).toHaveBeenCalled()
await app.renderOnce()
expect(app.captureSpans()).not.toEqual(recolored)
expect(art.live).toBeFalse()
expect(app.renderer.liveRequestCount).toBe(0)
expect(app.renderer.targetFps).toBe(60)
expect(app.renderer.maxFps).toBe(60)
setVisible(false)
await app.renderOnce()
requests.mockClear()
await Bun.sleep(80)
expect(requests).not.toHaveBeenCalled()
setVisible(true)
await app.renderOnce()
await config.update((draft) => {
draft.animations = false
})
await app.renderOnce()
const frozen = app.captureSpans()
requests.mockClear()
await Bun.sleep(80)
expect(requests).not.toHaveBeenCalled()
await app.renderOnce()
expect(app.captureSpans()).toEqual(frozen)
await config.update((draft) => {
draft.animations = true
})
setMounted(false)
await app.renderOnce()
await new Promise<void>((resolve) => process.nextTick(resolve))
expect(art.isDestroyed).toBeTrue()
requests.mockClear()
await Bun.sleep(80)
expect(requests).not.toHaveBeenCalled()
expect(app.renderer.targetFps).toBe(60)
expect(app.renderer.maxFps).toBe(60)
requests.mockRestore()
} finally {
app.renderer.destroy()
}
})
@@ -0,0 +1,266 @@
/** @jsxImportSource @opentui/solid */
import type { OpenCodeEvent } from "@opencode-ai/client"
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import path from "node:path"
import { createSignal, onCleanup } from "solid-js"
import { ConfigProvider } from "../../src/config"
import { ClientProvider, useClient } from "../../src/context/client"
import { DataProvider, useData } from "../../src/context/data"
import { Keymap } from "../../src/context/keymap"
import { TuiAppProvider } from "../../src/context/runtime"
import { StorageProvider, useStorage } from "../../src/context/storage"
import { ThemeProvider } from "../../src/context/theme"
import { useGoUpsell } from "../../src/routes/session/go-upsell"
import { DialogProvider, useDialog } from "../../src/ui/dialog"
import { DialogConfirm } from "../../src/ui/dialog-confirm"
import { ToastProvider } from "../../src/ui/toast"
import { emptyThemeSource, tmpdir } from "../fixture/fixture"
import { createApi, createEventStream, createFetch, directory } from "../fixture/tui-client"
import { TestTuiContexts } from "../fixture/tui-environment"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
async function renderUpsell(root: string, width = 100, animations = true) {
const events = createEventStream()
const [session, setSession] = createSignal("ses_current")
let dialog!: ReturnType<typeof useDialog>
let storage!: ReturnType<typeof useStorage>
let client!: ReturnType<typeof useClient>
let received = 0
let sequence = 0
function Probe() {
dialog = useDialog()
storage = useStorage()
client = useClient()
useGoUpsell(session)
onCleanup(useData().on("session.execution.failed", () => received++))
return <text>Session content</text>
}
const app = await testRender(
() => (
<TestTuiContexts paths={{ state: root }}>
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
<StorageProvider>
<ConfigProvider config={createTuiResolvedConfig({ animations })}>
<ClientProvider api={createApi(createFetch(undefined, events).fetch)}>
<DataProvider directory={directory}>
<ThemeProvider mode={width === 40 ? "light" : "dark"} source={emptyThemeSource}>
<Keymap.Provider>
<ToastProvider>
<DialogProvider>
<Probe />
</DialogProvider>
</ToastProvider>
</Keymap.Provider>
</ThemeProvider>
</DataProvider>
</ClientProvider>
</ConfigProvider>
</StorageProvider>
</TuiAppProvider>
</TestTuiContexts>
),
{ width, height: 24, kittyKeyboard: true },
)
app.renderer.start()
await app.waitFor(() => client.connection.status() === "connected")
return {
app,
dialog,
setSession,
async fail(sessionID = session(), type = "provider.free-tier-limit") {
const count = received + 1
// Execution failures are server-wide events, without a location envelope.
events.emit({
id: `evt_failure_${++sequence}`,
created: Date.now(),
type: "session.execution.failed",
durable: { aggregateID: sessionID, seq: sequence, version: 1 },
data: { sessionID, error: { type, message: "Free usage limit reached", status: 429 } },
} satisfies OpenCodeEvent)
await app.waitFor(() => received === count)
await app.renderOnce()
},
async persisted() {
await storage.flush()
return Bun.file(path.join(root, "test", "tui", "go-upsell.json")).json()
},
async age(milliseconds: number) {
const [, update] = storage.store("go-upsell", { initial: { lastSeenAt: 0, dontShowAgain: false } })
await update((draft) => {
draft.lastSeenAt = Date.now() - milliseconds
})
},
async [Symbol.asyncDispose]() {
dialog.clear()
await storage.flush()
app.renderer.destroy()
},
}
}
for (const [width, animations] of [
[40, true],
[100, true],
[100, false],
] as const) {
test(`server-wide free-limit event renders the Go offer at ${width} columns with animations ${animations}`, async () => {
await using temporary = await tmpdir()
await using setup = await renderUpsell(temporary.path, width, animations)
await setup.fail()
const frame = setup.app.captureCharFrame()
expect(frame).toContain("Free limit reached")
expect(frame).toContain("Subscribe")
expect(frame).toContain("Don't show again")
expect(frame).not.toContain("Don'T")
expect(frame.replace(/\s/g, "")).toContain("$5/month")
expect(frame).toContain("https://opencode.ai/go")
expect(frame).toContain("▀")
expect(frame.split("\n").find((line) => line.includes("Free limit reached"))).toContain("esc")
expect(setup.dialog.stack).toHaveLength(1)
})
}
for (const shift of [false, true]) {
test(`${shift ? "Shift+Tab" : "Tab"} selects Don't show again`, async () => {
await using temporary = await tmpdir()
await using setup = await renderUpsell(temporary.path)
await setup.fail()
setup.app.mockInput.pressTab({ shift })
setup.app.mockInput.pressEnter()
await setup.app.waitFor(() => setup.dialog.stack.length === 0)
expect((await setup.persisted()).dontShowAgain).toBe(true)
})
}
test("only the current session's exact free-tier error opens the offer", async () => {
await using temporary = await tmpdir()
await using setup = await renderUpsell(temporary.path)
await setup.fail("ses_other")
await setup.fail("ses_current", "provider.rate-limit")
await setup.fail("ses_current", "unknown")
expect(setup.dialog.stack).toHaveLength(0)
expect(setup.app.captureCharFrame()).not.toContain("Free limit reached")
setup.setSession("ses_next")
await setup.fail("ses_current")
expect(setup.dialog.stack).toHaveLength(0)
await setup.fail("ses_next")
expect(setup.app.captureCharFrame()).toContain("Free limit reached")
})
test("does not replace another modal or recreate an already open offer", async () => {
await using temporary = await tmpdir()
await using setup = await renderUpsell(temporary.path)
setup.dialog.replace(() => <DialogConfirm title="Keep working" message="An existing dialog" />)
const existing = setup.dialog.stack[0]
await setup.fail()
expect(setup.dialog.stack[0]).toBe(existing)
expect(setup.app.captureCharFrame()).toContain("Keep working")
expect(setup.app.captureCharFrame()).toContain("Cancel Confirm")
expect(setup.app.captureCharFrame()).not.toContain("Free limit reached")
setup.app.mockInput.pressEscape()
await setup.fail()
const offer = setup.dialog.stack[0]
expect(setup.app.captureCharFrame()).toContain("Free limit reached")
await setup.fail()
expect(setup.dialog.stack).toHaveLength(1)
expect(setup.dialog.stack[0]).toBe(offer)
expect(await Bun.file(path.join(temporary.path, "test", "tui", "go-upsell.json")).exists()).toBe(false)
})
test("Escape persists a cooldown across remounts without opting out and permits another offer after 24h", async () => {
await using temporary = await tmpdir()
{
await using setup = await renderUpsell(temporary.path)
await setup.fail()
const before = Date.now()
setup.app.mockInput.pressEscape()
await setup.app.waitFor(() => setup.dialog.stack.length === 0)
const saved = await setup.persisted()
expect(saved.dontShowAgain).toBe(false)
expect(saved.lastSeenAt).toBeGreaterThanOrEqual(before)
expect(saved.lastSeenAt).toBeLessThanOrEqual(Date.now())
await setup.fail()
expect(setup.dialog.stack).toHaveLength(0)
}
await using setup = await renderUpsell(temporary.path)
await setup.fail()
expect(setup.dialog.stack).toHaveLength(0)
await setup.age(23 * 60 * 60 * 1000)
await setup.fail()
expect(setup.dialog.stack).toHaveLength(0)
await setup.age(24 * 60 * 60 * 1000)
await setup.fail()
expect(setup.app.captureCharFrame()).toContain("Free limit reached")
})
for (const input of ["keyboard", "pointer"]) {
test(`Don't show again via ${input} survives a remount beyond the cooldown`, async () => {
await using temporary = await tmpdir()
{
await using setup = await renderUpsell(temporary.path)
await setup.fail()
if (input === "keyboard") {
setup.app.mockInput.pressArrow("right")
setup.app.mockInput.pressEnter()
}
if (input === "pointer") {
const lines = setup.app.captureCharFrame().split("\n")
const row = lines.findIndex((line) => line.includes("Don't show again"))
expect(row).toBeGreaterThanOrEqual(0)
await setup.app.mockMouse.click(lines[row].indexOf("Don't show again"), row)
}
await setup.app.waitFor(() => setup.dialog.stack.length === 0)
expect((await setup.persisted()).dontShowAgain).toBe(true)
await setup.age(7 * 24 * 60 * 60 * 1000)
}
await using setup = await renderUpsell(temporary.path)
await setup.fail()
setup.setSession("ses_next")
await setup.fail()
expect(setup.dialog.stack).toHaveLength(0)
expect(setup.app.captureCharFrame()).not.toContain("Free limit reached")
expect((await setup.persisted()).dontShowAgain).toBe(true)
})
}
test.skipIf(process.platform !== "linux")(
"Subscribe opens the Go URL and starts a cooldown without opting out",
async () => {
await using temporary = await tmpdir()
const original = {
BROWSER: process.env.BROWSER,
XDG_CURRENT_DESKTOP: process.env.XDG_CURRENT_DESKTOP,
GO_UPSELL_BROWSER_LOG: process.env.GO_UPSELL_BROWSER_LOG,
}
process.env.BROWSER = `sh ${path.join(import.meta.dir, "../fixture/go-upsell-browser.sh")} %s`
process.env.XDG_CURRENT_DESKTOP = "X-Generic"
process.env.GO_UPSELL_BROWSER_LOG = path.join(temporary.path, "opened-url")
try {
await using setup = await renderUpsell(temporary.path)
await setup.fail()
setup.app.mockInput.pressEnter()
await setup.app.waitFor(() => setup.dialog.stack.length === 0)
await setup.app.waitFor(() => Bun.file(path.join(temporary.path, "opened-url")).exists())
expect(await Bun.file(path.join(temporary.path, "opened-url")).text()).toBe("https://opencode.ai/go\n")
const saved = await setup.persisted()
expect(saved.lastSeenAt).toBeGreaterThan(0)
expect(saved.dontShowAgain).toBe(false)
await setup.fail()
expect(setup.dialog.stack).toHaveLength(0)
} finally {
for (const [key, value] of Object.entries(original)) {
if (value === undefined) delete process.env[key]
if (value !== undefined) process.env[key] = value
}
}
},
)
@@ -0,0 +1,3 @@
#!/bin/sh
set -eu
printf '%s\n' "$1" >> "$GO_UPSELL_BROWSER_LOG"