Bläddra i källkod

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

Simon Klee 2 veckor sedan
förälder
incheckning
766aaf448d

+ 19 - 3
packages/tui/src/component/command-palette.tsx

@@ -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
packages/tui/src/component/dialog-config.tsx

@@ -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
packages/tui/src/ui/dialog-select.tsx

@@ -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 - 0
packages/tui/test/cli/tui/command-palette.test.tsx

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