فهرست منبع

improve go sub animation perf (#26251)

Sebastian 3 ماه پیش
والد
کامیت
5c401673b2

+ 429 - 0
packages/opencode/src/cli/cmd/tui/component/bg-pulse-render.ts

@@ -0,0 +1,429 @@
+import { OptimizedBuffer, RGBA, TextAttributes } from "@opentui/core"
+import { go } from "@/cli/logo"
+
+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 the bg ring emits from the estimated GO center when the logo shimmer peaks.
+const PHASE_OFFSET = 0.29
+const LOGO_GAP = 1
+const LOGO_TOP_BIAS = -1
+const LOGO_LEFT_WIDTH = go.left[0]?.length ?? 0
+const LOGO_LINES = go.left.map((line, index) => line + " ".repeat(LOGO_GAP) + go.right[index])
+const LOGO_WIDTH = LOGO_LINES[0]?.length ?? 0
+const LOGO_HEIGHT = LOGO_LINES.length
+const SPACE = " ".codePointAt(0)!
+const TOP_HALF = "▀".codePointAt(0)!
+const FULL_BLOCK = "█".codePointAt(0)!
+const RING_SCALE = 1 / RINGS
+const TAIL_SCALE = 1 / TAIL
+const LOGO_REACH = Math.hypot(LOGO_WIDTH, LOGO_HEIGHT * 2) + 3
+
+const enum LogoCellKind {
+  Background,
+  Top,
+  ShadowTop,
+  Solid,
+  Char,
+}
+
+type LogoTemplateCell = {
+  x: number
+  y: number
+  kind: LogoCellKind
+  charCode: number
+  attributes: number
+  topDist: number
+  bottomDist: number
+}
+
+const LOGO_TEMPLATE: LogoTemplateCell[] = LOGO_LINES.flatMap((line, y) =>
+  Array.from(line)
+    .map((char, x) => {
+      if (char === " ") return
+      const kind =
+        char === "_"
+          ? LogoCellKind.Background
+          : char === "^"
+            ? LogoCellKind.Top
+            : char === "~"
+              ? LogoCellKind.ShadowTop
+              : char === "█"
+                ? LogoCellKind.Solid
+                : LogoCellKind.Char
+      return {
+        x,
+        y,
+        kind,
+        charCode: char.codePointAt(0) ?? SPACE,
+        attributes: x > LOGO_LEFT_WIDTH ? TextAttributes.BOLD : 0,
+        topDist: Math.hypot(x + 0.5 - LOGO_WIDTH / 2, y * 2 - LOGO_HEIGHT),
+        bottomDist: Math.hypot(x + 0.5 - LOGO_WIDTH / 2, y * 2 + 1 - LOGO_HEIGHT),
+      }
+    })
+    .filter((cell): cell is LogoTemplateCell => !!cell),
+)
+
+export type Rgb = [number, number, number]
+
+export type GoUpsellArtRenderOptions = {
+  deltaTime?: number
+  rgb?: boolean
+  cache?: boolean
+}
+
+const CACHE_FRAME_COUNT = Math.round(PERIOD / (1000 / 30))
+const CACHE_FRAMES_PER_RENDER = 1
+
+export function toRgb(color: RGBA): Rgb {
+  const [r, g, b] = color.toInts()
+  return [r, g, b]
+}
+
+function clamp(n: number) {
+  return Math.max(0, Math.min(1, n))
+}
+
+function writeRgb(buffer: Uint16Array, offset: number, r: number, g: number, b: number, a = 255) {
+  buffer[offset] = r
+  buffer[offset + 1] = g
+  buffer[offset + 2] = b
+  buffer[offset + 3] = a
+}
+
+function mixChannel(base: number, overlay: number, alpha: number) {
+  return Math.round(base + (overlay - base) * clamp(alpha))
+}
+
+function writeLogoTint(buffer: Uint16Array, offset: number, base: Rgb, primary: Rgb, primaryMix: number, peakMix: number) {
+  const p = clamp(primaryMix)
+  const q = clamp(peakMix)
+  const r = mixChannel(mixChannel(base[0], primary[0], p), 255, q)
+  const g = mixChannel(mixChannel(base[1], primary[1], p), 255, q)
+  const b = mixChannel(mixChannel(base[2], primary[2], p), 255, q)
+  writeRgb(buffer, offset, r, g, b)
+}
+
+function sameRgb(a: Rgb, b: Rgb) {
+  return a[0] === b[0] && a[1] === b[1] && a[2] === b[2]
+}
+
+export class GoUpsellArtPainter {
+  private panelRgb: Rgb = [0, 0, 0]
+  private primaryRgb: Rgb = [255, 255, 255]
+  private logoBaseRgb: Rgb = [180, 180, 180]
+  private elapsed = 0
+  private distances = new Float32Array(0)
+  private edgeFalloff = new Float32Array(0)
+  private geometryWidth = 0
+  private geometryHeight = 0
+  private reach = 1
+  private logoX = 0
+  private logoY = 0
+  private logoIndexes = new Int32Array(0)
+  private logoRgb: boolean | undefined
+  private pulsePeak = 0
+  private pulsePrimary = 0
+  private cacheDirty = true
+  private frameCache: Array<{ fg: Uint16Array; bg: Uint16Array }> = []
+  private cacheBuildIndex = 0
+
+  setBackgroundPanel(value: RGBA | Rgb | undefined) {
+    if (!value) return false
+    const next = value instanceof RGBA ? toRgb(value) : value
+    if (sameRgb(this.panelRgb, next)) return false
+    this.panelRgb = next
+    this.invalidateCache()
+    return true
+  }
+
+  setLogoBase(value: RGBA | Rgb | undefined) {
+    if (!value) return false
+    const next = value instanceof RGBA ? toRgb(value) : value
+    if (sameRgb(this.logoBaseRgb, next)) return false
+    this.logoBaseRgb = next
+    this.invalidateCache()
+    return true
+  }
+
+  setPrimary(value: RGBA | Rgb | undefined) {
+    if (!value) return false
+    const next = value instanceof RGBA ? toRgb(value) : value
+    if (sameRgb(this.primaryRgb, next)) return false
+    this.primaryRgb = next
+    this.invalidateCache()
+    return true
+  }
+
+  render(frameBuffer: OptimizedBuffer, options: GoUpsellArtRenderOptions = {}) {
+    const rgb = options.rgb === true
+    this.elapsed = (this.elapsed + (options.deltaTime ?? 0)) % PERIOD
+    this.rebuildGeometry(frameBuffer, rgb)
+    if (options.cache !== false) {
+      this.drawCached(frameBuffer, rgb)
+      return
+    }
+    this.drawBackground(frameBuffer, this.elapsed)
+    this.drawLogo(frameBuffer, this.elapsed, rgb)
+  }
+
+  private invalidateCache() {
+    this.cacheDirty = true
+    this.cacheBuildIndex = 0
+    this.frameCache = []
+  }
+
+  private rebuildGeometry(frameBuffer: OptimizedBuffer, rgb: boolean) {
+    const width = frameBuffer.width
+    const height = frameBuffer.height
+    const geometryChanged = width !== this.geometryWidth || height !== this.geometryHeight
+    const logoTemplateChanged = this.logoRgb !== rgb
+    if (!geometryChanged && !logoTemplateChanged) return
+
+    if (geometryChanged) {
+      this.geometryWidth = width
+      this.geometryHeight = height
+      this.logoX = Math.max(0, Math.floor((width - LOGO_WIDTH) / 2))
+      this.logoY = Math.max(
+        0,
+        Math.min(Math.max(0, height - LOGO_HEIGHT), Math.round((height - LOGO_HEIGHT) / 2) + LOGO_TOP_BIAS),
+      )
+
+      const centerX = this.logoX + LOGO_WIDTH / 2
+      const centerY = this.logoY + LOGO_HEIGHT / 2
+      this.reach = Math.hypot(Math.max(centerX, width - centerX), Math.max(centerY, height - centerY) * 2) + TAIL
+      this.distances = new Float32Array(width * height)
+      this.edgeFalloff = new Float32Array(width * height)
+
+      for (let y = 0; y < height; y++) {
+        for (let x = 0; x < width; x++) {
+          const index = y * width + x
+          const dist = Math.hypot(x + 0.5 - centerX, (y + 0.5 - centerY) * 2)
+          this.distances[index] = dist
+          this.edgeFalloff[index] = Math.max(0, 1 - (dist / (this.reach * 0.85)) ** 2)
+        }
+      }
+    }
+
+    this.logoRgb = rgb
+    this.invalidateCache()
+    this.rebuildCellTemplate(frameBuffer, rgb)
+  }
+
+  private drawCached(frameBuffer: OptimizedBuffer, rgb: boolean) {
+    if (this.cacheDirty) this.startFrameCache(frameBuffer, rgb)
+    if (this.cacheBuildIndex < CACHE_FRAME_COUNT) {
+      this.buildFrameCache(frameBuffer, rgb)
+      this.drawBackground(frameBuffer, this.elapsed)
+      this.drawLogo(frameBuffer, this.elapsed, rgb)
+      return
+    }
+
+    const frame = this.frameCache[Math.floor((this.elapsed / PERIOD) * CACHE_FRAME_COUNT) % CACHE_FRAME_COUNT]
+    if (frame) {
+      frameBuffer.buffers.fg.set(frame.fg)
+      frameBuffer.buffers.bg.set(frame.bg)
+    }
+  }
+
+  private startFrameCache(frameBuffer: OptimizedBuffer, rgb: boolean) {
+    this.frameCache = []
+    this.cacheBuildIndex = 0
+    this.rebuildCellTemplate(frameBuffer, rgb)
+    this.cacheDirty = false
+  }
+
+  private buildFrameCache(frameBuffer: OptimizedBuffer, rgb: boolean) {
+    const end = Math.min(CACHE_FRAME_COUNT, this.cacheBuildIndex + CACHE_FRAMES_PER_RENDER)
+    for (; this.cacheBuildIndex < end; this.cacheBuildIndex++) {
+      const t = (this.cacheBuildIndex / CACHE_FRAME_COUNT) * PERIOD
+      this.drawBackground(frameBuffer, t)
+      this.drawLogo(frameBuffer, t, rgb)
+      this.frameCache.push({
+        fg: new Uint16Array(frameBuffer.buffers.fg),
+        bg: new Uint16Array(frameBuffer.buffers.bg),
+      })
+    }
+  }
+
+  private rebuildCellTemplate(frameBuffer: OptimizedBuffer, rgb: boolean) {
+    const buffers = frameBuffer.buffers
+    buffers.char.fill(SPACE)
+    buffers.attributes.fill(0)
+
+    if (this.geometryWidth < LOGO_WIDTH || this.geometryHeight < LOGO_HEIGHT) {
+      this.logoIndexes = new Int32Array(0)
+      return
+    }
+
+    this.logoIndexes = new Int32Array(LOGO_TEMPLATE.length)
+    for (let i = 0; i < LOGO_TEMPLATE.length; i++) {
+      const cell = LOGO_TEMPLATE[i]!
+      const index = (this.logoY + cell.y) * this.geometryWidth + this.logoX + cell.x
+      this.logoIndexes[i] = index
+      buffers.attributes[index] = cell.attributes
+      buffers.char[index] =
+        cell.kind === LogoCellKind.Background
+          ? SPACE
+          : cell.kind === LogoCellKind.Top || cell.kind === LogoCellKind.ShadowTop
+            ? TOP_HALF
+            : cell.kind === LogoCellKind.Solid
+              ? rgb
+                ? TOP_HALF
+                : FULL_BLOCK
+              : cell.charCode
+    }
+  }
+
+  private drawBackground(frameBuffer: OptimizedBuffer, t: number) {
+    const buffers = frameBuffer.buffers
+    const fg = buffers.fg
+    const bg = buffers.bg
+    const distances = this.distances
+    const edgeFalloff = this.edgeFalloff
+    const baseR = this.panelRgb[0]
+    const baseG = this.panelRgb[1]
+    const baseB = this.panelRgb[2]
+    const deltaR = this.primaryRgb[0] - baseR
+    const deltaG = this.primaryRgb[1] - baseG
+    const deltaB = this.primaryRgb[2] - baseB
+    const breath = (0.5 + 0.5 * Math.sin(t * BREATH_SPEED)) * BREATH_AMP
+
+    const phase0 = (t / PERIOD - PHASE_OFFSET + 1) % 1
+    const phase1 = (t / PERIOD + 1 / RINGS - PHASE_OFFSET + 1) % 1
+    const phase2 = (t / PERIOD + 2 / RINGS - PHASE_OFFSET + 1) % 1
+    const envelope0 = Math.sin(phase0 * Math.PI)
+    const envelope1 = Math.sin(phase1 * Math.PI)
+    const envelope2 = Math.sin(phase2 * Math.PI)
+    const eased0 = envelope0 * envelope0 * (3 - 2 * envelope0)
+    const eased1 = envelope1 * envelope1 * (3 - 2 * envelope1)
+    const eased2 = envelope2 * envelope2 * (3 - 2 * envelope2)
+    const head0 = phase0 * this.reach
+    const head1 = phase1 * this.reach
+    const head2 = phase2 * this.reach
+
+    for (let index = 0; index < distances.length; index++) {
+      const dist = distances[index]
+      const delta0 = dist - head0
+      const abs0 = delta0 < 0 ? -delta0 : delta0
+      const crest0 = abs0 < WIDTH ? 0.5 + 0.5 * Math.cos((delta0 / WIDTH) * Math.PI) : 0
+      const tail0 = delta0 < 0 && delta0 > -TAIL ? (1 + delta0 * TAIL_SCALE) ** 2.3 : 0
+
+      const delta1 = dist - head1
+      const abs1 = delta1 < 0 ? -delta1 : delta1
+      const crest1 = abs1 < WIDTH ? 0.5 + 0.5 * Math.cos((delta1 / WIDTH) * Math.PI) : 0
+      const tail1 = delta1 < 0 && delta1 > -TAIL ? (1 + delta1 * TAIL_SCALE) ** 2.3 : 0
+
+      const delta2 = dist - head2
+      const abs2 = delta2 < 0 ? -delta2 : delta2
+      const crest2 = abs2 < WIDTH ? 0.5 + 0.5 * Math.cos((delta2 / WIDTH) * Math.PI) : 0
+      const tail2 = delta2 < 0 && delta2 > -TAIL ? (1 + delta2 * TAIL_SCALE) ** 2.3 : 0
+
+      const level =
+        (crest0 * AMP + tail0 * TAIL_AMP) * eased0 +
+        (crest1 * AMP + tail1 * TAIL_AMP) * eased1 +
+        (crest2 * AMP + tail2 * TAIL_AMP) * eased2
+      const rawStrength = (level * RING_SCALE + breath) * edgeFalloff[index]
+      const strength = (rawStrength > 1 ? 1 : rawStrength) * 0.7
+      const offset = index * 4
+      const r = Math.round(baseR + deltaR * strength)
+      const g = Math.round(baseG + deltaG * strength)
+      const b = Math.round(baseB + deltaB * strength)
+      bg[offset] = fg[offset] = r
+      bg[offset + 1] = fg[offset + 1] = g
+      bg[offset + 2] = fg[offset + 2] = b
+      bg[offset + 3] = fg[offset + 3] = 255
+    }
+  }
+
+  private setLogoPulse(dist: number, head0: number, eased0: number, head1: number, eased1: number) {
+    let peak = 0.04
+    let primary = 0
+
+    const delta0 = dist - head0
+    const core0 = Math.exp(-(Math.abs(delta0 / 1.2) ** 1.8))
+    const soft0 = Math.exp(-(Math.abs(delta0 / 7) ** 1.6))
+    const tail0 = delta0 < 0 && delta0 > -7 ? (1 + delta0 / 7) ** 2.6 : 0
+    peak += core0 * 0.65 * eased0
+    primary += (soft0 * 0.16 + tail0 * 0.22) * eased0
+
+    const delta1 = dist - head1
+    const core1 = Math.exp(-(Math.abs(delta1 / 1.2) ** 1.8))
+    const soft1 = Math.exp(-(Math.abs(delta1 / 7) ** 1.6))
+    const tail1 = delta1 < 0 && delta1 > -7 ? (1 + delta1 / 7) ** 2.6 : 0
+    peak += core1 * 0.65 * eased1
+    primary += (soft1 * 0.16 + tail1 * 0.22) * eased1
+
+    this.pulsePeak = peak > 1 ? 1 : peak
+    this.pulsePrimary = primary > 1 ? 1 : primary
+  }
+
+  private drawLogo(frameBuffer: OptimizedBuffer, t: number, rgb: boolean) {
+    if (this.logoIndexes.length === 0) return
+
+    const buffers = frameBuffer.buffers
+    const fg = buffers.fg
+    const bg = buffers.bg
+    const shadow: Rgb = [
+      mixChannel(this.panelRgb[0], this.logoBaseRgb[0], 0.25),
+      mixChannel(this.panelRgb[1], this.logoBaseRgb[1], 0.25),
+      mixChannel(this.panelRgb[2], this.logoBaseRgb[2], 0.25),
+    ]
+    const phase0 = (t / PERIOD) % 1
+    const phase1 = (t / PERIOD + 0.5) % 1
+    const envelope0 = Math.sin(phase0 * Math.PI)
+    const envelope1 = Math.sin(phase1 * Math.PI)
+    const eased0 = envelope0 * envelope0 * (3 - 2 * envelope0)
+    const eased1 = envelope1 * envelope1 * (3 - 2 * envelope1)
+    const head0 = phase0 * LOGO_REACH
+    const head1 = phase1 * LOGO_REACH
+
+    for (let i = 0; i < LOGO_TEMPLATE.length; i++) {
+      const cell = LOGO_TEMPLATE[i]!
+      const index = this.logoIndexes[i]!
+      const offset = index * 4
+      this.setLogoPulse(cell.topDist, head0, eased0, head1, eased1)
+      const topPeak = this.pulsePeak
+      const topPrimary = this.pulsePrimary
+      this.setLogoPulse(cell.bottomDist, head0, eased0, head1, eased1)
+      const bottomPeak = this.pulsePeak
+      const bottomPrimary = this.pulsePrimary
+
+      if (cell.kind === LogoCellKind.Background) {
+        writeLogoTint(bg, offset, shadow, this.primaryRgb, 0, Math.max(topPeak, bottomPeak) * 0.18)
+        continue
+      }
+
+      if (cell.kind === LogoCellKind.Top) {
+        writeLogoTint(fg, offset, this.logoBaseRgb, this.primaryRgb, topPrimary, topPeak)
+        writeLogoTint(bg, offset, shadow, this.primaryRgb, 0, bottomPeak * 0.18)
+        continue
+      }
+
+      if (cell.kind === LogoCellKind.ShadowTop) {
+        writeLogoTint(fg, offset, shadow, this.primaryRgb, 0, topPeak * 0.18)
+        continue
+      }
+
+      if (cell.kind === LogoCellKind.Solid && rgb) {
+        writeLogoTint(fg, offset, this.logoBaseRgb, this.primaryRgb, topPrimary, topPeak)
+        writeLogoTint(bg, offset, this.logoBaseRgb, this.primaryRgb, bottomPrimary, bottomPeak)
+        continue
+      }
+
+      writeLogoTint(
+        fg,
+        offset,
+        this.logoBaseRgb,
+        this.primaryRgb,
+        (topPrimary + bottomPrimary) / 2,
+        (topPeak + bottomPeak) / 2,
+      )
+    }
+  }
+}

+ 76 - 113
packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx

@@ -1,130 +1,93 @@
-import { BoxRenderable, RGBA } from "@opentui/core"
-import { createMemo, createSignal, For, onCleanup, onMount } from "solid-js"
+import { FrameBufferRenderable, RGBA, type OptimizedBuffer, type RenderContext, type RenderableOptions } from "@opentui/core"
+import { extend, useRenderer } from "@opentui/solid"
+import { onCleanup, onMount } from "solid-js"
 import { tint, useTheme } from "@tui/context/theme"
+import { GoUpsellArtPainter } from "./bg-pulse-render"
 
-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
+type GoUpsellArtOptions = RenderableOptions<FrameBufferRenderable> & {
+  backgroundPanel?: RGBA
+  primary?: RGBA
+  logoBase?: RGBA
 }
 
-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
+class GoUpsellArtRenderable extends FrameBufferRenderable {
+  private painter = new GoUpsellArtPainter()
+
+  constructor(ctx: RenderContext, options: GoUpsellArtOptions = {}) {
+    const width = typeof options.width === "number" ? options.width : 1
+    const height = typeof options.height === "number" ? options.height : 1
+    super(ctx, {
+      ...options,
+      width,
+      height,
+      live: options.live ?? true,
+      respectAlpha: false,
+    })
+
+    if (options.width !== undefined && typeof options.width !== "number") this.width = options.width
+    if (options.height !== undefined && typeof options.height !== "number") this.height = options.height
+    this.painter.setBackgroundPanel(options.backgroundPanel)
+    this.painter.setPrimary(options.primary)
+    this.painter.setLogoBase(options.logoBase)
+  }
+
+  set backgroundPanel(value: RGBA | undefined) {
+    if (this.painter.setBackgroundPanel(value)) this.requestRender()
+  }
+
+  set logoBase(value: RGBA | undefined) {
+    if (this.painter.setLogoBase(value)) this.requestRender()
+  }
+
+  set primary(value: RGBA | undefined) {
+    if (this.painter.setPrimary(value)) this.requestRender()
+  }
 
-  const timer = setInterval(() => setNow(performance.now()), 50)
-  onCleanup(() => clearInterval(timer))
+  protected override renderSelf(buffer: OptimizedBuffer, deltaTime = 0): void {
+    if (!this.visible || this.isDestroyed) return
 
-  const sync = () => {
-    if (!box) return
-    setSize({ width: box.width, height: box.height })
+    this.painter.render(this.frameBuffer, {
+      deltaTime,
+      rgb: this._ctx.capabilities?.rgb === true,
+    })
+    super.renderSelf(buffer)
+  }
+}
+
+declare module "@opentui/solid" {
+  interface OpenTUIComponents {
+    go_upsell_art: typeof GoUpsellArtRenderable
   }
+}
+
+extend({ go_upsell_art: GoUpsellArtRenderable })
+
+export function BgPulse() {
+  const { theme } = useTheme()
+  const renderer = useRenderer()
+  let targetFps = renderer.targetFps
+  let maxFps = renderer.maxFps
 
   onMount(() => {
-    sync()
-    box?.on("resize", sync)
+    targetFps = renderer.targetFps
+    maxFps = renderer.maxFps
+    renderer.targetFps = 30
+    renderer.maxFps = 30
   })
 
   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
+    renderer.targetFps = targetFps
+    renderer.maxFps = maxFps
   })
 
   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>
+    <go_upsell_art
+      width="100%"
+      height="100%"
+      backgroundPanel={theme.backgroundPanel}
+      primary={theme.primary}
+      logoBase={tint(theme.background, theme.text, 0.62)}
+      live
+    />
   )
 }

