Kaynağa Gözat

refactor(tui): consolidate settings in cli config

Dax Raad 1 ay önce
ebeveyn
işleme
775fce0177

+ 23 - 4
packages/cli/test/config.test.ts

@@ -35,9 +35,23 @@ test("migrates tui and kv config into cli.json", async () => {
     path.join(directory, "kv.json"),
     JSON.stringify({
       theme_mode_lock: "light",
+      attention_sound_pack: "custom.pack",
+      diff_wrap_mode: "none",
+      diff_viewer_show_file_tree: false,
+      diff_viewer_single_patch: true,
+      diff_viewer_view: "split",
+      terminal_title_enabled: false,
+      file_context_enabled: false,
       paste_summary_enabled: false,
+      sidebar: "hide",
+      scrollbar_visible: true,
+      thinking_mode: "show",
       exploration_grouping: false,
       tips_hidden: true,
+      dismissed_getting_started: true,
+      animations_enabled: false,
+      skipped_version: "9.9.9",
+      which_key_layout: "overlay",
     }),
   )
 
@@ -56,12 +70,17 @@ test("migrates tui and kv config into cli.json", async () => {
       plugins: [{ package: "example", options: { mode: "safe" } }, "-disabled"],
       leader: { timeout: 500 },
       scroll: { speed: 2, acceleration: true },
-      diffs: { view: "unified" },
-      prompt: { paste: "full" },
-      session: { grouping: "none" },
-      hints: { tips: false },
+      attention: { sound_pack: "custom.pack" },
+      diffs: { wrap: "none", tree: false, single: true, view: "split" },
+      terminal: { title: false },
+      prompt: { editor: false, paste: "full" },
+      session: { sidebar: "hide", scrollbar: true, thinking: "show", grouping: "none" },
+      hints: { tips: false, onboarding: false },
+      animations: false,
       mouse: false,
     })
+    expect(config).not.toHaveProperty("skipped_version")
+    expect(config).not.toHaveProperty("which_key")
     expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" })
     expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true)
     expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true)

+ 1 - 7
packages/plugin/src/tui.ts

@@ -358,12 +358,6 @@ export type TuiTheme = {
   readonly ready: boolean
 }
 
