Repository navigation
feat(tui): restore animated Go upsell modal on free usage limit - #57471
Conversation
7df6ec7 to
a6142e0
Compare
aedd9d9 to
50417f2
Compare
a6142e0 to
e1a4dd2
Compare
50417f2 to
310196f
Compare
e1a4dd2 to
c9c33dd
Compare
311bb3e to
2f50866
Compare
c9c33dd to
086051f
Compare
2f50866 to
deaf99d
Compare
…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.
deaf99d to
c18833b
Compare
40aa261 to
a469ac4
Compare
a469ac4 to
08f391b
Compare
There was a problem hiding this comment.
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
DialogIntegrationfor a newDialogGoUpsellConnect, a copy of the integration dialog's device-OAuth connect / poll / cancel / copy / open logic. The description still says Subscribe goes intoDialogIntegration(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-lifecycletests add about 830 lines for one modal.
Related: #49705
| ) | ||
| } | ||
|
|
||
| function DialogGoUpsellConnect(props: { canContinue?: () => boolean; onSubscribed: () => void }) { |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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={() => { |
There was a problem hiding this comment.
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 () => { |
There was a problem hiding this comment.
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.


Stack (merge in order):
feat(core): typed free-limit error with reset time(basedev)fix(app): restore free-tier usage limit dialog, timeline line, and prompt recall(base feat(core): typed free-limit error with reset time #57146)feat(tui): show free-limit notice, recall prompt, and skip failure toast(base feat(core): typed free-limit error with reset time #57146)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 subpixelGOlogo shimmer, radial background pulse, anddon't show again/dismiss/subscribeactions. Whenpackages/opencodewas removed infe0c4f8c74, that modal and itsgo_upsell_artrenderable were not ported topackages/tui.After
Restores v1's animated
DialogGoUpsellmodal on the firstprovider.free-limithit in 24 hours when the composer draft is empty and"don't show again"is not set:go_upsell_art): ports v1's radial pulse rings and half-blockGOlogo shimmer (GoUpsellArtRenderable), holding the renderer at 30 fps while animating, rendering a static frame withlive: falsewhenconfig.animations === false, and switching to a compact scrollable layout with stacked actions on short or narrow terminals (44×24,60×12).https://opencode.ai/goand transitions intoDialogIntegration(integrationID="opencode-go",autoConnect) with account sign-in ordered before API key auth; Dismiss (esc) closes for 24 hours and leavesFreeLimitNoticein the transcript; Don't show again persistsdontShow: truein thego-upsellstore. Eligibility is claimed atomically inside the file-lockedgo-upsellstorage mutation and reserved synchronously in memory.dev)How
packages/tui/src/component/go-upsell-art.ts: implementGoUpsellArtRenderableextending@opentui/coreRenderablewithlivegated onanimate,destroySelfcleanup, and subpixelGOglyph shading fromlogo.ts.packages/tui/src/component/dialog-go-upsell.tsx: renderDialogGoUpsellwithdon't show again,dismiss, andsubscribeactions, 30 fps cap cleanup on unmount/disable, and compact layout adaptation (<52columns or<22rows).packages/tui/src/routes/session/index.tsx: claim the 24h /dontShowgate insideupdateGoUpsell's locked storage mutation (plus synchronousstorage.memoryreservation), revalidate session/dialog/composer state after awaiting the lock, and openDialogGoUpsell.Proof
packages/tui/test/app-lifecycle.test.tsx:Free limit reachedwith theGOlogo and reset time, restores composer focus ondismiss/esc, suppresses re-opening at24h - 1ms, and re-opens at24h.subscribetoDialogIntegration(Connect OpenCode Gowith account sign-in first when OAuth is available, or key prompt / delayed catalog resolution).don't show againto disk across a fresh TUI restart past 48 hours, and atomically deduplicates concurrent free-limit events across two TUI instances sharing a state directory.animations: truelive renderable lifecycle (live,isDestroyed, 30 fps cap and restoration on close) and compact layouts at44×24and60×12(including mouse click targets).Risk
Low. The modal only opens when
provider.free-limitfires on an active session with an empty composer, no open dialog, and an unclaimed 24-hour window; dismissing or opting out always leaves the inlineFreeLimitNoticeintact.Added by the opencode bot (AI-generated).
Related: #49705