Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added assets/happy-cat.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/head-empty-cat.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions packages/opencode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions packages/opencode/src/cli/cmd/tui/assets.ts
Original file line number Diff line number Diff line change
@@ -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
141 changes: 141 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/gif-renderer.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof setTimeout> | 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 <box ref={(r) => (parent = r)} width={props.width} height={props.height} />
}
7 changes: 4 additions & 3 deletions packages/opencode/src/cli/cmd/tui/routes/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -111,7 +112,7 @@ export function Home() {
<box flexGrow={1} minHeight={0} />
<box height={4} minHeight={0} flexShrink={1} />
<box flexShrink={0}>
<Logo />
<GifRenderer path={gifAssets["happy-cat"]} width={60} height={30} />
</box>
<box height={1} minHeight={0} flexShrink={1} />
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/gif.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
declare module "*.gif" {
const path: string
export default path
}