From e417c2dd68f3e50e69fe58446faad6477e89c3dd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 20 Oct 2026 11:17:35 -0700 Subject: [PATCH 1/3] feat(tui): restore animated Go upsell modal on free usage limit --- .../tui/src/component/dialog-go-upsell.tsx | 181 +++++++ packages/tui/src/component/go-upsell-art.ts | 222 +++++++++ packages/tui/src/routes/session/index.tsx | 100 +++- packages/tui/test/app-lifecycle.test.tsx | 444 +++++++++++++++++- 4 files changed, 926 insertions(+), 21 deletions(-) create mode 100644 packages/tui/src/component/dialog-go-upsell.tsx create mode 100644 packages/tui/src/component/go-upsell-art.ts diff --git a/packages/tui/src/component/dialog-go-upsell.tsx b/packages/tui/src/component/dialog-go-upsell.tsx new file mode 100644 index 000000000000..4c5f1a83d7bd --- /dev/null +++ b/packages/tui/src/component/dialog-go-upsell.tsx @@ -0,0 +1,181 @@ +import { RGBA, TextAttributes, type Renderable } from "@opentui/core" +import { useRenderer, useTerminalDimensions } from "@opentui/solid" +import * as Open from "@opencode/util/open" +import { createEffect, createSignal, For, onCleanup, Show } from "solid-js" +import { useConfig } from "../config" +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 "./go-upsell-art" + +export const GO_URL = "https://opencode.ai/go" +const ACTIONS = ["don't show again", "dismiss", "subscribe"] as const +type Action = (typeof ACTIONS)[number] + +export function DialogGoUpsell(props: { reset?: string; onSubscribe: () => void; onDontShowAgain: () => void }) { + const dialog = useDialog() + const dimensions = useTerminalDimensions() + const theme = useTheme().surface("dialog") + const config = useConfig().data + const renderer = useRenderer() + const narrow = () => dimensions().width < 52 + const short = () => dimensions().height < 22 + const compact = () => short() || dimensions().height < (narrow() ? 26 : 22) + const animate = () => (config.animations ?? true) && !compact() + const [selected, setSelected] = createSignal("subscribe") + const [anchor, setAnchor] = createSignal() + // Text sits on a translucent panel so the rings stay legible behind it. + const textBg = () => { + const [r, g, b] = theme.background.base.toInts() + return RGBA.fromInts(r, g, b, 186) + } + const primary = () => theme.background.action.primary.focused + + createEffect(() => { + if (!short()) return + dialog.setCentered(true) + onCleanup(() => { + if (dialog.stack.length > 0) dialog.setCentered(false) + }) + }) + + // Every cell changes each frame, so hold the renderer to 30 fps while the art plays. + createEffect(() => { + if (!animate()) return + const fps = { target: renderer.targetFps, max: renderer.maxFps } + renderer.targetFps = 30 + renderer.maxFps = 30 + onCleanup(() => { + renderer.targetFps = fps.target + renderer.maxFps = fps.max + }) + }) + + const run = (action: Action) => { + if (action === "subscribe") { + Open.openUrl(GO_URL).catch(() => {}) + props.onSubscribe() + return + } + if (action === "don't show again") props.onDontShowAgain() + dialog.clear() + } + const step = (by: number) => + setSelected((value) => ACTIONS[(ACTIONS.indexOf(value) + by + ACTIONS.length) % ACTIONS.length]!) + + Keymap.createLayer(() => ({ + mode: "modal", + commands: [ + { bind: "return", title: "Confirm Go option", group: "Dialog", run: () => run(selected()) }, + { bind: "left", title: "Previous Go option", group: "Dialog", run: () => step(-1) }, + { bind: "up", title: "Previous Go option", group: "Dialog", run: () => step(-1) }, + { bind: "shift+tab", title: "Previous Go option", group: "Dialog", run: () => step(-1) }, + { bind: "right", title: "Next Go option", group: "Dialog", run: () => step(1) }, + { bind: "down", title: "Next Go option", group: "Dialog", run: () => step(1) }, + { bind: "tab", title: "Next Go option", group: "Dialog", run: () => step(1) }, + ], + })) + + const button = (action: Action) => ( + setSelected(action)} + onMouseUp={() => run(action)} + > + + {action} + + + ) + + const inset = () => (narrow() ? 2 : 3) + const bodyMaxHeight = () => { + const topInset = short() ? 1 : Math.floor(dimensions().height / 4) + 1 + const footerRows = narrow() ? ACTIONS.length : 1 + const gaps = short() ? 1 : 4 + return Math.max(2, dimensions().height - topInset - footerRows - gaps) + } + + return ( + + + + + + + + + + Free limit reached + + dialog.clear()}> + esc + + + + + You've used today's free messages.{props.reset ? ` Resets at ${props.reset}.` : ""} + + + OpenCode Go gives you reliable access to the best open-source models for $10/month. + + + + } + > + + + You've used today's free messages.{props.reset ? ` Resets at ${props.reset}.` : ""} + + + OpenCode Go gives you reliable access to the best open-source models for $10/month. + + + + + + + + {(action) => button(action)} + + } + > + + {button("don't show again")} + + {(action) => button(action)} + + + + + + ) +} diff --git a/packages/tui/src/component/go-upsell-art.ts b/packages/tui/src/component/go-upsell-art.ts new file mode 100644 index 000000000000..850cdc565e4a --- /dev/null +++ b/packages/tui/src/component/go-upsell-art.ts @@ -0,0 +1,222 @@ +import { + Renderable, + RGBA, + TextAttributes, + type OptimizedBuffer, + type RenderableOptions, + type RenderContext, +} from "@opentui/core" +import { extend } from "@opentui/solid" +import { go } from "../logo" + +// Ported from the v1 TUI's Go upsell (bg-pulse-render.ts): rings radiate from the GO logo while a shimmer crosses it. +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 background ring emits from the GO center when the logo shimmer peaks. +const PHASE_OFFSET = 0.29 +// The frame shown when animations are off. +const STILL = PERIOD * 0.5 +const LOGO_LEFT_WIDTH = go.left[0]?.length ?? 0 +const LOGO_LINES = go.left.map((line, index) => line + " " + go.right[index]) +const LOGO_WIDTH = LOGO_LINES[0]?.length ?? 0 +const LOGO_HEIGHT = LOGO_LINES.length +const LOGO_REACH = Math.hypot(LOGO_WIDTH, LOGO_HEIGHT * 2) + 3 + +type Options = RenderableOptions & { + animate?: boolean + background?: RGBA + primary?: RGBA + logoBase?: RGBA + /** The logo is centred on this renderable's rows. */ + logoAnchor?: Renderable +} + +const clamp = (value: number) => Math.max(0, Math.min(1, value)) +const ease = (phase: number) => { + const envelope = Math.sin(phase * Math.PI) + return envelope * envelope * (3 - 2 * envelope) +} +const mix = (out: RGBA, a: RGBA, b: RGBA, amount: number) => { + const t = clamp(amount) + out.r = a.r + (b.r - a.r) * t + out.g = a.g + (b.g - a.g) * t + out.b = a.b + (b.b - a.b) * t + out.a = 1 + return out +} + +export class GoUpsellArtRenderable extends Renderable { + private elapsed: number + private _animate: boolean + private _background = RGBA.fromInts(0, 0, 0) + private _primary = RGBA.fromInts(255, 255, 255) + private _logoBase = RGBA.fromInts(180, 180, 180) + private anchor: Renderable | undefined + private readonly white = RGBA.fromInts(255, 255, 255) + private readonly shadow = RGBA.fromInts(0, 0, 0) + private readonly pulse = RGBA.fromInts(0, 0, 0) + private readonly fg = RGBA.fromInts(0, 0, 0) + private readonly bg = RGBA.fromInts(0, 0, 0) + private peak = 0 + private glow = 0 + + constructor(ctx: RenderContext, options: Options = {}) { + const animate = options.animate ?? true + super(ctx, { ...options, live: animate }) + this._animate = animate + this.elapsed = animate ? 0 : STILL + if (options.background) this._background = options.background + if (options.primary) this._primary = options.primary + if (options.logoBase) this._logoBase = options.logoBase + this.anchor = options.logoAnchor + } + + set animate(value: boolean) { + if (value === this._animate) return + this._animate = value + this.live = value + if (!value) this.elapsed = STILL + this.requestRender() + } + + set background(value: RGBA) { + this._background = value + this.requestRender() + } + + set primary(value: RGBA) { + this._primary = value + this.requestRender() + } + + set logoBase(value: RGBA) { + this._logoBase = value + this.requestRender() + } + + set logoAnchor(value: Renderable | undefined) { + this.anchor = value + this.requestRender() + } + + protected override onUpdate(deltaTime: number) { + if (this._animate) this.elapsed = (this.elapsed + deltaTime) % PERIOD + } + + protected override destroySelf() { + this.live = false + this.anchor = undefined + super.destroySelf() + } + + /** Sets peak (white core) and glow (primary tint) for a logo half-cell at dist from the logo centre. */ + private logoPulse(dist: number, heads: [number, number], eased: [number, number]) { + let peak = 0.04 + let glow = 0 + for (let i = 0; i < 2; i++) { + const delta = dist - heads[i]! + const core = Math.exp(-(Math.abs(delta / 1.2) ** 1.8)) + const soft = Math.exp(-(Math.abs(delta / 7) ** 1.6)) + const tail = delta < 0 && delta > -7 ? (1 + delta / 7) ** 2.6 : 0 + peak += core * 0.65 * eased[i]! + glow += (soft * 0.16 + tail * 0.22) * eased[i]! + } + this.peak = Math.min(1, peak) + this.glow = Math.min(1, glow) + } + + private logoTint(out: RGBA, base: RGBA, glow: number, peak: number) { + return mix(out, mix(out, base, this._primary, glow), this.white, peak) + } + + protected override renderSelf(buffer: OptimizedBuffer) { + const width = this.width + const height = this.height + if (!this.visible || this.isDestroyed || width <= 0 || height <= 0) return + const t = this.elapsed + const rgb = this._ctx.capabilities?.rgb === true + const logoX = Math.max(0, Math.floor((width - LOGO_WIDTH) / 2)) + const rawLogoY = this.anchor ? this.anchor.screenY - this.screenY : Math.round((height - LOGO_HEIGHT) / 2) - 1 + const logoY = Math.max(0, Math.min(Math.max(0, height - LOGO_HEIGHT), rawLogoY)) + const centerX = logoX + LOGO_WIDTH / 2 + const centerY = logoY + LOGO_HEIGHT / 2 + const reach = Math.hypot(Math.max(centerX, width - centerX), Math.max(centerY, height - centerY) * 2) + TAIL + const breath = (0.5 + 0.5 * Math.sin(t * BREATH_SPEED)) * BREATH_AMP + const ringHeads: number[] = [] + const ringEased: number[] = [] + for (let ring = 0; ring < RINGS; ring++) { + const phase = (t / PERIOD + ring / RINGS - PHASE_OFFSET + 1) % 1 + ringHeads.push(phase * reach) + ringEased.push(ease(phase)) + } + const logoPhase0 = (t / PERIOD) % 1 + const logoPhase1 = (t / PERIOD + 0.5) % 1 + const logoHeads: [number, number] = [logoPhase0 * LOGO_REACH, logoPhase1 * LOGO_REACH] + const logoEased: [number, number] = [ease(logoPhase0), ease(logoPhase1)] + mix(this.shadow, this._background, this._logoBase, 0.25) + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const dist = Math.hypot(x + 0.5 - centerX, (y + 0.5 - centerY) * 2) + let level = 0 + for (let ring = 0; ring < RINGS; ring++) { + const delta = dist - ringHeads[ring]! + const crest = Math.abs(delta) < WIDTH ? 0.5 + 0.5 * Math.cos((delta / WIDTH) * Math.PI) : 0 + const tail = delta < 0 && delta > -TAIL ? (1 + delta / TAIL) ** 2.3 : 0 + level += (crest * AMP + tail * TAIL_AMP) * ringEased[ring]! + } + const falloff = Math.max(0, 1 - (dist / (reach * 0.85)) ** 2) + mix(this.pulse, this._background, this._primary, Math.min(1, (level / RINGS + breath) * falloff) * 0.7) + + const char = LOGO_LINES[y - logoY]?.[x - logoX] + if (char === undefined || char === " ") { + buffer.setCell(this.screenX + x, this.screenY + y, " ", this.pulse, this.pulse) + continue + } + const lx = x - logoX + const ly = y - logoY + const attributes = lx > LOGO_LEFT_WIDTH ? TextAttributes.BOLD : 0 + this.logoPulse(Math.hypot(lx + 0.5 - LOGO_WIDTH / 2, ly * 2 - LOGO_HEIGHT), logoHeads, logoEased) + const topPeak = this.peak + const topGlow = this.glow + this.logoPulse(Math.hypot(lx + 0.5 - LOGO_WIDTH / 2, ly * 2 + 1 - LOGO_HEIGHT), logoHeads, logoEased) + const bottomPeak = this.peak + const bottomGlow = this.glow + const cellX = this.screenX + x + const cellY = this.screenY + y + if (char === "_") { + mix(this.bg, this.shadow, this.white, Math.max(topPeak, bottomPeak) * 0.18) + buffer.setCell(cellX, cellY, " ", this.bg, this.bg, attributes) + } else if (char === "^") { + this.logoTint(this.fg, this._logoBase, topGlow, topPeak) + mix(this.bg, this.shadow, this.white, bottomPeak * 0.18) + buffer.setCell(cellX, cellY, "▀", this.fg, this.bg, attributes) + } else if (char === "~") { + mix(this.fg, this.shadow, this.white, topPeak * 0.18) + buffer.setCell(cellX, cellY, "▀", this.fg, this.pulse, attributes) + } else if (char === "█" && rgb) { + this.logoTint(this.fg, this._logoBase, topGlow, topPeak) + this.logoTint(this.bg, this._logoBase, bottomGlow, bottomPeak) + buffer.setCell(cellX, cellY, "▀", this.fg, this.bg, attributes) + } else { + this.logoTint(this.fg, this._logoBase, (topGlow + bottomGlow) / 2, (topPeak + bottomPeak) / 2) + buffer.setCell(cellX, cellY, char, this.fg, this.pulse, attributes) + } + } + } + } +} + +declare module "@opentui/solid" { + interface OpenTUIComponents { + go_upsell_art: typeof GoUpsellArtRenderable + } +} + +extend({ go_upsell_art: GoUpsellArtRenderable }) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 30d88d3565f0..fd459a434440 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -70,6 +70,10 @@ import { statusLabel } from "../../component/dialog-workspace-file-changes" import { DialogMessage } from "./dialog-message" import { DialogFork } from "./dialog-fork" import { DialogTimeline } from "./dialog-timeline" +import { DialogIntegration } from "../../component/dialog-integration" +import { DialogGoUpsell } from "../../component/dialog-go-upsell" +import { DialogModel } from "../../component/dialog-model" +import { useStorage } from "../../context/storage" import { Composer } from "./composer" import { filetype } from "../../util/filetype" import parsers from "../../parsers-config" @@ -625,8 +629,20 @@ export function Session(props: { current.submit() }) const dialog = useDialog() + const storage = useStorage() + const [goUpsell, updateGoUpsell] = storage.store<{ lastSeenAt?: number; dontShow?: boolean }>("go-upsell", { + initial: {}, + }) + const [goUpsellMemory, updateGoUpsellMemory] = storage.memory<{ lastSeenAt?: number; dontShow?: boolean }>( + "go-upsell", + { initial: {} }, + ) const handledFreeLimits = new Set() - // Restore the prompt when the free allowance stops it. + let sessionDisposed = false + onCleanup(() => { + sessionDisposed = true + }) + // Restore the prompt when the free allowance stops it, and offer OpenCode Go on the first hit in a day. onCleanup( data.on("session.step.failed", (event) => { if (event.data.sessionID !== route.sessionID || event.data.error.type !== "provider.free-limit") return @@ -652,6 +668,88 @@ export function Session(props: { break } } + if (dialog.stack.length > 0) return + if (goUpsell.dontShow || goUpsellMemory.dontShow) return + const now = Date.now() + const seen = Math.max(goUpsell.lastSeenAt ?? 0, goUpsellMemory.lastSeenAt ?? 0) + if (seen > 0 && now - seen < 86_400_000) return + const expectedText = current.current.text + const expectedFiles = current.current.files?.length ?? 0 + const expectedAgents = current.current.agents?.length ?? 0 + const expectedSkills = current.current.skills?.length ?? 0 + const expectedPasted = current.current.pasted.length + const canOpen = () => { + const active = prompt() + return Boolean( + !sessionDisposed && + route.sessionID === event.data.sessionID && + dialog.stack.length === 0 && + !goUpsell.dontShow && + !goUpsellMemory.dontShow && + active && + active.current.text === expectedText && + (active.current.files?.length ?? 0) === expectedFiles && + (active.current.agents?.length ?? 0) === expectedAgents && + (active.current.skills?.length ?? 0) === expectedSkills && + active.current.pasted.length === expectedPasted, + ) + } + const previousSeen = goUpsellMemory.lastSeenAt + updateGoUpsellMemory((draft) => { + draft.lastSeenAt = now + }) + let claimed = false + void updateGoUpsell((draft) => { + if (draft.dontShow) { + updateGoUpsellMemory((mem) => { + mem.dontShow = true + mem.lastSeenAt = draft.lastSeenAt ?? previousSeen + }) + return + } + if ((draft.lastSeenAt ?? 0) > 0 && now - (draft.lastSeenAt ?? 0) < 86_400_000) { + updateGoUpsellMemory((mem) => { + mem.lastSeenAt = draft.lastSeenAt + }) + return + } + if (!canOpen()) { + updateGoUpsellMemory((mem) => { + mem.lastSeenAt = previousSeen + }) + return + } + draft.lastSeenAt = now + claimed = true + }) + .catch(() => { + claimed = true + }) + .then(() => { + if (!claimed || !canOpen()) return + dialog.replace(() => ( + { + updateGoUpsellMemory((draft) => { + draft.dontShow = true + }) + void updateGoUpsell((draft) => { + draft.dontShow = true + }).catch(() => undefined) + }} + onSubscribe={() => + dialog.replace(() => ( + dialog.replace(() => )} + /> + )) + } + /> + )) + }) }), ) const runPendingAction = createSingleFlight() diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 99f090418de3..658ec7caa6b8 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -1,8 +1,10 @@ -import { expect, test } from "bun:test" -import { EmbeddedTerminalRenderable } from "@opentui/core" +import { expect, spyOn, test } from "bun:test" +import { EmbeddedTerminalRenderable, type Renderable } from "@opentui/core" import { createTestRenderer } from "@opentui/core/testing" import { Effect, FileSystem } from "effect" import { Global } from "@opencode/util/global" +import * as Open from "@opencode/util/open" +import { GoUpsellArtRenderable } from "../src/component/go-upsell-art" import { SimulationRenderer } from "@opencode/simulation/frontend/renderer" import path from "node:path" import { Writable } from "node:stream" @@ -1701,8 +1703,11 @@ test("server plugin failures share one notice and use source names before an ID expect(setup.captureCharFrame()).toContain("Open plugins") }) -test("free usage limit lists next steps, recalls the delivered prompt once, and skips the failure toast", async () => { +test("free usage limit offers OpenCode Go once per 24h, transitions Subscribe to Connect Go, and restores focus", async () => { await using state = await tmpdir() + const baseNow = 1_700_000_000_000 + let currentNow = baseNow + using _now = spyOn(Date, "now").mockImplementation(() => currentNow) const session = { id: "ses_free_limit", projectID: "proj_test", @@ -1727,6 +1732,18 @@ test("free usage limit lists next steps, recalls the delivered prompt once, and fetch: (url) => { if (url.pathname === "/api/session") return json({ data: [session], cursor: {} }) if (url.pathname === `/api/session/${session.id}`) return json({ data: session }) + if (url.pathname === "/api/integration") + return json({ + location: { directory, project: { id: "proj_test", directory, canonical: directory } }, + data: [ + { + id: "opencode-go", + name: "OpenCode Go", + methods: [{ type: "key" }, { id: "device", type: "oauth", label: "Sign in with your OpenCode account" }], + connections: [], + }, + ], + }) if (url.pathname === `/api/session/${session.id}/message`) return json({ data: [ @@ -1764,20 +1781,35 @@ test("free usage limit lists next steps, recalls the delivered prompt once, and await setup.waitForFrame((frame) => frame.includes("Refactor the parser")) setup.events.emit({ id: "evt_free_limit_1", - created: 3, + created: baseNow, type: "session.step.failed", durable: { aggregateID: session.id, seq: 1, version: 1 }, data: { sessionID: session.id, assistantMessageID: "msg_free_assistant", error }, }) setup.events.emit({ id: "evt_free_limit_exec", - created: 3, + created: baseNow, type: "session.execution.failed", durable: { aggregateID: session.id, seq: 2, version: 1 }, data: { sessionID: session.id, error }, }) - await setup.waitForFrame((frame) => frame.includes("You've used today's free messages. They reset at")) + using openSpy = spyOn(Open, "openUrl").mockResolvedValue(undefined as never) + await setup.waitForFrame((frame) => frame.includes("Free limit reached")) + const upsell = setup.captureCharFrame() + expect(upsell).toContain("OpenCode Go gives you reliable access") + expect(upsell).toContain("▀▀▀▀ ▀▀▀▀") + expect(upsell).toContain("opencode.ai/go") + expect(upsell).toContain("don't show again") + expect(upsell).toContain("dismiss") + expect(upsell).toContain("subscribe") + expect(upsell).not.toContain("Connect OpenCode Go") + expect(upsell).not.toContain("Session failed") + // Dismiss closes the modal, leaves the muted notice, and restores focus to the composer. + setup.mockInput.pressArrow("left") + setup.mockInput.pressEnter() + await setup.waitForFrame((frame) => !frame.includes("Free limit reached")) const frame = setup.captureCharFrame() + expect(frame).toContain("You've used today's free messages. They reset at") expect(frame).toContain("Upgrade to OpenCode Go → /connect · Switch model → /models") expect(frame).not.toContain("Error:") expect(frame).not.toContain("Session failed") @@ -1785,12 +1817,13 @@ test("free usage limit lists next steps, recalls the delivered prompt once, and await setup.waitForFrame((frame) => frame.split("Refactor the parser").length === 3) expect(setup.captureCharFrame().split("Queued follow-up").length).toBe(2) - // Clear the composer: a duplicate event for the same step does not re-restore into the cleared composer. + // Clear the composer (which also verifies composer focus was restored after Dismiss): a duplicate event does not re-restore. + await Bun.sleep(10) setup.mockInput.pressKey("c", { ctrl: true }) await setup.waitForFrame((frame) => frame.split("Refactor the parser").length === 2) setup.events.emit({ id: "evt_free_limit_dup", - created: 3, + created: baseNow, type: "session.step.failed", durable: { aggregateID: session.id, seq: 3, version: 1 }, data: { @@ -1802,7 +1835,8 @@ test("free usage limit lists next steps, recalls the delivered prompt once, and await setup.waitForFrame((frame) => !frame.includes("They reset at")) expect(setup.captureCharFrame().split("Refactor the parser").length).toBe(2) - // With the composer still empty, delivering the next turn and hitting the limit again recalls that turn's prompt. + // Just before 24h (86_400_000 - 1 ms) with an empty composer: recalls the next turn's prompt without reopening Free limit reached. + currentNow = baseNow + 86_400_000 - 1 setup.events.emit({ id: "evt_free_limit_delivered", created: 4, @@ -1819,7 +1853,7 @@ test("free usage limit lists next steps, recalls the delivered prompt once, and }) setup.events.emit({ id: "evt_free_limit_3", - created: 6, + created: currentNow, type: "session.step.failed", durable: { aggregateID: session.id, seq: 6, version: 1 }, data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_2", error }, @@ -1828,15 +1862,49 @@ test("free usage limit lists next steps, recalls the delivered prompt once, and (frame) => frame.split("You've used today's free messages").length === 3 && frame.split("Queued follow-up").length === 3, ) + expect(setup.captureCharFrame()).not.toContain("Free limit reached") + expect(setup.captureCharFrame()).not.toContain("Connect OpenCode Go") + + // Exactly at 24h (86_400_000 ms) with an empty composer: reopens Free limit reached, and Subscribe transitions to Connect OpenCode Go with account sign-in first. + currentNow = baseNow + 86_400_000 + setup.mockInput.pressKey("c", { ctrl: true }) + await setup.waitForFrame((frame) => frame.split("Queued follow-up").length === 2) + setup.events.emit({ + id: "evt_free_limit_4", + created: 7, + type: "session.step.started", + durable: { aggregateID: session.id, seq: 7, version: 1 }, + data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_3", agent: "build", model, started: 7 }, + }) + setup.events.emit({ + id: "evt_free_limit_5", + created: currentNow, + type: "session.step.failed", + durable: { aggregateID: session.id, seq: 8, version: 1 }, + data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_3", error }, + }) + await setup.waitForFrame((frame) => frame.includes("Free limit reached")) + setup.mockInput.pressEnter() + await setup.waitForFrame((frame) => frame.includes("Connect OpenCode Go")) + expect(openSpy).toHaveBeenCalledWith("https://opencode.ai/go") + const connectFrame = setup.captureCharFrame() + expect(connectFrame).toContain("Sign in with your OpenCode account") + expect(connectFrame.indexOf("Sign in with your OpenCode account")).toBeLessThan(connectFrame.indexOf("API key")) + setup.mockInput.pressEscape() + await setup.waitForFrame((frame) => !frame.includes("Connect OpenCode Go")) }) -test("free usage limit does not overwrite a draft while typing", async () => { +test("free usage limit Subscribe works with delayed or key-only catalogs, and Don't show again persists across restart", async () => { await using state = await tmpdir() + const baseNow = 1_700_000_000_000 + let currentNow = baseNow + using _now = spyOn(Date, "now").mockImplementation(() => currentNow) + using openSpy = spyOn(Open, "openUrl").mockResolvedValue(undefined as never) const session = { - id: "ses_free_limit_typing", + id: "ses_free_limit_key", projectID: "proj_test", location: { directory }, - title: "Free limit typing", + title: "Free limit key only", cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 1, updated: 1 }, @@ -1848,14 +1916,25 @@ test("free usage limit does not overwrite a draft while typing", async () => { status: 429, retryAfterMs: 3_600_000, } + let releaseCatalog!: () => void + const catalogGate = new Promise((resolve) => { + releaseCatalog = resolve + }) await using setup = await createAppFixture({ width: 100, state: state.path, args: { sessionID: session.id }, config: { animations: false, tabs: { mode: "off" } }, - fetch: (url) => { + fetch: async (url) => { if (url.pathname === "/api/session") return json({ data: [session], cursor: {} }) if (url.pathname === `/api/session/${session.id}`) return json({ data: session }) + if (url.pathname === "/api/integration") { + await catalogGate + return json({ + location: { directory, project: { id: "proj_test", directory, canonical: directory } }, + data: [{ id: "opencode-go", name: "OpenCode Go", methods: [{ type: "key", label: "Enter Go API key" }], connections: [] }], + }) + } if (url.pathname === `/api/session/${session.id}/message`) return json({ data: [ @@ -1878,19 +1957,344 @@ test("free usage limit does not overwrite a draft while typing", async () => { }) await setup.ready await setup.waitForFrame((frame) => frame.includes("Refactor the parser")) - await setup.mockInput.typeText("Draft in progress") - await setup.waitForFrame((frame) => frame.includes("Draft in progress")) + // Subscribe before /api/integration resolves: enters DialogIntegration and transitions to the key prompt once the catalog loads. setup.events.emit({ - id: "evt_free_limit_typing_1", - created: 3, + id: "evt_free_limit_key_1", + created: baseNow, type: "session.step.failed", durable: { aggregateID: session.id, seq: 1, version: 1 }, data: { sessionID: session.id, assistantMessageID: "msg_free_assistant", error }, }) - await setup.waitForFrame((frame) => frame.includes("You've used today's free messages. They reset at")) - const frame = setup.captureCharFrame() + await setup.waitForFrame((frame) => frame.includes("Free limit reached")) + setup.mockInput.pressEnter() + expect(openSpy).toHaveBeenCalledWith("https://opencode.ai/go") + await setup.waitForFrame((frame) => frame.includes("Connect an integration")) + releaseCatalog() + await setup.waitForFrame((frame) => frame.includes("Enter Go API key")) + setup.mockInput.pressEscape() + await setup.waitForFrame((frame) => !frame.includes("Enter Go API key")) + + // Advance past 24h, clear composer, trigger again, and choose "don't show again". + currentNow = baseNow + 2 * 86_400_000 + await Bun.sleep(10) + setup.mockInput.pressKey("c", { ctrl: true }) + await setup.waitForFrame((frame) => frame.split("Refactor the parser").length === 2) + setup.events.emit({ + id: "evt_free_limit_key_2", + created: 5, + type: "session.step.started", + durable: { aggregateID: session.id, seq: 2, version: 1 }, + data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_2", agent: "build", model, started: 5 }, + }) + setup.events.emit({ + id: "evt_free_limit_key_3", + created: currentNow, + type: "session.step.failed", + durable: { aggregateID: session.id, seq: 3, version: 1 }, + data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_2", error }, + }) + await setup.waitForFrame((frame) => frame.includes("Free limit reached")) + setup.mockInput.pressArrow("right") + setup.mockInput.pressEnter() + await setup.waitForFrame((frame) => !frame.includes("Free limit reached")) + await Bun.sleep(30) + + // Restart against the same state directory past another 48h: fresh in-memory store still respects persisted dontShow. + currentNow = baseNow + 4 * 86_400_000 + await using restarted = await createAppFixture({ + width: 100, + state: state.path, + args: { sessionID: session.id }, + config: { animations: false, tabs: { mode: "off" } }, + fetch: (url) => { + if (url.pathname === "/api/session") return json({ data: [session], cursor: {} }) + if (url.pathname === `/api/session/${session.id}`) return json({ data: session }) + if (url.pathname === "/api/integration") + return json({ + location: { directory, project: { id: "proj_test", directory, canonical: directory } }, + data: [{ id: "opencode-go", name: "OpenCode Go", methods: [{ type: "key" }], connections: [] }], + }) + if (url.pathname === `/api/session/${session.id}/message`) + return json({ + data: [ + { + id: "msg_free_assistant_3", + type: "assistant", + agent: "build", + model, + content: [], + time: { created: 8 }, + }, + { id: "msg_free_user", type: "user", text: "Refactor the parser", time: { created: 1 } }, + ], + cursor: {}, + }) + if ([`/api/session/${session.id}/inbox`, `/api/session/${session.id}/permission`].includes(url.pathname)) + return json({ data: [] }) + return undefined + }, + }) + await restarted.ready + await restarted.waitForFrame((frame) => frame.includes("Refactor the parser")) + restarted.events.emit({ + id: "evt_free_limit_key_restart", + created: currentNow, + type: "session.step.failed", + durable: { aggregateID: session.id, seq: 1, version: 1 }, + data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_3", error }, + }) + await restarted.waitForFrame((frame) => frame.includes("You've used today's free messages. They reset at")) + await Bun.sleep(30) + await restarted.renderOnce() + expect(restarted.captureCharFrame()).not.toContain("Free limit reached") +}) + +test("free usage limit claims suppression atomically across two TUI instances and protects typing drafts", async () => { + await using state = await tmpdir() + const session = { + id: "ses_free_limit_race", + projectID: "proj_test", + location: { directory }, + title: "Free limit race", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + } + const model = { id: "big-pickle", providerID: "opencode" } + const error = { + type: "provider.free-limit", + message: "Free usage limit reached.", + status: 429, + retryAfterMs: 3_600_000, + } + const fetch = (url: URL) => { + if (url.pathname === "/api/session") return json({ data: [session], cursor: {} }) + if (url.pathname === `/api/session/${session.id}`) return json({ data: session }) + if (url.pathname === "/api/integration") + return json({ + location: { directory, project: { id: "proj_test", directory, canonical: directory } }, + data: [ + { + id: "opencode-go", + name: "OpenCode Go", + methods: [{ id: "device", type: "oauth", label: "Sign in with your OpenCode account" }, { type: "key" }], + connections: [], + }, + ], + }) + if (url.pathname === `/api/session/${session.id}/message`) + return json({ + data: [ + { + id: "msg_free_assistant", + type: "assistant", + agent: "build", + model, + content: [], + time: { created: 2 }, + }, + { id: "msg_free_user", type: "user", text: "Refactor the parser", time: { created: 1 } }, + ], + cursor: {}, + }) + if ([`/api/session/${session.id}/inbox`, `/api/session/${session.id}/permission`].includes(url.pathname)) + return json({ data: [] }) + return undefined + } + await using first = await createAppFixture({ + width: 100, + state: state.path, + args: { sessionID: session.id }, + config: { animations: false, tabs: { mode: "off" } }, + fetch, + }) + await using second = await createAppFixture({ + width: 100, + state: state.path, + args: { sessionID: session.id }, + config: { animations: false, tabs: { mode: "off" } }, + fetch, + }) + await Promise.all([first.ready, second.ready]) + await Promise.all([ + first.waitForFrame((frame) => frame.includes("Refactor the parser")), + second.waitForFrame((frame) => frame.includes("Refactor the parser")), + ]) + // Emit concurrently in both processes: only one claims the 24h window and opens the modal. + const event = { + id: "evt_free_limit_race_1", + created: 3, + type: "session.step.failed" as const, + durable: { aggregateID: session.id, seq: 1, version: 1 as const }, + data: { sessionID: session.id, assistantMessageID: "msg_free_assistant", error }, + } + first.events.emit(event) + second.events.emit(event) + await Promise.all([ + first.waitForFrame((frame) => frame.includes("You've used today's free messages. They reset at")), + second.waitForFrame((frame) => frame.includes("You've used today's free messages. They reset at")), + ]) + await first.waitForFrame( + () => + first.captureCharFrame().includes("Free limit reached") || + second.captureCharFrame().includes("Free limit reached"), + ) + await Bun.sleep(30) + await Promise.all([first.renderOnce(), second.renderOnce()]) + const opened = [first.captureCharFrame(), second.captureCharFrame()].filter((frame) => + frame.includes("Free limit reached"), + ) + expect(opened).toHaveLength(1) + + // Typing draft protection: does not overwrite a draft or open Free limit reached while typing. + await using typingState = await tmpdir() + await using typing = await createAppFixture({ + width: 100, + state: typingState.path, + args: { sessionID: session.id }, + config: { animations: false, tabs: { mode: "off" } }, + fetch, + }) + await typing.ready + await typing.waitForFrame((frame) => frame.includes("Refactor the parser")) + await typing.mockInput.typeText("Draft in progress") + await typing.waitForFrame((frame) => frame.includes("Draft in progress")) + typing.events.emit(event) + await typing.waitForFrame((frame) => frame.includes("You've used today's free messages. They reset at")) + const frame = typing.captureCharFrame() expect(frame).toContain("Draft in progress") expect(frame.split("Refactor the parser").length).toBe(2) + expect(frame).not.toContain("Free limit reached") + expect(frame).not.toContain("Connect OpenCode Go") +}) + +test("free usage limit modal manages live animation FPS and adapts to 44x24 and 60x12 terminals", async () => { + const session = { + id: "ses_free_limit_anim", + projectID: "proj_test", + location: { directory }, + title: "Free limit anim", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + } + const model = { id: "big-pickle", providerID: "opencode" } + const error = { + type: "provider.free-limit", + message: "Free usage limit reached.", + status: 429, + retryAfterMs: 3_600_000, + } + const fetch = (url: URL) => { + if (url.pathname === "/api/session") return json({ data: [session], cursor: {} }) + if (url.pathname === `/api/session/${session.id}`) return json({ data: session }) + if (url.pathname === `/api/session/${session.id}/message`) + return json({ + data: [ + { + id: "msg_free_assistant", + type: "assistant", + agent: "build", + model, + content: [], + time: { created: 2 }, + }, + { id: "msg_free_user", type: "user", text: "Refactor the parser", time: { created: 1 } }, + ], + cursor: {}, + }) + if ([`/api/session/${session.id}/inbox`, `/api/session/${session.id}/permission`].includes(url.pathname)) + return json({ data: [] }) + return undefined + } + const findArt = (node: Renderable): GoUpsellArtRenderable | undefined => { + if (node instanceof GoUpsellArtRenderable) return node + for (const child of node.getChildren()) { + const found = findArt(child) + if (found) return found + } + return undefined + } + + await using animState = await tmpdir() + await using anim = await createAppFixture({ + width: 100, + height: 30, + state: animState.path, + args: { sessionID: session.id }, + config: { animations: true, tabs: { mode: "off" } }, + fetch, + }) + await anim.ready + await anim.waitForFrame((frame) => frame.includes("Refactor the parser")) + const initialTargetFps = anim.renderer.targetFps + const initialMaxFps = anim.renderer.maxFps + anim.events.emit({ + id: "evt_free_limit_anim_1", + created: 3, + type: "session.step.failed", + durable: { aggregateID: session.id, seq: 1, version: 1 }, + data: { sessionID: session.id, assistantMessageID: "msg_free_assistant", error }, + }) + await anim.waitForFrame((frame) => frame.includes("Free limit reached")) + const art = findArt(anim.renderer.root) + expect(art).toBeDefined() + expect(art!.live).toBe(true) + expect(art!.isDestroyed).toBe(false) + expect(anim.renderer.targetFps).toBe(30) + expect(anim.renderer.maxFps).toBe(30) + // Toggling animate off disables live updates; toggling back on re-enables them. + art!.animate = false + expect(art!.live).toBe(false) + art!.animate = true + expect(art!.live).toBe(true) + // Closing the modal destroys the art renderable and restores FPS. + anim.mockInput.pressEscape() + await anim.waitForFrame((frame) => !frame.includes("Free limit reached")) + expect(art!.isDestroyed).toBe(true) + expect(art!.live).toBe(false) + expect(findArt(anim.renderer.root)).toBeUndefined() + expect(anim.renderer.targetFps).toBe(initialTargetFps) + expect(anim.renderer.maxFps).toBe(initialMaxFps) + + // Compact sizes: 44x24 (narrow, stacked buttons) and 60x12 (short, scrollable body). + for (const [width, height] of [ + [44, 24], + [60, 12], + ] as const) { + await using compactState = await tmpdir() + await using compact = await createAppFixture({ + width, + height, + state: compactState.path, + args: { sessionID: session.id }, + config: { animations: true, tabs: { mode: "off" } }, + fetch, + }) + await compact.ready + await compact.waitForFrame((frame) => frame.includes("Refactor the parser")) + compact.events.emit({ + id: `evt_free_limit_${width}x${height}`, + created: 3, + type: "session.step.failed", + durable: { aggregateID: session.id, seq: 1, version: 1 }, + data: { sessionID: session.id, assistantMessageID: "msg_free_assistant", error }, + }) + await compact.waitForFrame((frame) => frame.includes("Free limit reached")) + const frame = compact.captureCharFrame() + expect(frame).toContain("Free limit reached") + expect(frame).toContain("don't show again") + expect(frame).toContain("dismiss") + expect(frame).toContain("subscribe") + expect(findArt(compact.renderer.root)).toBeUndefined() + // Locate "dismiss" in the rendered frame and click it with the mouse. + const lines = frame.split("\n") + const row = lines.findIndex((line) => line.includes("dismiss")) + expect(row).toBeGreaterThanOrEqual(0) + const col = lines[row]!.indexOf("dismiss") + 2 + await compact.mockMouse.click(col, row) + await compact.waitForFrame((next) => !next.includes("Free limit reached")) + } }) test.each([44, 100])( From c18833b4fc53b12aaa101c754f6dd06210196702 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 20 Oct 2026 14:16:12 -0700 Subject: [PATCH 2/3] perf(tui): draw Go upsell art into its own buffer without per-cell draw calls The art recorded one setCell draw per cell each frame (about 7 JS objects per cell, ~21k per frame at 100x30). It now uses a buffered renderable, writes the cells through withBuffers, and blits once. Distances and falloff are computed once per size, the logo glyphs and distances once per module, and a still frame is drawn once and reused. Output is byte-identical. --- packages/tui/src/component/go-upsell-art.ts | 242 +++++++++++++----- packages/tui/test/app-lifecycle.test.tsx | 2 + .../tui/test/component/go-upsell-art.test.tsx | 87 +++++++ 3 files changed, 267 insertions(+), 64 deletions(-) create mode 100644 packages/tui/test/component/go-upsell-art.test.tsx diff --git a/packages/tui/src/component/go-upsell-art.ts b/packages/tui/src/component/go-upsell-art.ts index 850cdc565e4a..79c482647e4e 100644 --- a/packages/tui/src/component/go-upsell-art.ts +++ b/packages/tui/src/component/go-upsell-art.ts @@ -27,6 +27,40 @@ const LOGO_LINES = go.left.map((line, index) => line + " " + go.right[index]) const LOGO_WIDTH = LOGO_LINES[0]?.length ?? 0 const LOGO_HEIGHT = LOGO_LINES.length const LOGO_REACH = Math.hypot(LOGO_WIDTH, LOGO_HEIGHT * 2) + 3 +const SPACE = 32 +const UPPER_HALF = "▀".codePointAt(0)! + +const enum Glyph { + None, + Shadow, + ShadowTop, + ShadowBottom, + Block, + Other, +} +const GLYPHS: Record = { + " ": Glyph.None, + _: Glyph.Shadow, + "^": Glyph.ShadowTop, + "~": Glyph.ShadowBottom, +} + +// The logo never changes, so its glyphs and the distance of each half-cell from its centre are fixed. +const LOGO_GLYPH = new Uint8Array(LOGO_WIDTH * LOGO_HEIGHT) +const LOGO_CHAR = new Uint32Array(LOGO_WIDTH * LOGO_HEIGHT) +const LOGO_ATTRIBUTES = new Uint32Array(LOGO_WIDTH * LOGO_HEIGHT) +const LOGO_TOP_DIST = new Float64Array(LOGO_WIDTH * LOGO_HEIGHT) +const LOGO_BOTTOM_DIST = new Float64Array(LOGO_WIDTH * LOGO_HEIGHT) +LOGO_LINES.forEach((line, ly) => { + Array.from(line).forEach((char, lx) => { + const index = ly * LOGO_WIDTH + lx + LOGO_GLYPH[index] = GLYPHS[char] ?? (char === "█" ? Glyph.Block : Glyph.Other) + LOGO_CHAR[index] = char.codePointAt(0) ?? SPACE + LOGO_ATTRIBUTES[index] = lx > LOGO_LEFT_WIDTH ? TextAttributes.BOLD : 0 + LOGO_TOP_DIST[index] = Math.hypot(lx + 0.5 - LOGO_WIDTH / 2, ly * 2 - LOGO_HEIGHT) + LOGO_BOTTOM_DIST[index] = Math.hypot(lx + 0.5 - LOGO_WIDTH / 2, ly * 2 + 1 - LOGO_HEIGHT) + }) +}) type Options = RenderableOptions & { animate?: boolean @@ -50,7 +84,19 @@ const mix = (out: RGBA, a: RGBA, b: RGBA, amount: number) => { out.a = 1 return out } +const put = (plane: Uint16Array, offset: number, color: RGBA) => { + const source = color.buffer + plane[offset] = source[0]! + plane[offset + 1] = source[1]! + plane[offset + 2] = source[2]! + plane[offset + 3] = source[3]! +} +/** + * Draws into its own frame buffer (`buffered`) through `withBuffers`, so a frame is plain typed-array + * writes plus one blit instead of a recorded draw call per cell. Live (and so the renderer's frame loop) + * only while animating and visible; a still frame is drawn once and reused until something changes. + */ export class GoUpsellArtRenderable extends Renderable { private elapsed: number private _animate: boolean @@ -63,12 +109,22 @@ export class GoUpsellArtRenderable extends Renderable { private readonly pulse = RGBA.fromInts(0, 0, 0) private readonly fg = RGBA.fromInts(0, 0, 0) private readonly bg = RGBA.fromInts(0, 0, 0) + private readonly ringHeads = new Float64Array(RINGS) + private readonly ringEased = new Float64Array(RINGS) + private readonly logoHeads = new Float64Array(2) + private readonly logoEased = new Float64Array(2) private peak = 0 private glow = 0 + // Distance and falloff for every cell, rebuilt only when the size or logo position changes. + private dist = new Float64Array(0) + private falloff = new Float64Array(0) + private fieldKey = "" + private reach = 0 + private drawnKey = "" constructor(ctx: RenderContext, options: Options = {}) { const animate = options.animate ?? true - super(ctx, { ...options, live: animate }) + super(ctx, { ...options, live: animate, buffered: true }) this._animate = animate this.elapsed = animate ? 0 : STILL if (options.background) this._background = options.background @@ -82,26 +138,31 @@ export class GoUpsellArtRenderable extends Renderable { this._animate = value this.live = value if (!value) this.elapsed = STILL - this.requestRender() + this.redraw() } set background(value: RGBA) { this._background = value - this.requestRender() + this.redraw() } set primary(value: RGBA) { this._primary = value - this.requestRender() + this.redraw() } set logoBase(value: RGBA) { this._logoBase = value - this.requestRender() + this.redraw() } set logoAnchor(value: Renderable | undefined) { this.anchor = value + this.redraw() + } + + private redraw() { + this.drawnKey = "" this.requestRender() } @@ -112,20 +173,43 @@ export class GoUpsellArtRenderable extends Renderable { protected override destroySelf() { this.live = false this.anchor = undefined + this.dist = new Float64Array(0) + this.falloff = new Float64Array(0) super.destroySelf() } + private field(width: number, height: number, logoX: number, logoY: number) { + const key = `${width}x${height}@${logoX},${logoY}` + if (key === this.fieldKey) return + this.fieldKey = key + const centerX = logoX + LOGO_WIDTH / 2 + const centerY = logoY + LOGO_HEIGHT / 2 + const reach = Math.hypot(Math.max(centerX, width - centerX), Math.max(centerY, height - centerY) * 2) + TAIL + this.reach = reach + if (this.dist.length !== width * height) { + this.dist = new Float64Array(width * height) + this.falloff = new Float64Array(width * height) + } + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const dist = Math.hypot(x + 0.5 - centerX, (y + 0.5 - centerY) * 2) + this.dist[y * width + x] = dist + this.falloff[y * width + x] = Math.max(0, 1 - (dist / (reach * 0.85)) ** 2) + } + } + } + /** Sets peak (white core) and glow (primary tint) for a logo half-cell at dist from the logo centre. */ - private logoPulse(dist: number, heads: [number, number], eased: [number, number]) { + private logoPulse(dist: number) { let peak = 0.04 let glow = 0 for (let i = 0; i < 2; i++) { - const delta = dist - heads[i]! + const delta = dist - this.logoHeads[i]! const core = Math.exp(-(Math.abs(delta / 1.2) ** 1.8)) const soft = Math.exp(-(Math.abs(delta / 7) ** 1.6)) const tail = delta < 0 && delta > -7 ? (1 + delta / 7) ** 2.6 : 0 - peak += core * 0.65 * eased[i]! - glow += (soft * 0.16 + tail * 0.22) * eased[i]! + peak += core * 0.65 * this.logoEased[i]! + glow += (soft * 0.16 + tail * 0.22) * this.logoEased[i]! } this.peak = Math.min(1, peak) this.glow = Math.min(1, glow) @@ -139,77 +223,107 @@ export class GoUpsellArtRenderable extends Renderable { const width = this.width const height = this.height if (!this.visible || this.isDestroyed || width <= 0 || height <= 0) return + // Without its own frame buffer (not yet sized) there is nothing safe to write into directly. + if (buffer !== this.frameBuffer) return const t = this.elapsed const rgb = this._ctx.capabilities?.rgb === true const logoX = Math.max(0, Math.floor((width - LOGO_WIDTH) / 2)) const rawLogoY = this.anchor ? this.anchor.screenY - this.screenY : Math.round((height - LOGO_HEIGHT) / 2) - 1 const logoY = Math.max(0, Math.min(Math.max(0, height - LOGO_HEIGHT), rawLogoY)) - const centerX = logoX + LOGO_WIDTH / 2 - const centerY = logoY + LOGO_HEIGHT / 2 - const reach = Math.hypot(Math.max(centerX, width - centerX), Math.max(centerY, height - centerY) * 2) + TAIL + this.field(width, height, logoX, logoY) + // The frame buffer keeps the last frame, so a still frame is drawn once. + const drawnKey = this._animate ? "" : `${this.fieldKey}:${rgb}` + if (drawnKey && drawnKey === this.drawnKey) return const breath = (0.5 + 0.5 * Math.sin(t * BREATH_SPEED)) * BREATH_AMP - const ringHeads: number[] = [] - const ringEased: number[] = [] for (let ring = 0; ring < RINGS; ring++) { const phase = (t / PERIOD + ring / RINGS - PHASE_OFFSET + 1) % 1 - ringHeads.push(phase * reach) - ringEased.push(ease(phase)) + this.ringHeads[ring] = phase * this.reach + this.ringEased[ring] = ease(phase) } const logoPhase0 = (t / PERIOD) % 1 const logoPhase1 = (t / PERIOD + 0.5) % 1 - const logoHeads: [number, number] = [logoPhase0 * LOGO_REACH, logoPhase1 * LOGO_REACH] - const logoEased: [number, number] = [ease(logoPhase0), ease(logoPhase1)] + this.logoHeads[0] = logoPhase0 * LOGO_REACH + this.logoHeads[1] = logoPhase1 * LOGO_REACH + this.logoEased[0] = ease(logoPhase0) + this.logoEased[1] = ease(logoPhase1) mix(this.shadow, this._background, this._logoBase, 0.25) - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const dist = Math.hypot(x + 0.5 - centerX, (y + 0.5 - centerY) * 2) - let level = 0 - for (let ring = 0; ring < RINGS; ring++) { - const delta = dist - ringHeads[ring]! - const crest = Math.abs(delta) < WIDTH ? 0.5 + 0.5 * Math.cos((delta / WIDTH) * Math.PI) : 0 - const tail = delta < 0 && delta > -TAIL ? (1 + delta / TAIL) ** 2.3 : 0 - level += (crest * AMP + tail * TAIL_AMP) * ringEased[ring]! - } - const falloff = Math.max(0, 1 - (dist / (reach * 0.85)) ** 2) - mix(this.pulse, this._background, this._primary, Math.min(1, (level / RINGS + breath) * falloff) * 0.7) - - const char = LOGO_LINES[y - logoY]?.[x - logoX] - if (char === undefined || char === " ") { - buffer.setCell(this.screenX + x, this.screenY + y, " ", this.pulse, this.pulse) - continue - } - const lx = x - logoX + buffer.withBuffers((cells) => { + const stride = cells.width + const rows = Math.min(height, cells.height) + const cols = Math.min(width, stride) + const { char, fg, bg, attributes } = cells + for (let y = 0; y < rows; y++) { const ly = y - logoY - const attributes = lx > LOGO_LEFT_WIDTH ? TextAttributes.BOLD : 0 - this.logoPulse(Math.hypot(lx + 0.5 - LOGO_WIDTH / 2, ly * 2 - LOGO_HEIGHT), logoHeads, logoEased) - const topPeak = this.peak - const topGlow = this.glow - this.logoPulse(Math.hypot(lx + 0.5 - LOGO_WIDTH / 2, ly * 2 + 1 - LOGO_HEIGHT), logoHeads, logoEased) - const bottomPeak = this.peak - const bottomGlow = this.glow - const cellX = this.screenX + x - const cellY = this.screenY + y - if (char === "_") { - mix(this.bg, this.shadow, this.white, Math.max(topPeak, bottomPeak) * 0.18) - buffer.setCell(cellX, cellY, " ", this.bg, this.bg, attributes) - } else if (char === "^") { - this.logoTint(this.fg, this._logoBase, topGlow, topPeak) - mix(this.bg, this.shadow, this.white, bottomPeak * 0.18) - buffer.setCell(cellX, cellY, "▀", this.fg, this.bg, attributes) - } else if (char === "~") { - mix(this.fg, this.shadow, this.white, topPeak * 0.18) - buffer.setCell(cellX, cellY, "▀", this.fg, this.pulse, attributes) - } else if (char === "█" && rgb) { - this.logoTint(this.fg, this._logoBase, topGlow, topPeak) - this.logoTint(this.bg, this._logoBase, bottomGlow, bottomPeak) - buffer.setCell(cellX, cellY, "▀", this.fg, this.bg, attributes) - } else { - this.logoTint(this.fg, this._logoBase, (topGlow + bottomGlow) / 2, (topPeak + bottomPeak) / 2) - buffer.setCell(cellX, cellY, char, this.fg, this.pulse, attributes) + const inLogoRow = ly >= 0 && ly < LOGO_HEIGHT + for (let x = 0; x < cols; x++) { + const field = y * width + x + const dist = this.dist[field]! + let level = 0 + for (let ring = 0; ring < RINGS; ring++) { + const delta = dist - this.ringHeads[ring]! + const crest = Math.abs(delta) < WIDTH ? 0.5 + 0.5 * Math.cos((delta / WIDTH) * Math.PI) : 0 + const tail = delta < 0 && delta > -TAIL ? (1 + delta / TAIL) ** 2.3 : 0 + level += (crest * AMP + tail * TAIL_AMP) * this.ringEased[ring]! + } + mix( + this.pulse, + this._background, + this._primary, + Math.min(1, (level / RINGS + breath) * this.falloff[field]!) * 0.7, + ) + + const cell = y * stride + x + const color = cell * 4 + const lx = x - logoX + const logo = inLogoRow && lx >= 0 && lx < LOGO_WIDTH ? ly * LOGO_WIDTH + lx : -1 + const glyph = logo < 0 ? Glyph.None : (LOGO_GLYPH[logo]! as Glyph) + if (glyph === Glyph.None) { + char[cell] = SPACE + put(fg, color, this.pulse) + put(bg, color, this.pulse) + attributes[cell] = 0 + continue + } + attributes[cell] = LOGO_ATTRIBUTES[logo]! + this.logoPulse(LOGO_TOP_DIST[logo]!) + const topPeak = this.peak + const topGlow = this.glow + this.logoPulse(LOGO_BOTTOM_DIST[logo]!) + const bottomPeak = this.peak + const bottomGlow = this.glow + if (glyph === Glyph.Shadow) { + mix(this.bg, this.shadow, this.white, Math.max(topPeak, bottomPeak) * 0.18) + char[cell] = SPACE + put(fg, color, this.bg) + put(bg, color, this.bg) + } else if (glyph === Glyph.ShadowTop) { + this.logoTint(this.fg, this._logoBase, topGlow, topPeak) + mix(this.bg, this.shadow, this.white, bottomPeak * 0.18) + char[cell] = UPPER_HALF + put(fg, color, this.fg) + put(bg, color, this.bg) + } else if (glyph === Glyph.ShadowBottom) { + mix(this.fg, this.shadow, this.white, topPeak * 0.18) + char[cell] = UPPER_HALF + put(fg, color, this.fg) + put(bg, color, this.pulse) + } else if (glyph === Glyph.Block && rgb) { + this.logoTint(this.fg, this._logoBase, topGlow, topPeak) + this.logoTint(this.bg, this._logoBase, bottomGlow, bottomPeak) + char[cell] = UPPER_HALF + put(fg, color, this.fg) + put(bg, color, this.bg) + } else { + this.logoTint(this.fg, this._logoBase, (topGlow + bottomGlow) / 2, (topPeak + bottomPeak) / 2) + char[cell] = LOGO_CHAR[logo]! + put(fg, color, this.fg) + put(bg, color, this.pulse) + } } } - } + }) + this.drawnKey = drawnKey } } diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 658ec7caa6b8..20f0e6b42f80 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -2229,6 +2229,7 @@ test("free usage limit modal manages live animation FPS and adapts to 44x24 and await anim.waitForFrame((frame) => frame.includes("Refactor the parser")) const initialTargetFps = anim.renderer.targetFps const initialMaxFps = anim.renderer.maxFps + const initialLive = anim.renderer.liveRequestCount anim.events.emit({ id: "evt_free_limit_anim_1", created: 3, @@ -2254,6 +2255,7 @@ test("free usage limit modal manages live animation FPS and adapts to 44x24 and expect(art!.isDestroyed).toBe(true) expect(art!.live).toBe(false) expect(findArt(anim.renderer.root)).toBeUndefined() + expect(anim.renderer.liveRequestCount).toBe(initialLive) expect(anim.renderer.targetFps).toBe(initialTargetFps) expect(anim.renderer.maxFps).toBe(initialMaxFps) diff --git a/packages/tui/test/component/go-upsell-art.test.tsx b/packages/tui/test/component/go-upsell-art.test.tsx new file mode 100644 index 000000000000..28f8c6e5e99e --- /dev/null +++ b/packages/tui/test/component/go-upsell-art.test.tsx @@ -0,0 +1,87 @@ +import { expect, spyOn, test } from "bun:test" +/** @jsxImportSource @opentui/solid */ +import { OptimizedBuffer, RGBA } from "@opentui/core" +import { testRender } from "@opentui/solid" +import { createSignal, Show } from "solid-js" +import "../../src/component/go-upsell-art" +import type { GoUpsellArtRenderable } from "../../src/component/go-upsell-art" + +const colors = { + background: RGBA.fromHex("#0a0a0a"), + primary: RGBA.fromHex("#fab283"), + logoBase: RGBA.fromHex("#9a9a9a"), +} + +test("the art stops every frame when hidden, closed, or still, and leaves nothing live after repeated opens", async () => { + const [open, setOpen] = createSignal(true) + const [visible, setVisible] = createSignal(true) + const [animate, setAnimate] = createSignal(true) + let art: GoUpsellArtRenderable | undefined + const app = await testRender( + () => ( + + + (art = value)} + width="100%" + height="100%" + visible={visible()} + animate={animate()} + {...colors} + /> + + + ), + { width: 60, height: 20 }, + ) + const draws = spyOn(OptimizedBuffer.prototype, "withBuffers") + const setCell = spyOn(OptimizedBuffer.prototype, "setCell") + + try { + await app.renderOnce() + expect(app.renderer.liveRequestCount).toBe(1) + // Each frame is one direct write into the art's own buffer, never a draw call per cell. + expect(draws).toHaveBeenCalledTimes(1) + expect(setCell).not.toHaveBeenCalled() + expect(app.captureCharFrame()).toContain("█▀▀▀ █▀▀█") + + setVisible(false) + expect(app.renderer.liveRequestCount).toBe(0) + setVisible(true) + expect(app.renderer.liveRequestCount).toBe(1) + + // A still frame is drawn once and then reused. + setAnimate(false) + expect(app.renderer.liveRequestCount).toBe(0) + draws.mockClear() + await app.renderOnce() + await app.renderOnce() + expect(draws).toHaveBeenCalledTimes(1) + setAnimate(true) + + for (let i = 0; i < 20; i++) { + const previous = art! + setOpen(false) + expect(app.renderer.liveRequestCount).toBe(0) + expect(app.renderer.root.liveCount).toBe(0) + await app.renderOnce() + expect(previous.isDestroyed).toBe(true) + expect(previous.live).toBe(false) + setOpen(true) + expect(art).not.toBe(previous) + expect(app.renderer.liveRequestCount).toBe(1) + } + + setOpen(false) + await app.renderOnce() + expect(app.renderer.liveRequestCount).toBe(0) + expect(app.renderer.isRunning).toBe(false) + const frames = app.renderer.frameId + await Bun.sleep(120) + expect(app.renderer.frameId).toBe(frames) + } finally { + draws.mockRestore() + setCell.mockRestore() + app.renderer.destroy() + } +}) From 08f391b912d304109a3ce691e6d5a8a9d2d92137 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 20 Oct 2026 17:36:38 -0700 Subject: [PATCH 3/3] fix(tui): start OpenCode device sign-in on Go upsell subscribe --- .../tui/src/component/dialog-go-upsell.tsx | 290 +++++++++++- packages/tui/src/routes/session/index.tsx | 22 +- packages/tui/test/app-lifecycle.test.tsx | 437 ++++++++++++++++-- 3 files changed, 701 insertions(+), 48 deletions(-) diff --git a/packages/tui/src/component/dialog-go-upsell.tsx b/packages/tui/src/component/dialog-go-upsell.tsx index 4c5f1a83d7bd..025c7d6cb804 100644 --- a/packages/tui/src/component/dialog-go-upsell.tsx +++ b/packages/tui/src/component/dialog-go-upsell.tsx @@ -1,20 +1,47 @@ import { RGBA, TextAttributes, type Renderable } from "@opentui/core" import { useRenderer, useTerminalDimensions } from "@opentui/solid" +import type { FormAnswer, IntegrationOauthConnectOutput, IntegrationOAuthMethod } from "@opencode/client" import * as Open from "@opencode/util/open" -import { createEffect, createSignal, For, onCleanup, Show } from "solid-js" -import { useConfig } from "../config" +import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js" +import { useConfig, useNewDesigns } from "../config" +import { useClient } from "../context/client" +import { useClipboard } from "../context/clipboard" +import { useData } from "../context/data" import { Keymap } from "../context/keymap" +import { useLocal } from "../context/local" +import { useLocation } from "../context/location" import { useTheme } from "../context/theme" import { tint } from "../theme/color" -import { useDialog } from "../ui/dialog" +import { DialogFooter, DialogHeader, DialogLayout, useDialog } from "../ui/dialog" import { Link } from "../ui/link" +import { useToast } from "../ui/toast" +import { errorMessage } from "../util/error" +import { credentialConnections } from "./dialog-integration" +import { DialogModel } from "./dialog-model" import "./go-upsell-art" export const GO_URL = "https://opencode.ai/go" const ACTIONS = ["don't show again", "dismiss", "subscribe"] as const type Action = (typeof ACTIONS)[number] +type IntegrationAttempt = IntegrationOauthConnectOutput["data"] +const OAUTH_CODE = /[A-Z0-9]{4}-[A-Z0-9]{4,5}/ -export function DialogGoUpsell(props: { reset?: string; onSubscribe: () => void; onDontShowAgain: () => void }) { +let inflightConnect: Promise = Promise.resolve() +function trackConnect(task: Promise) { + const next = task.catch(() => undefined) + inflightConnect = inflightConnect.then(() => next) +} + +export function waitForGoUpsellConnect() { + return inflightConnect +} + +export function DialogGoUpsell(props: { + reset?: string + canContinue?: () => boolean + onSubscribe: () => void + onDontShowAgain: () => void +}) { const dialog = useDialog() const dimensions = useTerminalDimensions() const theme = useTheme().surface("dialog") @@ -56,7 +83,7 @@ export function DialogGoUpsell(props: { reset?: string; onSubscribe: () => void; const run = (action: Action) => { if (action === "subscribe") { Open.openUrl(GO_URL).catch(() => {}) - props.onSubscribe() + dialog.replace(() => ) return } if (action === "don't show again") props.onDontShowAgain() @@ -179,3 +206,256 @@ export function DialogGoUpsell(props: { reset?: string; onSubscribe: () => void; ) } + +function DialogGoUpsellConnect(props: { canContinue?: () => boolean; onSubscribed: () => void }) { + const data = useData() + const currentLocation = useLocation() + const location = currentLocation.ref ?? data.location.default() + const dialog = useDialog() + const client = useClient() + const toast = useToast() + const clipboard = useClipboard() + const local = useLocal() + const newDesigns = useNewDesigns() + const theme = useTheme().surface("dialog") + const [attempt, setAttempt] = createSignal() + const integration = createMemo(() => data.location.integration.list(location)?.find((item) => item.id === "opencode")) + const method = createMemo(() => + integration()?.methods.find((item): item is IntegrationOAuthMethod => item.type === "oauth" && item.id === "device"), + ) + + let started = false + let settled = false + let closed = false + let timer: ReturnType | undefined + let pendingAttemptID: string | undefined + + const valid = () => { + const current = data.location.integration.list(location)?.find((item) => item.id === "opencode") + const active = current ? credentialConnections(current)[0] : undefined + return Boolean(active && !active.status) + } + + const finish = () => { + if (props.canContinue && !props.canContinue()) { + dialog.clear() + return + } + const goModel = (data.location.model.list(location) ?? []).find( + (item) => item.providerID === "opencode-go" && item.status !== "deprecated", + ) + if (goModel) { + local.model.set({ providerID: goModel.providerID, modelID: goModel.id }, { recent: true }) + dialog.clear() + props.onSubscribed() + return + } + dialog.replace(() => ) + } + + const sync = async (announce: boolean) => { + data.location.integration.invalidate(location) + data.location.model.invalidate(location) + data.location.provider.invalidate(location) + try { + await Promise.all([ + data.location.integration.sync(location), + data.location.model.sync(location), + data.location.provider.sync(location), + ]) + } catch (cause) { + if (closed || dialog.stack.length === 0) return false + toast.show({ variant: "error", message: errorMessage(cause) }) + dialog.clear() + return false + } + if (closed || dialog.stack.length === 0) return false + if (!valid()) return null + if (announce) { + toast.show({ variant: "success", message: `Connected ${integration()?.name ?? "OpenCode Console"}` }) + } + finish() + return true + } + + const poll = (current: IntegrationAttempt) => { + trackConnect( + (async () => { + try { + const result = await client.api.integration.oauth.status({ + integrationID: "opencode", + attemptID: current.attemptID, + location: { directory: location.directory }, + }) + if (closed) return + const status = result.data + if (status.status === "pending") { + timer = setTimeout(() => poll(current), 500) + return + } + settled = true + if (status.status === "complete") { + const ok = await sync(true) + if (ok === null && !closed) dialog.clear() + return + } + toast.show({ + variant: "error", + message: status.status === "failed" ? status.message : "Authorization expired", + }) + dialog.clear() + } catch (cause) { + if (closed) return + settled = true + toast.show({ variant: "error", message: errorMessage(cause) }) + dialog.clear() + } + })(), + ) + } + + const startOAuth = (oauthMethod: IntegrationOAuthMethod) => { + const answer: FormAnswer = {} + for (const field of oauthMethod.form ?? []) { + if (field.type !== "external" && field.default !== undefined) { + answer[field.key] = field.default + } + } + trackConnect( + (async () => { + try { + const base = { + integrationID: "opencode", + location: { directory: location.directory }, + methodID: oauthMethod.id, + } + const result = await client.api.integration.oauth.connect( + Object.keys(answer).length > 0 ? { ...base, answer } : base, + ) + if (closed) { + void client.api.integration.oauth.cancel({ + integrationID: "opencode", + attemptID: result.data.attemptID, + location: { directory: location.directory }, + }) + return + } + pendingAttemptID = result.data.attemptID + setAttempt(result.data) + poll(result.data) + } catch (cause) { + if (closed) return + toast.show({ variant: "error", message: errorMessage(cause) }) + dialog.clear() + } + })(), + ) + } + + createEffect(() => { + if (started || closed) return + const current = integration() + const oauthMethod = method() + if (!current || !oauthMethod) return + started = true + if (valid()) { + trackConnect( + (async () => { + const ok = await sync(false) + if (ok === null && !closed) startOAuth(oauthMethod) + })(), + ) + return + } + startOAuth(oauthMethod) + }) + + onCleanup(() => { + closed = true + if (timer) clearTimeout(timer) + if (settled || !pendingAttemptID) return + void client.api.integration.oauth.cancel({ + integrationID: "opencode", + attemptID: pendingAttemptID, + location: { directory: location.directory }, + }) + }) + + const open = () => { + const current = attempt() + if (!current) return + Open.openUrl(current.url).catch(() => + toast.show({ + message: "Could not open the browser. Copy the URL and continue manually.", + variant: "error", + }), + ) + } + + const copy = () => { + const current = attempt() + if (!current) return + const value = current.instructions.match(OAUTH_CODE)?.[0] ?? current.url + clipboard + .write(value) + .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) + .catch(toast.error) + } + + Keymap.createLayer(() => ({ + mode: "modal", + enabled: () => Boolean(attempt()), + commands: [ + { bind: newDesigns() ? "return" : "o", title: "Open authorization URL", group: "Dialog", run: open }, + { bind: "c", title: "Copy authorization details", group: "Dialog", run: copy }, + ], + })) + + const title = () => method()?.label ?? "Connect an integration" + + return ( + } + footer={{ + actions: { + primary: attempt() + ? [ + { + title: attempt()?.instructions.match(OAUTH_CODE) ? "copy code" : "copy link", + key: "c", + onTrigger: copy, + }, + { title: "open link", key: "enter", onTrigger: open }, + ] + : [], + }, + }} + legacyFooter={ + + } + > + + + {(current) => ( + + + + {(instructions) => {instructions()}} + + + )} + + {attempt() ? "Waiting for authorization…" : "Starting authorization…"} + + + ) +} diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index fd459a434440..895ad2e26f92 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -70,9 +70,7 @@ import { statusLabel } from "../../component/dialog-workspace-file-changes" import { DialogMessage } from "./dialog-message" import { DialogFork } from "./dialog-fork" import { DialogTimeline } from "./dialog-timeline" -import { DialogIntegration } from "../../component/dialog-integration" import { DialogGoUpsell } from "../../component/dialog-go-upsell" -import { DialogModel } from "../../component/dialog-model" import { useStorage } from "../../context/storage" import { Composer } from "./composer" import { filetype } from "../../util/filetype" @@ -678,14 +676,11 @@ export function Session(props: { const expectedAgents = current.current.agents?.length ?? 0 const expectedSkills = current.current.skills?.length ?? 0 const expectedPasted = current.current.pasted.length - const canOpen = () => { + const hasRecalledPrompt = () => { const active = prompt() return Boolean( !sessionDisposed && route.sessionID === event.data.sessionID && - dialog.stack.length === 0 && - !goUpsell.dontShow && - !goUpsellMemory.dontShow && active && active.current.text === expectedText && (active.current.files?.length ?? 0) === expectedFiles && @@ -694,6 +689,8 @@ export function Session(props: { active.current.pasted.length === expectedPasted, ) } + const canOpen = () => + Boolean(hasRecalledPrompt() && dialog.stack.length === 0 && !goUpsell.dontShow && !goUpsellMemory.dontShow) const previousSeen = goUpsellMemory.lastSeenAt updateGoUpsellMemory((draft) => { draft.lastSeenAt = now @@ -730,6 +727,7 @@ export function Session(props: { dialog.replace(() => ( { updateGoUpsellMemory((draft) => { draft.dontShow = true @@ -738,15 +736,9 @@ export function Session(props: { draft.dontShow = true }).catch(() => undefined) }} - onSubscribe={() => - dialog.replace(() => ( - dialog.replace(() => )} - /> - )) - } + onSubscribe={() => { + prompt()?.submit() + }} /> )) }) diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 20f0e6b42f80..d3050c43cefb 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -4,6 +4,7 @@ import { createTestRenderer } from "@opentui/core/testing" import { Effect, FileSystem } from "effect" import { Global } from "@opencode/util/global" import * as Open from "@opencode/util/open" +import { waitForGoUpsellConnect } from "../src/component/dialog-go-upsell" import { GoUpsellArtRenderable } from "../src/component/go-upsell-art" import { SimulationRenderer } from "@opencode/simulation/frontend/renderer" import path from "node:path" @@ -1713,37 +1714,136 @@ test("free usage limit offers OpenCode Go once per 24h, transitions Subscribe to projectID: "proj_test", location: { directory }, title: "Free limit", + agent: "build", cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 1, updated: 1 }, } const model = { id: "big-pickle", providerID: "opencode" } + const freeModel = { + id: "big-pickle", + providerID: "opencode", + name: "Big Pickle", + status: "active", + cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }], + capabilities: { input: ["text"], output: ["text"], reasoning: false, toolCall: true, attachment: false }, + limit: { context: 200_000, output: 8_192 }, + variants: [], + time: { released: 1 }, + } + const goModel = { + id: "glm-5-go", + providerID: "opencode-go", + name: "GLM-5 Go", + status: "active", + cost: [{ input: 1, output: 2, cache: { read: 0, write: 0 } }], + capabilities: { input: ["text"], output: ["text"], reasoning: false, toolCall: true, attachment: false }, + limit: { context: 200_000, output: 8_192 }, + variants: [], + time: { released: 2 }, + } const error = { type: "provider.free-limit", message: "Free usage limit reached.", status: 429, retryAfterMs: 3_600_000, } + let authorized = false + let releaseOAuth!: () => void + const oauthGate = new Promise((resolve) => { + releaseOAuth = resolve + }) + const oauthRequests: unknown[] = [] + const switchedModels: unknown[] = [] + const resentPrompts: unknown[] = [] + const promptResent = Promise.withResolvers() await using setup = await createAppFixture({ width: 100, state: state.path, args: { sessionID: session.id }, config: { animations: false, tabs: { mode: "off" } }, - fetch: (url) => { + fetch: async (url, request) => { + const location = { directory, project: { id: "proj_test", directory, canonical: directory } } if (url.pathname === "/api/session") return json({ data: [session], cursor: {} }) if (url.pathname === `/api/session/${session.id}`) return json({ data: session }) + if (url.pathname === "/api/agent") + return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] }) + if (url.pathname === "/api/provider") + return json({ + location, + data: [ + { id: "opencode", name: "OpenCode Zen" }, + ...(authorized ? [{ id: "opencode-go", integrationID: "opencode", name: "OpenCode Go" }] : []), + ], + }) + if (url.pathname === "/api/model") + return json({ + location, + data: authorized ? [goModel, freeModel] : [freeModel], + }) if (url.pathname === "/api/integration") return json({ - location: { directory, project: { id: "proj_test", directory, canonical: directory } }, + location, data: [ + { + id: "opencode", + name: "OpenCode Console", + methods: [ + { + id: "device", + type: "oauth", + label: "OpenCode Console account", + form: [ + { + key: "server", + type: "string", + format: "uri", + hidden: true, + default: "https://opencode.ai/console", + }, + ], + }, + { type: "key", label: "API key (service account)" }, + ], + connections: authorized + ? [{ type: "credential", method: "oauth", id: "cred_console", label: "Anomaly" }] + : [], + }, { id: "opencode-go", name: "OpenCode Go", - methods: [{ type: "key" }, { id: "device", type: "oauth", label: "Sign in with your OpenCode account" }], + methods: [{ type: "key", label: "Enter Go API key" }], connections: [], }, ], }) + if (url.pathname === "/api/integration/opencode/connect/oauth" && request.method === "POST") { + oauthRequests.push(await request.json()) + return json({ + location, + data: { + attemptID: "attempt_go", + mode: "auto", + url: "https://opencode.ai/console/device?user_code=GOGO-1234", + instructions: "Enter code: GOGO-1234", + }, + }) + } + if (url.pathname === "/api/integration/opencode/connect/oauth/attempt_go") { + if (request.method === "DELETE") return new Response(null, { status: 204 }) + await oauthGate + authorized = true + return json({ location, data: { status: "complete" } }) + } + if (url.pathname === `/api/session/${session.id}/model` && request.method === "POST") { + switchedModels.push(await request.json()) + return new Response(null, { status: 204 }) + } + if (url.pathname === `/api/session/${session.id}/prompt` && request.method === "POST") { + resentPrompts.push(await request.json()) + promptResent.resolve() + return json({ data: {} }) + } if (url.pathname === `/api/session/${session.id}/message`) return json({ data: [ @@ -1865,7 +1965,7 @@ test("free usage limit offers OpenCode Go once per 24h, transitions Subscribe to expect(setup.captureCharFrame()).not.toContain("Free limit reached") expect(setup.captureCharFrame()).not.toContain("Connect OpenCode Go") - // Exactly at 24h (86_400_000 ms) with an empty composer: reopens Free limit reached, and Subscribe transitions to Connect OpenCode Go with account sign-in first. + // Exactly at 24h (86_400_000 ms) with an empty composer: reopens Free limit reached, and Subscribe starts OpenCode device OAuth directly, selects Go, and resends the saved prompt. currentNow = baseNow + 86_400_000 setup.mockInput.pressKey("c", { ctrl: true }) await setup.waitForFrame((frame) => frame.split("Queued follow-up").length === 2) @@ -1885,16 +1985,26 @@ test("free usage limit offers OpenCode Go once per 24h, transitions Subscribe to }) await setup.waitForFrame((frame) => frame.includes("Free limit reached")) setup.mockInput.pressEnter() - await setup.waitForFrame((frame) => frame.includes("Connect OpenCode Go")) + await setup.waitForFrame((frame) => frame.includes("GOGO-1234")) expect(openSpy).toHaveBeenCalledWith("https://opencode.ai/go") + expect(oauthRequests).toEqual([ + { methodID: "device", answer: { server: "https://opencode.ai/console" } }, + ]) const connectFrame = setup.captureCharFrame() - expect(connectFrame).toContain("Sign in with your OpenCode account") - expect(connectFrame.indexOf("Sign in with your OpenCode account")).toBeLessThan(connectFrame.indexOf("API key")) - setup.mockInput.pressEscape() - await setup.waitForFrame((frame) => !frame.includes("Connect OpenCode Go")) + expect(connectFrame).toContain("OpenCode Console account") + expect(connectFrame).not.toContain("Enter Go API key") + expect(connectFrame).not.toContain("API key (service account)") + releaseOAuth() + await promptResent.promise + await setup.waitForFrame((frame) => !frame.includes("GOGO-1234") && frame.includes("GLM-5 Go")) + expect(switchedModels).toEqual([{ model: { providerID: "opencode-go", id: "glm-5-go" } }]) + expect(resentPrompts).toHaveLength(1) + expect(resentPrompts[0]).toMatchObject({ + text: "Queued follow-up", + }) }) -test("free usage limit Subscribe works with delayed or key-only catalogs, and Don't show again persists across restart", async () => { +test("free usage limit Subscribe handles mid-auth/mid-sync Escape, sync failure, already-connected accounts, and Don't show again across restart", async () => { await using state = await tmpdir() const baseNow = 1_700_000_000_000 let currentNow = baseNow @@ -1904,12 +2014,35 @@ test("free usage limit Subscribe works with delayed or key-only catalogs, and Do id: "ses_free_limit_key", projectID: "proj_test", location: { directory }, - title: "Free limit key only", + title: "Free limit no Go subscription", + agent: "build", cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 1, updated: 1 }, } const model = { id: "big-pickle", providerID: "opencode" } + const freeModel = { + id: "big-pickle", + providerID: "opencode", + name: "Big Pickle", + status: "active", + cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }], + capabilities: { input: ["text"], output: ["text"], reasoning: false, toolCall: true, attachment: false }, + limit: { context: 200_000, output: 8_192 }, + variants: [], + time: { released: 1 }, + } + const goModel = { + id: "glm-5-go", + providerID: "opencode-go", + name: "GLM-5 Go", + status: "active", + cost: [{ input: 1, output: 2, cache: { read: 0, write: 0 } }], + capabilities: { input: ["text"], output: ["text"], reasoning: false, toolCall: true, attachment: false }, + limit: { context: 200_000, output: 8_192 }, + variants: [], + time: { released: 2 }, + } const error = { type: "provider.free-limit", message: "Free usage limit reached.", @@ -1920,21 +2053,97 @@ test("free usage limit Subscribe works with delayed or key-only catalogs, and Do const catalogGate = new Promise((resolve) => { releaseCatalog = resolve }) + let oauthGate = Promise.resolve() + let modelSyncGate = Promise.resolve() + let onModelSyncRequest: (() => void) | undefined + let failModelSync = false + let connected = false + let needsAuth = false + let goEntitled = false + let oauthConnectCalls = 0 + const switchedModels: unknown[] = [] + const resentPrompts: unknown[] = [] + const promptResent = Promise.withResolvers() await using setup = await createAppFixture({ width: 100, state: state.path, args: { sessionID: session.id }, config: { animations: false, tabs: { mode: "off" } }, - fetch: async (url) => { + fetch: async (url, request) => { + const location = { directory, project: { id: "proj_test", directory, canonical: directory } } if (url.pathname === "/api/session") return json({ data: [session], cursor: {} }) if (url.pathname === `/api/session/${session.id}`) return json({ data: session }) + if (url.pathname === "/api/agent") + return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] }) + if (url.pathname === "/api/provider") + return json({ + location, + data: [ + { id: "opencode", name: "OpenCode Zen" }, + ...(goEntitled ? [{ id: "opencode-go", integrationID: "opencode", name: "OpenCode Go" }] : []), + ], + }) + if (url.pathname === "/api/model") { + onModelSyncRequest?.() + await modelSyncGate + if (failModelSync) return json({ message: "Model catalog sync failed" }, { status: 500 }) + return json({ location, data: goEntitled ? [goModel, freeModel] : [freeModel] }) + } if (url.pathname === "/api/integration") { await catalogGate return json({ - location: { directory, project: { id: "proj_test", directory, canonical: directory } }, - data: [{ id: "opencode-go", name: "OpenCode Go", methods: [{ type: "key", label: "Enter Go API key" }], connections: [] }], + location, + data: [ + { + id: "opencode", + name: "OpenCode Console", + methods: [ + { id: "device", type: "oauth", label: "OpenCode Console account" }, + { type: "key", label: "API key (service account)" }, + ], + connections: connected + ? [ + { + type: "credential", + method: "oauth", + id: "cred_console", + label: "Personal", + ...(needsAuth ? { status: { type: "needs_auth", message: "Session expired" } } : {}), + }, + { type: "credential", method: "oauth", id: "cred_inactive", label: "Inactive backup" }, + ] + : [], + }, + ], }) } + if (url.pathname === "/api/integration/opencode/connect/oauth" && request.method === "POST") { + oauthConnectCalls += 1 + return json({ + location, + data: { + attemptID: "attempt_no_go", + mode: "auto", + url: "https://opencode.ai/console/device?user_code=NOGO-5678", + instructions: "Enter code: NOGO-5678", + }, + }) + } + if (url.pathname === "/api/integration/opencode/connect/oauth/attempt_no_go") { + if (request.method === "DELETE") return new Response(null, { status: 204 }) + await oauthGate + if (failModelSync) connected = true + return json({ location, data: { status: "complete" } }) + } + if (url.pathname === `/api/session/${session.id}/model` && request.method === "POST") { + switchedModels.push(await request.json()) + return new Response(null, { status: 204 }) + } + if (url.pathname === `/api/session/${session.id}/prompt` && request.method === "POST") { + resentPrompts.push(await request.json()) + promptResent.resolve() + return json({ data: {} }) + } if (url.pathname === `/api/session/${session.id}/message`) return json({ data: [ @@ -1957,9 +2166,15 @@ test("free usage limit Subscribe works with delayed or key-only catalogs, and Do }) await setup.ready await setup.waitForFrame((frame) => frame.includes("Refactor the parser")) - // Subscribe before /api/integration resolves: enters DialogIntegration and transitions to the key prompt once the catalog loads. + + // 1a. Escape mid-auth: cancelling while oauth.status is in flight must not select a model or resubmit the prompt. + let releaseMidAuth!: () => void + oauthGate = new Promise((resolve) => { + releaseMidAuth = resolve + }) + goEntitled = true setup.events.emit({ - id: "evt_free_limit_key_1", + id: "evt_free_limit_mid_auth", created: baseNow, type: "session.step.failed", durable: { aggregateID: session.id, seq: 1, version: 1 }, @@ -1970,37 +2185,203 @@ test("free usage limit Subscribe works with delayed or key-only catalogs, and Do expect(openSpy).toHaveBeenCalledWith("https://opencode.ai/go") await setup.waitForFrame((frame) => frame.includes("Connect an integration")) releaseCatalog() - await setup.waitForFrame((frame) => frame.includes("Enter Go API key")) + await setup.waitForFrame((frame) => frame.includes("Waiting for authorization…")) setup.mockInput.pressEscape() - await setup.waitForFrame((frame) => !frame.includes("Enter Go API key")) - - // Advance past 24h, clear composer, trigger again, and choose "don't show again". - currentNow = baseNow + 2 * 86_400_000 - await Bun.sleep(10) - setup.mockInput.pressKey("c", { ctrl: true }) + await setup.waitForFrame((frame) => !frame.includes("Waiting for authorization…")) + releaseMidAuth() + await waitForGoUpsellConnect() + expect(switchedModels).toHaveLength(0) + expect(resentPrompts).toHaveLength(0) + expect(setup.captureCharFrame().split("Refactor the parser").length).toBe(3) + + // 1b. Escape mid-sync: cancelling while model sync is awaited must not select a model or resubmit the prompt. + currentNow = baseNow + 86_400_000 + oauthGate = Promise.resolve() + let releaseMidSync!: () => void + const midSyncStarted = Promise.withResolvers() + onModelSyncRequest = () => midSyncStarted.resolve() + modelSyncGate = new Promise((resolve) => { + releaseMidSync = resolve + }) + await setup.mockInput.pressKey("c", { ctrl: true }) await setup.waitForFrame((frame) => frame.split("Refactor the parser").length === 2) setup.events.emit({ - id: "evt_free_limit_key_2", - created: 5, + id: "evt_free_limit_mid_sync_start", + created: 3, type: "session.step.started", durable: { aggregateID: session.id, seq: 2, version: 1 }, - data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_2", agent: "build", model, started: 5 }, + data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_2", agent: "build", model, started: 3 }, }) setup.events.emit({ - id: "evt_free_limit_key_3", + id: "evt_free_limit_mid_sync_fail", created: currentNow, type: "session.step.failed", durable: { aggregateID: session.id, seq: 3, version: 1 }, data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_2", error }, }) await setup.waitForFrame((frame) => frame.includes("Free limit reached")) + setup.mockInput.pressEnter() + await setup.waitForFrame((frame) => frame.includes("Waiting for authorization…")) + await midSyncStarted.promise + setup.mockInput.pressEscape() + await setup.waitForFrame((frame) => !frame.includes("Waiting for authorization…")) + releaseMidSync() + await waitForGoUpsellConnect() + onModelSyncRequest = undefined + expect(switchedModels).toHaveLength(0) + expect(resentPrompts).toHaveLength(0) + expect(setup.captureCharFrame().split("Refactor the parser").length).toBe(3) + + // 2. Model-sync rejection: catches the failure, closes "Waiting for authorization…", shows the error, and keeps the recalled prompt. + currentNow = baseNow + 2 * 86_400_000 + modelSyncGate = Promise.resolve() + failModelSync = true + await setup.mockInput.pressKey("c", { ctrl: true }) + await setup.waitForFrame((frame) => frame.split("Refactor the parser").length === 2) + setup.events.emit({ + id: "evt_free_limit_sync_err_start", + created: 5, + type: "session.step.started", + durable: { aggregateID: session.id, seq: 4, version: 1 }, + data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_3", agent: "build", model, started: 5 }, + }) + setup.events.emit({ + id: "evt_free_limit_sync_err_fail", + created: currentNow, + type: "session.step.failed", + durable: { aggregateID: session.id, seq: 5, version: 1 }, + data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_3", error }, + }) + await setup.waitForFrame((frame) => frame.includes("Free limit reached")) + setup.mockInput.pressEnter() + await setup.waitForFrame( + (frame) => !frame.includes("Waiting for authorization…") && frame.includes("UnexpectedStatus: 500"), + ) + expect(switchedModels).toHaveLength(0) + expect(resentPrompts).toHaveLength(0) + expect(setup.captureCharFrame().split("Refactor the parser").length).toBe(3) + + // 3a. Expired/needs_auth active credential (even with a status-free inactive backup): refresh detects needs_auth and routes to device sign-in without submitting. + failModelSync = false + goEntitled = true + connected = true + needsAuth = true + let releaseNeedsAuthOAuth!: () => void + oauthGate = new Promise((resolve) => { + releaseNeedsAuthOAuth = resolve + }) + currentNow = baseNow + 3 * 86_400_000 + await setup.mockInput.pressKey("c", { ctrl: true }) + await setup.waitForFrame((frame) => frame.split("Refactor the parser").length === 2) + setup.events.emit({ + id: "evt_free_limit_expired_start", + created: 6, + type: "session.step.started", + durable: { aggregateID: session.id, seq: 6, version: 1 }, + data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_expired", agent: "build", model, started: 6 }, + }) + setup.events.emit({ + id: "evt_free_limit_expired_fail", + created: currentNow, + type: "session.step.failed", + durable: { aggregateID: session.id, seq: 7, version: 1 }, + data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_expired", error }, + }) + await setup.waitForFrame((frame) => frame.includes("Free limit reached")) + const oauthCallsBeforeExpired = oauthConnectCalls + setup.mockInput.pressEnter() + await setup.waitForFrame((frame) => frame.includes("Waiting for authorization…") && frame.includes("NOGO-5678")) + expect(oauthConnectCalls).toBe(oauthCallsBeforeExpired + 1) + expect(switchedModels).toHaveLength(0) + expect(resentPrompts).toHaveLength(0) + // Complete sign-in so the refreshed active credential becomes valid again, then cancel before testing already-connected. + setup.mockInput.pressEscape() + await setup.waitForFrame((frame) => !frame.includes("Waiting for authorization…")) + releaseNeedsAuthOAuth() + await waitForGoUpsellConnect() + + // 3b. Cached active credential with needs_auth (and status-free inactive backup): goes straight to device sign-in, then once re-authenticated without Go lands on /models. + oauthGate = Promise.resolve() + goEntitled = false + currentNow = baseNow + 4 * 86_400_000 + await setup.mockInput.pressKey("c", { ctrl: true }) + await setup.waitForFrame((frame) => frame.split("Refactor the parser").length === 2) + setup.events.emit({ + id: "evt_free_limit_no_go_start", + created: 7, + type: "session.step.started", + durable: { aggregateID: session.id, seq: 8, version: 1 }, + data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_4", agent: "build", model, started: 7 }, + }) + setup.events.emit({ + id: "evt_free_limit_no_go_fail", + created: currentNow, + type: "session.step.failed", + durable: { aggregateID: session.id, seq: 9, version: 1 }, + data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_4", error }, + }) + await setup.waitForFrame((frame) => frame.includes("Free limit reached")) + needsAuth = false + const oauthCallsBeforeCachedExpired = oauthConnectCalls + setup.mockInput.pressEnter() + await setup.waitForFrame((frame) => frame.includes("Select model") && frame.includes("Big Pickle")) + expect(oauthConnectCalls).toBe(oauthCallsBeforeCachedExpired + 1) + setup.mockInput.pressEscape() + await setup.waitForFrame((frame) => !frame.includes("Select model")) + + // 3c. Already-connected valid account with Go entitlement: skips device OAuth, refreshes catalog, selects Go, and resends the prompt. + const oauthCallsBeforeAlreadyConnected = oauthConnectCalls + goEntitled = true + currentNow = baseNow + 5 * 86_400_000 + await setup.mockInput.pressKey("c", { ctrl: true }) + await setup.waitForFrame((frame) => frame.split("Refactor the parser").length === 2) + setup.events.emit({ + id: "evt_free_limit_already_go_start", + created: 9, + type: "session.step.started", + durable: { aggregateID: session.id, seq: 10, version: 1 }, + data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_5", agent: "build", model, started: 9 }, + }) + setup.events.emit({ + id: "evt_free_limit_already_go_fail", + created: currentNow, + type: "session.step.failed", + durable: { aggregateID: session.id, seq: 11, version: 1 }, + data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_5", error }, + }) + await setup.waitForFrame((frame) => frame.includes("Free limit reached")) + setup.mockInput.pressEnter() + await promptResent.promise + await setup.waitForFrame((frame) => !frame.includes("Free limit reached") && frame.includes("GLM-5 Go")) + expect(oauthConnectCalls).toBe(oauthCallsBeforeAlreadyConnected) + expect(switchedModels).toEqual([{ model: { providerID: "opencode-go", id: "glm-5-go" } }]) + expect(resentPrompts).toHaveLength(1) + expect(resentPrompts[0]).toMatchObject({ text: "Refactor the parser" }) + + // Advance past 24h, trigger again, and choose "don't show again". + currentNow = baseNow + 6 * 86_400_000 + setup.events.emit({ + id: "evt_free_limit_key_2", + created: 11, + type: "session.step.started", + durable: { aggregateID: session.id, seq: 12, version: 1 }, + data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_6", agent: "build", model, started: 11 }, + }) + setup.events.emit({ + id: "evt_free_limit_key_3", + created: currentNow, + type: "session.step.failed", + durable: { aggregateID: session.id, seq: 13, version: 1 }, + data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_6", error }, + }) + await setup.waitForFrame((frame) => frame.includes("Free limit reached")) setup.mockInput.pressArrow("right") setup.mockInput.pressEnter() await setup.waitForFrame((frame) => !frame.includes("Free limit reached")) await Bun.sleep(30) // Restart against the same state directory past another 48h: fresh in-memory store still respects persisted dontShow. - currentNow = baseNow + 4 * 86_400_000 + currentNow = baseNow + 8 * 86_400_000 await using restarted = await createAppFixture({ width: 100, state: state.path,