Compare commits

...
7 changed files with 163 additions and 10 deletions
+10
View File
@@ -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(() => <DialogProject />)
},
},
...Array.from({ length: 9 }, (_, i) => ({
name: `session.quick_switch.${i + 1}`,
title: `Switch to session in quick slot ${i + 1}`,
@@ -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)
}}
/>
)
}
+2
View File
@@ -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 = {
@@ -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 (
<Show when={directory()}>
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={props.context.theme.text.subdued} />}
</Show>
<FadeFilePath
value={directory()}
maxWidth={props.maxWidth}
fg={props.context.theme.text.subdued}
bg={props.context.theme.background.default}
/>
)
}
+18 -4
View File
@@ -2,8 +2,10 @@ import { PluginContextProvider, type Plugin } from "@opencode-ai/plugin/tui"
import {
batch,
createContext,
createEffect,
createMemo,
For,
on,
onCleanup,
onMount,
useContext,
@@ -372,13 +374,13 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
return true
}
const reconcile = async () => {
const reconcile = async (configured = config.data.plugins ?? []) => {
await Promise.all(
Object.entries(store.registrations)
.filter(([, registration]) => registration.active)
.map(([id]) => deactivate(id)),
)
const entries = [...(await discoverTuiPlugins(paths.cwd)), ...(config.data.plugins ?? [])]
const entries = [...(await discoverTuiPlugins(paths.cwd)), ...configured]
batch(() => {
setStore("registrations", reconcileStore({}))
setStore("states", [])
@@ -452,8 +454,21 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
])
}
}
let loading = Promise.resolve()
createEffect(
on(
() => JSON.stringify(config.data.plugins ?? []),
() => {
const configured = config.data.plugins ?? []
loading = loading.catch(() => undefined).then(() => reconcile(configured))
void loading.then(
() => setStore("ready", true),
() => setStore("ready", true),
)
},
),
)
onMount(() => {
const loading = reconcile()
let disposing: Promise<void> | undefined
const dispose = () => {
if (disposing) return disposing
@@ -474,7 +489,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
unregister()
void dispose()
})
void loading.finally(() => setStore("ready", true))
})
return (
+3 -2
View File
@@ -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()
+56
View 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>
)
}