From c0526d403ee5fba87eddddfe56f138bc5d2ddfe1 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 29 Jul 2026 19:21:02 -0400 Subject: [PATCH] feat(tui): replace footer typewriter with cross-dissolve --- packages/tui/src/component/dialog-project.tsx | 3 +- .../tui/src/feature-plugins/home/footer.tsx | 22 ++--- .../src/feature-plugins/sidebar/footer.tsx | 24 ++--- packages/tui/src/ui/dissolve-file-path.tsx | 94 +++++++++++++++++++ packages/tui/src/ui/typewriter.ts | 41 -------- 5 files changed, 111 insertions(+), 73 deletions(-) create mode 100644 packages/tui/src/ui/dissolve-file-path.tsx delete mode 100644 packages/tui/src/ui/typewriter.ts diff --git a/packages/tui/src/component/dialog-project.tsx b/packages/tui/src/component/dialog-project.tsx index c6bf40f8ec..b0e5fb5931 100644 --- a/packages/tui/src/component/dialog-project.tsx +++ b/packages/tui/src/component/dialog-project.tsx @@ -53,9 +53,10 @@ export function DialogProject() { onSelect={(option) => { dialog.clear() if (option.value === current()) return + // Navigating while already home would remount the footer mid-animation. + if (route.data.type !== "home") route.navigate({ type: "home" }) void data.location .setDefault(option.value) - .then(() => route.navigate({ type: "home" })) .catch((error) => toast.show({ variant: "error", title: "Failed to switch project", message: errorMessage(error) }), ) diff --git a/packages/tui/src/feature-plugins/home/footer.tsx b/packages/tui/src/feature-plugins/home/footer.tsx index bb870e3812..4a72e3de35 100644 --- a/packages/tui/src/feature-plugins/home/footer.tsx +++ b/packages/tui/src/feature-plugins/home/footer.tsx @@ -1,29 +1,21 @@ import { Plugin } from "@opencode-ai/plugin/tui" import { createMemo, Match, Show, Switch } from "solid-js" import { useTerminalDimensions } from "@opentui/solid" -import { FilePath } from "../../ui/file-path" import { stringWidth } from "../../util/string-width" -import { createTypewriter } from "../../ui/typewriter" +import { DissolveFilePath } from "../../ui/dissolve-file-path" function Directory(props: { context: Plugin.Context; maxWidth: number }) { const directory = createMemo(() => props.context.location ? props.context.ui.format.path(props.context.location.directory) : undefined, ) - const typed = createTypewriter(directory) return ( - - - - - - - - + ) } diff --git a/packages/tui/src/feature-plugins/sidebar/footer.tsx b/packages/tui/src/feature-plugins/sidebar/footer.tsx index 67e1b21d01..06c160180e 100644 --- a/packages/tui/src/feature-plugins/sidebar/footer.tsx +++ b/packages/tui/src/feature-plugins/sidebar/footer.tsx @@ -1,26 +1,18 @@ import { Plugin } from "@opencode-ai/plugin/tui" -import { createMemo, Show } from "solid-js" -import { FilePath } from "../../ui/file-path" -import { createTypewriter } from "../../ui/typewriter" +import { createMemo } from "solid-js" +import { DissolveFilePath } from "../../ui/dissolve-file-path" function View(props: { context: Plugin.Context }) { const directory = createMemo(() => props.context.location ? props.context.ui.format.path(props.context.location.directory) : undefined, ) - const typed = createTypewriter(directory) return ( - - - - - - - - + ) } diff --git a/packages/tui/src/ui/dissolve-file-path.tsx b/packages/tui/src/ui/dissolve-file-path.tsx new file mode 100644 index 0000000000..2000afda1f --- /dev/null +++ b/packages/tui/src/ui/dissolve-file-path.tsx @@ -0,0 +1,94 @@ +import { RGBA } from "@opentui/core" +import { createEffect, createMemo, Index, on, onCleanup, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { useConfig } from "../config" +import { FilePath, truncateFilePath } from "./file-path" + +const DURATION = 600 +const FEATHER = 8 + +// FilePath that cross-dissolves left to right when its value changes. The old +// path fades out while the new one fades in behind a soft sweep. The initial +// value renders immediately; only subsequent changes animate. +export function DissolveFilePath(props: { + value: string | undefined + maxWidth: number + fg: RGBA + bg: RGBA + basenameFg?: RGBA +}) { + const config = useConfig().data + const [store, setStore] = createStore({ + text: props.value, + previous: undefined as string | undefined, + progress: 1, + }) + + createEffect( + on( + () => props.value, + (text) => { + // The source can flicker to undefined while a new location syncs; + // retain the last path so the change animates as one transition. + if (text === undefined || text === store.text) return + if (store.text === undefined || !(config.animations ?? true)) { + setStore({ text, previous: undefined, progress: 1 }) + return + } + setStore({ text, previous: store.text, progress: 0 }) + const started = performance.now() + const timer = setInterval(() => { + const progress = Math.min(1, (performance.now() - started) / DURATION) + setStore("progress", progress) + if (progress >= 1) { + clearInterval(timer) + setStore("previous", undefined) + } + }, 33) + onCleanup(() => clearInterval(timer)) + }, + { defer: true }, + ), + ) + + const cells = createMemo(() => { + const previous = [...truncateFilePath(store.previous ?? "", props.maxWidth)] + const next = [...truncateFilePath(store.text ?? "", props.maxWidth)] + const width = Math.max(previous.length, next.length) + const sweep = store.progress * (width + FEATHER) + return Array.from({ length: width }, (_, index) => { + const t = Math.min(1, Math.max(0, (sweep - index) / FEATHER)) + // Inside the sweep band the cell dips toward the background before the + // new character surfaces, reading as a per-column cross-dissolve. + const fade = Math.abs(t - 0.5) * 2 + return { + char: (t < 0.5 ? previous[index] : next[index]) ?? " ", + fg: mix(props.bg, props.fg, fade), + } + }) + }) + + return ( + + + } + > + + {(cell) => {cell().char}} + + + + ) +} + +function mix(from: RGBA, to: RGBA, t: number) { + return RGBA.fromValues( + from.r + (to.r - from.r) * t, + from.g + (to.g - from.g) * t, + from.b + (to.b - from.b) * t, + from.a + (to.a - from.a) * t, + ) +} diff --git a/packages/tui/src/ui/typewriter.ts b/packages/tui/src/ui/typewriter.ts deleted file mode 100644 index 51a1d318c7..0000000000 --- a/packages/tui/src/ui/typewriter.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { createEffect, on, onCleanup } from "solid-js" -import { createStore } from "solid-js/store" -import { useConfig } from "../config" - -// Retypes a string like a typewriter whenever the source changes. The initial -// value renders immediately; only subsequent changes animate. While `active`, -// callers should render a cursor and a brighter style, then settle back down. -export function createTypewriter(source: () => string | undefined) { - const config = useConfig().data - const [store, setStore] = createStore({ - text: source(), - active: false, - }) - createEffect( - on( - source, - (text) => { - if (text === undefined || !(config.animations ?? true)) { - setStore({ text, active: false }) - return - } - const timeouts: ReturnType[] = [] - setStore({ text: "", active: true }) - let i = 0 - const type = () => { - if (i < text.length) { - i++ - setStore("text", text.slice(0, i)) - timeouts.push(setTimeout(type, Math.random() < 0.1 ? 40 + Math.random() * 40 : 8 + Math.random() * 18)) - return - } - timeouts.push(setTimeout(() => setStore("active", false), 1200)) - } - timeouts.push(setTimeout(type, 120)) - onCleanup(() => timeouts.forEach(clearTimeout)) - }, - { defer: true }, - ), - ) - return store -}