Skip to content

feat(tui): restore animated Go upsell modal on free usage limit - #57471

Merged
kitlangton merged 3 commits into
devfrom
free-limit-tui-modal
Oct 21, 2026
Merged

kitlangton merged 3 commits into
devfrom
free-limit-tui-modal

Conversation

@kitlangton

@kitlangton kitlangton commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Stack (merge in order):

  1. feat(core): typed free-limit error with reset time #57146 — feat(core): typed free-limit error with reset time (base dev)
  2. fix(app): restore free-tier usage limit dialog, timeline line, and prompt recall #57466 — fix(app): restore free-tier usage limit dialog, timeline line, and prompt recall (base feat(core): typed free-limit error with reset time #57146)
  3. feat(tui): show free-limit notice, recall prompt, and skip failure toast #57467 — feat(tui): show free-limit notice, recall prompt, and skip failure toast (base feat(core): typed free-limit error with reset time #57146)
  4. feat(tui): restore animated Go upsell modal on free usage limit #57471 — feat(tui): restore animated Go upsell modal on free usage limit (this PR, base feat(tui): show free-limit notice, recall prompt, and skip failure toast #57467)

▶ TUI before / after (animated Go upsell modal):

57146-tui-before-after.mp4

Head: aedd9d9f91c22245f26925dc834c0163fb2a3df6 · Size: +927/−22 (4 files)

Related: #50996, #52894

Today

In the v1 TUI (#21583, #22976), hitting the free usage limit opened an animated "Free limit reached" modal featuring a subpixel GO logo shimmer, radial background pulse, and don't show again / dismiss / subscribe actions. When packages/opencode was removed in fe0c4f8c74, that modal and its go_upsell_art renderable were not ported to packages/tui.

After

Restores v1's animated DialogGoUpsell modal on the first provider.free-limit hit in 24 hours when the composer draft is empty and "don't show again" is not set:

  • Animated art (go_upsell_art): ports v1's radial pulse rings and half-block GO logo shimmer (GoUpsellArtRenderable), holding the renderer at 30 fps while animating, rendering a static frame with live: false when config.animations === false, and switching to a compact scrollable layout with stacked actions on short or narrow terminals (44×24, 60×12).
  • Actions & gating: Subscribe opens https://opencode.ai/go and transitions into DialogIntegration (integrationID="opencode-go", autoConnect) with account sign-in ordered before API key auth; Dismiss (esc) closes for 24 hours and leaves FreeLimitNotice in the transcript; Don't show again persists dontShow: true in the go-upsell store. Eligibility is claimed atomically inside the file-locked go-upsell storage mutation and reserved synchronously in memory.
Today (dev) After (#57471)
tui-before tui-after-modal

How

  • packages/tui/src/component/go-upsell-art.ts: implement GoUpsellArtRenderable extending @opentui/core Renderable with live gated on animate, destroySelf cleanup, and subpixel GO glyph shading from logo.ts.
  • packages/tui/src/component/dialog-go-upsell.tsx: render DialogGoUpsell with don't show again, dismiss, and subscribe actions, 30 fps cap cleanup on unmount/disable, and compact layout adaptation (<52 columns or <22 rows).
  • packages/tui/src/routes/session/index.tsx: claim the 24h / dontShow gate inside updateGoUpsell's locked storage mutation (plus synchronous storage.memory reservation), revalidate session/dialog/composer state after awaiting the lock, and open DialogGoUpsell.

Proof

  • packages/tui/test/app-lifecycle.test.tsx:
    • Opens Free limit reached with the GO logo and reset time, restores composer focus on dismiss / esc, suppresses re-opening at 24h - 1ms, and re-opens at 24h.
    • Transitions from subscribe to DialogIntegration (Connect OpenCode Go with account sign-in first when OAuth is available, or key prompt / delayed catalog resolution).
    • Persists don't show again to disk across a fresh TUI restart past 48 hours, and atomically deduplicates concurrent free-limit events across two TUI instances sharing a state directory.
    • Verifies animations: true live renderable lifecycle (live, isDestroyed, 30 fps cap and restoration on close) and compact layouts at 44×24 and 60×12 (including mouse click targets).

Risk

Low. The modal only opens when provider.free-limit fires on an active session with an empty composer, no open dialog, and an unclaimed 24-hour window; dismissing or opting out always leaves the inline FreeLimitNotice intact.


Added by the opencode bot (AI-generated).

Related: #49705

…aw 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.
@kitlangton
kitlangton force-pushed the free-limit-tui-modal branch from a469ac4 to 08f391b Compare October 21, 2026 01:29
@kitlangton
kitlangton marked this pull request as ready for review October 21, 2026 01:35
@kitlangton
kitlangton merged commit adcda1b into dev Oct 21, 2026
12 checks passed
@kitlangton
kitlangton deleted the free-limit-tui-modal branch October 21, 2026 01:35

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Works as described. I drove the TUI with the simulated model returning a FreeUsageLimitError: on dev only the inline notice appears, and with this PR the animated "Free limit reached" modal opens over it (screenshots below). The art and lifecycle tests pass, and packages/tui type-checks cleanly.

Public API: unchanged. The only new persisted state is TUI-local (a go-upsell key in TUI storage).

Worth fixing (details inline):

  • The last commit swaps DialogIntegration for a new DialogGoUpsellConnect, a copy of the integration dialog's device-OAuth connect / poll / cancel / copy / open logic. The description still says Subscribe goes into DialogIntegration (opencode-go, autoConnect).
  • The once-a-day gate keeps a memory store and a file store in sync with manual rollbacks, to deduplicate across concurrent TUI instances. That's a lot of code for a rare case.
  • The new app-lifecycle tests add about 830 lines for one modal.

Related: #49705

Before

After

)
}

