tui: add settings to command palette search (#39058)

This commit is contained in:
Simon Klee 2026-07-27 10:45:35 +02:00 committed by GitHub
parent f14d78afeb
commit 766aaf448d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 160 additions and 10 deletions

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

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" }]}

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}

View file

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