mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-21 20:50:09 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d366053499 | |||
| 8960001ef3 | |||
| d05f997605 | |||
| 73416e30ac | |||
| 5a03af9033 |
@@ -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") ||
|
||||
|
||||
@@ -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")(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 }) }
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user