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