From 8daf912fbf6ef7384c356d90242f1d61cf14f233 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Jul 2026 02:48:45 +0000 Subject: [PATCH] feat(tui): add settings dialog --- packages/tui/src/app.tsx | 28 +- packages/tui/src/component/dialog-config.tsx | 443 +++++++++++++++++++ packages/tui/src/config/index.tsx | 5 +- packages/tui/src/context/theme.tsx | 9 + 4 files changed, 478 insertions(+), 7 deletions(-) create mode 100644 packages/tui/src/component/dialog-config.tsx 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()