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