function DialogGoUpsellConnect(props: { canContinue?: () => boolean; onSubscribed: () => void }) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This component re-implements most of DialogIntegration's OAuth flow: connect with form defaults, poll oauth.status every 500 ms, cancel on cleanup, OAUTH_CODE copy/open keybinds and the legacy and new footers. That's about 250 lines that will drift from the real connect dialog. The previous commit used DialogIntegration with integrationID + autoConnect, and the description still says so. Could we go back to that and extend DialogIntegration with what's missing (e.g. preselecting the opencode device method and an onConnected hook that picks a Go model), instead of keeping a parallel copy?

}
const canOpen = () =>
Boolean(hasRecalledPrompt() && dialog.stack.length === 0 && !goUpsell.dontShow && !goUpsellMemory.dontShow)
const previousSeen = goUpsellMemory.lastSeenAt

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This gate tracks state in both storage.memory and the locked storage.store, rolls lastSeenAt back by hand on three paths, re-checks canOpen() both inside and after the lock, and treats a storage failure as a claim. All of that exists so two TUIs sharing a state dir don't both show the modal for the same free-limit hit. That seems unlikely, and seeing the modal twice is harmless. AGENTS.md asks to agree on this kind of race-handling complexity before adding it. A single check of goUpsell.dontShow/lastSeenAt, then writing lastSeenAt and opening the dialog, would cover the real case in a few lines and remove most of the related test scaffolding.

draft.dontShow = true
}).catch(() => undefined)
}}
onSubscribe={() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After a successful sign-in, onSubscribe submits the restored prompt right away. The user has just come back from the browser and may not expect a message to go out on a paid Go model without confirming. Is that intended? Leaving the prompt restored in the composer, with the Go model selected, would let them send it themselves.

})

test("free usage limit does not overwrite a draft while typing", 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 () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Together these four tests are about 830 lines. This one covers Escape mid-auth, Escape mid-sync, sync failure, already-connected accounts and don't-show-again across a restart all in one test, and the two-instance test exercises the cross-process race. They're hard to follow and depend on a lot of timing. Could you cut them down to the core cases: the modal opens once per 24h and restores focus on dismiss, Subscribe hands off to the connect flow, and don't-show-again persists? The race and the mid-flight Escape variants can go, especially if the gate is simplified.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant