From cccb907a9b3df7eb6fae71ee9e2392dccc73e9d3 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 16 Apr 2026 23:19:18 -0400 Subject: [PATCH] feat(tui): animated GO logo + radial pulse in free-limit upsell dialog (#22976) --- .../src/cli/cmd/tui/component/bg-pulse.tsx | 130 +++++++ .../cmd/tui/component/dialog-go-upsell.tsx | 150 +++++--- .../src/cli/cmd/tui/component/logo.tsx | 335 ++++++++++++++---- .../cli/cmd/tui/component/shimmer-config.ts | 49 +++ packages/opencode/src/cli/logo.ts | 7 +- 5 files changed, 563 insertions(+), 108 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/component/shimmer-config.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx b/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx new file mode 100644 index 0000000000..541ecea4e1 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx @@ -0,0 +1,130 @@ +import { BoxRenderable, RGBA } from "@opentui/core" +import { createMemo, createSignal, For, onCleanup, onMount } from "solid-js" +import { tint, useTheme } from "@tui/context/theme" + +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 bg ring emits from GO center at the moment the logo pulse peaks. +const PHASE_OFFSET = 0.29 + +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 }) + let box: BoxRenderable | undefined + + const timer = setInterval(() => setNow(performance.now()), 50) + onCleanup(() => clearInterval(timer)) + + const sync = () => { + if (!box) return + setSize({ width: box.width, height: box.height }) + } + + onMount(() => { + sync() + box?.on("resize", sync) + }) + + onCleanup(() => { + box?.off("resize", sync) + }) + + const grid = createMemo(() => { + const t = now() + const w = size().width + const h = size().height + if (w === 0 || h === 0) return [] as RGBA[][] + 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[] + for (let x = 0; x < w; x++) { + const dx = x + 0.5 - cxv + const dy = (y + 0.5 - cyv) * 2 + const dist = Math.hypot(dx, dy) + let level = 0 + 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) * 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 + 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) + } + return rows + }) + + return ( + (box = item)} width="100%" height="100%"> + + {(row) => ( + + + {(color) => ( + + {" "} + + )} + + + )} + + + ) +} 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 2d200ca3b8..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 @@ -1,12 +1,16 @@ -import { RGBA, TextAttributes } from "@opentui/core" +import { BoxRenderable, RGBA, TextAttributes } from "@opentui/core" import { useKeyboard } from "@opentui/solid" import open from "open" -import { createSignal } from "solid-js" +import { createSignal, onCleanup, onMount } from "solid-js" 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, type BgPulseMask } from "./bg-pulse" const GO_URL = "https://opencode.ai/go" +const PAD_X = 3 +const PAD_TOP_OUTER = 1 export type DialogGoUpsellProps = { onClose?: (dontShowAgain?: boolean) => void @@ -27,62 +31,116 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) { const dialog = useDialog() const { theme } = useTheme() const fg = selectedForeground(theme) - const [selected, setSelected] = createSignal(0) + 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 + 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() + for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.on("resize", sync) + }) + + onCleanup(() => { + for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.off("resize", sync) + }) useKeyboard((evt) => { if (evt.name === "left" || evt.name === "right" || evt.name === "tab") { - setSelected((s) => (s === 0 ? 1 : 0)) + setSelected((s) => (s === "subscribe" ? "dismiss" : "subscribe")) return } - if (evt.name !== "return") return - if (selected() === 0) subscribe(props, dialog) - else dismiss(props, dialog) + if (evt.name === "return") { + if (selected() === "subscribe") subscribe(props, dialog) + else dismiss(props, dialog) + } }) return ( - - - - Free limit reached - - dialog.clear()}> - esc - + (content = item)}> + + - - - Subscribe to OpenCode Go to keep going with reliable access to the best open-source models, starting at - $5/month. - - + + (headingBox = item)} flexDirection="row" justifyContent="space-between"> + + Free limit reached + + dialog.clear()}> + esc + + + (descBox = item)} gap={0}> + + Subscribe to + + OpenCode Go + + for reliable access to the + + best open-source models, starting at $5/month. + + + (logoBox = item)}> + + - - - setSelected(0)} - onMouseUp={() => subscribe(props, dialog)} - > - - subscribe - - - setSelected(1)} - onMouseUp={() => dismiss(props, dialog)} - > - (buttonsBox = item)} flexDirection="row" justifyContent="space-between"> + setSelected("dismiss")} + onMouseUp={() => dismiss(props, dialog)} > - don't show again - + + don't show again + + + setSelected("subscribe")} + onMouseUp={() => subscribe(props, dialog)} + > + + subscribe + + diff --git a/packages/opencode/src/cli/cmd/tui/component/logo.tsx b/packages/opencode/src/cli/cmd/tui/component/logo.tsx index e53974871a..17368ddad8 100644 --- a/packages/opencode/src/cli/cmd/tui/component/logo.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/logo.tsx @@ -1,8 +1,14 @@ import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core" -import { For, createMemo, createSignal, onCleanup, type JSX } from "solid-js" +import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js" import { useTheme, tint } from "@tui/context/theme" import * as Sound from "@tui/util/sound" -import { logo } from "@/cli/logo" +import { go, logo } from "@/cli/logo" +import { shimmerConfig, type ShimmerConfig } from "./shimmer-config" + +export type LogoShape = { + left: string[] + right: string[] +} // Shadow markers (rendered chars in parens): // _ = full shadow cell (space with bg=shadow) @@ -74,9 +80,6 @@ type Frame = { spark: number } -const LEFT = logo.left[0]?.length ?? 0 -const FULL = logo.left.map((line, i) => line + " ".repeat(GAP) + logo.right[i]) -const SPAN = Math.hypot(FULL[0]?.length ?? 0, FULL.length * 2) * 0.94 const NEAR = [ [1, 0], [1, 1], @@ -140,7 +143,7 @@ function noise(x: number, y: number, t: number) { } function lit(char: string) { - return char !== " " && char !== "_" && char !== "~" + return char !== " " && char !== "_" && char !== "~" && char !== "," } function key(x: number, y: number) { @@ -188,12 +191,12 @@ function route(list: Array<{ x: number; y: number }>) { return path } -function mapGlyphs() { +function mapGlyphs(full: string[]) { const cells = [] as Array<{ x: number; y: number }> - for (let y = 0; y < FULL.length; y++) { - for (let x = 0; x < (FULL[y]?.length ?? 0); x++) { - if (lit(FULL[y]?.[x] ?? " ")) cells.push({ x, y }) + for (let y = 0; y < full.length; y++) { + for (let x = 0; x < (full[y]?.length ?? 0); x++) { + if (lit(full[y]?.[x] ?? " ")) cells.push({ x, y }) } } @@ -237,9 +240,25 @@ function mapGlyphs() { return { glyph, trace, center } } -const MAP = mapGlyphs() +type LogoContext = { + LEFT: number + FULL: string[] + SPAN: number + MAP: ReturnType + shape: LogoShape +} -function shimmer(x: number, y: number, frame: Frame) { +function build(shape: LogoShape): LogoContext { + const LEFT = shape.left[0]?.length ?? 0 + const FULL = shape.left.map((line, i) => line + " ".repeat(GAP) + shape.right[i]) + const SPAN = Math.hypot(FULL[0]?.length ?? 0, FULL.length * 2) * 0.94 + return { LEFT, FULL, SPAN, MAP: mapGlyphs(FULL), shape } +} + +const DEFAULT = build(logo) +const GO = build(go) + +function shimmer(x: number, y: number, frame: Frame, ctx: LogoContext) { return frame.list.reduce((best, item) => { const age = frame.t - item.at if (age < SHIMMER_IN || age > LIFE) return best @@ -247,7 +266,7 @@ function shimmer(x: number, y: number, frame: Frame) { const dy = y * 2 + 1 - item.y const dist = Math.hypot(dx, dy) const p = age / LIFE - const r = SPAN * (1 - (1 - p) ** EXPAND) + const r = ctx.SPAN * (1 - (1 - p) ** EXPAND) const lag = r - dist if (lag < 0.18 || lag > SHIMMER_OUT) return best const band = Math.exp(-(((lag - 1.05) / 0.68) ** 2)) @@ -258,19 +277,19 @@ function shimmer(x: number, y: number, frame: Frame) { }, 0) } -function remain(x: number, y: number, item: Release, t: number) { +function remain(x: number, y: number, item: Release, t: number, ctx: LogoContext) { const age = t - item.at if (age < 0 || age > LIFE) return 0 const p = age / LIFE const dx = x + 0.5 - item.x - 0.5 const dy = y * 2 + 1 - item.y * 2 - 1 const dist = Math.hypot(dx, dy) - const r = SPAN * (1 - (1 - p) ** EXPAND) + const r = ctx.SPAN * (1 - (1 - p) ** EXPAND) if (dist > r) return 1 return clamp((r - dist) / 1.35 < 1 ? 1 - (r - dist) / 1.35 : 0) } -function wave(x: number, y: number, frame: Frame, live: boolean) { +function wave(x: number, y: number, frame: Frame, live: boolean, ctx: LogoContext) { return frame.list.reduce((sum, item) => { const age = frame.t - item.at if (age < 0 || age > LIFE) return sum @@ -278,7 +297,7 @@ function wave(x: number, y: number, frame: Frame, live: boolean) { const dx = x + 0.5 - item.x const dy = y * 2 + 1 - item.y const dist = Math.hypot(dx, dy) - const r = SPAN * (1 - (1 - p) ** EXPAND) + const r = ctx.SPAN * (1 - (1 - p) ** EXPAND) const fade = (1 - p) ** 1.32 const j = 1.02 + noise(x + item.x * 0.7, y + item.y * 0.7, item.at * 0.002 + age * 0.06) * 0.52 const edge = Math.exp(-(((dist - r) / WIDTH) ** 2)) * GAIN * fade * item.force * j @@ -292,7 +311,7 @@ function wave(x: number, y: number, frame: Frame, live: boolean) { }, 0) } -function field(x: number, y: number, frame: Frame) { +function field(x: number, y: number, frame: Frame, ctx: LogoContext) { const held = frame.hold const rest = frame.release const item = held ?? rest @@ -326,11 +345,11 @@ function field(x: number, y: number, frame: Frame) { Math.max(0, noise(item.x * 3.1, item.y * 2.7, frame.t * 1.7) - 0.72) * Math.exp(-(dist * dist) / 0.15) * lerp(0.08, 0.42, body) - const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1 + const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1 return (core + shell + ember + ring + fork + glitch + lash + flicker - dim) * fade } -function pick(x: number, y: number, frame: Frame) { +function pick(x: number, y: number, frame: Frame, ctx: LogoContext) { const held = frame.hold const rest = frame.release const item = held ?? rest @@ -339,26 +358,26 @@ function pick(x: number, y: number, frame: Frame) { const dx = x + 0.5 - item.x - 0.5 const dy = y * 2 + 1 - item.y * 2 - 1 const dist = Math.hypot(dx, dy) - const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1 + const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1 return Math.exp(-(dist * dist) / 1.7) * lerp(0.2, 0.96, rise) * fade } -function select(x: number, y: number) { - const direct = MAP.glyph.get(key(x, y)) +function select(x: number, y: number, ctx: LogoContext) { + const direct = ctx.MAP.glyph.get(key(x, y)) if (direct !== undefined) return direct - const near = NEAR.map(([dx, dy]) => MAP.glyph.get(key(x + dx, y + dy))).find( + const near = NEAR.map(([dx, dy]) => ctx.MAP.glyph.get(key(x + dx, y + dy))).find( (item): item is number => item !== undefined, ) return near } -function trace(x: number, y: number, frame: Frame) { +function trace(x: number, y: number, frame: Frame, ctx: LogoContext) { const held = frame.hold const rest = frame.release const item = held ?? rest if (!item || item.glyph === undefined) return 0 - const step = MAP.trace.get(key(x, y)) + const step = ctx.MAP.trace.get(key(x, y)) if (!step || step.glyph !== item.glyph || step.l < 2) return 0 const age = frame.t - item.at const rise = held ? ramp(age, HOLD, CHARGE) : rest!.rise @@ -368,29 +387,125 @@ function trace(x: number, y: number, frame: Frame) { const dist = Math.min(Math.abs(step.i - head), step.l - Math.abs(step.i - head)) const tail = (head - TAIL + step.l) % step.l const lag = Math.min(Math.abs(step.i - tail), step.l - Math.abs(step.i - tail)) - const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1 + const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1 const core = Math.exp(-((dist / 1.05) ** 2)) * lerp(0.8, 2.35, rise) const glow = Math.exp(-((dist / 1.85) ** 2)) * lerp(0.08, 0.34, rise) const trail = Math.exp(-((lag / 1.45) ** 2)) * lerp(0.04, 0.42, rise) return (core + glow + trail) * appear * fade } -function bloom(x: number, y: number, frame: Frame) { +function idle( + x: number, + pixelY: number, + frame: Frame, + ctx: LogoContext, + state: IdleState, +): { glow: number; peak: number; primary: number } { + const cfg = state.cfg + const dx = x + 0.5 - cfg.originX + const dy = pixelY - cfg.originY + const dist = Math.hypot(dx, dy) + const angle = Math.atan2(dy, dx) + const wob1 = noise(x * 0.32, pixelY * 0.25, frame.t * 0.0005) - 0.5 + const wob2 = noise(x * 0.12, pixelY * 0.08, frame.t * 0.00022) - 0.5 + const ripple = Math.sin(angle * 3 + frame.t * 0.0012) * 0.3 + const jitter = (wob1 * 0.55 + wob2 * 0.32 + ripple * 0.18) * cfg.noise + const traveled = dist + jitter + let glow = 0 + let peak = 0 + let halo = 0 + let primary = 0 + let ambient = 0 + for (const active of state.active) { + const head = active.head + const eased = active.eased + const delta = traveled - head + // 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 + } + ambient /= state.rings + return { + glow: glow / state.rings, + peak: cfg.breathBase + ambient + (peak + halo) / state.rings, + primary: (primary / state.rings) * cfg.primaryMix, + } +} + +function bloom(x: number, y: number, frame: Frame, ctx: LogoContext) { const item = frame.glow if (!item) return 0 - const glyph = MAP.glyph.get(key(x, y)) + const glyph = ctx.MAP.glyph.get(key(x, y)) if (glyph !== item.glyph) return 0 const age = frame.t - item.at if (age < 0 || age > GLOW_OUT) return 0 const p = age / GLOW_OUT const flash = (1 - p) ** 2 - const dx = x + 0.5 - MAP.center.get(item.glyph)!.x - const dy = y * 2 + 1 - MAP.center.get(item.glyph)!.y + const dx = x + 0.5 - ctx.MAP.center.get(item.glyph)!.x + const dy = y * 2 + 1 - ctx.MAP.center.get(item.glyph)!.y const bias = Math.exp(-((Math.hypot(dx, dy) / 2.8) ** 2)) return lerp(item.force, item.force * 0.18, p) * lerp(0.72, 1.1, bias) * flash } -export function Logo() { +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() const [rings, setRings] = createSignal([]) const [hold, setHold] = createSignal() @@ -430,6 +545,7 @@ export function Logo() { } if (!live) setRelease(undefined) if (live || hold() || release() || glow()) return + if (props.idle) return stop() } @@ -438,8 +554,20 @@ export function Logo() { timer = setInterval(tick, 16) } + onCleanup(() => { + stop() + hum = false + Sound.dispose() + }) + + onMount(() => { + if (!props.idle) return + setNow(performance.now()) + start() + }) + const hit = (x: number, y: number) => { - const char = FULL[y]?.[x] + const char = ctx.FULL[y]?.[x] return char !== undefined && char !== " " } @@ -448,7 +576,7 @@ export function Logo() { if (last) burst(last.x, last.y) setNow(t) if (!last) setRelease(undefined) - setHold({ x, y, at: t, glyph: select(x, y) }) + setHold({ x, y, at: t, glyph: select(x, y, ctx) }) hum = false start() } @@ -508,6 +636,8 @@ export function Logo() { } }) + const idleState = createMemo(() => (props.idle ? buildIdleState(frame().t, ctx) : undefined)) + const renderLine = ( line: string, y: number, @@ -516,24 +646,64 @@ export function Logo() { 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) => { - const h = field(off + i, y, frame) - const n = wave(off + i, y, frame, lit(char)) + h - const s = wave(off + i, y, dusk, false) + h - const p = lit(char) ? pick(off + i, y, frame) : 0 - const e = lit(char) ? trace(off + i, y, frame) : 0 - const b = lit(char) ? bloom(off + i, y, frame) : 0 - const q = shimmer(off + i, y, frame) + if (char === " ") { + return ( + + {char} + + ) + } + + const h = field(off + i, y, frame, ctx) + 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 = 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 ( @@ -545,8 +715,8 @@ export function Logo() { if (char === "^") { return ( @@ -557,34 +727,60 @@ export function Logo() { if (char === "~") { return ( - + ) } - if (char === " ") { + if (char === ",") { return ( - - {char} + + ▄ + + ) + } + + // Solid █: render as ▀ so the top pixel (fg) and bottom pixel (bg) can carry independent shimmer values + if (char === "█") { + return ( + + ▀ + + ) + } + + // ▀ 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 ( + + ▄ ) } return ( - + {char} ) }) } - onCleanup(() => { - stop() - hum = false - Sound.dispose() - }) - const mouse = (evt: MouseEvent) => { if (!box) return if ((evt.type === "down" || evt.type === "drag") && evt.button === MouseButton.LEFT) { @@ -613,17 +809,28 @@ export function Logo() { position="absolute" top={0} left={0} - width={FULL[0]?.length ?? 0} - height={FULL.length} + width={ctx.FULL[0]?.length ?? 0} + height={ctx.FULL.length} zIndex={1} onMouse={mouse} /> - + {(line, index) => ( - {renderLine(line, index(), theme.textMuted, false, 0, frame(), dusk())} - {renderLine(logo.right[index()], index(), theme.text, true, LEFT + GAP, frame(), dusk())} + {renderLine(line, index(), props.ink ?? theme.textMuted, !!props.ink, 0, frame(), dusk(), idleState())} + + + {renderLine( + ctx.shape.right[index()], + index(), + props.ink ?? theme.text, + true, + ctx.LEFT + GAP, + frame(), + dusk(), + idleState(), + )} )} @@ -631,3 +838,9 @@ export function Logo() { ) } + +export function GoLogo() { + const { theme } = useTheme() + 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 } diff --git a/packages/opencode/src/cli/logo.ts b/packages/opencode/src/cli/logo.ts index 44fb93c15b..a58a8cf995 100644 --- a/packages/opencode/src/cli/logo.ts +++ b/packages/opencode/src/cli/logo.ts @@ -3,4 +3,9 @@ export const logo = { right: [" ▄ ", "█▀▀▀ █▀▀█ █▀▀█ █▀▀█", "█___ █__█ █__█ █^^^", "▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"], } -export const marks = "_^~" +export const go = { + left: [" ", "█▀▀▀", "█_^█", "▀▀▀▀"], + right: [" ", "█▀▀█", "█__█", "▀▀▀▀"], +} + +export const marks = "_^~,"