diff --git a/packages/ai/src/provider-error.ts b/packages/ai/src/provider-error.ts index 06162f04241c..cc082c0b1e71 100644 --- a/packages/ai/src/provider-error.ts +++ b/packages/ai/src/provider-error.ts @@ -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") || diff --git a/packages/ai/src/schema/errors.ts b/packages/ai/src/schema/errors.ts index 79a8f7886910..1d6cef336572 100644 --- a/packages/ai/src/schema/errors.ts +++ b/packages/ai/src/schema/errors.ts @@ -55,7 +55,10 @@ export class RateLimitError extends Schema.TaggedError("AI.Error export class QuotaExceededError extends Schema.TaggedError("AI.Error.QuotaExceeded")( "QuotaExceeded", - ReasonFields, + { + ...ReasonFields, + classification: Schema.optional(Schema.Literal("free-tier")), + }, ) {} export class ContentPolicyError extends Schema.TaggedError("AI.Error.ContentPolicy")( diff --git a/packages/ai/test/executor.test.ts b/packages/ai/test/executor.test.ts index 014cba9bc9e5..77253b448236 100644 --- a/packages/ai/test/executor.test.ts +++ b/packages/ai/test/executor.test.ts @@ -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") diff --git a/packages/ai/test/provider-error.test.ts b/packages/ai/test/provider-error.test.ts index f8c74cb39fe8..419d070170d6 100644 --- a/packages/ai/test/provider-error.test.ts +++ b/packages/ai/test/provider-error.test.ts @@ -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", diff --git a/packages/app/e2e/session/free-usage-limit.spec.ts b/packages/app/e2e/session/free-usage-limit.spec.ts new file mode 100644 index 000000000000..0f4542ee1ae4 --- /dev/null +++ b/packages/app/e2e/session/free-usage-limit.spec.ts @@ -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>, + 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", + }) +}) diff --git a/packages/app/src/session/usage-exceeded-dialogs.tsx b/packages/app/src/session/usage-exceeded-dialogs.tsx index 7b29a5c16f37..5c5ff889fbc2 100644 --- a/packages/app/src/session/usage-exceeded-dialogs.tsx +++ b/packages/app/src/session/usage-exceeded-dialogs.tsx @@ -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(() => ( + { + 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(() => ) + }) + } + }} + /> + )) + } + + 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(() => ( - { - 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(() => ) - }) - } - }} - /> - )) - } else if (action.reason === "account_rate_limit") { + if (action.reason === "account_rate_limit") { dialog.show(() => ( }), ) +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( diff --git a/packages/core/test/session-error.test.ts b/packages/core/test/session-error.test.ts index 4836feb2d2a9..59700619cc3b 100644 --- a/packages/core/test/session-error.test.ts +++ b/packages/core/test/session-error.test.ts @@ -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() }) }) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 3a6486655a13..cde4f2356cb7 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -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())) diff --git a/packages/tui/src/component/bg-pulse-render.ts b/packages/tui/src/component/bg-pulse-render.ts new file mode 100644 index 000000000000..e9cf0f7946ba --- /dev/null +++ b/packages/tui/src/component/bg-pulse-render.ts @@ -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, + ) + } + } +} diff --git a/packages/tui/src/component/bg-pulse.tsx b/packages/tui/src/component/bg-pulse.tsx new file mode 100644 index 000000000000..e2c625e676b3 --- /dev/null +++ b/packages/tui/src/component/bg-pulse.tsx @@ -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 & { + 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 ( + + ) +} diff --git a/packages/tui/src/component/dialog-go.tsx b/packages/tui/src/component/dialog-go.tsx new file mode 100644 index 000000000000..21e7c89d2f30 --- /dev/null +++ b/packages/tui/src/component/dialog-go.tsx @@ -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 ( + + + + + + + + Free limit reached + + dialog.clear()}> + esc + + + + Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month. + + + + + + + {(key) => ( + setStore("selected", key)} + onMouseUp={() => select(key)} + > + + {key === "subscribe" ? "Subscribe" : "Don't show again"} + + + )} + + + + + ) +} diff --git a/packages/tui/src/routes/session/go-upsell.tsx b/packages/tui/src/routes/session/go-upsell.tsx new file mode 100644 index 000000000000..6213d6e6844d --- /dev/null +++ b/packages/tui/src/routes/session/go-upsell.tsx @@ -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) { + 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( + () => ( + 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) + }, + ) + }), + ) +} diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 7ecc44ce0bd6..1d5b4fbe886a 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -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() diff --git a/packages/tui/test/component/bg-pulse-render.test.ts b/packages/tui/test/component/bg-pulse-render.test.ts new file mode 100644 index 000000000000..6391675c4b92 --- /dev/null +++ b/packages/tui/test/component/bg-pulse-render.test.ts @@ -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)) + } +}) diff --git a/packages/tui/test/component/bg-pulse.test.tsx b/packages/tui/test/component/bg-pulse.test.tsx new file mode 100644 index 000000000000..3161fcee5953 --- /dev/null +++ b/packages/tui/test/component/bg-pulse.test.tsx @@ -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 + let box!: BoxRenderable + + function Art() { + config = useConfig() + return ( + + + + + + ) + } + + const app = await testRender( + () => ( + settings, + update: async (update) => { + update(settings) + return settings + }, + }} + > + + + ), + { 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((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() + } +}) diff --git a/packages/tui/test/component/go-upsell.test.tsx b/packages/tui/test/component/go-upsell.test.tsx new file mode 100644 index 000000000000..d56da48c9eea --- /dev/null +++ b/packages/tui/test/component/go-upsell.test.tsx @@ -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 + let storage!: ReturnType + let client!: ReturnType + let received = 0 + let sequence = 0 + + function Probe() { + dialog = useDialog() + storage = useStorage() + client = useClient() + useGoUpsell(session) + onCleanup(useData().on("session.execution.failed", () => received++)) + return Session content + } + + const app = await testRender( + () => ( + + + + + + + + + + + + + + + + + + + + + + ), + { 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(() => ) + 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 + } + } + }, +) diff --git a/packages/tui/test/fixture/go-upsell-browser.sh b/packages/tui/test/fixture/go-upsell-browser.sh new file mode 100644 index 000000000000..f6aae9cad513 --- /dev/null +++ b/packages/tui/test/fixture/go-upsell-browser.sh @@ -0,0 +1,3 @@ +#!/bin/sh +set -eu +printf '%s\n' "$1" >> "$GO_UPSELL_BROWSER_LOG"