Skip to content

Commit 77aa85c

Browse files
authored
feat(tui): project picker with footer crossfade (#39566)
1 parent a618946 commit 77aa85c

6 files changed

Lines changed: 145 additions & 6 deletions

File tree

packages/tui/src/app.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import { DialogThemeList } from "./component/dialog-theme-list"
6666
import { DialogHelp } from "./ui/dialog-help"
6767
import { DialogAgent } from "./component/dialog-agent"
6868
import { DialogSessionList } from "./component/dialog-session-list"
69+
import { DialogProject } from "./component/dialog-project"
6970
import { SessionTabs } from "./component/session-tabs"
7071
import { ThemeErrorToast } from "./component/theme-error-toast"
7172
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
@@ -648,6 +649,15 @@ function App(props: { pair?: DialogPairCredentials }) {
648649
dialog.clear()
649650
},
650651
},
652+
{
653+
name: "project.switch",
654+
title: "Switch project",
655+
category: "Session",
656+
slash: { name: "projects", aliases: ["project"] },
657+
run: () => {
658+
dialog.replace(() => <DialogProject />)
659+
},
660+
},
651661
...Array.from({ length: 9 }, (_, i) => ({
652662
name: `session.quick_switch.${i + 1}`,
653663
title: `Switch to session in quick slot ${i + 1}`,
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import path from "path"
2+
import { createMemo } from "solid-js"
3+
import { DialogSelect } from "../ui/dialog-select"
4+
import { useDialog } from "../ui/dialog"
5+
import { useData } from "../context/data"
6+
import { useRoute } from "../context/route"
7+
import { abbreviateHome } from "../runtime"
8+
import { useTuiPaths } from "../context/runtime"
9+
import { useLocation } from "../context/location"
10+
import { useToast } from "../ui/toast"
11+
12+
export function DialogProject() {
13+
const dialog = useDialog()
14+
const data = useData()
15+
const route = useRoute()
16+
const paths = useTuiPaths()
17+
const location = useLocation()
18+
const toast = useToast()
19+
20+
data.project.invalidate()
21+
void data.project.sync().catch(toast.error)
22+
23+
const current = () => location.current?.project
24+
25+
const options = createMemo(() => {
26+
const seen = new Set<string>()
27+
return data.project
28+
.list()
29+
.filter((project) => {
30+
if (project.canonical === "/" || seen.has(project.canonical)) return false
31+
seen.add(project.canonical)
32+
return true
33+
})
34+
.toSorted((a, b) => {
35+
if (a.id === current()?.id) return -1
36+
if (b.id === current()?.id) return 1
37+
return 0
38+
})
39+
.map((project) => ({
40+
title: project.name ?? path.basename(project.canonical),
41+
description: abbreviateHome(project.canonical, paths.home),
42+
value: project.canonical,
43+
category: project.id === current()?.id ? "Current" : "Projects",
44+
}))
45+
})
46+
47+
return (
48+
<DialogSelect
49+
title="Switch project"
50+
placeholder="Search projects…"
51+
options={options()}
52+
current={current()?.canonical}
53+
emptyView={
54+
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
55+
<text>No projects found</text>
56+
</box>
57+
}
58+
onSelect={(option) => {
59+
dialog.clear()
60+
if (option.value === current()?.canonical) return
61+
const target = { directory: option.value }
62+
route.navigate({ type: "home", location: target })
63+
location.set(target)
64+
}}
65+
/>
66+
)
67+
}

packages/tui/src/context/route.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { createStore, reconcile } from "solid-js/store"
2+
import type { LocationRef } from "@opencode-ai/client"
23
import { createSimpleContext } from "./helper"
34
import type { PromptInfo } from "../prompt/history"
45
import { useTuiStartup } from "./runtime"
56

67
export type HomeRoute = {
78
type: "home"
89
prompt?: PromptInfo
10+
location?: LocationRef
911
}
1012

1113
export type SessionRoute = {

packages/tui/src/feature-plugins/home/footer.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
import { Plugin } from "@opencode-ai/plugin/tui"
22
import { createMemo, Match, Show, Switch } from "solid-js"
33
import { useTerminalDimensions } from "@opentui/solid"
4-
import { FilePath } from "../../ui/file-path"
54
import { stringWidth } from "../../util/string-width"
5+
import { FadeFilePath } from "../../ui/fade-file-path"
66

77
function Directory(props: { context: Plugin.Context; maxWidth: number }) {
88
const directory = createMemo(() =>
99
props.context.location ? props.context.ui.format.path(props.context.location.directory) : undefined,
1010
)
1111

1212
return (
13-
<Show when={directory()}>
14-
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={props.context.theme.text.subdued} />}
15-
</Show>
13+
<FadeFilePath
14+
value={directory()}
15+
maxWidth={props.maxWidth}
16+
fg={props.context.theme.text.subdued}
17+
bg={props.context.theme.background.default}
18+
/>
1619
)
1720
}
1821

packages/tui/src/routes/home.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,11 @@ export function Home() {
2727
const data = useData()
2828
const location = useLocation()
2929
// Global MCP elicitations can arrive without a session route, so keep them reachable from Home.
30-
const forms = createMemo(() => data.session.form.list("global", data.location.default()) ?? [])
30+
const currentLocation = () => route.location ?? data.location.default()
31+
const forms = createMemo(() => data.session.form.list("global", currentLocation()) ?? [])
3132
let sent = false
3233

33-
createEffect(() => location.set(data.location.default()))
34+
createEffect(() => location.set(currentLocation()))
3435

3536
onMount(() => {
3637
editor.clearSelection()
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { RGBA } from "@opentui/core"
2+
import { createEffect, createMemo, createSignal, Show } from "solid-js"
3+
import { useConfig } from "../config"
4+
import { tint } from "../theme/color"
5+
import { createAnimatable, tween } from "./animation"
6+
import { FilePath } from "./file-path"
7+
8+
// FilePath that crossfades when its value changes: the old path fades to the
9+
// background, the text swaps at the midpoint, and the new path fades back in.
10+
// The initial value renders immediately; only subsequent changes animate.
11+
export function FadeFilePath(props: {
12+
value: string | undefined
13+
maxWidth: number
14+
fg: RGBA
15+
bg: RGBA
16+
basenameFg?: RGBA
17+
}) {
18+
const config = useConfig().data
19+
const fade = createAnimatable(
20+
{ front: 1 },
21+
{
22+
enabled: () => config.animations ?? true,
23+
transition: tween({ duration: 0.3 }),
24+
},
25+
)
26+
const [text, setText] = createSignal(props.value)
27+
const [previous, setPrevious] = createSignal<string>()
28+
29+
createEffect((current: string | undefined) => {
30+
const next = props.value
31+
if (next === undefined || next === current) return current
32+
setText(next)
33+
if (current === undefined) return next
34+
setPrevious(current)
35+
fade.jump({ front: 0 })
36+
fade.animate({ front: 1 })
37+
return next
38+
}, props.value)
39+
40+
const display = createMemo(() => (previous() !== undefined && fade.value().front < 0.5 ? previous() : text()))
41+
const fg = createMemo(() => {
42+
if (previous() === undefined || fade.value().front >= 1) return props.fg
43+
return tint(props.bg, props.fg, Math.abs(fade.value().front * 2 - 1))
44+
})
45+
46+
return (
47+
<Show when={display() !== undefined}>
48+
<FilePath
49+
value={display() ?? ""}
50+
maxWidth={props.maxWidth}
51+
fg={fg()}
52+
basenameFg={fade.value().front >= 1 ? props.basenameFg : undefined}
53+
/>
54+
</Show>
55+
)
56+
}

0 commit comments

Comments
 (0)