feat(tui): project picker with footer crossfade (#39566)
This commit is contained in:
parent
a618946b7e
commit
77aa85c589
6 changed files with 145 additions and 6 deletions
|
|
@ -66,6 +66,7 @@ import { DialogThemeList } from "./component/dialog-theme-list"
|
||||||
import { DialogHelp } from "./ui/dialog-help"
|
import { DialogHelp } from "./ui/dialog-help"
|
||||||
import { DialogAgent } from "./component/dialog-agent"
|
import { DialogAgent } from "./component/dialog-agent"
|
||||||
import { DialogSessionList } from "./component/dialog-session-list"
|
import { DialogSessionList } from "./component/dialog-session-list"
|
||||||
|
import { DialogProject } from "./component/dialog-project"
|
||||||
import { SessionTabs } from "./component/session-tabs"
|
import { SessionTabs } from "./component/session-tabs"
|
||||||
import { ThemeErrorToast } from "./component/theme-error-toast"
|
import { ThemeErrorToast } from "./component/theme-error-toast"
|
||||||
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
|
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
|
||||||
|
|
@ -648,6 +649,15 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "project.switch",
|
||||||
|
title: "Switch project",
|
||||||
|
category: "Session",
|
||||||
|
slash: { name: "projects", aliases: ["project"] },
|
||||||
|
run: () => {
|
||||||
|
dialog.replace(() => <DialogProject />)
|
||||||
|
},
|
||||||
|
},
|
||||||
...Array.from({ length: 9 }, (_, i) => ({
|
...Array.from({ length: 9 }, (_, i) => ({
|
||||||
name: `session.quick_switch.${i + 1}`,
|
name: `session.quick_switch.${i + 1}`,
|
||||||
title: `Switch to session in quick slot ${i + 1}`,
|
title: `Switch to session in quick slot ${i + 1}`,
|
||||||
|
|
|
||||||
67
packages/tui/src/component/dialog-project.tsx
Normal file
67
packages/tui/src/component/dialog-project.tsx
Normal file
|
|
@ -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<string>()
|
||||||
|
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 (
|
||||||
|
<DialogSelect
|
||||||
|
title="Switch project"
|
||||||
|
placeholder="Search projects…"
|
||||||
|
options={options()}
|
||||||
|
current={current()?.canonical}
|
||||||
|
emptyView={
|
||||||
|
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||||
|
<text>No projects found</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
onSelect={(option) => {
|
||||||
|
dialog.clear()
|
||||||
|
if (option.value === current()?.canonical) return
|
||||||
|
const target = { directory: option.value }
|
||||||
|
route.navigate({ type: "home", location: target })
|
||||||
|
location.set(target)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
|
import type { LocationRef } from "@opencode-ai/client"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import type { PromptInfo } from "../prompt/history"
|
import type { PromptInfo } from "../prompt/history"
|
||||||
import { useTuiStartup } from "./runtime"
|
import { useTuiStartup } from "./runtime"
|
||||||
|
|
@ -6,6 +7,7 @@ import { useTuiStartup } from "./runtime"
|
||||||
export type HomeRoute = {
|
export type HomeRoute = {
|
||||||
type: "home"
|
type: "home"
|
||||||
prompt?: PromptInfo
|
prompt?: PromptInfo
|
||||||
|
location?: LocationRef
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionRoute = {
|
export type SessionRoute = {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||||
import { createMemo, Match, Show, Switch } from "solid-js"
|
import { createMemo, Match, Show, Switch } from "solid-js"
|
||||||
import { useTerminalDimensions } from "@opentui/solid"
|
import { useTerminalDimensions } from "@opentui/solid"
|
||||||
import { FilePath } from "../../ui/file-path"
|
|
||||||
import { stringWidth } from "../../util/string-width"
|
import { stringWidth } from "../../util/string-width"
|
||||||
|
import { FadeFilePath } from "../../ui/fade-file-path"
|
||||||
|
|
||||||
function Directory(props: { context: Plugin.Context; maxWidth: number }) {
|
function Directory(props: { context: Plugin.Context; maxWidth: number }) {
|
||||||
const directory = createMemo(() =>
|
const directory = createMemo(() =>
|
||||||
|
|
@ -10,9 +10,12 @@ function Directory(props: { context: Plugin.Context; maxWidth: number }) {
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={directory()}>
|
<FadeFilePath
|
||||||
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={props.context.theme.text.subdued} />}
|
value={directory()}
|
||||||
</Show>
|
maxWidth={props.maxWidth}
|
||||||
|
fg={props.context.theme.text.subdued}
|
||||||
|
bg={props.context.theme.background.default}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,11 @@ export function Home() {
|
||||||
const data = useData()
|
const data = useData()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
// Global MCP elicitations can arrive without a session route, so keep them reachable from Home.
|
// 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
|
let sent = false
|
||||||
|
|
||||||
createEffect(() => location.set(data.location.default()))
|
createEffect(() => location.set(currentLocation()))
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
editor.clearSelection()
|
editor.clearSelection()
|
||||||
|
|
|
||||||
56
packages/tui/src/ui/fade-file-path.tsx
Normal file
56
packages/tui/src/ui/fade-file-path.tsx
Normal file
|
|
@ -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<string>()
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Show when={display() !== undefined}>
|
||||||
|
<FilePath
|
||||||
|
value={display() ?? ""}
|
||||||
|
maxWidth={props.maxWidth}
|
||||||
|
fg={fg()}
|
||||||
|
basenameFg={fade.value().front >= 1 ? props.basenameFg : undefined}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue