diff --git a/assets/happy-cat.gif b/assets/happy-cat.gif new file mode 100644 index 000000000000..fbc3131fee7c Binary files /dev/null and b/assets/happy-cat.gif differ diff --git a/assets/head-empty-cat.gif b/assets/head-empty-cat.gif new file mode 100644 index 000000000000..b5ae28632f8a Binary files /dev/null and b/assets/head-empty-cat.gif differ diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 7b765e1cc2ba..1a17fdfc5a75 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -52,6 +52,7 @@ "@types/bun": "catalog:", "@types/cross-spawn": "6.0.6", "@types/mime-types": "3.0.1", + "@types/omggif": "1.0.5", "@types/semver": "^7.5.8", "@types/turndown": "5.0.5", "@types/which": "3.0.4", @@ -131,6 +132,7 @@ "jsonc-parser": "3.3.1", "mime-types": "3.0.2", "minimatch": "10.0.3", + "omggif": "1.0.10", "open": "10.1.2", "opencode-gitlab-auth": "2.0.0", "opentui-spinner": "0.0.6", diff --git a/packages/opencode/src/cli/cmd/tui/assets.ts b/packages/opencode/src/cli/cmd/tui/assets.ts new file mode 100644 index 000000000000..d7fb3da88564 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/assets.ts @@ -0,0 +1,7 @@ +import happyCat from "../../../../../../assets/happy-cat.gif" with { type: "file" } +import headEmptyCat from "../../../../../../assets/head-empty-cat.gif" with { type: "file" } + +export const gifAssets = { + "happy-cat": happyCat, + "head-empty-cat": headEmptyCat, +} as const diff --git a/packages/opencode/src/cli/cmd/tui/component/gif-renderer.tsx b/packages/opencode/src/cli/cmd/tui/component/gif-renderer.tsx new file mode 100644 index 000000000000..49f4bb41fa3d --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/gif-renderer.tsx @@ -0,0 +1,141 @@ +import { FrameBufferRenderable, RGBA, type BoxRenderable } from "@opentui/core" +import { useRenderer } from "@opentui/solid" +import { onCleanup, onMount } from "solid-js" +import { GifReader } from "omggif" + +export function GifRenderer(props: { path: string; width?: number; height?: number }) { + const renderer = useRenderer() + let fb: FrameBufferRenderable | null = null + let timeout: ReturnType | null = null + let aborted = false + let parent: BoxRenderable | undefined + + const draw = (target: FrameBufferRenderable, rgba: Uint8Array, w: number, h: number) => { + const buf = target.frameBuffer + const targetW = buf.width + // Half-block rendering: each cell = 2 vertical pixels + const scaleX = w / targetW + const scaleY = h / (buf.height * 2) + + buf.clear(RGBA.fromValues(0, 0, 0, 0)) + + for (let row = 0; row < buf.height; row++) { + for (let x = 0; x < targetW; x++) { + const srcX = Math.floor(x * scaleX) + + // Top pixel + const topIdx = (Math.floor(row * 2 * scaleY) * w + srcX) * 4 + const tr = rgba[topIdx] + const tg = rgba[topIdx + 1] + const tb = rgba[topIdx + 2] + const ta = rgba[topIdx + 3] + + // Bottom pixel + const botIdx = (Math.min(Math.floor((row * 2 + 1) * scaleY), h - 1) * w + srcX) * 4 + const br = rgba[botIdx] + const bg = rgba[botIdx + 1] + const bb = rgba[botIdx + 2] + const ba = rgba[botIdx + 3] + + if (ta < 32 && ba < 32) continue + + // ▀ = top half block: fg = top pixel, bg = bottom pixel + buf.setCell( + x, + row, + "▀", + RGBA.fromValues(tr / 255, tg / 255, tb / 255, ta / 255), + RGBA.fromValues(br / 255, bg / 255, bb / 255, ba / 255), + ) + } + } + } + + onMount(async () => { + const file = Bun.file(props.path) + if (!(await file.exists())) return + if (aborted) return + + const gif = new GifReader(new Uint8Array(await file.arrayBuffer())) + if (aborted) return + + const count = gif.numFrames() + if (count === 0) return + + const targetW = props.width ?? Math.min(Math.floor(gif.width / 2), 40) + const targetH = props.height ?? Math.min(Math.floor(gif.height / 4), 20) + + fb = new FrameBufferRenderable(renderer, { + id: "gif-renderer", + width: targetW, + height: targetH, + position: "relative", + live: count > 1, + }) + + if (parent) parent.add(fb) + + // Pre-composite all frames into full-canvas RGBA buffers + const canvas = new Uint8Array(gif.width * gif.height * 4) + const backup = new Uint8Array(gif.width * gif.height * 4) + const composited: Uint8Array[] = [] + const delays: number[] = [] + + for (let i = 0; i < count; i++) { + const info = gif.frameInfo(i) + delays.push(Math.max((info.delay || 10) * 10, 20)) + + if (info.disposal === 3) backup.set(canvas) + + gif.decodeAndBlitFrameRGBA(i, canvas) + composited.push(new Uint8Array(canvas)) + + // Handle disposal + switch (info.disposal) { + case 2: + // Restore to background + for (let y = info.y; y < info.y + info.height; y++) { + for (let x = info.x; x < info.x + info.width; x++) { + const idx = (y * gif.width + x) * 4 + canvas[idx] = 0 + canvas[idx + 1] = 0 + canvas[idx + 2] = 0 + canvas[idx + 3] = 0 + } + } + break + case 3: + canvas.set(backup) + break + } + } + + draw(fb, composited[0], gif.width, gif.height) + fb.requestRender() + + if (count > 1) { + let frame = 0 + const step = () => { + if (aborted) return + frame = (frame + 1) % count + if (fb) { + draw(fb, composited[frame], gif.width, gif.height) + fb.requestRender() + } + timeout = setTimeout(step, delays[frame]) + } + timeout = setTimeout(step, delays[0]) + } + }) + + onCleanup(() => { + aborted = true + if (timeout) clearTimeout(timeout) + if (fb && parent) { + parent.remove(fb.id) + fb = null + } + }) + + return (parent = r)} width={props.width} height={props.height} /> +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index e76e165b2639..697d07644719 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -2,11 +2,11 @@ import { Prompt, type PromptRef } from "@tui/component/prompt" import { createEffect, createMemo, Match, on, onMount, Show, Switch } from "solid-js" import { useTheme } from "@tui/context/theme" import { useKeybind } from "@tui/context/keybind" -import { Logo } from "../component/logo" import { Tips } from "../component/tips" +import { GifRenderer } from "../component/gif-renderer" import { Locale } from "@/util/locale" import { useSync } from "../context/sync" -import { Toast } from "../ui/toast" +import { Toast } from "@tui/ui/toast" import { useArgs } from "../context/args" import { useDirectory } from "../context/directory" import { useRouteData } from "@tui/context/route" @@ -15,6 +15,7 @@ import { Installation } from "@/installation" import { useKV } from "../context/kv" import { useCommandDialog } from "../component/dialog-command" import { useLocal } from "../context/local" +import { gifAssets } from "../assets" // TODO: what is the best way to do this? let once = false @@ -111,7 +112,7 @@ export function Home() { - + diff --git a/packages/opencode/src/gif.d.ts b/packages/opencode/src/gif.d.ts new file mode 100644 index 000000000000..cdf452f9ae63 --- /dev/null +++ b/packages/opencode/src/gif.d.ts @@ -0,0 +1,4 @@ +declare module "*.gif" { + const path: string + export default path +}