refactor(tui): consolidate settings in cli config

This commit is contained in:
Dax Raad 2026-07-13 12:55:54 -04:00
commit 775fce0177
24 changed files with 297 additions and 430 deletions

View file

@ -1,107 +0,0 @@
import { createEffect, createSignal, type Setter } from "solid-js"
import { createStore, unwrap } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { Flock } from "@opencode-ai/core/util/flock"
import { Global } from "@opencode-ai/core/global"
import { readJson, writeJsonAtomic } from "../util/persistence"
import { useTuiPaths } from "./runtime"
import path from "path"
import { useConfigOptional, type Config } from "../config"
export const { use: useKV, provider: KVProvider } = createSimpleContext({
name: "KV",
init: (props: { config?: Config.Info }) => {
const config = props.config ?? useConfigOptional()?.data
const paths = useTuiPaths()
void Global.Path.state
const file = path.join(paths.state, "kv.json")
const lock = `tui-kv:${file}`
const [ready, setReady] = createSignal(false)
const [store, setStore] = createStore<Record<string, any>>()
// Queue same-process writes so rapid updates persist in order.
let write = Promise.resolve()
Flock.withLock(lock, () => readJson<Record<string, unknown>>(file))
.then((x) => {
const values: Record<string, any> = { ...x }
Object.entries(configValues(config ?? {})).forEach(([key, value]) => {
if (value === undefined) delete values[key]
else values[key] = value
})
setStore(values)
})
.catch((error) => {
console.error("Failed to read KV state", { error })
})
.finally(() => {
setReady(true)
})
createEffect(() => {
if (!ready() || !config) return
Object.entries(configValues(config)).forEach(([key, value]) => {
if (value === undefined) setStore(key, undefined)
else setStore(key, value)
})
})
const result = {
get ready() {
return ready()
},
get store() {
return store
},
signal<T>(name: string, defaultValue: T) {
if (store[name] === undefined) setStore(name, defaultValue)
return [
function () {
return result.get(name)
},
function setter(next: Setter<T>) {
result.set(name, next)
},
] as const
},
get(key: string, defaultValue?: any) {
return store[key] ?? defaultValue
},
set(key: string, value: any) {
setStore(key, value)
const snapshot = structuredClone(unwrap(store))
write = write
.then(() => Flock.withLock(lock, () => writeJsonAtomic(file, snapshot)))
.catch((error) => {
console.error("Failed to write KV state", { error })
})
},
}
return result
},
})
function configValues(config: Config.Info) {
const values: Record<string, any> = {}
if (config.theme?.name !== undefined) values.theme = config.theme.name
if (config.theme?.mode !== undefined) {
values.theme_mode_lock = config.theme.mode === "system" ? undefined : config.theme.mode
values.theme_mode = undefined
}
if (config.attention?.sound_pack !== undefined) values.attention_sound_pack = config.attention.sound_pack
if (config.diffs?.wrap !== undefined) values.diff_wrap_mode = config.diffs.wrap
if (config.diffs?.tree !== undefined) values.diff_viewer_show_file_tree = config.diffs.tree
if (config.diffs?.single !== undefined) values.diff_viewer_single_patch = config.diffs.single
if (config.diffs?.view !== undefined)
values.diff_viewer_view = config.diffs.view === "auto" ? undefined : config.diffs.view
if (config.terminal?.title !== undefined) values.terminal_title_enabled = config.terminal.title
if (config.prompt?.editor !== undefined) values.file_context_enabled = config.prompt.editor
if (config.prompt?.paste !== undefined) values.paste_summary_enabled = config.prompt.paste === "compact"
if (config.session?.sidebar !== undefined) values.sidebar = config.session.sidebar
if (config.session?.scrollbar !== undefined) values.scrollbar_visible = config.session.scrollbar
if (config.session?.thinking !== undefined) values.thinking_mode = config.session.thinking
if (config.session?.grouping !== undefined) values.exploration_grouping = config.session.grouping === "auto"
if (config.hints?.tips !== undefined) values.tips_hidden = !config.hints.tips
if (config.hints?.onboarding !== undefined) values.dismissed_getting_started = !config.hints.onboarding
if (config.animations !== undefined) values.animations_enabled = config.animations
return values
}

View file

