From 5c401673b2486bd3743fb27ab172051e0f336758 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 8 May 2026 04:39:42 +0200 Subject: [PATCH] improve go sub animation perf (#26251) --- .../cli/cmd/tui/component/bg-pulse-render.ts | 429 ++++++++++++++++++ .../src/cli/cmd/tui/component/bg-pulse.tsx | 189 ++++---- .../cmd/tui/component/dialog-retry-action.tsx | 98 ++-- packages/opencode/src/cli/cmd/tui/ui/link.tsx | 2 + 4 files changed, 542 insertions(+), 176 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/bg-pulse-render.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/bg-pulse-render.ts b/packages/opencode/src/cli/cmd/tui/component/bg-pulse-render.ts new file mode 100644 index 0000000000..25b728f1e5 --- /dev/null +++ b/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, + ) + } + } +} diff --git a/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx b/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx index 541ecea4e1..0482adea33 100644 --- a/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx +++ b/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 & { + 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() - const timer = setInterval(() => setNow(performance.now()), 50) - onCleanup(() => clearInterval(timer)) + 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, + }) - const sync = () => { - if (!box) return - setSize({ width: box.width, height: box.height }) + 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() + } + + protected override renderSelf(buffer: OptimizedBuffer, deltaTime = 0): void { + if (!this.visible || this.isDestroyed) return + + 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 = item)} width="100%" height="100%"> - - {(row) => ( - - - {(color) => ( - - {" "} - - )} - - - )} - - + ) } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx index 9dad1b4561..cbc8f0ef08 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx +++ b/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 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([]) - 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 ( - (content = item)}> + {showGoTreatment() ? ( - + ) : null} - - (headingBox = item)} flexDirection="row" justifyContent="space-between"> - + + + {props.title} - dialog.clear()}> + dialog.clear()}> esc - (descBox = item)} gap={0}> - {props.message} + + + {props.message} + - - {showGoTreatment() ? ( - (logoBox = item)} alignItems="center"> - + {props.link ? ( + showGoTreatment() ? ( + + - ) : null} - {props.link ? ( - + ) : ( + - ) : null} - - (buttonsBox = item)} flexDirection="row" justifyContent="space-between"> + ) + ) : ( + + )} + don't show again @@ -156,6 +127,7 @@ export function DialogRetryAction(props: DialogRetryActionProps) { > {props.label} diff --git a/packages/opencode/src/cli/cmd/tui/ui/link.tsx b/packages/opencode/src/cli/cmd/tui/ui/link.tsx index 01c4b6e713..cfd78bc333 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/link.tsx +++ b/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 ( {