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
This commit is contained in:
Dax Raad
2025-10-31 16:12:58 -04:00
parent 4a292bf977
commit afe8cecc2b
5 changed files with 106 additions and 97 deletions

View File

@@ -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))
})
}
})

View File

@@ -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)
}}
/>
)

View File

@@ -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,
}),
)
},
}
},
})

View File

@@ -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
},

View File

@@ -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
},