Skip to content

Commit cccb907

Browse files
authored
feat(tui): animated GO logo + radial pulse in free-limit upsell dialog (#22976)
1 parent ee7339f commit cccb907

5 files changed

Lines changed: 563 additions & 108 deletions

File tree

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { BoxRenderable, RGBA } from "@opentui/core"
2+
import { createMemo, createSignal, For, onCleanup, onMount } from "solid-js"
3+
import { tint, useTheme } from "@tui/context/theme"
4+
5+
const PERIOD = 4600
6+
const RINGS = 3
7+
const WIDTH = 3.8
8+
const TAIL = 9.5
9+
const AMP = 0.55
10+
const TAIL_AMP = 0.16
11+
const BREATH_AMP = 0.05
12+
const BREATH_SPEED = 0.0008
13+
// Offset so bg ring emits from GO center at the moment the logo pulse peaks.
14+
const PHASE_OFFSET = 0.29
15+
16+
export type BgPulseMask = {
17+
x: number
18+
y: number
19+
width: number
20+
height: number
21+
pad?: number
22+
strength?: number
23+
}
24+
25+
export function BgPulse(props: { centerX?: number; centerY?: number; masks?: BgPulseMask[] }) {
26+
const { theme } = useTheme()
27+
const [now, setNow] = createSignal(performance.now())
28+
const [size, setSize] = createSignal<{ width: number; height: number }>({ width: 0, height: 0 })
29+
let box: BoxRenderable | undefined
30+
31+
const timer = setInterval(() => setNow(performance.now()), 50)
32+
onCleanup(() => clearInterval(timer))
33+
34+
const sync = () => {
35+
if (!box) return
36+
setSize({ width: box.width, height: box.height })
37+
}
38+
39+
onMount(() => {
40+
sync()
41+
box?.on("resize", sync)
42+
})
43+
44+
onCleanup(() => {
45+
box?.off("resize", sync)
46+
})
47+
48+
const grid = createMemo(() => {
49+
const t = now()
50+
const w = size().width
51+
const h = size().height
52+
if (w === 0 || h === 0) return [] as RGBA[][]
53+
const cxv = props.centerX ?? w / 2
54+
const cyv = props.centerY ?? h / 2
55+
const reach = Math.hypot(Math.max(cxv, w - cxv), Math.max(cyv, h - cyv) * 2) + TAIL
56+
const ringStates = Array.from({ length: RINGS }, (_, i) => {
57+
const offset = i / RINGS
58+
const phase = (t / PERIOD + offset - PHASE_OFFSET + 1) % 1
59+
const envelope = Math.sin(phase * Math.PI)
60+
const eased = envelope * envelope * (3 - 2 * envelope)
61+
return {
62+
head: phase * reach,
63+
eased,
64+
}
65+
})
66+
const normalizedMasks = props.masks?.map((m) => {
67+
const pad = m.pad ?? 2
68+
return {
69+
left: m.x - pad,
70+
right: m.x + m.width + pad,
71+
top: m.y - pad,
72+
bottom: m.y + m.height + pad,
73+
pad,
74+
strength: m.strength ?? 0.85,
75+
}
76+
})
77+
const rows = [] as RGBA[][]
78+
for (let y = 0; y < h; y++) {
79+
const row = [] as RGBA[]
80+
for (let x = 0; x < w; x++) {
81+
const dx = x + 0.5 - cxv
82+
const dy = (y + 0.5 - cyv) * 2
83+
const dist = Math.hypot(dx, dy)
84+
let level = 0
85+
for (const ring of ringStates) {
86+
const delta = dist - ring.head
87+
const crest = Math.abs(delta) < WIDTH ? 0.5 + 0.5 * Math.cos((delta / WIDTH) * Math.PI) : 0
88+
const tail = delta < 0 && delta > -TAIL ? (1 + delta / TAIL) ** 2.3 : 0
89+
level += (crest * AMP + tail * TAIL_AMP) * ring.eased
90+
}
91+
const edgeFalloff = Math.max(0, 1 - (dist / (reach * 0.85)) ** 2)
92+
const breath = (0.5 + 0.5 * Math.sin(t * BREATH_SPEED)) * BREATH_AMP
93+
let maskAtten = 1
94+
if (normalizedMasks) {
95+
for (const m of normalizedMasks) {
96+
if (x < m.left || x > m.right || y < m.top || y > m.bottom) continue
97+
const inX = Math.min(x - m.left, m.right - x)
98+
const inY = Math.min(y - m.top, m.bottom - y)
99+
const edge = Math.min(inX / m.pad, inY / m.pad, 1)
100+
const eased = edge * edge * (3 - 2 * edge)
101+
const reduce = 1 - m.strength * eased
102+
if (reduce < maskAtten) maskAtten = reduce
103+
}
104+
}
105+
const strength = Math.min(1, ((level / RINGS) * edgeFalloff + breath * edgeFalloff) * maskAtten)
106+
row.push(tint(theme.backgroundPanel, theme.primary, strength * 0.7))
107+
}
108+
rows.push(row)
109+
}
110+
return rows
111+
})
112+
113+
return (
114+
<box ref={(item: BoxRenderable) => (box = item)} width="100%" height="100%">
115+
<For each={grid()}>
116+
{(row) => (
117+
<box flexDirection="row">
118+
<For each={row}>
119+
{(color) => (
120+
<text bg={color} fg={color} selectable={false}>
121+
{" "}
122+
</text>
123+
)}
124+
</For>
125+
</box>
126+
)}
127+
</For>
128+
</box>
129+
)
130+
}

packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx

Lines changed: 104 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1-
import { RGBA, TextAttributes } from "@opentui/core"
1+
import { BoxRenderable, RGBA, TextAttributes } from "@opentui/core"
22
import { useKeyboard } from "@opentui/solid"
33
import open from "open"
4-
import { createSignal } from "solid-js"
4+
import { createSignal, onCleanup, onMount } from "solid-js"
55
import { selectedForeground, useTheme } from "@tui/context/theme"
66
import { useDialog, type DialogContext } from "@tui/ui/dialog"
77
import { Link } from "@tui/ui/link"
8+
import { GoLogo } from "./logo"
9+
import { BgPulse, type BgPulseMask } from "./bg-pulse"
810

911
const GO_URL = "https://opencode.ai/go"
12+
const PAD_X = 3
13+
const PAD_TOP_OUTER = 1
1014

1115
export type DialogGoUpsellProps = {
1216
onClose?: (dontShowAgain?: boolean) => void
@@ -27,62 +31,116 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) {
2731
const dialog = useDialog()
2832
const { theme } = useTheme()
2933
const fg = selectedForeground(theme)
30-
const [selected, setSelected] = createSignal(0)
34+
const [selected, setSelected] = createSignal<"dismiss" | "subscribe">("subscribe")
35+
const [center, setCenter] = createSignal<{ x: number; y: number } | undefined>()
36+
const [masks, setMasks] = createSignal<BgPulseMask[]>([])
37+
let content: BoxRenderable | undefined
38+
let logoBox: BoxRenderable | undefined
39+
let headingBox: BoxRenderable | undefined
40+
let descBox: BoxRenderable | undefined
41+
let buttonsBox: BoxRenderable | undefined
42+
43+
const sync = () => {
44+
if (!content || !logoBox) return
45+
setCenter({
46+
x: logoBox.x - content.x + logoBox.width / 2,
47+
y: logoBox.y - content.y + logoBox.height / 2 + PAD_TOP_OUTER,
48+
})
49+
const next: BgPulseMask[] = []
50+
const baseY = PAD_TOP_OUTER
51+
for (const b of [headingBox, descBox, buttonsBox]) {
52+
if (!b) continue
53+
next.push({
54+
x: b.x - content.x,
55+
y: b.y - content.y + baseY,
56+
width: b.width,
57+
height: b.height,
58+
pad: 2,
59+
strength: 0.78,
60+
})
61+
}
62+
setMasks(next)
63+
}
64+
65+
onMount(() => {
66+
sync()
67+
for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.on("resize", sync)
68+
})
69+
70+
onCleanup(() => {
71+
for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.off("resize", sync)
72+
})
3173

3274
useKeyboard((evt) => {
3375
if (evt.name === "left" || evt.name === "right" || evt.name === "tab") {
34-
setSelected((s) => (s === 0 ? 1 : 0))
76+
setSelected((s) => (s === "subscribe" ? "dismiss" : "subscribe"))
3577
return
3678
}
37-
if (evt.name !== "return") return
38-
if (selected() === 0) subscribe(props, dialog)
39-
else dismiss(props, dialog)
79+
if (evt.name === "return") {
80+
if (selected() === "subscribe") subscribe(props, dialog)
81+
else dismiss(props, dialog)
82+
}
4083
})
4184

4285
return (
43-
<box paddingLeft={2} paddingRight={2} gap={1}>
44-
<box flexDirection="row" justifyContent="space-between">
45-
<text attributes={TextAttributes.BOLD} fg={theme.text}>
46-
Free limit reached
47-
</text>
48-
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
49-
esc
50-
</text>
51-
</box>
52-
<box gap={1} paddingBottom={1}>
53-
<text fg={theme.textMuted}>
54-
Subscribe to OpenCode Go to keep going with reliable access to the best open-source models, starting at
55-
$5/month.
56-
</text>
57-
<box flexDirection="row" gap={1}>
58-
<Link href={GO_URL} fg={theme.primary} />
59-
</box>
86+
<box ref={(item: BoxRenderable) => (content = item)}>
87+
<box position="absolute" top={-PAD_TOP_OUTER} left={0} right={0} bottom={0} zIndex={0}>
88+
<BgPulse centerX={center()?.x} centerY={center()?.y} masks={masks()} />
6089
</box>
61-
<box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
62-
<box
63-
paddingLeft={3}
64-
paddingRight={3}
65-
backgroundColor={selected() === 0 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
66-
onMouseOver={() => setSelected(0)}
67-
onMouseUp={() => subscribe(props, dialog)}
68-
>
69-
<text fg={selected() === 0 ? fg : theme.text} attributes={selected() === 0 ? TextAttributes.BOLD : undefined}>
70-
subscribe
90+
<box paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
91+
<box ref={(item: BoxRenderable) => (headingBox = item)} flexDirection="row" justifyContent="space-between">
92+
<text attributes={TextAttributes.BOLD} fg={theme.text}>
93+
Free limit reached
7194
</text>
95+
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
96+
esc
97+
</text>
98+
</box>
99+
<box ref={(item: BoxRenderable) => (descBox = item)} gap={0}>
100+
<box flexDirection="row">
101+
<text fg={theme.textMuted}>Subscribe to </text>
102+
<text attributes={TextAttributes.BOLD} fg={theme.textMuted}>
103+
OpenCode Go
104+
</text>
105+
<text fg={theme.textMuted}> for reliable access to the</text>
106+
</box>
107+
<text fg={theme.textMuted}>best open-source models, starting at $5/month.</text>
72108
</box>
73-
<box
74-
paddingLeft={3}
75-
paddingRight={3}
76-
backgroundColor={selected() === 1 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
77-
onMouseOver={() => setSelected(1)}
78-
onMouseUp={() => dismiss(props, dialog)}
79-
>
80-
<text
81-
fg={selected() === 1 ? fg : theme.textMuted}
82-
attributes={selected() === 1 ? TextAttributes.BOLD : undefined}
109+
<box alignItems="center" gap={1} paddingBottom={1}>
110+
<box ref={(item: BoxRenderable) => (logoBox = item)}>
111+
<GoLogo />
112+
</box>
113+
<Link href={GO_URL} fg={theme.primary} />
114+
</box>
115+
<box ref={(item: BoxRenderable) => (buttonsBox = item)} flexDirection="row" justifyContent="space-between">
116+
<box
117+
paddingLeft={2}
118+
paddingRight={2}
119+
backgroundColor={selected() === "dismiss" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
120+
onMouseOver={() => setSelected("dismiss")}
121+
onMouseUp={() => dismiss(props, dialog)}
83122
>
84-
don't show again
85-
</text>
123+
<text
124+
fg={selected() === "dismiss" ? fg : theme.textMuted}
125+
attributes={selected() === "dismiss" ? TextAttributes.BOLD : undefined}
126+
>
127+
don't show again
128+
</text>
129+
</box>
130+
<box
131+
paddingLeft={2}
132+
paddingRight={2}
133+
backgroundColor={selected() === "subscribe" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
134+
onMouseOver={() => setSelected("subscribe")}
135+
onMouseUp={() => subscribe(props, dialog)}
136+
>
137+
<text
138+
fg={selected() === "subscribe" ? fg : theme.text}
139+
attributes={selected() === "subscribe" ? TextAttributes.BOLD : undefined}
140+
>
141+
subscribe
142+
</text>
143+
</box>
86144
</box>
87145
</box>
88146
</box>

0 commit comments

Comments
 (0)