Explorar el Código

feat(tui): animated GO logo + radial pulse in free-limit upsell dialog (#22976)

Kit Langton hace 4 días
padre
commit
cccb907a9b

+ 130 - 0
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 ref={(item: BoxRenderable) => (box = item)} width="100%" height="100%">
+      <For each={grid()}>
+        {(row) => (
+          <box flexDirection="row">
+            <For each={row}>
+              {(color) => (
+                <text bg={color} fg={color} selectable={false}>
+                  {" "}
+                </text>
+              )}
+            </For>
+          </box>
+        )}
+      </For>
+    </box>
+  )
+}

+ 104 - 46
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<BgPulseMask[]>([])
+  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 (
-    <box paddingLeft={2} paddingRight={2} gap={1}>
-      <box flexDirection="row" justifyContent="space-between">
-        <text attributes={TextAttributes.BOLD} fg={theme.text}>
-          Free limit reached
-        </text>
-        <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
-          esc
-        </text>
-      </box>
-      <box gap={1} paddingBottom={1}>
-        <text fg={theme.textMuted}>
-          Subscribe to OpenCode Go to keep going with reliable access to the best open-source models, starting at
-          $5/month.
-        </text>
-        <box flexDirection="row" gap={1}>
-          <Link href={GO_URL} fg={theme.primary} />
-        </box>
+    <box ref={(item: BoxRenderable) => (content = item)}>
+      <box position="absolute" top={-PAD_TOP_OUTER} left={0} right={0} bottom={0} zIndex={0}>
+        <BgPulse centerX={center()?.x} centerY={center()?.y} masks={masks()} />
       </box>
-      <box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
-        <box
-          paddingLeft={3}
-          paddingRight={3}
-          backgroundColor={selected() === 0 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
-          onMouseOver={() => setSelected(0)}
-          onMouseUp={() => subscribe(props, dialog)}
-        >
-          <text fg={selected() === 0 ? fg : theme.text} attributes={selected() === 0 ? TextAttributes.BOLD : undefined}>
-            subscribe
+      <box paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
+        <box ref={(item: BoxRenderable) => (headingBox = item)} flexDirection="row" justifyContent="space-between">
+          <text attributes={TextAttributes.BOLD} fg={theme.text}>
+            Free limit reached
           </text>
+          <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
+            esc
+          </text>
+        </box>
+        <box ref={(item: BoxRenderable) => (descBox = item)} gap={0}>
+          <box flexDirection="row">
+            <text fg={theme.textMuted}>Subscribe to </text>
+            <text attributes={TextAttributes.BOLD} fg={theme.textMuted}>
+              OpenCode Go
+            </text>
+            <text fg={theme.textMuted}> for reliable access to the</text>
+          </box>
+          <text fg={theme.textMuted}>best open-source models, starting at $5/month.</text>
         </box>
-        <box
-          paddingLeft={3}
-          paddingRight={3}
-          backgroundColor={selected() === 1 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
-          onMouseOver={() => setSelected(1)}
-          onMouseUp={() => dismiss(props, dialog)}
-        >
-          <text
-            fg={selected() === 1 ? fg : theme.textMuted}
-            attributes={selected() === 1 ? TextAttributes.BOLD : undefined}
+        <box alignItems="center" gap={1} paddingBottom={1}>
+          <box ref={(item: BoxRenderable) => (logoBox = item)}>
+            <GoLogo />
+          </box>
+          <Link href={GO_URL} fg={theme.primary} />
+        </box>
+        <box ref={(item: BoxRenderable) => (buttonsBox = item)} flexDirection="row" justifyContent="space-between">
+          <box
+            paddingLeft={2}
+            paddingRight={2}
+            backgroundColor={selected() === "dismiss" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
+            onMouseOver={() => setSelected("dismiss")}
+            onMouseUp={() => dismiss(props, dialog)}
           >
-            don't show again
-          </text>
+            <text
+              fg={selected() === "dismiss" ? fg : theme.textMuted}
+              attributes={selected() === "dismiss" ? TextAttributes.BOLD : undefined}
+            >
+              don't show again
+            </text>
+          </box>
+          <box
+            paddingLeft={2}
+            paddingRight={2}
+            backgroundColor={selected() === "subscribe" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
+            onMouseOver={() => setSelected("subscribe")}
+            onMouseUp={() => subscribe(props, dialog)}
+          >
+            <text
+              fg={selected() === "subscribe" ? fg : theme.text}
+              attributes={selected() === "subscribe" ? TextAttributes.BOLD : undefined}
+            >
+              subscribe
+            </text>
+          </box>
         </box>
       </box>
     </box>

+ 274 - 61
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<typeof mapGlyphs>
+  shape: LogoShape
+}
+
+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) {
+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<Ring[]>([])
   const [hold, setHold] = createSignal<Hold>()
@@ -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 (
+          <text fg={ink} attributes={attrs} selectable={false}>
+            {char}
+          </text>
+        )
+      }
+
+      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 (
           <text
-            fg={shade(ink, theme, s * 0.08)}
-            bg={shade(shadow, theme, ghost(s, 0.24) + ghost(q, 0.06))}
+            fg={shade(inkTinted, theme, s * 0.08)}
+            bg={shade(shadowTinted, theme, ghost(s, 0.24) + ghost(q, 0.06))}
             attributes={attrs}
             selectable={false}
           >
@@ -545,8 +715,8 @@ export function Logo() {
       if (char === "^") {
         return (
           <text
-            fg={shade(ink, theme, n + p + e + b)}
-            bg={shade(shadow, theme, ghost(s, 0.18) + ghost(q, 0.05) + ghost(b, 0.08))}
+            fg={shade(inkTop, theme, n + p + e + b)}
+            bg={shade(shadowBot, theme, ghost(s, 0.18) + ghost(q, 0.05) + ghost(b, 0.08))}
             attributes={attrs}
             selectable={false}
           >
@@ -557,34 +727,60 @@ export function Logo() {
 
       if (char === "~") {
         return (
-          <text fg={shade(shadow, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
+          <text fg={shade(shadowTop, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
           </text>
         )
       }
 
-      if (char === " ") {
+      if (char === ",") {
         return (
-          <text fg={ink} attributes={attrs} selectable={false}>
-            {char}
+          <text fg={shade(shadowBot, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
+            ▄
+          </text>
+        )
+      }
+
+      // Solid █: render as ▀ so the top pixel (fg) and bottom pixel (bg) can carry independent shimmer values
+      if (char === "█") {
+        return (
+          <text
+            fg={shade(inkTop, theme, n + p + e + b)}
+            bg={shade(inkBot, theme, n + p + e + b)}
+            attributes={attrs}
+            selectable={false}
+          >
+            ▀
+          </text>
+        )
+      }
+
+      // ▀ top-half-lit: fg uses top-pixel sample, bg stays transparent/panel
+      if (char === "▀") {
+        return (
+          <text fg={shade(inkTop, theme, n + p + e + b)} attributes={attrs} selectable={false}>
+            ▀
+          </text>
+        )
+      }
+
+      // ▄ bottom-half-lit: fg uses bottom-pixel sample
+      if (char === "▄") {
+        return (
+          <text fg={shade(inkBot, theme, n + p + e + b)} attributes={attrs} selectable={false}>
+            ▄
           </text>
         )
       }
 
       return (
-        <text fg={shade(ink, theme, n + p + e + b)} attributes={attrs} selectable={false}>
+        <text fg={shade(inkTinted, theme, n + p + e + b)} attributes={attrs} selectable={false}>
           {char}
         </text>
       )
     })
   }
 
-  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}
       />
-      <For each={logo.left}>
+      <For each={ctx.shape.left}>
         {(line, index) => (
           <box flexDirection="row" gap={1}>
-            <box flexDirection="row">{renderLine(line, index(), theme.textMuted, false, 0, frame(), dusk())}</box>
             <box flexDirection="row">
-              {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())}
+            </box>
+            <box flexDirection="row">
+              {renderLine(
+                ctx.shape.right[index()],
+                index(),
+                props.ink ?? theme.text,
+                true,
+                ctx.LEFT + GAP,
+                frame(),
+                dusk(),
+                idleState(),
+              )}
             </box>
           </box>
         )}
@@ -631,3 +838,9 @@ export function Logo() {
     </box>
   )
 }
+
+export function GoLogo() {
+  const { theme } = useTheme()
+  const base = tint(theme.background, theme.text, 0.62)
+  return <Logo shape={go} ink={base} idle />
+}

+ 49 - 0
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 }

+ 6 - 1
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 = "_^~,"