Просмотр исходного кода

tui: add persistent key-value storage for user preferences

- Add KVProvider context for storing user preferences like theme and warnings
- Update theme context to use KV storage instead of sync config
- Move openrouter warning to persistent KV storage
- Refactor theme selection to persist user choice across sessions
Dax Raad 3 месяцев назад
Родитель
Сommit
afe8cecc2b

+ 27 - 23
packages/opencode/src/cli/cmd/tui/app.tsx

@@ -27,6 +27,7 @@ import { ExitProvider } from "./context/exit"
 import type { SessionRoute } from "./context/route"
 import { Session as SessionApi } from "@/session"
 import { TuiEvent } from "./event"
+import { KVProvider, useKV } from "./context/kv"
 
 export function tui(input: {
   url: string
@@ -54,27 +55,29 @@ export function tui(input: {
         return (
           <ErrorBoundary fallback={<text>Something went wrong</text>}>
             <ExitProvider onExit={onExit}>
-              <ToastProvider>
-                <RouteProvider data={routeData}>
-                  <SDKProvider url={input.url}>
-                    <SyncProvider>
-                      <ThemeProvider>
-                        <LocalProvider initialModel={input.model} initialAgent={input.agent}>
-                          <KeybindProvider>
-                            <DialogProvider>
-                              <CommandProvider>
-                                <PromptHistoryProvider>
-                                  <App />
-                                </PromptHistoryProvider>
-                              </CommandProvider>
-                            </DialogProvider>
-                          </KeybindProvider>
-                        </LocalProvider>
-                      </ThemeProvider>
-                    </SyncProvider>
-                  </SDKProvider>
-                </RouteProvider>
-              </ToastProvider>
+              <KVProvider>
+                <ToastProvider>
+                  <RouteProvider data={routeData}>
+                    <SDKProvider url={input.url}>
+                      <SyncProvider>
+                        <ThemeProvider>
+                          <LocalProvider initialModel={input.model} initialAgent={input.agent}>
+                            <KeybindProvider>
+                              <DialogProvider>
+                                <CommandProvider>
+                                  <PromptHistoryProvider>
+                                    <App />
+                                  </PromptHistoryProvider>
+                                </CommandProvider>
+                              </DialogProvider>
+                            </KeybindProvider>
+                          </LocalProvider>
+                        </ThemeProvider>
+                      </SyncProvider>
+                    </SDKProvider>
+                  </RouteProvider>
+                </ToastProvider>
+              </KVProvider>
             </ExitProvider>
           </ErrorBoundary>
         )
@@ -95,6 +98,7 @@ function App() {
   renderer.disableStdoutInterception()
   const dialog = useDialog()
   const local = useLocal()
+  const kv = useKV()
   const command = useCommandDialog()
   const { event } = useSDK()
   const sync = useSync()
@@ -222,13 +226,13 @@ function App() {
 
   createEffect(() => {
     const providerID = local.model.current().providerID
-    if (providerID === "openrouter" && !local.kv.data.openrouter_warning) {
+    if (providerID === "openrouter" && !kv.data.openrouter_warning) {
       untrack(() => {
         DialogAlert.show(
           dialog,
           "Warning",
           "While openrouter is a convenient way to access LLMs your request will often be routed to subpar providers that do not work well in our testing.\n\nFor reliable access to models check out OpenCode Zen\nhttps://opencode.ai/zen",
-        ).then(() => local.kv.set("openrouter_warning", true))
+        ).then(() => kv.set("openrouter_warning", true))
       })
     }
   })

+ 8 - 8
packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx

@@ -4,23 +4,23 @@ import { useDialog } from "../ui/dialog"
 import { onCleanup, onMount } from "solid-js"
 
 export function DialogThemeList() {
-  const { selectedTheme, setSelectedTheme } = useTheme()
+  const theme = useTheme()
   const options = Object.keys(THEMES).map((value) => ({
     title: value,
     value: value as keyof typeof THEMES,
   }))
-  const initial = selectedTheme()
   const dialog = useDialog()
   let confirmed = false
   let ref: DialogSelectRef<keyof typeof THEMES>
+  const initial = theme.selectedTheme
 
   onMount(() => {
     // highlight the first theme in the list when we open it for UX
-    setSelectedTheme(Object.keys(THEMES)[0] as keyof typeof THEMES)
+    theme.setSelectedTheme(Object.keys(THEMES)[0] as keyof typeof THEMES)
   })
   onCleanup(() => {
     // if we close the dialog without confirming, reset back to the initial theme
-    if (!confirmed) setSelectedTheme(initial)
+    if (!confirmed) theme.setSelectedTheme(initial)
   })
 
   return (
@@ -28,10 +28,10 @@ export function DialogThemeList() {
       title="Themes"
       options={options}
       onMove={(opt) => {
-        setSelectedTheme(opt.value)
+        theme.setSelectedTheme(opt.value)
       }}
       onSelect={(opt) => {
-        setSelectedTheme(opt.value)
+        theme.setSelectedTheme(opt.value)
         confirmed = true
         dialog.clear()
       }}
@@ -40,12 +40,12 @@ export function DialogThemeList() {
       }}
       onFilter={(query) => {
         if (query.length === 0) {
-          setSelectedTheme(initial)
+          theme.setSelectedTheme(initial)
           return
         }
 
         const first = ref.filtered[0]
-        if (first) setSelectedTheme(first.value)
+        if (first) theme.setSelectedTheme(first.value)
       }}
     />
   )

+ 45 - 0
packages/opencode/src/cli/cmd/tui/context/kv.tsx

@@ -0,0 +1,45 @@
+import { Global } from "@/global"
+import { createSignal } from "solid-js"
+import { createStore } from "solid-js/store"
+import { createSimpleContext } from "./helper"
+import path from "path"
+
+export const { use: useKV, provider: KVProvider } = createSimpleContext({
+  name: "KV",
+  init: () => {
+    const [ready, setReady] = createSignal(false)
+    const [kvStore, setKvStore] = createStore({
+      openrouter_warning: false,
+      theme: "opencode",
+    })
+    const file = Bun.file(path.join(Global.Path.state, "kv.json"))
+
+    file
+      .json()
+      .then((x) => {
+        setKvStore(x)
+      })
+      .catch(() => {})
+      .finally(() => {
+        setReady(true)
+      })
+
+    return {
+      get data() {
+        return kvStore
+      },
+      get ready() {
+        return ready()
+      },
+      set(key: string, value: any) {
+        setKvStore(key as any, value)
+        Bun.write(
+          file,
+          JSON.stringify({
+            [key]: value,
+          }),
+        )
+      },
+    }
+  },
+})

+ 6 - 45
packages/opencode/src/cli/cmd/tui/context/local.tsx

@@ -15,17 +15,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
     const sync = useSync()
     const toast = useToast()
 
-    function isModelValid(model: { providerID: string, modelID: string }) {
+    function isModelValid(model: { providerID: string; modelID: string }) {
       const provider = sync.data.provider.find((x) => x.id === model.providerID)
       return !!provider?.models[model.modelID]
     }
 
-    function getFirstValidModel(...modelFns: (() => { providerID: string, modelID: string } | undefined)[]) {
+    function getFirstValidModel(
+      ...modelFns: (() => { providerID: string; modelID: string } | undefined)[]
+    ) {
       for (const modelFn of modelFns) {
         const model = modelFn()
         if (!model) continue
-        if (isModelValid(model))
-          return model
+        if (isModelValid(model)) return model
       }
     }
 
@@ -141,7 +142,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
         .then((x) => {
           setModelStore("recent", x.recent)
         })
-        .catch(() => { })
+        .catch(() => {})
         .finally(() => {
           setModelStore("ready", true)
         })
@@ -227,49 +228,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
       }
     })
 
-    const kv = iife(() => {
-      const [ready, setReady] = createSignal(false)
-      const [kvStore, setKvStore] = createStore({
-        openrouter_warning: false,
-      })
-      const file = Bun.file(path.join(Global.Path.state, "kv.json"))
-
-      file
-        .json()
-        .then((x) => {
-          setKvStore(x)
-        })
-        .catch(() => { })
-        .finally(() => {
-          setReady(true)
-        })
-
-      return {
-        get data() {
-          return kvStore
-        },
-        get ready() {
-          return ready()
-        },
-        set(key: string, value: any) {
-          setKvStore(key as any, value)
-          Bun.write(
-            file,
-            JSON.stringify({
-              [key]: value,
-            }),
-          )
-        },
-      }
-    })
-
     const result = {
       model,
       agent,
-      kv,
-      get ready() {
-        return kv.ready && model.ready
-      },
     }
     return result
   },

+ 20 - 21
packages/opencode/src/cli/cmd/tui/context/theme.tsx

@@ -1,5 +1,5 @@
 import { SyntaxStyle, RGBA } from "@opentui/core"
-import { createMemo, createSignal, createEffect } from "solid-js"
+import { createMemo } from "solid-js"
 import { useSync } from "@tui/context/sync"
 import { createSimpleContext } from "./helper"
 import aura from "../../../../../../tui/internal/theme/themes/aura.json" with { type: "json" }
@@ -24,8 +24,7 @@ import synthwave84 from "../../../../../../tui/internal/theme/themes/synthwave84
 import tokyonight from "../../../../../../tui/internal/theme/themes/tokyonight.json" with { type: "json" }
 import vesper from "../../../../../../tui/internal/theme/themes/vesper.json" with { type: "json" }
 import zenburn from "../../../../../../tui/internal/theme/themes/zenburn.json" with { type: "json" }
-import { iife } from "@/util/iife"
-import { createStore, reconcile } from "solid-js/store"
+import { useKV } from "./kv"
 
 type Theme = {
   primary: RGBA
@@ -628,28 +627,28 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
   name: "Theme",
   init: () => {
     const sync = useSync()
-    const [selectedTheme, setSelectedTheme] = createSignal<keyof typeof THEMES>("opencode")
-    const [theme, setTheme] = createStore({} as Theme)
-    createEffect(() => {
-      if (!sync.ready) return
-      setSelectedTheme(
-        iife(() => {
-          if (typeof sync.data.config.theme === "string" && sync.data.config.theme in THEMES) {
-            return sync.data.config.theme as keyof typeof THEMES
-          }
-          return "opencode"
-        }),
-      )
-    })
+    const kv = useKV()
 
-    createEffect(() => {
-      setTheme(reconcile(THEMES[selectedTheme()]))
+    const theme = createMemo(() => {
+      console.log(kv.data.theme)
+      return { ...(THEMES[kv.data.theme as keyof typeof THEMES] ?? THEMES.opencode) }
     })
 
     return {
-      theme,
-      selectedTheme,
-      setSelectedTheme,
+      get theme() {
+        return new Proxy(theme(), {
+          get(_target, prop) {
+            // @ts-expect-error
+            return theme()[prop]
+          },
+        })
+      },
+      get selectedTheme() {
+        return kv.data.theme
+      },
+      setSelectedTheme(theme: string) {
+        kv.set("theme", theme)
+      },
       get ready() {
         return sync.ready
       },