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

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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