diff --git a/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx b/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx index 1c7e78b532..541ecea4e1 100644 --- a/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx @@ -4,17 +4,25 @@ import { tint, useTheme } from "@tui/context/theme" const PERIOD = 4600 const RINGS = 3 -const WIDTH = 4.2 -const TAIL = 10.5 -const AMP = 0.4 -const TAIL_AMP = 0.14 -const BREATH_AMP = 0.06 +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 bg ring emits from GO center at the moment the logo shine arrives there. -// Logo shine travels ~0.29 of its period to cross from origin to GO center. +// Offset so bg ring emits from GO center at the moment the logo pulse peaks. const PHASE_OFFSET = 0.29 -export function BgPulse(props: { centerX?: number; centerY?: number }) { +export type BgPulseMask = { + x: number + y: number + width: number + height: number + pad?: number + strength?: number +} + +export function BgPulse(props: { centerX?: number; centerY?: number; masks?: BgPulseMask[] }) { const { theme } = useTheme() const [now, setNow] = createSignal(performance.now()) const [size, setSize] = createSignal<{ width: number; height: number }>({ width: 0, height: 0 }) @@ -45,6 +53,27 @@ export function BgPulse(props: { centerX?: number; centerY?: number }) { const cxv = props.centerX ?? w / 2 const cyv = props.centerY ?? h / 2 const reach = Math.hypot(Math.max(cxv, w - cxv), Math.max(cyv, h - cyv) * 2) + TAIL + const ringStates = Array.from({ length: RINGS }, (_, i) => { + const offset = i / RINGS + const phase = (t / PERIOD + offset - PHASE_OFFSET + 1) % 1 + const envelope = Math.sin(phase * Math.PI) + const eased = envelope * envelope * (3 - 2 * envelope) + return { + head: phase * reach, + eased, + } + }) + const normalizedMasks = props.masks?.map((m) => { + const pad = m.pad ?? 2 + return { + left: m.x - pad, + right: m.x + m.width + pad, + top: m.y - pad, + bottom: m.y + m.height + pad, + pad, + strength: m.strength ?? 0.85, + } + }) const rows = [] as RGBA[][] for (let y = 0; y < h; y++) { const row = [] as RGBA[] @@ -53,19 +82,27 @@ export function BgPulse(props: { centerX?: number; centerY?: number }) { const dy = (y + 0.5 - cyv) * 2 const dist = Math.hypot(dx, dy) let level = 0 - for (let i = 0; i < RINGS; i++) { - const offset = i / RINGS - const phase = (t / PERIOD + offset - PHASE_OFFSET + 1) % 1 - const envelope = Math.sin(phase * Math.PI) - const eased = envelope * envelope * (3 - 2 * envelope) - const head = phase * reach - const delta = dist - head + for (const ring of ringStates) { + const delta = dist - ring.head 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) * eased + level += (crest * AMP + tail * TAIL_AMP) * ring.eased } + const edgeFalloff = Math.max(0, 1 - (dist / (reach * 0.85)) ** 2) const breath = (0.5 + 0.5 * Math.sin(t * BREATH_SPEED)) * BREATH_AMP - const strength = Math.min(1, level / RINGS + breath) + let maskAtten = 1 + if (normalizedMasks) { + for (const m of normalizedMasks) { + if (x < m.left || x > m.right || y < m.top || y > m.bottom) continue + const inX = Math.min(x - m.left, m.right - x) + const inY = Math.min(y - m.top, m.bottom - y) + const edge = Math.min(inX / m.pad, inY / m.pad, 1) + const eased = edge * edge * (3 - 2 * edge) + const reduce = 1 - m.strength * eased + if (reduce < maskAtten) maskAtten = reduce + } + } + const strength = Math.min(1, ((level / RINGS) * edgeFalloff + breath * edgeFalloff) * maskAtten) row.push(tint(theme.backgroundPanel, theme.primary, strength * 0.7)) } rows.push(row) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx index da91b1323a..ace4b090bc 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx @@ -6,7 +6,7 @@ import { selectedForeground, useTheme } from "@tui/context/theme" import { useDialog, type DialogContext } from "@tui/ui/dialog" import { Link } from "@tui/ui/link" import { GoLogo } from "./logo" -import { BgPulse } from "./bg-pulse" +import { BgPulse, type BgPulseMask } from "./bg-pulse" const GO_URL = "https://opencode.ai/go" const PAD_X = 3 @@ -33,26 +33,42 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) { const fg = selectedForeground(theme) const [selected, setSelected] = createSignal<"dismiss" | "subscribe">("subscribe") const [center, setCenter] = createSignal<{ x: number; y: number } | undefined>() + const [masks, setMasks] = createSignal([]) let content: BoxRenderable | undefined let logoBox: BoxRenderable | undefined + let headingBox: BoxRenderable | undefined + let descBox: BoxRenderable | undefined + let buttonsBox: BoxRenderable | undefined const sync = () => { if (!content || !logoBox) return setCenter({ x: logoBox.x - content.x + logoBox.width / 2, - y: logoBox.y - content.y + logoBox.height / 2, + y: logoBox.y - content.y + logoBox.height / 2 + PAD_TOP_OUTER, }) + const next: BgPulseMask[] = [] + const baseY = PAD_TOP_OUTER + for (const b of [headingBox, descBox, buttonsBox]) { + if (!b) continue + next.push({ + x: b.x - content.x, + y: b.y - content.y + baseY, + width: b.width, + height: b.height, + pad: 2, + strength: 0.78, + }) + } + setMasks(next) } onMount(() => { sync() - content?.on("resize", sync) - logoBox?.on("resize", sync) + for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.on("resize", sync) }) onCleanup(() => { - content?.off("resize", sync) - logoBox?.off("resize", sync) + for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.off("resize", sync) }) useKeyboard((evt) => { @@ -69,10 +85,10 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) { return ( (content = item)}> - + - + (headingBox = item)} flexDirection="row" justifyContent="space-between"> Free limit reached @@ -80,11 +96,15 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) { esc - - - Subscribe to OpenCode Go to keep going with reliable access to the best open-source models, starting at - $5/month. - + (descBox = item)} gap={0}> + + Subscribe to + + OpenCode Go + + for reliable access to the + + best open-source models, starting at $5/month. (logoBox = item)}> @@ -92,7 +112,7 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) { - + (buttonsBox = item)} flexDirection="row" justifyContent="space-between"> -tailRange ? (1 + delta / tailRange) ** 2.2 : 0 - glow += (sharp * IDLE_AMP + tail * IDLE_TAIL_AMP) * eased - peak += sharp * IDLE_PEAK * eased + // Use shallower exponent (1.6 vs 2) for softer edges on the Gaussians + // so adjacent pixels have smaller brightness deltas + const core = Math.exp(-(Math.abs(delta / cfg.coreWidth) ** 1.8)) + const soft = Math.exp(-(Math.abs(delta / cfg.softWidth) ** 1.6)) + const tailRange = cfg.tail * 2.6 + const tail = delta < 0 && delta > -tailRange ? (1 + delta / tailRange) ** 2.6 : 0 + const haloDelta = delta + cfg.haloOffset + const haloBand = Math.exp(-(Math.abs(haloDelta / cfg.haloWidth) ** 1.6)) + glow += (soft * cfg.softAmp + tail * cfg.tailAmp) * eased + peak += core * cfg.coreAmp * eased + halo += haloBand * cfg.haloAmp * eased + // Primary-tinted fringe follows the halo (which trails behind the core) and the tail + primary += (haloBand + tail * 0.6) * eased + ambient += active.ambient } - const angular = 0.84 + 0.16 * Math.sin(angle * 1.6 + frame.t * 0.0005) - const falloff = Math.max(0, 1 - dist / (reach * 0.95)) - const breath = (0.5 + 0.5 * Math.sin(frame.t * IDLE_PULSE)) * IDLE_PULSE_AMP - const rings = IDLE_RINGS + ambient /= state.rings return { - glow: (glow / rings) * falloff * angular + breath, - peak: (peak / rings) * falloff, + glow: glow / state.rings, + peak: cfg.breathBase + ambient + (peak + halo) / state.rings, + primary: (primary / state.rings) * cfg.primaryMix, } } @@ -460,6 +458,52 @@ function bloom(x: number, y: number, frame: Frame, ctx: LogoContext) { return lerp(item.force, item.force * 0.18, p) * lerp(0.72, 1.1, bias) * flash } +type IdleState = { + cfg: ShimmerConfig + reach: number + rings: number + active: Array<{ + head: number + eased: number + ambient: number + }> +} + +function buildIdleState(t: number, ctx: LogoContext): IdleState { + const cfg = shimmerConfig + const w = ctx.FULL[0]?.length ?? 1 + const h = ctx.FULL.length * 2 + const corners: [number, number][] = [ + [0, 0], + [w, 0], + [0, h], + [w, h], + ] + let maxCorner = 0 + for (const [cx, cy] of corners) { + const d = Math.hypot(cx - cfg.originX, cy - cfg.originY) + if (d > maxCorner) maxCorner = d + } + const reach = maxCorner + cfg.tail * 2 + const rings = Math.max(1, Math.floor(cfg.rings)) + const active = [] as IdleState["active"] + for (let i = 0; i < rings; i++) { + const offset = i / rings + const cyclePhase = (t / cfg.period + offset) % 1 + if (cyclePhase >= cfg.sweepFraction) continue + const phase = cyclePhase / cfg.sweepFraction + const envelope = Math.sin(phase * Math.PI) + const eased = envelope * envelope * (3 - 2 * envelope) + const d = (phase - cfg.ambientCenter) / cfg.ambientWidth + active.push({ + head: phase * reach, + eased, + ambient: Math.abs(d) < 1 ? (1 - d * d) ** 2 * cfg.ambientAmp : 0, + }) + } + return { cfg, reach, rings, active } +} + export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = {}) { const ctx = props.shape ? build(props.shape) : DEFAULT const { theme } = useTheme() @@ -510,10 +554,17 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = timer = setInterval(tick, 16) } - if (props.idle) { + onCleanup(() => { + stop() + hum = false + Sound.dispose() + }) + + onMount(() => { + if (!props.idle) return setNow(performance.now()) start() - } + }) const hit = (x: number, y: number) => { const char = ctx.FULL[y]?.[x] @@ -585,6 +636,8 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = } }) + const idleState = createMemo(() => (props.idle ? buildIdleState(frame().t, ctx) : undefined)) + const renderLine = ( line: string, y: number, @@ -593,27 +646,64 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = off: number, frame: Frame, dusk: Frame, + state: IdleState | undefined, ): JSX.Element[] => { const shadow = tint(theme.background, ink, 0.25) const attrs = bold ? TextAttributes.BOLD : undefined return Array.from(line).map((char, i) => { + if (char === " ") { + return ( + + {char} + + ) + } + const h = field(off + i, y, frame, ctx) - const pulse = props.idle ? idle(off + i, y, frame, ctx) : { glow: 0, peak: 0 } - const peakMix = lit(char) ? Math.min(1, pulse.peak) : 0 - const inkTinted = peakMix > 0 ? tint(ink, PEAK, peakMix) : ink - const n = wave(off + i, y, frame, lit(char), ctx) + h + const charLit = lit(char) + // Sub-pixel sampling: cells are 2 pixels tall. Sample at top (y*2) and bottom (y*2+1) pixel rows. + const pulseTop = state ? idle(off + i, y * 2, frame, ctx, state) : { glow: 0, peak: 0, primary: 0 } + const pulseBot = state ? idle(off + i, y * 2 + 1, frame, ctx, state) : { glow: 0, peak: 0, primary: 0 } + const peakMixTop = charLit ? Math.min(1, pulseTop.peak) : 0 + const peakMixBot = charLit ? Math.min(1, pulseBot.peak) : 0 + const primaryMixTop = charLit ? Math.min(1, pulseTop.primary) : 0 + const primaryMixBot = charLit ? Math.min(1, pulseBot.primary) : 0 + // Layer primary tint first, then white peak on top — so the halo/tail pulls toward primary, + // while the bright core stays pure white + const inkTopTint = primaryMixTop > 0 ? tint(ink, theme.primary, primaryMixTop) : ink + const inkBotTint = primaryMixBot > 0 ? tint(ink, theme.primary, primaryMixBot) : ink + const inkTop = peakMixTop > 0 ? tint(inkTopTint, PEAK, peakMixTop) : inkTopTint + const inkBot = peakMixBot > 0 ? tint(inkBotTint, PEAK, peakMixBot) : inkBotTint + // For the non-peak-aware brightness channels, use the average of top/bot + const pulse = { + glow: (pulseTop.glow + pulseBot.glow) / 2, + peak: (pulseTop.peak + pulseBot.peak) / 2, + primary: (pulseTop.primary + pulseBot.primary) / 2, + } + const peakMix = charLit ? Math.min(1, pulse.peak) : 0 + const primaryMix = charLit ? Math.min(1, pulse.primary) : 0 + const inkPrimary = primaryMix > 0 ? tint(ink, theme.primary, primaryMix) : ink + const inkTinted = peakMix > 0 ? tint(inkPrimary, PEAK, peakMix) : inkPrimary + const shadowMixCfg = state?.cfg.shadowMix ?? shimmerConfig.shadowMix + const shadowMixTop = Math.min(1, pulseTop.peak * shadowMixCfg) + const shadowMixBot = Math.min(1, pulseBot.peak * shadowMixCfg) + const shadowTop = shadowMixTop > 0 ? tint(shadow, PEAK, shadowMixTop) : shadow + const shadowBot = shadowMixBot > 0 ? tint(shadow, PEAK, shadowMixBot) : shadow + const shadowMix = Math.min(1, pulse.peak * shadowMixCfg) + const shadowTinted = shadowMix > 0 ? tint(shadow, PEAK, shadowMix) : shadow + const n = wave(off + i, y, frame, charLit, ctx) + h const s = wave(off + i, y, dusk, false, ctx) + h - const p = lit(char) ? pick(off + i, y, frame, ctx) : 0 - const e = lit(char) ? trace(off + i, y, frame, ctx) : 0 - const b = lit(char) ? bloom(off + i, y, frame, ctx) : 0 + const p = charLit ? pick(off + i, y, frame, ctx) : 0 + const e = charLit ? trace(off + i, y, frame, ctx) : 0 + const b = charLit ? bloom(off + i, y, frame, ctx) : 0 const q = shimmer(off + i, y, frame, ctx) if (char === "_") { return ( @@ -625,8 +715,8 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = if (char === "^") { return ( @@ -637,7 +727,7 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = if (char === "~") { return ( - + ) @@ -645,16 +735,40 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = if (char === ",") { return ( - + ) } - if (char === " ") { + // Solid █: render as ▀ so the top pixel (fg) and bottom pixel (bg) can carry independent shimmer values + if (char === "█") { return ( - - {char} + + ▀ + + ) + } + + // ▀ top-half-lit: fg uses top-pixel sample, bg stays transparent/panel + if (char === "▀") { + return ( + + ▀ + + ) + } + + // ▄ bottom-half-lit: fg uses bottom-pixel sample + if (char === "▄") { + return ( + + ▄ ) } @@ -667,12 +781,6 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = }) } - onCleanup(() => { - stop() - hum = false - Sound.dispose() - }) - const mouse = (evt: MouseEvent) => { if (!box) return if ((evt.type === "down" || evt.type === "drag") && evt.button === MouseButton.LEFT) { @@ -710,7 +818,7 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = {(line, index) => ( - {renderLine(line, index(), props.ink ?? theme.textMuted, !!props.ink, 0, frame(), dusk())} + {renderLine(line, index(), props.ink ?? theme.textMuted, !!props.ink, 0, frame(), dusk(), idleState())} {renderLine( @@ -721,6 +829,7 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = ctx.LEFT + GAP, frame(), dusk(), + idleState(), )} @@ -732,6 +841,6 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = export function GoLogo() { const { theme } = useTheme() - const base = tint(theme.background, theme.text, 0.82) + const base = tint(theme.background, theme.text, 0.62) return } diff --git a/packages/opencode/src/cli/cmd/tui/component/shimmer-config.ts b/packages/opencode/src/cli/cmd/tui/component/shimmer-config.ts new file mode 100644 index 0000000000..01bc136f5d --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/shimmer-config.ts @@ -0,0 +1,49 @@ +export type ShimmerConfig = { + period: number + rings: number + sweepFraction: number + coreWidth: number + coreAmp: number + softWidth: number + softAmp: number + tail: number + tailAmp: number + haloWidth: number + haloOffset: number + haloAmp: number + breathBase: number + noise: number + ambientAmp: number + ambientCenter: number + ambientWidth: number + shadowMix: number + primaryMix: number + originX: number + originY: number +} + +export const shimmerDefaults: ShimmerConfig = { + period: 4600, + rings: 2, + sweepFraction: 1, + coreWidth: 1.2, + coreAmp: 1.9, + softWidth: 10, + softAmp: 1.6, + tail: 5, + tailAmp: 0.64, + haloWidth: 4.3, + haloOffset: 0.6, + haloAmp: 0.16, + breathBase: 0.04, + noise: 0.1, + ambientAmp: 0.36, + ambientCenter: 0.5, + ambientWidth: 0.34, + shadowMix: 0.1, + primaryMix: 0.3, + originX: 4.5, + originY: 13.5, +} + +export const shimmerConfig: ShimmerConfig = { ...shimmerDefaults }