Compare commits

...

1 Commits

Author SHA1 Message Date
Simon Klee 407687d5ff tui: add settings to command palette search
Settings were only reachable through the config dialog, so users
had to know exact titles or browse categories. Include them in the
palette with keyword search and open the matching setting directly.
2026-07-27 08:26:11 +02:00
4 changed files with 160 additions and 10 deletions
+19 -3
View File
@@ -2,6 +2,7 @@ import { createMemo } from "solid-js"
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
import { type DialogContext } from "../ui/dialog"
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "../context/keymap"
import { DialogConfig, settingID, settings } from "./dialog-config"
function isSuggestedPaletteCommand(command: KeymapCommand) {
const suggested = command.suggested
@@ -16,11 +17,14 @@ export function CommandPaletteDialog() {
const options = createMemo(() =>
commands().flatMap((command) => {
if (!command.id || !command.palette || command.id === COMMAND_PALETTE_COMMAND) return []
const footer = shortcuts.all(command.id)
return {
title: command.title ?? command.id,
description: command.description,
category: command.group,
footer: shortcuts.all(command.id),
searchText: [command.id, command.description].filter(Boolean).join(" "),
searchFooter: [command.group, footer].filter(Boolean).join(" · "),
footer,
value: command.id,
suggested: isSuggestedPaletteCommand(command),
onSelect: (dialog: DialogContext) => {
@@ -30,10 +34,20 @@ export function CommandPaletteDialog() {
}
}),
)
const settingOptions = settings.map((setting) => ({
title: setting.title,
category: setting.category,
searchText: setting.keywords?.join(" "),
searchFooter: `Settings · ${setting.category}`,
value: `setting:${settingID(setting)}`,
onSelect: (dialog: DialogContext) => {
dialog.replace(() => <DialogConfig current={settingID(setting)} />)
},
}))
let ref: DialogSelectRef<string>
const list = () => {
if (ref?.filter) return options()
if (ref?.filter) return [...options(), ...settingOptions]
return [
...options()
.filter((option) => option.suggested)
@@ -46,5 +60,7 @@ export function CommandPaletteDialog() {
]
}
return <DialogSelect ref={(value) => (ref = value)} title="Commands" options={list()} />
return (
<DialogSelect ref={(value) => (ref = value)} title="Commands" options={list()} flat={true} filterThreshold={0.7} />
)
}
+42 -3
View File
@@ -15,14 +15,16 @@ type Setting = {
min?: number
max?: number
format?: (value: unknown) => string
keywords?: readonly string[]
}
const settings: Setting[] = [
export const settings: Setting[] = [
{
title: "Theme",
category: "Appearance",
path: ["theme", "name"],
default: "opencode",
keywords: ["color scheme", "colors"],
},
{
title: "Color mode",
@@ -30,6 +32,7 @@ const settings: Setting[] = [
path: ["theme", "mode"],
default: "system",
values: ["system", "dark", "light"],
keywords: ["dark mode", "light mode", "system theme"],
},
{
title: "Animations",
@@ -38,6 +41,7 @@ const settings: Setting[] = [
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["motion", "effects"],
},
{
title: "Onboarding",
@@ -46,6 +50,7 @@ const settings: Setting[] = [
default: true,
values: [false, true],
labels: ["off", "on"],
keywords: ["hints", "getting started", "guidance"],
},
{
title: "Sidebar",
@@ -53,6 +58,7 @@ const settings: Setting[] = [
path: ["session", "sidebar"],
default: "auto",
values: ["hide", "auto"],
keywords: ["side panel"],
},
{
title: "Scrollbar",
@@ -61,6 +67,7 @@ const settings: Setting[] = [
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["scroll bar"],
},
{
title: "Thinking",
@@ -68,6 +75,7 @@ const settings: Setting[] = [
path: ["session", "thinking"],
default: "hide",
values: ["hide", "show"],
keywords: ["reasoning", "chain of thought"],
},
{
title: "Markdown",
@@ -75,6 +83,7 @@ const settings: Setting[] = [
path: ["session", "markdown"],
default: "rendered",
values: ["source", "rendered"],
keywords: ["syntax", "concealment", "rendering"],
},
{
title: "Grouping",
@@ -82,6 +91,7 @@ const settings: Setting[] = [
path: ["session", "grouping"],
default: "auto",
values: ["none", "auto"],
keywords: ["transcript", "messages"],
},
{
title: "Layout",
@@ -89,6 +99,7 @@ const settings: Setting[] = [
path: ["diffs", "view"],
default: "auto",
values: ["auto", "split", "unified"],
keywords: ["diff layout", "split diff", "unified diff"],
},
{
title: "Wrapping",
@@ -96,6 +107,7 @@ const settings: Setting[] = [
path: ["diffs", "wrap"],
default: "word",
values: ["none", "word"],
keywords: ["diff wrap", "word wrap", "line wrap"],
},
{
title: "File tree",
@@ -104,6 +116,7 @@ const settings: Setting[] = [
default: true,
values: [false, true],
labels: ["off", "on"],
keywords: ["diff files"],
},
{
title: "Single patch",
@@ -112,6 +125,7 @@ const settings: Setting[] = [
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["one file", "selected file"],
},
{
title: "Scroll speed",
@@ -122,6 +136,7 @@ const settings: Setting[] = [
min: 0.25,
max: 10,
format: (value) => Number(value).toFixed(2),
keywords: ["scrolling"],
},
{
title: "Acceleration",
@@ -130,6 +145,7 @@ const settings: Setting[] = [
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["scroll acceleration"],
},
{
title: "Mouse",
@@ -138,6 +154,7 @@ const settings: Setting[] = [
default: true,
values: [false, true],
labels: ["off", "on"],
keywords: ["mouse capture"],
},
{
title: "Editor context",
@@ -146,6 +163,7 @@ const settings: Setting[] = [
default: true,
values: [false, true],
labels: ["off", "on"],
keywords: ["file context", "prompt context", "editor selection"],
},
{
title: "Large pastes",
@@ -153,6 +171,7 @@ const settings: Setting[] = [
path: ["prompt", "paste"],
default: "compact",
values: ["compact", "full"],
keywords: ["paste summary", "clipboard", "pasted content"],
},
{
title: "Leader timeout",
@@ -163,6 +182,7 @@ const settings: Setting[] = [
min: 250,
max: 10000,
format: (value) => `${value} ms`,
keywords: ["leader key", "shortcut timeout"],
},
{
title: "Attention",
@@ -171,6 +191,7 @@ const settings: Setting[] = [
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["alerts"],
},
{
title: "Notifications",
@@ -179,6 +200,7 @@ const settings: Setting[] = [
default: true,
values: [false, true],
labels: ["off", "on"],
keywords: ["system notifications", "desktop notifications", "alerts"],
},
{
title: "Sounds",
@@ -187,6 +209,7 @@ const settings: Setting[] = [
default: true,
values: [false, true],
labels: ["off", "on"],
keywords: ["audio", "sound effects"],
},
{
title: "Volume",
@@ -197,6 +220,7 @@ const settings: Setting[] = [
min: 0,
max: 1,
format: (value) => `${Math.round(Number(value) * 100)}%`,
keywords: ["sound volume", "audio volume"],
},
{
title: "Window title",
@@ -205,6 +229,7 @@ const settings: Setting[] = [
default: true,
values: [false, true],
labels: ["off", "on"],
keywords: ["terminal title", "tab title"],
},
{
title: "Copy on select",
@@ -213,6 +238,7 @@ const settings: Setting[] = [
default: process.platform !== "win32",
values: [false, true],
labels: ["off", "on"],
keywords: ["selection", "clipboard"],
},
{
title: "DevTools",
@@ -221,6 +247,7 @@ const settings: Setting[] = [
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["debug bar", "developer tools"],
},
{
title: "Turn token usage",
@@ -229,14 +256,23 @@ const settings: Setting[] = [
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["tokens", "usage", "debug"],
},
]
export function DialogConfig() {
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 [selected, setSelected] = createSignal(0)
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) => {
@@ -261,6 +297,7 @@ export function DialogConfig() {
settings.map((setting, index) => ({
title: setting.title,
category: setting.category,
searchText: setting.keywords?.join(" "),
footer: display(setting),
value: index,
})),
@@ -292,6 +329,8 @@ export function DialogConfig() {
<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" }]}
+10 -4
View File
@@ -22,6 +22,7 @@ export interface DialogSelectProps<T> {
noMatchView?: JSX.Element
options: DialogSelectOption<T>[]
flat?: boolean
filterThreshold?: number
ref?: (ref: DialogSelectRef<T>) => void
onMove?: (option: DialogSelectOption<T>) => void
onFilter?: (query: string) => void
@@ -64,6 +65,8 @@ export interface DialogSelectOption<T = any> {
titleView?: JSX.Element
value: T
description?: string
searchText?: string
searchFooter?: JSX.Element | string
details?: string[]
detailsColor?: RGBA
detailsWrap?: boolean
@@ -164,12 +167,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
)
if (!needle) return options
// prioritize title matches (weight: 2) over category matches (weight: 1).
// prioritize title matches (weight: 2) over category and supplemental search text matches (weight: 1).
// users typically search by the item name, and not its category.
const result = fuzzysort
.go(needle, options, {
keys: ["title", "category"],
scoreFn: (r) => r[0].score * 2 + r[1].score,
keys: ["title", "category", "searchText"],
scoreFn: (r) => r[0].score * 2 + r[1].score + r[2].score,
threshold: props.filterThreshold,
})
.map((x) => x.obj)
@@ -704,7 +708,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<Option
title={option.title}
titleView={option.titleView}
footer={flatten() ? (option.category ?? option.footer) : option.footer}
footer={
flatten() ? (option.searchFooter ?? option.category ?? option.footer) : option.footer
}
titleWidth={option.titleWidth}
truncateTitle={option.truncateTitle}
description={option.description !== category ? option.description : undefined}
@@ -0,0 +1,89 @@
/** @jsxImportSource @opentui/solid */
import { InputRenderable } from "@opentui/core"
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { onMount } from "solid-js"
import { ConfigProvider, resolve, type Info, type Interface } from "../../../src/config"
import { CommandPaletteDialog } from "../../../src/component/command-palette"
import { Keymap } from "../../../src/context/keymap"
import { ThemeProvider } from "../../../src/context/theme"
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
import { TestTuiContexts } from "../../fixture/tui-environment"
test("searches settings globally and opens the matching setting", async () => {
let current: Info = {}
const service: Interface = {
get: async () => current,
update: async (update) => {
const draft = structuredClone(current)
update(draft)
current = draft
return current
},
}
function Fixture() {
const dialog = useDialog()
Keymap.createLayer(() => ({
mode: "global",
commands: [
{
id: "session.new",
title: "New session",
group: "Session",
palette: true,
run() {},
},
{
id: "model.list",
title: "Switch model",
group: "Agent",
palette: true,
run() {},
},
],
}))
onMount(() => dialog.replace(() => <CommandPaletteDialog />))
return null
}
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={resolve(current, { terminalSuspend: true })} service={service}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ToastProvider>
<DialogProvider>
<Fixture />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
),
{ width: 80, height: 24, kittyKeyboard: true },
)
app.renderer.start()
try {
await app.waitForFrame((frame) => frame.includes("New session"))
expect(app.captureCharFrame()).not.toContain("Animations")
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
for (const key of "side") app.mockInput.pressKey(key)
await app.waitForFrame((frame) => frame.includes("Sidebar"))
expect(app.captureCharFrame()).not.toContain("New session")
expect(app.captureCharFrame()).not.toContain("Switch model")
expect(app.captureCharFrame()).not.toContain("Markdown")
app.mockInput.pressEnter()
await app.waitForFrame((frame) => frame.includes("Settings") && frame.includes("Color mode"))
app.mockInput.pressEnter()
await app.waitFor(() => current.session?.sidebar === "hide")
} finally {
app.renderer.destroy()
}
})