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
-}