-export type TuiKV = {
-  get: <Value = unknown>(key: string, fallback?: Value) => Value
-  set: (key: string, value: unknown) => void
-  readonly ready: boolean
-}
-
 export type TuiState = {
   readonly ready: boolean
   readonly config: SdkConfig
@@ -434,6 +428,7 @@ type TuiConfigView = {
     sidebar?: "auto" | "hide"
     scrollbar?: boolean
     thinking?: "show" | "hide"
+    markdown?: "source" | "rendered"
     grouping?: "auto" | "none"
   }
   hints?: { tips?: boolean; onboarding?: boolean }
@@ -628,7 +623,6 @@ export type TuiPluginApi = {
     dialog: TuiDialogStack
   }
   readonly tuiConfig: Frozen<TuiConfigView>
-  kv: TuiKV
   state: TuiState
   theme: TuiTheme
   client: OpencodeClient

+ 0 - 1
packages/tui/package.json

@@ -19,7 +19,6 @@
     "./context/args": "./src/context/args.tsx",
     "./context/epilogue": "./src/context/epilogue.tsx",
     "./context/exit": "./src/context/exit.tsx",
-    "./context/kv": "./src/context/kv.tsx",
     "./context/log": "./src/context/log.tsx",
     "./context/project": "./src/context/project.tsx",
     "./context/runtime": "./src/context/runtime.tsx",

+ 102 - 112
packages/tui/src/app.tsx

@@ -71,7 +71,6 @@ import { DialogAlert } from "./ui/dialog-alert"
 import { DialogConfirm } from "./ui/dialog-confirm"
 import { ToastProvider, useToast } from "./ui/toast"
 import { isDefaultTitle } from "./util/session"
-import { KVProvider, useKV } from "./context/kv"
 import * as Model from "./util/model"
 import { ArgsProvider, useArgs, type Args } from "./context/args"
 import open from "open"
@@ -181,23 +180,6 @@ function errorMessage(error: unknown) {
   return error instanceof Error ? error.message : String(error)
 }
 
-function isVersionGreater(left: string, right: string) {
-  const parse = (value: string) => {
-    const [core, prerelease] = value.replace(/^v/, "").split("-", 2)
-    return { core: core.split(".").map((part) => Number.parseInt(part, 10) || 0), prerelease }
-  }
-  const a = parse(left)
-  const b = parse(right)
-  for (let index = 0; index < Math.max(a.core.length, b.core.length); index++) {
-    const difference = (a.core[index] ?? 0) - (b.core[index] ?? 0)
-    if (difference) return difference > 0
-  }
-  if (a.prerelease === b.prerelease) return false
-  if (!a.prerelease) return true
-  if (!b.prerelease) return false
-  return a.prerelease.localeCompare(b.prerelease, undefined, { numeric: true }) > 0
-}
-
 export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
   const log = input.log ?? (() => {})
   const global = yield* Global.Service
@@ -343,67 +325,65 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
                                   service={input.config}
                                   options={{ terminalSuspend: process.platform !== "win32" }}
                                 >
-                                  <KVProvider>
-                                    <ToastProvider>
-                                      <RouteProvider
-                                        initialRoute={
-                                          input.args.continue
-                                            ? {
-                                                type: "session",
-                                                sessionID: "dummy",
-                                              }
-                                            : undefined
-                                        }
-                                      >
-                                        <PluginRuntimeProvider value={pluginRuntime}>
-                                          <SDKProvider
-                                            client={createOpencodeClient({ ...options, directory })}
-                                            api={api}
-                                            reconnect={reconnect}
-                                            reload={input.server.reload}
-                                          >
-                                            <PermissionProvider>
-                                              <ProjectProvider>
-                                                <SyncProvider>
-                                                  <DataProvider>
-                                                    <ThemeProvider mode={mode}>
-                                                      <LocalProvider>
-                                                        <PromptStashProvider>
-                                                          <DialogProvider>
-                                                            <FrecencyProvider>
-                                                              <PromptHistoryProvider>
-                                                                <PromptRefProvider>
-                                                                  <EditorContextProvider>
-                                                                    <LocationProvider>
-                                                                      <App
-                                                                        pluginHost={input.pluginHost}
-                                                                        pair={
-                                                                          input.server.endpoint.auth
-                                                                            ? input.server.endpoint.auth
-                                                                            : {
-                                                                                username: "opencode",
-                                                                                password: "",
-                                                                              }
-                                                                        }
-                                                                      />
-                                                                    </LocationProvider>
-                                                                  </EditorContextProvider>
-                                                                </PromptRefProvider>
-                                                              </PromptHistoryProvider>
-                                                            </FrecencyProvider>
-                                                          </DialogProvider>
-                                                        </PromptStashProvider>
-                                                      </LocalProvider>
-                                                    </ThemeProvider>
-                                                  </DataProvider>
-                                                </SyncProvider>
-                                              </ProjectProvider>
-                                            </PermissionProvider>
-                                          </SDKProvider>
-                                        </PluginRuntimeProvider>
-                                      </RouteProvider>
-                                    </ToastProvider>
-                                  </KVProvider>
+                                  <ToastProvider>
+                                    <RouteProvider
+                                      initialRoute={
+                                        input.args.continue
+                                          ? {
+                                              type: "session",
+                                              sessionID: "dummy",
+                                            }
+                                          : undefined
+                                      }
+                                    >
+                                      <PluginRuntimeProvider value={pluginRuntime}>
+                                        <SDKProvider
+                                          client={createOpencodeClient({ ...options, directory })}
+                                          api={api}
+                                          reconnect={reconnect}
+                                          reload={input.server.reload}
+                                        >
+                                          <PermissionProvider>
+                                            <ProjectProvider>
+                                              <SyncProvider>
+                                                <DataProvider>
+                                                  <ThemeProvider mode={mode}>
+                                                    <LocalProvider>
+                                                      <PromptStashProvider>
+                                                        <DialogProvider>
+                                                          <FrecencyProvider>
+                                                            <PromptHistoryProvider>
+                                                              <PromptRefProvider>
+                                                                <EditorContextProvider>
+                                                                  <LocationProvider>
+                                                                    <App
+                                                                      pluginHost={input.pluginHost}
+                                                                      pair={
+                                                                        input.server.endpoint.auth
+                                                                          ? input.server.endpoint.auth
+                                                                          : {
+                                                                              username: "opencode",
+                                                                              password: "",
+                                                                            }
+                                                                      }
+                                                                    />
+                                                                  </LocationProvider>
+                                                                </EditorContextProvider>
+                                                              </PromptRefProvider>
+                                                            </PromptHistoryProvider>
+                                                          </FrecencyProvider>
+                                                        </DialogProvider>
+                                                      </PromptStashProvider>
+                                                    </LocalProvider>
+                                                  </ThemeProvider>
+                                                </DataProvider>
+                                              </SyncProvider>
+                                            </ProjectProvider>
+                                          </PermissionProvider>
+                                        </SDKProvider>
+                                      </PluginRuntimeProvider>
+                                    </RouteProvider>
+                                  </ToastProvider>
                                 </ConfigProvider>
                               </ArgsProvider>
                             </OpencodeKeymapProvider>
@@ -440,13 +420,13 @@ function App(props: {
 }) {
   const log = useLog({ component: "app" })
   const startup = useTuiStartup()
-  const config = useConfig().data
+  const configState = useConfig()
+  const config = configState.data
   const route = useRoute()
   const dimensions = useTerminalDimensions()
   const renderer = useRenderer()
   const dialog = useDialog()
   const local = useLocal()
-  const kv = useKV()
   const keymap = useOpencodeKeymap()
   const event = useEvent()
   const sdk = useSDK()
@@ -459,7 +439,7 @@ function App(props: {
   const exit = useExit()
   const promptRef = usePromptRef()
   const pluginRuntime = usePluginRuntime()
-  const attention = createTuiAttention({ renderer, config, kv })
+  const attention = createTuiAttention({ renderer, config, update: configState.update })
   const clipboard = useClipboard()
 
   // Toast once when an MCP server enters a failed or needs-auth state so the user knows to act,
@@ -496,7 +476,6 @@ function App(props: {
       tuiConfig: config,
       dialog,
       keymap,
-      kv,
       route,
       routes: pluginRuntime.routes,
       event,
@@ -549,8 +528,8 @@ function App(props: {
 
     renderer.clearSelection()
   }
-  const [terminalTitleEnabled, setTerminalTitleEnabled] = kv.signal("terminal_title_enabled", true)
-  const [pasteSummaryEnabled, setPasteSummaryEnabled] = kv.signal("paste_summary_enabled", true)
+  const terminalTitleEnabled = () => config.terminal?.title ?? true
+  const pasteSummaryEnabled = () => config.prompt?.paste !== "full"
 
   createEffect(() => {
     renderer.useMouse = !Flag.OPENCODE_DISABLE_MOUSE && config.mouse
@@ -880,6 +859,7 @@ function App(props: {
       {
         name: "theme.switch_mode",
         title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode",
+        hidden: true,
         run: () => {
           setMode(mode() === "dark" ? "light" : "dark")
           dialog.clear()
@@ -889,6 +869,7 @@ function App(props: {
       {
         name: "theme.mode.lock",
         title: locked() ? "Unlock theme mode" : "Lock theme mode",
+        hidden: true,
         run: () => {
           if (locked()) unlock()
           else lock()
@@ -956,41 +937,57 @@ function App(props: {
         name: "terminal.title.toggle",
         title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
         category: "System",
+        hidden: true,
         run: () => {
-          setTerminalTitleEnabled((prev) => {
-            const next = !prev
-            kv.set("terminal_title_enabled", next)
-            if (!next) renderer.setTerminalTitle("")
-            return next
-          })
+          const next = !terminalTitleEnabled()
+          if (!next) renderer.setTerminalTitle("")
+          void configState
+            .update((draft) => {
+              draft.terminal = { ...draft.terminal, title: next }
+            })
+            .catch(toast.error)
           dialog.clear()
         },
       },
       {
         name: "app.toggle.animations",
-        title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
+        title: (config.animations ?? true) ? "Disable animations" : "Enable animations",
         category: "System",
+        hidden: true,
         run: () => {
-          kv.set("animations_enabled", !kv.get("animations_enabled", true))
+          void configState
+            .update((draft) => {
+              draft.animations = !(config.animations ?? true)
+            })
+            .catch(toast.error)
           dialog.clear()
         },
       },
       {
         name: "app.toggle.file_context",
-        title: kv.get("file_context_enabled", true) ? "Disable file context" : "Enable file context",
+        title: (config.prompt?.editor ?? true) ? "Disable file context" : "Enable file context",
         category: "System",
+        hidden: true,
         run: () => {
-          kv.set("file_context_enabled", !kv.get("file_context_enabled", true))
+          void configState
+            .update((draft) => {
+              draft.prompt = { ...draft.prompt, editor: !(config.prompt?.editor ?? true) }
+            })
+            .catch(toast.error)
           dialog.clear()
         },
       },
       {
         name: "app.toggle.diffwrap",
-        title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
+        title: (config.diffs?.wrap ?? "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
         category: "System",
+        hidden: true,
         run: () => {
-          const current = kv.get("diff_wrap_mode", "word")
-          kv.set("diff_wrap_mode", current === "word" ? "none" : "word")
+          void configState
+            .update((draft) => {
+              draft.diffs = { ...draft.diffs, wrap: (config.diffs?.wrap ?? "word") === "word" ? "none" : "word" }
+            })
+            .catch(toast.error)
           dialog.clear()
         },
       },
@@ -998,12 +995,13 @@ function App(props: {
         name: "app.toggle.paste_summary",
         title: pasteSummaryEnabled() ? "Disable paste summary" : "Enable paste summary",
         category: "System",
+        hidden: true,
         run: () => {
-          setPasteSummaryEnabled((prev) => {
-            const next = !prev
-            kv.set("paste_summary_enabled", next)
-            return next
-          })
+          void configState
+            .update((draft) => {
+              draft.prompt = { ...draft.prompt, paste: pasteSummaryEnabled() ? "full" : "compact" }
+            })
+            .catch(toast.error)
           dialog.clear()
         },
       },
@@ -1101,21 +1099,13 @@ function App(props: {
   event.on("installation.update-available", async (evt) => {
     const version = evt.data.version
 
-    const skipped = kv.get("skipped_version")
-    if (skipped && !isVersionGreater(version, skipped)) return
-
     const choice = await DialogConfirm.show(
       dialog,
       `Update Available`,
       `A new release v${version} is available. Would you like to update now?`,
-      "skip",
+      "later",
     )
 
-    if (choice === false) {
-      kv.set("skipped_version", version)
-      return
-    }
-
     if (choice !== true) return
 
     toast.show({

+ 8 - 6
packages/tui/src/attention.ts

@@ -5,7 +5,6 @@ import type {
   TuiAttentionNotifyResult,
   TuiAttentionNotifySkipReason,
   TuiAttentionWhen,
-  TuiKV,
   TuiAttentionSoundName,
   TuiAttentionSoundPack,
   TuiAttentionSoundPackInfo,
@@ -40,7 +39,6 @@ type TuiAttentionHost = TuiAttention & {
 
 const DEFAULT_TITLE = "opencode"
 const DEFAULT_PACK_ID = "opencode.default"
-const KV_SOUND_PACK = "attention_sound_pack"
 const TITLE_LIMIT = 80
 const MESSAGE_LIMIT = 240
 const BUILTIN_PACK: RegisteredSoundPack = {
@@ -114,7 +112,7 @@ function focusSkip(when: TuiAttentionWhen, focus: FocusState) {
 export function createTuiAttention(input: {
   renderer: AttentionRenderer
   config: Pick<Config.Resolved, "attention">
-  kv?: TuiKV
+  update?: Config.Interface["update"]
   audio?: Pick<typeof TuiAudio, "loadSoundFile" | "play">
 }): TuiAttentionHost {
   let focus: FocusState = "unknown"
@@ -134,8 +132,7 @@ export function createTuiAttention(input: {
   input.renderer.on("blur", onBlur)
 
   function configuredPackID() {
-    const stored = input.kv?.get<string | undefined>(KV_SOUND_PACK, undefined)
-    return activePackID ?? stored ?? input.config.attention.sound_pack
+    return activePackID ?? input.config.attention.sound_pack
   }
 
   function currentPack() {
@@ -234,7 +231,12 @@ export function createTuiAttention(input: {
         const pack = packs.get(id)
         if (!pack) return false
         activePackID = pack.id
-        if (options?.persist) input.kv?.set(KV_SOUND_PACK, pack.id)
+        if (options?.persist)
+          void input
+            .update?.((draft) => {
+              draft.attention = { ...draft.attention, sound_pack: pack.id }
+            })
+            .catch(() => {})
         return true
       },
       current() {

+ 7 - 0
packages/tui/src/component/dialog-config.tsx

@@ -77,6 +77,13 @@ const settings: Setting[] = [
     default: "hide",
     values: ["hide", "show"],
   },
+  {
+    title: "Markdown",
+    category: "Session",
+    path: ["session", "markdown"],
+    default: "rendered",
+    values: ["source", "rendered"],
+  },
   {
     title: "Grouping",
     category: "Session",

+ 4 - 6
packages/tui/src/component/prompt/index.tsx

@@ -43,7 +43,6 @@ import { useDialog } from "../../ui/dialog"
 import { DialogIntegration } from "../dialog-integration"
 import { useConnected } from "../use-connected"
 import { useToast } from "../../ui/toast"
-import { useKV } from "../../context/kv"
 import { createFadeIn } from "../../util/signal"
 import { DialogSkill } from "../dialog-skill"
 import { useArgs } from "../../context/args"
@@ -177,11 +176,10 @@ export function Prompt(props: PromptProps) {
   const exit = useExit()
   const dimensions = useTerminalDimensions()
   const { theme, syntax } = useTheme()
-  const kv = useKV()
-  const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
+  const animationsEnabled = createMemo(() => config.animations ?? true)
   const list = createMemo(() => props.placeholders?.normal ?? [])
   const shell = createMemo(() => props.placeholders?.shell ?? [])
-  const fileContextEnabled = createMemo(() => kv.get("file_context_enabled", true))
+  const fileContextEnabled = createMemo(() => config.prompt?.editor ?? true)
   const [dismissedEditorSelectionKey, setDismissedEditorSelectionKey] = createSignal<string>()
   const editorContext = createMemo(() => {
     const selection = fileContextEnabled() ? editor.selection() : undefined
@@ -1191,7 +1189,7 @@ export function Prompt(props: PromptProps) {
     const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
     if (
       (lineCount >= 3 || pastedContent.length > 150) &&
-      kv.get("paste_summary_enabled", true)
+      config.prompt?.paste !== "full"
     ) {
       pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
       return
@@ -1495,7 +1493,7 @@ export function Prompt(props: PromptProps) {
             <Match when={status() === "running"}>
               <box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
                 <box marginLeft={1}>
-                  <Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
+                  <Show when={config.animations ?? true} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
                     <spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
                   </Show>
                 </box>

+ 3 - 3
packages/tui/src/component/spinner.tsx

@@ -1,6 +1,6 @@
 import { Show } from "solid-js"
 import { useTheme } from "../context/theme"
-import { useKV } from "../context/kv"
+import { useConfig } from "../config"
 import type { JSX } from "@opentui/solid"
 import type { RGBA } from "@opentui/core"
 import { registerOpencodeSpinner } from "./register-spinner"
@@ -11,10 +11,10 @@ export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦",
 
 export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
   const { theme } = useTheme()
-  const kv = useKV()
+  const config = useConfig().data
   const color = () => props.color ?? theme.textMuted
   return (
-    <Show when={kv.get("animations_enabled", true)} fallback={<text fg={color()}>⋯ {props.children}</text>}>
+    <Show when={config.animations ?? true} fallback={<text fg={color()}>⋯ {props.children}</text>}>
       <box flexDirection="row" gap={1}>
         <spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
         <Show when={props.children}>

+ 3 - 0
packages/tui/src/config/index.tsx

@@ -118,6 +118,9 @@ export const Info = Schema.Struct({
       grouping: Schema.optional(Schema.Literals(["auto", "none"])).annotate({
         description: "Group related transcript items automatically or render each item separately",
       }),
+      markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({
+        description: "Show Markdown syntax markers or conceal them in rendered transcript content",
+      }),
     }),
   ).annotate({ description: "Session transcript presentation settings" }),
   hints: Schema.optional(

+ 0 - 107
packages/tui/src/context/kv.tsx

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

+ 25 - 22
packages/tui/src/context/theme.tsx

@@ -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() {

+ 0 - 47
packages/tui/src/context/thinking.ts

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

+ 10 - 2
packages/tui/src/feature-plugins/home/tips.tsx

@@ -5,10 +5,12 @@ import { Tips } from "./tips-view"
 import { useBindings } from "../../keymap"
 import { useData } from "../../context/data"
 import { hasConnectedProvider } from "../../util/connected-provider"
+import { useConfig } from "../../config"
 
 const id = "internal:home-tips"
 
 function View(props: { api: TuiPluginApi; hidden: boolean; show: boolean; connected: boolean }) {
+  const config = useConfig()
   useBindings(() => ({
     commands: [
       {
@@ -16,8 +18,13 @@ function View(props: { api: TuiPluginApi; hidden: boolean; show: boolean; connec
         title: props.hidden ? "Show tips" : "Hide tips",
         category: "System",
         namespace: "palette",
+        hidden: true,
         run() {
-          props.api.kv.set("tips_hidden", !props.api.kv.get("tips_hidden", false))
+          void config
+            .update((draft) => {
+              draft.hints = { ...draft.hints, tips: props.hidden }
+            })
+            .catch(() => {})
           props.api.ui.dialog.clear()
         },
       },
@@ -40,7 +47,8 @@ const tui: TuiPlugin = async (api) => {
     slots: {
       home_bottom() {
         const data = useData()
-        const hidden = createMemo(() => api.kv.get("tips_hidden", false))
+        const config = useConfig().data
+        const hidden = createMemo(() => !(config.hints?.tips ?? true))
         const first = createMemo(() => api.state.session.count() === 0)
         const connected = createMemo(() => hasConnectedProvider(data.location.integration.list() ?? []))
         const show = createMemo(() => (!first() || !connected()) && !hidden())

+ 13 - 2
packages/tui/src/feature-plugins/sidebar/footer.tsx

@@ -4,18 +4,20 @@ import { createMemo, Show } from "solid-js"
 import { abbreviateHome } from "../../runtime"
 import { useTuiPaths } from "../../context/runtime"
 import { FilePath } from "../../ui/file-path"
+import { useConfig } from "../../config"
 
 const id = "internal:sidebar-footer"
 
 function View(props: { api: TuiPluginApi; directory: string }) {
   const paths = useTuiPaths()
+  const config = useConfig()
   const theme = () => props.api.theme.current
   const has = createMemo(() =>
     props.api.state.provider.some(
       (item) => item.id !== "opencode" || Object.values(item.models).some((model) => model.cost?.input !== 0),
     ),
   )
-  const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false))
+  const done = createMemo(() => !(config.data.hints?.onboarding ?? true))
   const show = createMemo(() => !has() && !done())
   const location = createMemo(() => {
     const branch = props.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined
@@ -44,7 +46,16 @@ function View(props: { api: TuiPluginApi; directory: string }) {
               <text fg={theme().text}>
                 <b>Getting started</b>
               </text>
-              <text fg={theme().textMuted} onMouseDown={() => props.api.kv.set("dismissed_getting_started", true)}>
+              <text
+                fg={theme().textMuted}
+                onMouseDown={() =>
+                  void config
+                    .update((draft) => {
+                      draft.hints = { ...draft.hints, onboarding: false }
+                    })
+                    .catch(() => {})
+                }
+              >
               </text>
             </box>

+ 28 - 12
packages/tui/src/feature-plugins/system/diff-viewer.tsx

@@ -19,6 +19,7 @@ import { DiffViewerFileTree } from "./diff-viewer-file-tree"
 import { Panel, PanelGroup, Separator } from "./diff-viewer-ui"
 import { DialogSelect } from "../../ui/dialog-select"
 import { getScrollAcceleration } from "../../util/scroll"
+import { useConfig } from "../../config"
 import {
   allExpandedFileTreeDirectories,
   buildFileTree,
@@ -41,9 +42,6 @@ const MIN_SPLIT_WIDTH = 100
 const FILE_TREE_WIDTH = 32
 const PLAIN_TEXT_FILETYPE = "opencode-plain-text"
 const VCS_DIFF_CONTEXT_LINES = 12
-const KV_SHOW_FILE_TREE = "diff_viewer_show_file_tree"
-const KV_SINGLE_PATCH = "diff_viewer_single_patch"
-const KV_VIEW = "diff_viewer_view"
 type DiffMode = "working" | "branch" | "last-turn"
 type DiffViewerFocus = "patches" | "files"
 type DiffView = "split" | "unified"
@@ -92,6 +90,7 @@ function diffSourceLabel(mode: DiffMode) {
 function DiffViewer(props: { api: TuiPluginApi }) {
   const dimensions = useTerminalDimensions()
   const sdk = useSDK()
+  const config = useConfig()
   const themeState = useTheme()
   const theme = () => props.api.theme.current
   const params = () =>
@@ -135,11 +134,9 @@ function DiffViewer(props: { api: TuiPluginApi }) {
   })
   const files = createMemo(() => diff() ?? [])
   const [focus, setFocus] = createSignal<DiffViewerFocus>("patches")
-  const [fileTreeEnabled, setFileTreeEnabled] = createSignal(
-    props.api.kv.get<boolean>(KV_SHOW_FILE_TREE, true) !== false,
-  )
+  const [fileTreeEnabled, setFileTreeEnabled] = createSignal(config.data.diffs?.tree ?? true)
   const showFileTree = createMemo(() => showDiffViewerFileTree(fileTreeEnabled(), files().length))
-  const [singlePatch, setSinglePatch] = createSignal(props.api.kv.get<boolean>(KV_SINGLE_PATCH, false) === true)
+  const [singlePatch, setSinglePatch] = createSignal(config.data.diffs?.single ?? false)
   const patchPaneWidth = createMemo(() => dimensions().width - (showFileTree() ? 33 : 0) - 4)
   const patchLeftBorder = createMemo<BorderSides[]>(() => (showFileTree() ? ["left"] : []))
   const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH)
@@ -148,7 +145,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
     if (props.api.tuiConfig.diffs?.view === "split") return "split"
     return splitAvailable() ? "split" : "unified"
   })
-  const [viewOverride, setViewOverride] = createSignal<DiffView | undefined>(storedView(props.api.kv.get(KV_VIEW)))
+  const [viewOverride, setViewOverride] = createSignal<DiffView | undefined>(storedView(config.data.diffs?.view))
   const view = createMemo(() => (splitAvailable() ? (viewOverride() ?? defaultView()) : "unified"))
   const fileTree = createMemo(() => buildFileTree(files()))
   const [expandedFileNodes, setExpandedFileNodes] = createSignal<ReadonlySet<number>>(new Set())
@@ -623,23 +620,33 @@ function DiffViewer(props: { api: TuiPluginApi }) {
       name: "diff.toggle_file_tree",
       title: "Toggle diff viewer file tree",
       category: "VCS",
+      hidden: true,
       run() {
         const next = !fileTreeEnabled()
         if (!next) setFocus("patches")
         setFileTreeEnabled(next)
-        props.api.kv.set(KV_SHOW_FILE_TREE, next)
+        void config
+          .update((draft) => {
+            draft.diffs = { ...draft.diffs, tree: next }
+          })
+          .catch(() => {})
       },
     },
     {
       name: "diff.single_patch",
       title: "Toggle single patch view",
       category: "VCS",
+      hidden: true,
       run() {
         setSelectedHunk(undefined)
         if (!singlePatch()) {
           ensureHighlightedPatchFile()
           setSinglePatch(true)
-          props.api.kv.set(KV_SINGLE_PATCH, true)
+          void config
+            .update((draft) => {
+              draft.diffs = { ...draft.diffs, single: true }
+            })
+            .catch(() => {})
           scrollSinglePatchToTop()
           return
         }
@@ -653,7 +660,11 @@ function DiffViewer(props: { api: TuiPluginApi }) {
           )
         if (fileIndex !== undefined) selectPatchFile(fileIndex)
         setSinglePatch(false)
-        props.api.kv.set(KV_SINGLE_PATCH, false)
+        void config
+          .update((draft) => {
+            draft.diffs = { ...draft.diffs, single: false }
+          })
+          .catch(() => {})
         if (fileIndex !== undefined) scrollToPatchFileIndexAfterRender(fileIndex)
       },
     },
@@ -669,12 +680,17 @@ function DiffViewer(props: { api: TuiPluginApi }) {
       name: "diff.toggle_view",
       title: "Toggle diff viewer split or unified view",
       category: "VCS",
+      hidden: true,
       run() {
         if (!splitAvailable()) return
         setSelectedHunk(undefined)
         const next = view() === "split" ? "unified" : "split"
         setViewOverride(next)
-        props.api.kv.set(KV_VIEW, next)
+        void config
+          .update((draft) => {
+            draft.diffs = { ...draft.diffs, view: next }
+          })
+          .catch(() => {})
       },
     },
     {

+ 2 - 6
packages/tui/src/feature-plugins/system/which-key.tsx

@@ -22,8 +22,6 @@ const command = {
 } as const
 
 const LAYER_PRIORITY = 900
-const KV_LAYOUT = "which_key_layout"
-const KV_PENDING_PREVIEW = "which_key_pending_preview"
 const toggleCommands = [command.toggle, command.toggleLayout, command.togglePending] as const
 const scrollCommands = [
   command.scrollUp,
@@ -531,8 +529,8 @@ function WhichKeyPanel(props: {
 
 const tui: TuiPlugin = async (api) => {
   const [pinned, setPinned] = createSignal(false)
-  const [mode, setMode] = createSignal(layout(api.kv.get(KV_LAYOUT, "dock")))
-  const [pendingPreview, setPendingPreview] = createSignal(api.kv.get(KV_PENDING_PREVIEW, false))
+  const [mode, setMode] = createSignal(layout("dock"))
+  const [pendingPreview, setPendingPreview] = createSignal(false)
 
   api.keymap.registerLayer({
     priority: LAYER_PRIORITY,
@@ -554,7 +552,6 @@ const tui: TuiPlugin = async (api) => {
         run() {
           setMode((value) => {
             const next = value === "dock" ? "overlay" : "dock"
-            api.kv.set(KV_LAYOUT, next)
             return next
           })
         },
@@ -566,7 +563,6 @@ const tui: TuiPlugin = async (api) => {
         category: "System",
         run() {
           setPendingPreview((value) => {
-            api.kv.set(KV_PENDING_PREVIEW, !value)
             return !value
           })
         },

+ 0 - 13
packages/tui/src/plugin/adapters.tsx

@@ -8,7 +8,6 @@ import type { useData } from "../context/data"
 import type { useTheme } from "../context/theme"
 import { Dialog as DialogUI, type useDialog } from "../ui/dialog"
 import type { useOpencodeKeymap } from "../keymap"
-import type { useKV } from "../context/kv"
 import { DialogAlert } from "../ui/dialog-alert"
 import { DialogConfirm } from "../ui/dialog-confirm"
 import { DialogPrompt } from "../ui/dialog-prompt"
@@ -26,7 +25,6 @@ type Input = {
   tuiConfig: Config.Resolved
   dialog: ReturnType<typeof useDialog>
   keymap: ReturnType<typeof useOpencodeKeymap>
-  kv: ReturnType<typeof useKV>
   route: ReturnType<typeof useRoute>
   routes: PluginRoutes
   event: ReturnType<typeof useEvent>
@@ -291,17 +289,6 @@ export function createTuiApiAdapters(input: Input): Omit<TuiPluginApi, "lifecycl
     get tuiConfig() {
       return input.tuiConfig
     },
-    kv: {
-      get(key, fallback) {
-        return input.kv.get(key, fallback)
-      },
-      set(key, value) {
-        input.kv.set(key, value)
-      },
-      get ready() {
-        return input.kv.ready
-      },
-    },
     state: stateApi(input.sync, input.data),
     get client() {
       return input.sdk.client

+ 38 - 20
packages/tui/src/routes/session/index.tsx

@@ -54,7 +54,6 @@ import { filetype } from "../../util/filetype"
 import parsers from "../../parsers-config"
 import { errorMessage } from "../../util/error"
 import { Toast, useToast } from "../../ui/toast"
-import { useKV } from "../../context/kv.tsx"
 import stripAnsi from "strip-ansi"
 import { usePromptRef } from "../../context/prompt"
 import { useEpilogue } from "../../context/epilogue"
@@ -66,7 +65,7 @@ import { DialogExportResult } from "../../ui/dialog-export-result"
 import { sessionEpilogue } from "../../util/presentation"
 import { useConfig } from "../../config"
 import { useClipboard } from "../../context/clipboard"
-import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode } from "../../context/thinking"
+import { nextThinkingMode, reasoningSummary, type ThinkingMode } from "../../context/thinking"
 import { getScrollAcceleration } from "../../util/scroll"
 import { collapseToolOutput } from "../../util/collapse-tool-output"
 import { usePluginRuntime } from "../../plugin/runtime"
@@ -122,6 +121,7 @@ const context = createContext<{
   sessionID: string
   thinkingMode: () => ThinkingMode
   showThinking: () => boolean
+  markdownMode: () => "source" | "rendered"
   groupExploration: () => boolean
   diffWrapMode: () => "word" | "none"
   models: () => ModelInfo[]
@@ -147,8 +147,8 @@ export function Session() {
   const data = useData()
   const project = useProject()
   const paths = useTuiPaths()
-  const config = useConfig().data
-  const kv = useKV()
+  const configState = useConfig()
+  const config = configState.data
   const { theme } = useTheme()
   const promptRef = usePromptRef()
   const session = createMemo(() => data.session.get(route.sessionID))
@@ -194,15 +194,14 @@ export function Session() {
   })
 
   const dimensions = useTerminalDimensions()
-  const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "auto")
+  const sidebar = createMemo(() => config.session?.sidebar ?? "auto")
   const [sidebarOpen, setSidebarOpen] = createSignal(false)
-  const thinking = useThinkingMode()
-  const thinkingMode = thinking.mode
+  const thinkingMode = createMemo<ThinkingMode>(() => config.session?.thinking ?? "hide")
   const showThinking = createMemo(() => true)
-  const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false)
-  const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
-  const [_animationsEnabled, _setAnimationsEnabled] = kv.signal("animations_enabled", true)
-  const [groupExploration, setGroupExploration] = kv.signal("exploration_grouping", true)
+  const showScrollbar = createMemo(() => config.session?.scrollbar ?? false)
+  const markdownMode = createMemo(() => config.session?.markdown ?? "rendered")
+  const diffWrapMode = createMemo(() => config.diffs?.wrap ?? "word")
+  const groupExploration = createMemo(() => config.session?.grouping !== "none")
 
   const wide = createMemo(() => dimensions().width > 120)
   const sidebarVisible = createMemo(() => {
@@ -462,7 +461,11 @@ export function Session() {
       run: () => {
         batch(() => {
           const isVisible = sidebarVisible()
-          setSidebar(() => (isVisible ? "hide" : "auto"))
+          void configState
+            .update((draft) => {
+              draft.session = { ...draft.session, sidebar: isVisible ? "hide" : "auto" }
+            })
+            .catch(toast.error)
           setSidebarOpen(!isVisible)
         })
         dialog.clear()
@@ -476,12 +479,17 @@ export function Session() {
       })(),
       value: "session.toggle.thinking",
       category: "Session",
+      hidden: true,
       slash: {
         name: "thinking",
         aliases: ["toggle-thinking"],
       },
       run: () => {
-        thinking.set(nextThinkingMode(thinkingMode()))
+        void configState
+          .update((draft) => {
+            draft.session = { ...draft.session, thinking: nextThinkingMode(thinkingMode()) }
+          })
+          .catch(toast.error)
         dialog.clear()
       },
     },
@@ -489,8 +497,13 @@ export function Session() {
       title: "Toggle session scrollbar",
       value: "session.toggle.scrollbar",
       category: "Session",
+      hidden: true,
       run: () => {
-        setShowScrollbar((prev) => !prev)
+        void configState
+          .update((draft) => {
+            draft.session = { ...draft.session, scrollbar: !showScrollbar() }
+          })
+          .catch(toast.error)
         dialog.clear()
       },
     },
@@ -498,8 +511,13 @@ export function Session() {
       title: groupExploration() ? "Show tool calls individually" : "Group related tool calls",
       value: "session.toggle.exploration_grouping",
       category: "Session",
+      hidden: true,
       run: () => {
-        setGroupExploration((prev) => !prev)
+        void configState
+          .update((draft) => {
+            draft.session = { ...draft.session, grouping: groupExploration() ? "none" : "auto" }
+          })
+          .catch(toast.error)
         dialog.clear()
       },
     },
@@ -855,6 +873,7 @@ export function Session() {
           sessionID: route.sessionID,
           thinkingMode,
           showThinking,
+          markdownMode,
           groupExploration,
           diffWrapMode,
           models,
@@ -1287,7 +1306,6 @@ function SessionSkillMessage(props: { message: Extract<SessionMessageInfo, { typ
 
 function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type: "compaction" }> }) {
   const ctx = use()
-  const kv = useKV()
   const { theme, syntax } = useTheme()
   const status = () => props.message.status
   const text = () => (props.message.status === "failed" ? props.message.error.message : props.message.summary)
@@ -1300,7 +1318,7 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
         <box flexDirection="row" gap={1} paddingLeft={1} paddingRight={1}>
           <Switch>
             <Match when={status() === "running"}>
-              <Show when={kv.get("animations_enabled", true)} fallback={<text fg={color()}>⋯</text>}>
+              <Show when={ctx.config.animations ?? true} fallback={<text fg={color()}>⋯</text>}>
                 <spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
               </Show>
             </Match>
@@ -1320,7 +1338,7 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
             internalBlockMode="top-level"
             content={content()}
             tableOptions={{ style: "grid" }}
-            conceal={false}
+            conceal={ctx.markdownMode() === "rendered"}
             fg={theme.markdownText}
             bg={theme.background}
           />
@@ -1745,7 +1763,7 @@ function ReasoningPart(props: {
               streaming={true}
               syntaxStyle={syntax()}
               content={summary().body}
-              conceal={false}
+              conceal={ctx.markdownMode() === "rendered"}
               fg={theme.textMuted}
             />
           </box>
@@ -1811,7 +1829,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
           internalBlockMode="top-level"
           content={props.part.text.trim()}
           tableOptions={{ style: "grid" }}
-          conceal={false}
+          conceal={ctx.markdownMode() === "rendered"}
           fg={theme.markdownText}
           bg={theme.background}
         />

+ 14 - 19
packages/tui/test/cli/cmd/tui/sync-fixture.tsx

@@ -2,7 +2,6 @@
 import { testRender } from "@opentui/solid"
 import { onMount } from "solid-js"
 import { ArgsProvider } from "../../../../src/context/args"
-import { KVProvider, useKV } from "../../../../src/context/kv"
 import { ProjectProvider, useProject } from "../../../../src/context/project"
 import { SDKProvider } from "../../../../src/context/sdk"
 import { SyncProvider, useSync } from "../../../../src/context/sync"
@@ -20,25 +19,23 @@ export async function wait(fn: () => boolean, timeout = 2000) {
   }
 }
 
-type Ctx = { kv: ReturnType<typeof useKV>; project: ReturnType<typeof useProject>; sync: ReturnType<typeof useSync> }
+type Ctx = { project: ReturnType<typeof useProject>; sync: ReturnType<typeof useSync> }
 
 export async function mount(override?: FetchHandler, state?: string) {
   const events = createEventStream()
   const calls = createFetch(override, events)
   let sync!: ReturnType<typeof useSync>
   let project!: ReturnType<typeof useProject>
-  let kv!: ReturnType<typeof useKV>
   let done!: () => void
   const ready = new Promise<void>((resolve) => {
     done = resolve
   })
 
   function Probe() {
-    const ctx: Ctx = { kv: useKV(), project: useProject(), sync: useSync() }
+    const ctx: Ctx = { project: useProject(), sync: useSync() }
     onMount(() => {
       sync = ctx.sync
       project = ctx.project
-      kv = ctx.kv
       done()
     })
     return <box />
@@ -47,24 +44,22 @@ export async function mount(override?: FetchHandler, state?: string) {
   const app = await testRender(() => (
     <TestTuiContexts paths={state ? { state } : undefined}>
       <ArgsProvider>
-        <KVProvider>
-          <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
-            <PermissionProvider>
-              <ProjectProvider>
-                <ExitProvider exit={() => {}}>
-                  <SyncProvider>
-                    <Probe />
-                  </SyncProvider>
-                </ExitProvider>
-              </ProjectProvider>
-            </PermissionProvider>
-          </SDKProvider>
-        </KVProvider>
+        <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
+          <PermissionProvider>
+            <ProjectProvider>
+              <ExitProvider exit={() => {}}>
+                <SyncProvider>
+                  <Probe />
+                </SyncProvider>
+              </ExitProvider>
+            </ProjectProvider>
+          </PermissionProvider>
+        </SDKProvider>
       </ArgsProvider>
     </TestTuiContexts>
   ))
 
   await ready
   await wait(() => sync.status === "complete")
-  return { app, emit: events.emit, kv, project, sync, session: calls.session }
+  return { app, emit: events.emit, project, sync, session: calls.session }
 }

+ 7 - 12
packages/tui/test/cli/tui/dialog-prompt.test.tsx

@@ -26,12 +26,10 @@ async function mountPrompt(input: {
 }) {
   const state = path.join(input.root, "state")
   await mkdir(state, { recursive: true })
-  await Bun.write(path.join(state, "kv.json"), "{}")
 
   const [
     { DialogProvider },
     { DialogPrompt },
-    { KVProvider },
     { ThemeProvider },
     { ConfigProvider },
     { ToastProvider },
@@ -39,7 +37,6 @@ async function mountPrompt(input: {
   ] = await Promise.all([
     import("../../../src/ui/dialog"),
     import("../../../src/ui/dialog-prompt"),
-    import("../../../src/context/kv"),
     import("../../../src/context/theme"),
     import("../../../src/config"),
     import("../../../src/ui/toast"),
@@ -67,15 +64,13 @@ async function mountPrompt(input: {
       >
         <OpencodeKeymapProvider keymap={keymap}>
           <ConfigProvider config={resolvedConfig}>
-            <KVProvider>
-              <ThemeProvider mode="dark">
-                <ToastProvider>
-                  <DialogProvider>
-                    <DialogPrompt title="Rename Session" value="draft" onConfirm={input.onConfirm} />
-                  </DialogProvider>
-                </ToastProvider>
-              </ThemeProvider>
-            </KVProvider>
+            <ThemeProvider mode="dark">
+              <ToastProvider>
+                <DialogProvider>
+                  <DialogPrompt title="Rename Session" value="draft" onConfirm={input.onConfirm} />
+                </DialogProvider>
+              </ToastProvider>
+            </ThemeProvider>
           </ConfigProvider>
         </OpencodeKeymapProvider>
       </TestTuiContexts>

+ 1 - 4
packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx

@@ -4,7 +4,6 @@ import { RGBA } from "@opentui/core"
 import { testRender } from "@opentui/solid"
 import type { JSX } from "solid-js"
 import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
-import { KVProvider } from "../../../src/context/kv"
 import { ThemeProvider } from "../../../src/context/theme"
 import { ConfigProvider } from "../../../src/config"
 import { DiffViewerFileTree } from "../../../src/feature-plugins/system/diff-viewer-file-tree"
@@ -182,9 +181,7 @@ function withTheme(component: () => JSX.Element) {
   return (
     <TestTuiContexts>
       <ConfigProvider config={createTuiResolvedConfig()}>
-        <KVProvider>
-          <ThemeProvider mode="dark">{component()}</ThemeProvider>
-        </KVProvider>
+        <ThemeProvider mode="dark">{component()}</ThemeProvider>
       </ConfigProvider>
     </TestTuiContexts>
   )

+ 3 - 6
packages/tui/test/cli/tui/diff-viewer.test.tsx

@@ -5,7 +5,6 @@ import { DiffRenderable, type Renderable, ScrollBoxRenderable } from "@opentui/c
 import { testRender, useRenderer } from "@opentui/solid"
 import type { TuiPluginApi, TuiPluginMeta, TuiRouteCurrent, TuiRouteDefinition } from "@opencode-ai/plugin/tui"
 import type { Session } from "@opencode-ai/sdk/v2"
-import { KVProvider } from "../../../src/context/kv"
 import { ThemeProvider } from "../../../src/context/theme"
 import { ConfigProvider } from "../../../src/config"
 import { SDKProvider } from "../../../src/context/sdk"
@@ -174,11 +173,9 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
         <SDKProvider client={createClient(transport.fetch)} api={createApi(transport.fetch)}>
           <OpencodeKeymapProvider keymap={keymap}>
             <ConfigProvider config={config}>
-              <KVProvider>
-                <ThemeProvider mode="dark">
-                  {renderDiff?.({ params: "params" in current ? current.params : undefined })}
-                </ThemeProvider>
-              </KVProvider>
+              <ThemeProvider mode="dark">
+                {renderDiff?.({ params: "params" in current ? current.params : undefined })}
+              </ThemeProvider>
             </ConfigProvider>
           </OpencodeKeymapProvider>
         </SDKProvider>

+ 5 - 9
packages/tui/test/cli/tui/form.test.tsx

@@ -7,7 +7,6 @@ import path from "node:path"
 import { onCleanup } from "solid-js"
 import { ClipboardProvider } from "../../../src/context/clipboard"
 import type { FormWithLocation } from "../../../src/context/data"
-import { KVProvider } from "../../../src/context/kv"
 import { SDKProvider } from "../../../src/context/sdk"
 import { ThemeProvider } from "../../../src/context/theme"
 import { ConfigProvider } from "../../../src/config"
@@ -21,7 +20,6 @@ import { createApi, createClient, createEventStream, createFetch } from "../../f
 async function mountForm(root: string, width = 80) {
   const state = path.join(root, "state")
   await mkdir(state, { recursive: true })
-  await Bun.write(path.join(state, "kv.json"), "{}")
 
   const replies: unknown[] = []
   const copied: string[] = []
@@ -78,13 +76,11 @@ async function mountForm(root: string, width = 80) {
           <OpencodeKeymapProvider keymap={keymap}>
             <ConfigProvider config={config}>
               <SDKProvider client={createClient(transport.fetch)} api={createApi(transport.fetch)}>
-                <KVProvider>
-                  <ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
-                    <ToastProvider>
-                      <FormPrompt form={form} />
-                    </ToastProvider>
-                  </ThemeProvider>
-                </KVProvider>
+                <ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
+                  <ToastProvider>
+                    <FormPrompt form={form} />
+                  </ToastProvider>
+                </ThemeProvider>
               </SDKProvider>
             </ConfigProvider>
           </OpencodeKeymapProvider>

+ 0 - 10
packages/tui/test/fixture/tui-plugin.ts

@@ -11,7 +11,6 @@ type Opts = {
 }
 
 export function createTuiPluginApi(opts: Opts = {}) {
-  const values = new Map<string, unknown>()
   const color = RGBA.fromInts(200, 200, 200)
   const dialog = { clear() {}, replace() {}, setSize() {}, size: "medium" as const, depth: 0, open: false }
   return {
@@ -19,15 +18,6 @@ export function createTuiPluginApi(opts: Opts = {}) {
     client: opts.client,
     event: opts.event,
     keymap: opts.keymap,
-    kv: {
-      get(name: string, fallback?: unknown) {
-        return values.has(name) ? values.get(name) : fallback
-      },
-      set(name: string, value: unknown) {
-        values.set(name, value)
-      },
-      ready: true,
-    },
     state: { session: { get: () => undefined, ...opts.state?.session } },
     theme: { current: new Proxy({}, { get: () => color }) },
     tuiConfig: createTuiResolvedConfig(),