From 2a449db7afe224d58af61d72c02579bdd6bce124 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 29 Jul 2026 16:39:15 -0400 Subject: [PATCH] feat(tui): project picker with footer typewriter flash --- packages/tui/src/app.tsx | 10 +++ packages/tui/src/component/dialog-project.tsx | 65 +++++++++++++++++++ packages/tui/src/context/data.tsx | 6 ++ .../tui/src/feature-plugins/home/footer.tsx | 15 ++++- .../src/feature-plugins/sidebar/footer.tsx | 15 ++++- packages/tui/src/ui/typewriter.ts | 41 ++++++++++++ 6 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 packages/tui/src/component/dialog-project.tsx create mode 100644 packages/tui/src/ui/typewriter.ts diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index c4221ee5b7..0a81682d30 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -66,6 +66,7 @@ import { DialogThemeList } from "./component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" import { DialogAgent } from "./component/dialog-agent" import { DialogSessionList } from "./component/dialog-session-list" +import { DialogProject } from "./component/dialog-project" import { SessionTabs } from "./component/session-tabs" import { ThemeErrorToast } from "./component/theme-error-toast" import { ThemeProvider, useTheme, useThemes } from "./context/theme" @@ -648,6 +649,15 @@ function App(props: { pair?: DialogPairCredentials }) { dialog.clear() }, }, + { + name: "project.switch", + title: "Switch project", + category: "Session", + slash: { name: "projects", aliases: ["project"] }, + run: () => { + dialog.replace(() => ) + }, + }, ...Array.from({ length: 9 }, (_, i) => ({ name: `session.quick_switch.${i + 1}`, title: `Switch to session in quick slot ${i + 1}`, diff --git a/packages/tui/src/component/dialog-project.tsx b/packages/tui/src/component/dialog-project.tsx new file mode 100644 index 0000000000..c6bf40f8ec --- /dev/null +++ b/packages/tui/src/component/dialog-project.tsx @@ -0,0 +1,65 @@ +import path from "path" +import { createMemo, createResource } from "solid-js" +import { DialogSelect } from "../ui/dialog-select" +import { useDialog } from "../ui/dialog" +import { useClient } from "../context/client" +import { useData } from "../context/data" +import { useRoute } from "../context/route" +import { useToast } from "../ui/toast" +import { abbreviateHome } from "../runtime" +import { useTuiPaths } from "../context/runtime" +import { errorMessage } from "../util/error" + +export function DialogProject() { + const dialog = useDialog() + const client = useClient() + const data = useData() + const route = useRoute() + const toast = useToast() + const paths = useTuiPaths() + + const [projects] = createResource(() => client.api.project.list()) + const current = createMemo(() => data.location.info()?.project.directory ?? data.location.default().directory) + + const options = createMemo(() => { + const list = [...(projects() ?? [])] + list.sort((a, b) => { + if (a.worktree === current()) return -1 + if (b.worktree === current()) return 1 + return (b.time.initialized ?? 0) - (a.time.initialized ?? 0) + }) + return list + .filter((project) => project.worktree !== "/") + .filter((project, index, all) => all.findIndex((other) => other.worktree === project.worktree) === index) + .map((project) => ({ + title: project.name ?? path.basename(project.worktree), + description: abbreviateHome(project.worktree, paths.home), + value: project.worktree, + category: project.worktree === current() ? "Current" : "Projects", + })) + }) + + return ( + + {projects.loading ? "Loading projects…" : "No projects found"} + + } + onSelect={(option) => { + dialog.clear() + if (option.value === current()) return + 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/context/data.tsx b/packages/tui/src/context/data.tsx index 384fd3499c..e2b346ebdb 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -1116,6 +1116,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ default() { return defaultLocation() }, + // Repoints the whole TUI at another project directory, like a shell `cd`. + // The follow-up default sync lets the server canonicalize the directory. + async setDefault(directory: string) { + setDefaultLocation({ directory }) + await result.location.sync() + }, async sync(ref?: LocationRef) { const current = ref ?? defaultLocation() await sync.run(`location:${locationKey(current)}`, async () => { diff --git a/packages/tui/src/feature-plugins/home/footer.tsx b/packages/tui/src/feature-plugins/home/footer.tsx index 15cf984182..bb870e3812 100644 --- a/packages/tui/src/feature-plugins/home/footer.tsx +++ b/packages/tui/src/feature-plugins/home/footer.tsx @@ -3,15 +3,26 @@ 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" 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 ( - - {(value) => } + + + + + + + ) } diff --git a/packages/tui/src/feature-plugins/sidebar/footer.tsx b/packages/tui/src/feature-plugins/sidebar/footer.tsx index c233a47848..67e1b21d01 100644 --- a/packages/tui/src/feature-plugins/sidebar/footer.tsx +++ b/packages/tui/src/feature-plugins/sidebar/footer.tsx @@ -1,14 +1,25 @@ import { Plugin } from "@opencode-ai/plugin/tui" import { createMemo, Show } from "solid-js" import { FilePath } from "../../ui/file-path" +import { createTypewriter } from "../../ui/typewriter" 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 ( - - {(value) => } + + + + + + + ) } diff --git a/packages/tui/src/ui/typewriter.ts b/packages/tui/src/ui/typewriter.ts new file mode 100644 index 0000000000..51a1d318c7 --- /dev/null +++ b/packages/tui/src/ui/typewriter.ts @@ -0,0 +1,41 @@ +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 +}