mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-30 08:23:31 +00:00
tui: add settings to command palette search (#39058)
This commit is contained in:
parent
f14d78afeb
commit
766aaf448d
4 changed files with 160 additions and 10 deletions
|
|
@ -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} />
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }]}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
89
packages/tui/test/cli/tui/command-palette.test.tsx
Normal file
89
packages/tui/test/cli/tui/command-palette.test.tsx
Normal 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()
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue