diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx
index 568d9da639..a1a286aaf4 100644
--- a/packages/tui/src/app.tsx
+++ b/packages/tui/src/app.tsx
@@ -53,6 +53,7 @@ import { DialogModel } from "./component/dialog-model"
import { useConnected } from "./component/use-connected"
import { DialogMcp } from "./component/dialog-mcp"
import { DialogStatus } from "./component/dialog-status"
+import { DialogConfig } from "./component/dialog-config"
import { DialogDebug } from "./component/dialog-debug"
import { DialogPair, type DialogPairCredentials } from "./component/dialog-pair"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
@@ -218,7 +219,9 @@ function fromV1(config: TuiConfigV1.Resolved): Config.Info {
}
function isConfigInterface(config: Config.Interface | TuiConfigV1.Resolved): config is Config.Interface {
- return "get" in config && typeof config.get === "function" && "update" in config && typeof config.update === "function"
+ return (
+ "get" in config && typeof config.get === "function" && "update" in config && typeof config.update === "function"
+ )
}
export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
@@ -477,7 +480,8 @@ function App(props: {
}) {
const log = useLog({ component: "app" })
const startup = useTuiStartup()
- const config = useConfig().data
+ const configContext = useConfig()
+ const config = configContext.data
const route = useRoute()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
@@ -587,10 +591,12 @@ function App(props: {
renderer.clearSelection()
}
- const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
- const [pasteSummaryEnabled, setPasteSummaryEnabled] = createSignal(
- kv.get("paste_summary_enabled", true),
- )
+ const [terminalTitleEnabled, setTerminalTitleEnabled] = kv.signal("terminal_title_enabled", true)
+ const [pasteSummaryEnabled, setPasteSummaryEnabled] = kv.signal("paste_summary_enabled", true)
+
+ createEffect(() => {
+ renderer.useMouse = !Flag.OPENCODE_DISABLE_MOUSE && config.mouse
+ })
// Update terminal window title based on current route and session
createEffect(() => {
@@ -849,6 +855,16 @@ function App(props: {
},
category: "Integration",
},
+ {
+ name: "opencode.settings",
+ title: "Open settings",
+ slashName: "settings",
+ enabled: configContext.writable,
+ run: () => {
+ dialog.replace(() => )
+ },
+ category: "System",
+ },
{
name: "opencode.status",
title: "View status",
diff --git a/packages/tui/src/component/dialog-config.tsx b/packages/tui/src/component/dialog-config.tsx
new file mode 100644
index 0000000000..dbe653ac42
--- /dev/null
+++ b/packages/tui/src/component/dialog-config.tsx
@@ -0,0 +1,443 @@
+import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
+import { useTerminalDimensions } from "@opentui/solid"
+import { createMemo, createSignal, For, onMount, Show } from "solid-js"
+import { useConfig } from "../config"
+import { useTheme } from "../context/theme"
+import { useBindings } from "../keymap"
+import { useDialog } from "../ui/dialog"
+import { useToast } from "../ui/toast"
+
+type Setting = {
+ title: string
+ category: string
+ description: string
+ detail?: string
+ path: string[]
+ default: unknown
+ values?: readonly unknown[]
+ labels?: readonly string[]
+ step?: number
+ min?: number
+ max?: number
+ format?: (value: unknown) => string
+}
+
+const settings: Setting[] = [
+ {
+ title: "Theme",
+ category: "Appearance",
+ description: "Interface color theme",
+ detail:
+ "Choose the color theme used throughout OpenCode. Custom themes discovered from your config directory appear here alongside the built-in themes.",
+ path: ["theme", "name"],
+ default: "opencode",
+ },
+ {
+ title: "Color mode",
+ category: "Appearance",
+ description: "Terminal color preference",
+ detail:
+ "Choose how OpenCode selects its colors. System follows your terminal preference, while dark and light keep the interface in a fixed mode.",
+ path: ["theme", "mode"],
+ default: "system",
+ values: ["system", "dark", "light"],
+ },
+ {
+ title: "Animations",
+ category: "Appearance",
+ description: "Interface motion",
+ path: ["animations"],
+ default: true,
+ values: [false, true],
+ labels: ["off", "on"],
+ },
+ {
+ title: "Tips",
+ category: "Appearance",
+ description: "Home screen hints",
+ path: ["hints", "tips"],
+ default: true,
+ values: [false, true],
+ labels: ["off", "on"],
+ },
+ {
+ title: "Onboarding",
+ category: "Appearance",
+ description: "Getting-started guidance",
+ path: ["hints", "onboarding"],
+ default: true,
+ values: [false, true],
+ labels: ["off", "on"],
+ },
+ {
+ title: "Sidebar",
+ category: "Session",
+ description: "Session sidebar visibility",
+ path: ["session", "sidebar"],
+ default: "auto",
+ values: ["hide", "auto"],
+ },
+ {
+ title: "Scrollbar",
+ category: "Session",
+ description: "Transcript scrollbar",
+ path: ["session", "scrollbar"],
+ default: false,
+ values: [false, true],
+ labels: ["off", "on"],
+ },
+ {
+ title: "Thinking",
+ category: "Session",
+ description: "Model reasoning by default",
+ path: ["session", "thinking"],
+ default: "hide",
+ values: ["hide", "show"],
+ },
+ {
+ title: "Grouping",
+ category: "Session",
+ description: "Related transcript items",
+ path: ["session", "grouping"],
+ default: "auto",
+ values: ["none", "auto"],
+ },
+ {
+ title: "Layout",
+ category: "Diffs",
+ description: "Diff presentation",
+ path: ["diffs", "view"],
+ default: "auto",
+ values: ["auto", "split", "unified"],
+ },
+ {
+ title: "Wrapping",
+ category: "Diffs",
+ description: "Long diff lines",
+ path: ["diffs", "wrap"],
+ default: "word",
+ values: ["none", "word"],
+ },
+ {
+ title: "File tree",
+ category: "Diffs",
+ description: "Diff file navigation",
+ path: ["diffs", "tree"],
+ default: true,
+ values: [false, true],
+ labels: ["off", "on"],
+ },
+ {
+ title: "Single patch",
+ category: "Diffs",
+ description: "Only the selected patch",
+ path: ["diffs", "single"],
+ default: false,
+ values: [false, true],
+ labels: ["off", "on"],
+ },
+ {
+ title: "Scroll speed",
+ category: "Input",
+ description: "Distance per input tick",
+ path: ["scroll", "speed"],
+ default: 3,
+ step: 0.25,
+ min: 0.25,
+ max: 10,
+ format: (value) => Number(value).toFixed(2),
+ },
+ {
+ title: "Acceleration",
+ category: "Input",
+ description: "Repeated scrolling",
+ path: ["scroll", "acceleration"],
+ default: false,
+ values: [false, true],
+ labels: ["off", "on"],
+ },
+ {
+ title: "Mouse",
+ category: "Input",
+ description: "Terminal mouse capture",
+ path: ["mouse"],
+ default: true,
+ values: [false, true],
+ labels: ["off", "on"],
+ },
+ {
+ title: "Editor context",
+ category: "Input",
+ description: "Active selection in prompts",
+ path: ["prompt", "editor"],
+ default: true,
+ values: [false, true],
+ labels: ["off", "on"],
+ },
+ {
+ title: "Large pastes",
+ category: "Input",
+ description: "Paste display style",
+ path: ["prompt", "paste"],
+ default: "compact",
+ values: ["compact", "full"],
+ },
+ {
+ title: "Leader timeout",
+ category: "Input",
+ description: "Wait after leader key",
+ path: ["leader", "timeout"],
+ default: 2000,
+ step: 250,
+ min: 250,
+ max: 10000,
+ format: (value) => `${value} ms`,
+ },
+ {
+ title: "Attention",
+ category: "Alerts",
+ description: "Alerts when input is needed",
+ path: ["attention", "enabled"],
+ default: false,
+ values: [false, true],
+ labels: ["off", "on"],
+ },
+ {
+ title: "Notifications",
+ category: "Alerts",
+ description: "System notifications",
+ path: ["attention", "notifications"],
+ default: true,
+ values: [false, true],
+ labels: ["off", "on"],
+ },
+ {
+ title: "Sounds",
+ category: "Alerts",
+ description: "Attention sounds",
+ path: ["attention", "sound"],
+ default: true,
+ values: [false, true],
+ labels: ["off", "on"],
+ },
+ {
+ title: "Volume",
+ category: "Alerts",
+ description: "Attention sound level",
+ path: ["attention", "volume"],
+ default: 0.4,
+ step: 0.1,
+ min: 0,
+ max: 1,
+ format: (value) => `${Math.round(Number(value) * 100)}%`,
+ },
+ {
+ title: "Window title",
+ category: "Terminal",
+ description: "Update terminal title",
+ path: ["terminal", "title"],
+ default: true,
+ values: [false, true],
+ labels: ["off", "on"],
+ },
+]
+
+export function DialogConfig() {
+ const config = useConfig()
+ const dialog = useDialog()
+ const toast = useToast()
+ const themeState = useTheme()
+ const { theme } = themeState
+ const dimensions = useTerminalDimensions()
+ const [selected, setSelected] = createSignal(0)
+ const [saving, setSaving] = createSignal(false)
+ let scroll: ScrollBoxRenderable | undefined
+ onMount(() => {
+ dialog.setSize("xlarge")
+ dialog.setCentered(true)
+ })
+
+ const value = (setting: Setting) => {
+ const current = setting.path.reduce((result, key) => {
+ if (!result || typeof result !== "object") return undefined
+ return (result as Record)[key]
+ }, config.data)
+ if (setting.path.join(".") === "theme.name") return current ?? themeState.selected
+ return current ?? setting.default
+ }
+ const values = (setting: Setting) =>
+ setting.path.join(".") === "theme.name"
+ ? Object.keys(themeState.all()).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
+ : setting.values
+ const compact = (setting: Setting) => setting.path.join(".") === "theme.name" || !setting.values
+ const display = (setting: Setting) => setting.format?.(value(setting)) ?? String(value(setting))
+ const rows = createMemo(() =>
+ settings.map((setting, index) => ({
+ setting,
+ index,
+ heading: index === 0 || settings[index - 1].category !== setting.category,
+ })),
+ )
+ const split = createMemo(() => dimensions().width >= 110)
+ const height = createMemo(() => Math.max(8, Math.min(36, dimensions().height - 12)))
+
+ function move(direction: number) {
+ const next = (selected() + direction + settings.length) % settings.length
+ setSelected(next)
+ queueMicrotask(() => {
+ if (!scroll) return
+ const row =
+ next +
+ settings.slice(0, next + 1).filter((setting, index) => {
+ return index === 0 || settings[index - 1].category !== setting.category
+ }).length
+ if (row < scroll.scrollTop) scroll.scrollTo(row)
+ if (row >= scroll.scrollTop + scroll.viewport.height) scroll.scrollTo(row - scroll.viewport.height + 1)
+ })
+ }
+
+ async function change(direction: number) {
+ if (saving()) return
+ const setting = settings[selected()]
+ const current = value(setting)
+ const choices = values(setting)
+ const next = choices
+ ? choices[(choices.indexOf(current) + direction + choices.length) % choices.length]
+ : Math.min(setting.max!, Math.max(setting.min!, Number(current) + direction * setting.step!))
+ if (next === current) return
+ setSaving(true)
+ await config
+ .update((draft) => {
+ const parent = setting.path.slice(0, -1).reduce>((result, key) => {
+ if (!result[key] || typeof result[key] !== "object") result[key] = {}
+ return result[key] as Record
+ }, draft)
+ parent[setting.path.at(-1)!] = next
+ })
+ .catch(toast.error)
+ .finally(() => setSaving(false))
+ }
+
+ useBindings(() => ({
+ bindings: [
+ {
+ key: "up",
+ desc: "Previous setting",
+ group: "Settings",
+ cmd: () => move(-1),
+ },
+ {
+ key: "down",
+ desc: "Next setting",
+ group: "Settings",
+ cmd: () => move(1),
+ },
+ { key: "left", desc: "Previous value", group: "Settings", cmd: () => void change(-1) },
+ { key: "right", desc: "Next value", group: "Settings", cmd: () => void change(1) },
+ { key: "return", desc: "Next value", group: "Settings", cmd: () => void change(1) },
+ ],
+ }))
+
+ return (
+
+
+
+ Settings
+
+ (scroll = element)}
+ flexGrow={1}
+ scrollbarOptions={{ visible: false }}
+ >
+
+ {(row) => (
+ <>
+
+
+
+ {row.setting.category}
+
+
+
+
+
+ {row.setting.title}
+
+
+
+
+ ‹
+
+
+ {display(row.setting)}
+
+
+ ›
+
+
+ }
+ >
+
+
+ {(option, optionIndex) => {
+ const active = () => value(row.setting) === option
+ return (
+
+ {row.setting.labels?.[optionIndex()] ?? String(option)}
+
+ )
+ }}
+
+
+
+
+
+ >
+ )}
+
+
+
+
+
+
+
+ {settings[selected()].title}
+
+ dialog.clear()}>
+ esc
+
+
+
+
+ {settings[selected()].detail ?? settings[selected()].description}
+
+
+
+
+
+ )
+}
diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx
index 74fb00f7d7..94715d7212 100644
--- a/packages/tui/src/config/index.tsx
+++ b/packages/tui/src/config/index.tsx
@@ -179,6 +179,7 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
const ConfigContext = createContext<{
data: Resolved
update: Interface["update"]
+ writable: boolean
}>()
export function ConfigProvider(props: {
@@ -195,7 +196,9 @@ export function ConfigProvider(props: {
setConfig(reconcile(resolve(info, props.options ?? { terminalSuspend: true })))
return info
}
- return {props.children}
+ return (
+ {props.children}
+ )
}
export function useConfig() {
diff --git a/packages/tui/src/context/theme.tsx b/packages/tui/src/context/theme.tsx
index 5d72ea3bbc..a2c180feac 100644
--- a/packages/tui/src/context/theme.tsx
+++ b/packages/tui/src/context/theme.tsx
@@ -129,6 +129,15 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
if (theme) setStore("active", theme)
})
+ createEffect(() => {
+ const mode = config.theme?.mode
+ if (mode === "dark" || mode === "light") {
+ pin(mode)
+ return
+ }
+ if (mode === "system" && store.lock !== undefined) free()
+ })
+
function syncCustomThemes() {
return themes
.discover()