@ -22,7 +22,6 @@ import {
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useKV } from "./kv"
import { useConfig } from "../config"
import { Global } from "@opencode-ai/core/global"
import { Glob } from "@opencode-ai/core/util/glob"
@ -103,8 +102,8 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
name: "Theme",
init: (props: { mode: "dark" | "light"; source?: ThemeSource }) => {
const renderer = useRenderer()
const config = useConfig().data
const kv = useKV()
const configState = useConfig()
const config = configState.data
const themes = props.source ?? themeSource
const pick = (value: unknown) => {
if (value === "dark" || value === "light") return value
@ -113,12 +112,11 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
setStore(
produce((draft) => {
const lock = pick(kv.get("theme_mode_lock"))
const lock = pick(config.theme?.mode)
const mode = lock ?? pick(renderer.themeMode) ?? props.mode
if (!lock && pick(kv.get("theme_mode")) !== undefined) kv.set("theme_mode", undefined)
draft.mode = mode
draft.lock = lock
const active = config.theme?.name ?? kv.get("theme", "opencode")
const active = config.theme?.name ?? "opencode"
draft.active = typeof active === "string" ? active : "opencode"
draft.ready = false
}),
@ -132,10 +130,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
createEffect(() => {
const mode = config.theme?.mode
if (mode === "dark" || mode === "light") {
pin(mode)
pin(mode, false)
return
}
if (mode === "system" && store.lock !== undefined) free()
if (mode === "system" && store.lock !== undefined) free(false)
})
function syncCustomThemes() {
@ -209,23 +207,31 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
}
function apply(mode: "dark" | "light") {
if (store.lock !== undefined) kv.set("theme_mode", mode)
if (store.mode === mode) return
setStore("mode", mode)
refreshSystemTheme(mode)
}
function pin(mode: "dark" | "light" = store.mode) {
function pin(mode: "dark" | "light" = store.mode, persist = true) {
setStore("lock", mode)
kv.set("theme_mode_lock", mode)
apply(mode)
if (!persist) return
void configState
.update((draft) => {
draft.theme = { ...draft.theme, mode }
})
.catch(() => {})
}
function free() {
function free(persist = true) {
setStore("lock", undefined)
kv.set("theme_mode_lock", undefined)
kv.set("theme_mode", undefined)
refreshSystemTheme(renderer.themeMode ?? store.mode)
if (!persist) return
void configState
.update((draft) => {
draft.theme = { ...draft.theme, mode: "system" }
})
.catch(() => {})
}
const handle = (mode: "dark" | "light") => {
@ -265,13 +271,6 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
const values = createMemo(() => {
const active = store.themes[store.active]
if (active) return resolveTheme(active, store.mode)
const saved = kv.get("theme")
if (typeof saved === "string") {
const theme = store.themes[saved]
if (theme) return resolveTheme(theme, store.mode)
}
return resolveTheme(store.themes.opencode, store.mode)
})
@ -302,7 +301,11 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
set(theme: string) {
if (!hasTheme(theme)) return false
setStore("active", theme)
kv.set("theme", theme)
void configState
.update((draft) => {
draft.theme = { ...draft.theme, name: theme }
})
.catch(() => {})
return true
},
get ready() {

View file

@ -1,6 +1,3 @@
import { createMemo, type Setter } from "solid-js"
import { useKV } from "./kv"
export type ThinkingMode = "show" | "hide"
const MODES: readonly ThinkingMode[] = ["show", "hide"] as const
@ -16,52 +13,8 @@ export function reasoningSummary(text: string) {
return { title: match[1].trim(), body: content.slice(match[0].length).trimEnd() }
}
export function isThinkingMode(value: unknown): value is ThinkingMode {
return typeof value === "string" && (MODES as readonly string[]).includes(value)
}
// Cycle order matches the slash command: show → hide → show.
export function nextThinkingMode(current: ThinkingMode): ThinkingMode {
const idx = MODES.indexOf(current)
return MODES[(idx + 1) % MODES.length] ?? "show"
}
export function useThinkingMode() {
const kv = useKV()
// Capture pre-state before `kv.signal` seeds a default, so we can detect
// first-time users with a legacy `thinking_visibility` boolean and migrate.
// The KVProvider only renders children once kv.ready, so reads here are safe.
const hadStored = kv.get("thinking_mode") !== undefined
const legacy = kv.get("thinking_visibility")
const [stored, setStored] = kv.signal<ThinkingMode>("thinking_mode", "hide")
// The kv signal exposes its setter typed as `Setter<T>` which carries Solid's
// overload set; passing an updater fn through a property access loses the
// bivariance trick the existing `setX((prev) => ...)` callsites rely on.
// Wrap it in a sane shape so consumers can just call `set(next)` or pass
// an updater.
const set = (next: ThinkingMode | ((prev: ThinkingMode) => ThinkingMode)) => {
if (typeof next === "function") setStored(next as Setter<ThinkingMode>)
else setStored(() => next)
}
// Preserve previous experience for users who had explicitly toggled the
// legacy `thinking_visibility` boolean. First-time users (no legacy key)
// get the new "hide" default (collapsed thinking).
if (!hadStored) {
if (legacy === true) set("show")
else if (legacy === false) set("hide")
}
if ((stored() as string) === "minimal") set("hide")
const mode = createMemo<ThinkingMode>(() => {
const value = stored()
return isThinkingMode(value) ? value : "hide"
})
return {
mode,
set,
}
}