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