mirror of
https://github.com/aljazceru/opencode.git
synced 2026-01-02 23:45:03 +01:00
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:
@@ -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))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
packages/opencode/src/cli/cmd/tui/context/kv.tsx
Normal file
45
packages/opencode/src/cli/cmd/tui/context/kv.tsx
Normal 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,
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user