feat(tui): project picker with footer typewriter flash

This commit is contained in:
Kit Langton
2026-07-29 16:39:15 -04:00
parent 78c139b8b5
commit 2a449db7af
6 changed files with 148 additions and 4 deletions
+10
View File
@@ -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(() => <DialogProject />)
},
},
...Array.from({ length: 9 }, (_, i) => ({
name: `session.quick_switch.${i + 1}`,
title: `Switch to session in quick slot ${i + 1}`,
@@ -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 (
<DialogSelect
title="Switch project"
placeholder="Search projects…"
options={options()}
current={current()}
emptyView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text>{projects.loading ? "Loading projects…" : "No projects found"}</text>
</box>
}
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) }),
)
}}
/>
)
}
+6
View File
@@ -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 () => {
@@ -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 (
<Show when={directory()}>
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={props.context.theme.text.subdued} />}
<Show when={typed.text !== undefined}>
<box flexDirection="row" flexShrink={1}>
<FilePath
value={typed.text ?? ""}
maxWidth={props.maxWidth}
fg={typed.active ? props.context.theme.text.default : props.context.theme.text.subdued}
/>
<Show when={typed.active}>
<text fg={props.context.theme.text.default}></text>
</Show>
</box>
</Show>
)
}
@@ -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 (
<Show when={directory()}>
{(value) => <FilePath value={value()} maxWidth={38} fg={props.context.theme.text.subdued} />}
<Show when={typed.text !== undefined}>
<box flexDirection="row" flexShrink={1}>
<FilePath
value={typed.text ?? ""}
maxWidth={38}
fg={typed.active ? props.context.theme.text.default : props.context.theme.text.subdued}
/>
<Show when={typed.active}>
<text fg={props.context.theme.text.default}></text>
</Show>
</box>
</Show>
)
}
+41
View File
@@ -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<typeof setTimeout>[] = []
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
}