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..71378a8872
--- /dev/null
+++ b/packages/tui/src/component/dialog-project.tsx
@@ -0,0 +1,67 @@
+import path from "path"
+import { createMemo } from "solid-js"
+import { DialogSelect } from "../ui/dialog-select"
+import { useDialog } from "../ui/dialog"
+import { useData } from "../context/data"
+import { useRoute } from "../context/route"
+import { abbreviateHome } from "../runtime"
+import { useTuiPaths } from "../context/runtime"
+import { useLocation } from "../context/location"
+import { useToast } from "../ui/toast"
+
+export function DialogProject() {
+ const dialog = useDialog()
+ const data = useData()
+ const route = useRoute()
+ const paths = useTuiPaths()
+ const location = useLocation()
+ const toast = useToast()
+
+ data.project.invalidate()
+ void data.project.sync().catch(toast.error)
+
+ const current = () => location.current?.project
+
+ const options = createMemo(() => {
+ const seen = new Set()
+ return data.project
+ .list()
+ .filter((project) => {
+ if (project.canonical === "/" || seen.has(project.canonical)) return false
+ seen.add(project.canonical)
+ return true
+ })
+ .toSorted((a, b) => {
+ if (a.id === current()?.id) return -1
+ if (b.id === current()?.id) return 1
+ return 0
+ })
+ .map((project) => ({
+ title: project.name ?? path.basename(project.canonical),
+ description: abbreviateHome(project.canonical, paths.home),
+ value: project.canonical,
+ category: project.id === current()?.id ? "Current" : "Projects",
+ }))
+ })
+
+ return (
+
+ No projects found
+
+ }
+ onSelect={(option) => {
+ dialog.clear()
+ if (option.value === current()?.canonical) return
+ const target = { directory: option.value }
+ route.navigate({ type: "home", location: target })
+ location.set(target)
+ }}
+ />
+ )
+}
diff --git a/packages/tui/src/context/route.tsx b/packages/tui/src/context/route.tsx
index 0c0b7721e8..42025eb239 100644
--- a/packages/tui/src/context/route.tsx
+++ b/packages/tui/src/context/route.tsx
@@ -1,4 +1,5 @@
import { createStore, reconcile } from "solid-js/store"
+import type { LocationRef } from "@opencode-ai/client"
import { createSimpleContext } from "./helper"
import type { PromptInfo } from "../prompt/history"
import { useTuiStartup } from "./runtime"
@@ -6,6 +7,7 @@ import { useTuiStartup } from "./runtime"
export type HomeRoute = {
type: "home"
prompt?: PromptInfo
+ location?: LocationRef
}
export type SessionRoute = {
diff --git a/packages/tui/src/feature-plugins/home/footer.tsx b/packages/tui/src/feature-plugins/home/footer.tsx
index 15cf984182..11bb1a31aa 100644
--- a/packages/tui/src/feature-plugins/home/footer.tsx
+++ b/packages/tui/src/feature-plugins/home/footer.tsx
@@ -1,8 +1,8 @@
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 { FadeFilePath } from "../../ui/fade-file-path"
function Directory(props: { context: Plugin.Context; maxWidth: number }) {
const directory = createMemo(() =>
@@ -10,9 +10,12 @@ function Directory(props: { context: Plugin.Context; maxWidth: number }) {
)
return (
-
- {(value) => }
-
+
)
}
diff --git a/packages/tui/src/routes/home.tsx b/packages/tui/src/routes/home.tsx
index befc641270..922151d889 100644
--- a/packages/tui/src/routes/home.tsx
+++ b/packages/tui/src/routes/home.tsx
@@ -27,10 +27,11 @@ export function Home() {
const data = useData()
const location = useLocation()
// Global MCP elicitations can arrive without a session route, so keep them reachable from Home.
- const forms = createMemo(() => data.session.form.list("global", data.location.default()) ?? [])
+ const currentLocation = () => route.location ?? data.location.default()
+ const forms = createMemo(() => data.session.form.list("global", currentLocation()) ?? [])
let sent = false
- createEffect(() => location.set(data.location.default()))
+ createEffect(() => location.set(currentLocation()))
onMount(() => {
editor.clearSelection()
diff --git a/packages/tui/src/ui/fade-file-path.tsx b/packages/tui/src/ui/fade-file-path.tsx
new file mode 100644
index 0000000000..8243b7f2e4
--- /dev/null
+++ b/packages/tui/src/ui/fade-file-path.tsx
@@ -0,0 +1,56 @@
+import type { RGBA } from "@opentui/core"
+import { createEffect, createMemo, createSignal, Show } from "solid-js"
+import { useConfig } from "../config"
+import { tint } from "../theme/color"
+import { createAnimatable, tween } from "./animation"
+import { FilePath } from "./file-path"
+
+// FilePath that crossfades when its value changes: the old path fades to the
+// background, the text swaps at the midpoint, and the new path fades back in.
+// The initial value renders immediately; only subsequent changes animate.
+export function FadeFilePath(props: {
+ value: string | undefined
+ maxWidth: number
+ fg: RGBA
+ bg: RGBA
+ basenameFg?: RGBA
+}) {
+ const config = useConfig().data
+ const fade = createAnimatable(
+ { front: 1 },
+ {
+ enabled: () => config.animations ?? true,
+ transition: tween({ duration: 0.3 }),
+ },
+ )
+ const [text, setText] = createSignal(props.value)
+ const [previous, setPrevious] = createSignal()
+
+ createEffect((current: string | undefined) => {
+ const next = props.value
+ if (next === undefined || next === current) return current
+ setText(next)
+ if (current === undefined) return next
+ setPrevious(current)
+ fade.jump({ front: 0 })
+ fade.animate({ front: 1 })
+ return next
+ }, props.value)
+
+ const display = createMemo(() => (previous() !== undefined && fade.value().front < 0.5 ? previous() : text()))
+ const fg = createMemo(() => {
+ if (previous() === undefined || fade.value().front >= 1) return props.fg
+ return tint(props.bg, props.fg, Math.abs(fade.value().front * 2 - 1))
+ })
+
+ return (
+
+ = 1 ? props.basenameFg : undefined}
+ />
+
+ )
+}