From afe8cecc2bb8f9a1de780107c38c163a33f590a9 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 31 Oct 2025 16:12:58 -0400 Subject: [PATCH] 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 --- packages/opencode/src/cli/cmd/tui/app.tsx | 50 +++++++++--------- .../cmd/tui/component/dialog-theme-list.tsx | 16 +++--- .../opencode/src/cli/cmd/tui/context/kv.tsx | 45 ++++++++++++++++ .../src/cli/cmd/tui/context/local.tsx | 51 +++---------------- .../src/cli/cmd/tui/context/theme.tsx | 41 ++++++++------- 5 files changed, 106 insertions(+), 97 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/context/kv.tsx diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 75ea3fb2..cead812f 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/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 ( Something went wrong}> - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + ) @@ -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)) }) } }) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx index 9f7a9203..0135cfd2 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx +++ b/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 + 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) }} /> ) diff --git a/packages/opencode/src/cli/cmd/tui/context/kv.tsx b/packages/opencode/src/cli/cmd/tui/context/kv.tsx new file mode 100644 index 00000000..bb9ae847 --- /dev/null +++ b/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, + }), + ) + }, + } + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index 25ec00b3..e8f11a35 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/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 }, diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 894b87b0..74f403d5 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/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("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 },