opencode/packages/tui/src/component/dialog-config.tsx

353 lines
9.1 KiB
TypeScript

import { createMemo, createSignal } from "solid-js"
import { useConfig } from "../config"
import { useTheme } from "../context/theme"
import { DialogSelect } from "../ui/dialog-select"
import { useToast } from "../ui/toast"
type Setting = {
title: string
category: string
path: string[]
default: unknown
values?: readonly unknown[]
labels?: readonly string[]
step?: number
min?: number
max?: number
format?: (value: unknown) => string
keywords?: readonly string[]
}
export const settings: Setting[] = [
{
title: "Theme",
category: "Appearance",
path: ["theme", "name"],
default: "opencode",
keywords: ["color scheme", "colors"],
},
{
title: "Color mode",
category: "Appearance",
path: ["theme", "mode"],
default: "system",
values: ["system", "dark", "light"],
keywords: ["dark mode", "light mode", "system theme"],
},
{
title: "Animations",
category: "Appearance",
path: ["animations"],
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["motion", "effects"],
},
{
title: "Onboarding",
category: "Appearance",
path: ["hints", "onboarding"],
default: true,
values: [false, true],
labels: ["off", "on"],
keywords: ["hints", "getting started", "guidance"],
},
{
title: "Sidebar",
category: "Session",
path: ["session", "sidebar"],
default: "auto",
values: ["hide", "auto"],
keywords: ["side panel"],
},
{
title: "Scrollbar",
category: "Session",
path: ["session", "scrollbar"],
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["scroll bar"],
},
{
title: "Thinking",
category: "Session",
path: ["session", "thinking"],
default: "hide",
values: ["hide", "show"],
keywords: ["reasoning", "chain of thought"],
},
{
title: "Markdown",
category: "Session",
path: ["session", "markdown"],
default: "rendered",
values: ["source", "rendered"],
keywords: ["syntax", "concealment", "rendering"],
},
{
title: "Grouping",
category: "Session",
path: ["session", "grouping"],
default: "auto",
values: ["none", "auto"],
keywords: ["transcript", "messages"],
},
{
title: "Layout",
category: "Diffs",
path: ["diffs", "view"],
default: "auto",
values: ["auto", "split", "unified"],
keywords: ["diff layout", "split diff", "unified diff"],
},
{
title: "Wrapping",
category: "Diffs",
path: ["diffs", "wrap"],
default: "word",
values: ["none", "word"],
keywords: ["diff wrap", "word wrap", "line wrap"],
},
{
title: "File tree",
category: "Diffs",
path: ["diffs", "tree"],
default: true,
values: [false, true],
labels: ["off", "on"],
keywords: ["diff files"],
},
{
title: "Single patch",
category: "Diffs",
path: ["diffs", "single"],
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["one file", "selected file"],
},
{
title: "Scroll speed",
category: "Input",
path: ["scroll", "speed"],
default: 3,
step: 0.25,
min: 0.25,
max: 10,
format: (value) => Number(value).toFixed(2),
keywords: ["scrolling"],
},
{
title: "Acceleration",
category: "Input",
path: ["scroll", "acceleration"],
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["scroll acceleration"],
},
{
title: "Mouse",
category: "Input",
path: ["mouse"],
default: true,
values: [false, true],
labels: ["off", "on"],
keywords: ["mouse capture"],
},
{
title: "Editor context",
category: "Input",
path: ["prompt", "editor"],
default: true,
values: [false, true],
labels: ["off", "on"],
keywords: ["file context", "prompt context", "editor selection"],
},
{
title: "Large pastes",
category: "Input",
path: ["prompt", "paste"],
default: "compact",
values: ["compact", "full"],
keywords: ["paste summary", "clipboard", "pasted content"],
},
{
title: "Leader timeout",
category: "Input",
path: ["leader", "timeout"],
default: 2000,
step: 250,
min: 250,
max: 10000,
format: (value) => `${value} ms`,
keywords: ["leader key", "shortcut timeout"],
},
{
title: "Attention",
category: "Alerts",
path: ["attention", "enabled"],
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["alerts"],
},
{
title: "Notifications",
category: "Alerts",
path: ["attention", "notifications"],
default: true,
values: [false, true],
labels: ["off", "on"],
keywords: ["system notifications", "desktop notifications", "alerts"],
},
{
title: "Sounds",
category: "Alerts",
path: ["attention", "sound"],
default: true,
values: [false, true],
labels: ["off", "on"],
keywords: ["audio", "sound effects"],
},
{
title: "Volume",
category: "Alerts",
path: ["attention", "volume"],
default: 0.4,
step: 0.1,
min: 0,
max: 1,
format: (value) => `${Math.round(Number(value) * 100)}%`,
keywords: ["sound volume", "audio volume"],
},
{
title: "Window title",
category: "Terminal",
path: ["terminal", "title"],
default: true,
values: [false, true],
labels: ["off", "on"],
keywords: ["terminal title", "tab title"],
},
{
title: "Copy on select",
category: "Terminal",
path: ["terminal", "copy_on_select"],
default: process.platform !== "win32",
values: [false, true],
labels: ["off", "on"],
keywords: ["selection", "clipboard"],
},
{
title: "DevTools",
category: "Debug",
path: ["debug", "devtools"],
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["debug bar", "developer tools"],
},
{
title: "Turn token usage",
category: "Debug",
path: ["debug", "turn_tokens"],
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["tokens", "usage", "debug"],
},
]
export function settingID(setting: Setting) {
return setting.path.join(".")
}
export function DialogConfig(props: { current?: string }) {
const config = useConfig()
const toast = useToast()
const themeState = useTheme()
const current = Math.max(
0,
settings.findIndex((setting) => settingID(setting) === props.current),
)
const [selected, setSelected] = createSignal(current)
const [saving, setSaving] = createSignal(false)
const value = (setting: Setting) => {
const current = setting.path.reduce<unknown>((result, key) => {
if (!result || typeof result !== "object") return undefined
return (result as Record<string, unknown>)[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 display = (setting: Setting) => {
const current = value(setting)
if (setting.format) return setting.format(current)
const index = setting.values?.indexOf(current)
return index === undefined || index < 0 ? String(current) : (setting.labels?.[index] ?? String(current))
}
const options = createMemo(() =>
settings.map((setting, index) => ({
title: setting.title,
category: setting.category,
searchText: setting.keywords?.join(" "),
footer: display(setting),
value: index,
})),
)
async function change(direction: number, index = selected()) {
if (saving()) return
const setting = settings[index]
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<Record<string, unknown>>((result, key) => {
if (!result[key] || typeof result[key] !== "object") result[key] = {}
return result[key] as Record<string, unknown>
}, draft)
parent[setting.path.at(-1)!] = next
})
.catch(toast.error)
.finally(() => setSaving(false))
}
return (
<DialogSelect
title="Settings"
options={options()}
current={current}
filterThreshold={0.7}
onMove={(option) => setSelected(option.value)}
onSelect={(option) => void change(1, option.value)}
footerHints={[{ title: "←/→", label: "change" }]}
bindings={[
{
bind: "left",
title: "Previous value",
group: "Settings",
run: () => void change(-1),
},
{
bind: "right",
title: "Next value",
group: "Settings",
run: () => void change(1),
},
]}
/>
)
}