+ 35 - 63
packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx

@@ -1,15 +1,16 @@
-import { BoxRenderable, RGBA, TextAttributes } from "@opentui/core"
+import { RGBA, TextAttributes } from "@opentui/core"
 import open from "open"
-import { createSignal, onCleanup, onMount } from "solid-js"
+import { createSignal } 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"
+import { BgPulse } from "./bg-pulse"
 import { useBindings } from "../keymap"
 
+const GO_URL = "https://opencode.ai/go"
 const PAD_X = 3
 const PAD_TOP_OUTER = 1
+const FOREGROUND_ALPHA = 186
 
 export type DialogRetryActionProps = {
   title: string
@@ -30,52 +31,18 @@ function dismiss(props: DialogRetryActionProps, dialog: ReturnType<typeof useDia
   dialog.clear()
 }
 
+function panelOverlay(color: RGBA) {
+  const [r, g, b] = color.toInts()
+  return RGBA.fromInts(r, g, b, FOREGROUND_ALPHA)
+}
+
 export function DialogRetryAction(props: DialogRetryActionProps) {
   const dialog = useDialog()
   const { theme } = useTheme()
   const fg = selectedForeground(theme)
+  const showGoTreatment = () => props.link === GO_URL
+  const textBg = () => (showGoTreatment() ? panelOverlay(theme.backgroundPanel) : undefined)
   const [selected, setSelected] = createSignal<"dismiss" | "action">("action")
-  const [center, setCenter] = createSignal<{ x: number; y: number } | undefined>()
-  const [masks, setMasks] = createSignal<BgPulseMask[]>([])
-  const showGoTreatment = () => props.link === "https://opencode.ai/go"
-  let content: BoxRenderable | undefined
-  let logoBox: BoxRenderable | undefined
-  let headingBox: BoxRenderable | undefined
-  let descBox: BoxRenderable | undefined
-  let buttonsBox: BoxRenderable | undefined
-
-  const sync = () => {
-    if (!content) return
-    if (logoBox) {
-      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)
-  })
 
   useBindings(() => ({
     bindings: [
@@ -102,37 +69,40 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
   }))
 
   return (
-    <box ref={(item: BoxRenderable) => (content = item)}>
+    <box>
       {showGoTreatment() ? (
         <box position="absolute" top={-PAD_TOP_OUTER} left={0} right={0} bottom={0} zIndex={0}>
-          <BgPulse centerX={center()?.x} centerY={center()?.y} masks={masks()} />
+          <BgPulse />
         </box>
       ) : null}
-      <box paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1} zIndex={1}>
-        <box ref={(item: BoxRenderable) => (headingBox = item)} flexDirection="row" justifyContent="space-between">
-          <text attributes={TextAttributes.BOLD} fg={theme.text}>
+      <box zIndex={1} paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
+        <box flexDirection="row" justifyContent="space-between">
+          <text attributes={TextAttributes.BOLD} fg={theme.text} bg={textBg()}>
             {props.title}
           </text>
-          <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
+          <text fg={theme.textMuted} bg={textBg()} onMouseUp={() => dialog.clear()}>
             esc
           </text>
         </box>
-        <box ref={(item: BoxRenderable) => (descBox = item)} gap={0}>
-          <text fg={theme.textMuted}>{props.message}</text>
+        <box gap={0}>
+          <text fg={theme.textMuted} bg={textBg()}>
+            {props.message}
+          </text>
         </box>
-        <box gap={1} paddingBottom={1}>
-          {showGoTreatment() ? (
-            <box ref={(item: BoxRenderable) => (logoBox = item)} alignItems="center">
-              <GoLogo />
+        {props.link ? (
+          showGoTreatment() ? (
+            <box alignItems="center" justifyContent="flex-end" height={7} paddingBottom={1}>
+              <Link href={props.link} fg={theme.primary} bg={textBg()} wrapMode="none" />
             </box>
-          ) : null}
-          {props.link ? (
-            <box width="100%" flexDirection="row" justifyContent="center">
+          ) : (
+            <box width="100%" flexDirection="row" justifyContent="center" paddingBottom={1}>
               <Link href={props.link} fg={theme.primary} wrapMode="none" />
             </box>
-          ) : null}
-        </box>
-        <box ref={(item: BoxRenderable) => (buttonsBox = item)} flexDirection="row" justifyContent="space-between">
+          )
+        ) : (
+          <box paddingBottom={1} />
+        )}
+        <box flexDirection="row" justifyContent="space-between">
           <box
             paddingLeft={2}
             paddingRight={2}
@@ -142,6 +112,7 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
           >
             <text
               fg={selected() === "dismiss" ? fg : theme.textMuted}
+              bg={selected() === "dismiss" ? undefined : textBg()}
               attributes={selected() === "dismiss" ? TextAttributes.BOLD : undefined}
             >
               don't show again
@@ -156,6 +127,7 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
           >
             <text
               fg={selected() === "action" ? fg : theme.text}
+              bg={selected() === "action" ? undefined : textBg()}
               attributes={selected() === "action" ? TextAttributes.BOLD : undefined}
             >
               {props.label}

+ 2 - 0
packages/opencode/src/cli/cmd/tui/ui/link.tsx

@@ -6,6 +6,7 @@ export interface LinkProps {
   href: string
   children?: JSX.Element | string
   fg?: RGBA
+  bg?: RGBA
   width?: number | "auto" | `${number}%`
   wrapMode?: "word" | "none"
 }
@@ -20,6 +21,7 @@ export function Link(props: LinkProps) {
   return (
     <text
       fg={props.fg}
+      bg={props.bg}
       width={props.width}
       wrapMode={props.wrapMode}
       onMouseUp={() => {