Просмотр исходного кода

Add favorites to model selector (#23) (#4343)

Co-authored-by: Github Action <[email protected]>
shuv 2 месяцев назад
Родитель
Сommit
335f46122b

+ 18 - 0
packages/opencode/src/cli/cmd/tui/app.tsx

@@ -245,6 +245,24 @@ function App() {
         local.model.cycle(-1)
       },
     },
+    {
+      title: "Favorite cycle",
+      value: "model.cycle_favorite",
+      keybind: "model_cycle_favorite",
+      category: "Agent",
+      onSelect: () => {
+        local.model.cycleFavorite(1)
+      },
+    },
+    {
+      title: "Favorite cycle reverse",
+      value: "model.cycle_favorite_reverse",
+      keybind: "model_cycle_favorite_reverse",
+      category: "Agent",
+      onSelect: () => {
+        local.model.cycleFavorite(-1)
+      },
+    },
     {
       title: "Switch agent",
       value: "agent.list",

+ 124 - 29
packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx

@@ -1,10 +1,11 @@
 import { createMemo, createSignal } from "solid-js"
 import { useLocal } from "@tui/context/local"
 import { useSync } from "@tui/context/sync"
-import { map, pipe, flatMap, entries, filter, isDeepEqual, sortBy, take } from "remeda"
+import { map, pipe, flatMap, entries, filter, sortBy, take } from "remeda"
 import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
 import { useDialog } from "@tui/ui/dialog"
 import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
+import { Keybind } from "@/util/keybind"
 
 export function DialogModel() {
   const local = useLocal()
@@ -16,14 +17,45 @@ export function DialogModel() {
     sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)),
   )
 
-  const showRecent = createMemo(() => !ref()?.filter && local.model.recent().length > 0 && connected())
   const providers = createDialogProviderOptions()
 
   const options = createMemo(() => {
-    return [
-      ...(showRecent()
-        ? local.model.recent().flatMap((item) => {
-            const provider = sync.data.provider.find((x) => x.id === item.providerID)!
+    const query = ref()?.filter
+    const favorites = local.model.favorite()
+    const recents = local.model.recent()
+    const currentModel = local.model.current()
+
+    const orderedRecents = currentModel
+      ? [
+          currentModel,
+          ...recents.filter(
+            (item) => item.providerID !== currentModel.providerID || item.modelID !== currentModel.modelID,
+          ),
+        ]
+      : recents
+
+    const isCurrent = (item: { providerID: string; modelID: string }) =>
+      currentModel && item.providerID === currentModel.providerID && item.modelID === currentModel.modelID
+
+    const currentIsFavorite = currentModel && favorites.some((fav) => isCurrent(fav))
+
+    const recentList = orderedRecents
+      .filter((item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID))
+      .slice(0, 5)
+
+    const orderedFavorites = currentModel
+      ? [...favorites.filter((item) => isCurrent(item)), ...favorites.filter((item) => !isCurrent(item))]
+      : favorites
+
+    const orderedRecentList =
+      currentModel && !currentIsFavorite
+        ? [...recentList.filter((item) => isCurrent(item)), ...recentList.filter((item) => !isCurrent(item))]
+        : recentList
+
+    const favoriteOptions =
+      !query && favorites.length > 0
+        ? orderedFavorites.flatMap((item) => {
+            const provider = sync.data.provider.find((x) => x.id === item.providerID)
             if (!provider) return []
             const model = provider.models[item.modelID]
             if (!model) return []
@@ -35,8 +67,9 @@ export function DialogModel() {
                   modelID: model.id,
                 },
                 title: model.name ?? item.modelID,
-                description: provider.name,
-                category: "Recent",
+                description: `${provider.name} ★`,
+                category: "Favorites",
+                disabled: provider.id === "opencode" && model.id.includes("-nano"),
                 footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
                 onSelect: () => {
                   dialog.clear()
@@ -51,7 +84,44 @@ export function DialogModel() {
               },
             ]
           })
-        : []),
+        : []
+
+    const recentOptions = !query
+      ? orderedRecentList.flatMap((item) => {
+          const provider = sync.data.provider.find((x) => x.id === item.providerID)
+          if (!provider) return []
+          const model = provider.models[item.modelID]
+          if (!model) return []
+          return [
+            {
+              key: item,
+              value: {
+                providerID: provider.id,
+                modelID: model.id,
+              },
+              title: model.name ?? item.modelID,
+              description: provider.name,
+              category: "Recent",
+              disabled: provider.id === "opencode" && model.id.includes("-nano"),
+              footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
+              onSelect: () => {
+                dialog.clear()
+                local.model.set(
+                  {
+                    providerID: provider.id,
+                    modelID: model.id,
+                  },
+                  { recent: true },
+                )
+              },
+            },
+          ]
+        })
+      : []
+
+    return [
+      ...favoriteOptions,
+      ...recentOptions,
       ...pipe(
         sync.data.provider,
         sortBy(
@@ -62,28 +132,46 @@ export function DialogModel() {
           pipe(
             provider.models,
             entries(),
-            map(([model, info]) => ({
-              value: {
+            map(([model, info]) => {
+              const value = {
                 providerID: provider.id,
                 modelID: model,
-              },
-              title: info.name ?? model,
-              description: connected() ? provider.name : undefined,
-              category: connected() ? provider.name : undefined,
-              disabled: provider.id === "opencode" && model.includes("-nano"),
-              footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
-              onSelect() {
-                dialog.clear()
-                local.model.set(
-                  {
-                    providerID: provider.id,
-                    modelID: model,
-                  },
-                  { recent: true },
-                )
-              },
-            })),
-            filter((x) => !showRecent() || !local.model.recent().find((y) => isDeepEqual(y, x.value))),
+              }
+              const favorite = favorites.some(
+                (item) => item.providerID === value.providerID && item.modelID === value.modelID,
+              )
+              return {
+                value,
+                title: info.name ?? model,
+                description: connected() ? `${provider.name}${favorite ? " ★" : ""}` : undefined,
+                category: connected() ? provider.name : undefined,
+                disabled: provider.id === "opencode" && model.includes("-nano"),
+                footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
+                onSelect() {
+                  dialog.clear()
+                  local.model.set(
+                    {
+                      providerID: provider.id,
+                      modelID: model,
+                    },
+                    { recent: true },
+                  )
+                },
+              }
+            }),
+            filter((x) => {
+              if (query) return true
+              const value = x.value
+              const inFavorites = favorites.some(
+                (item) => item.providerID === value.providerID && item.modelID === value.modelID,
+              )
+              const inRecents = orderedRecents.some(
+                (item) => item.providerID === value.providerID && item.modelID === value.modelID,
+              )
+              if (inFavorites) return false
+              if (inRecents) return false
+              return true
+            }),
             sortBy((x) => x.title),
           ),
         ),
@@ -113,6 +201,13 @@ export function DialogModel() {
             dialog.replace(() => <DialogProvider />)
           },
         },
+        {
+          keybind: Keybind.parse("ctrl+f")[0],
+          title: "Favorite",
+          onTrigger: (option) => {
+            local.model.toggleFavorite(option.value as { providerID: string; modelID: string })
+          },
+        },
       ]}
       ref={setRef}
       title="Select model"

+ 69 - 8
packages/opencode/src/cli/cmd/tui/context/local.tsx

@@ -114,18 +114,34 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
           providerID: string
           modelID: string
         }[]
+        favorite: {
+          providerID: string
+          modelID: string
+        }[]
       }>({
         ready: false,
         model: {},
         recent: [],
+        favorite: [],
       })
 
       const file = Bun.file(path.join(Global.Path.state, "model.json"))
 
+      function save() {
+        Bun.write(
+          file,
+          JSON.stringify({
+            recent: modelStore.recent,
+            favorite: modelStore.favorite,
+          }),
+        )
+      }
+
       file
         .json()
         .then((x) => {
-          setModelStore("recent", x.recent)
+          if (Array.isArray(x.recent)) setModelStore("recent", x.recent)
+          if (Array.isArray(x.favorite)) setModelStore("favorite", x.favorite)
         })
         .catch(() => {})
         .finally(() => {
@@ -184,6 +200,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
         recent() {
           return modelStore.recent
         },
+        favorite() {
+          return modelStore.favorite
+        },
         parsed: createMemo(() => {
           const value = currentModel()
           const provider = sync.data.provider.find((x) => x.id === value.providerID)!
@@ -206,6 +225,33 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
           if (!val) return
           setModelStore("model", agent.current().name, { ...val })
         },
+        cycleFavorite(direction: 1 | -1) {
+          const favorites = modelStore.favorite.filter((item) => isModelValid(item))
+          if (!favorites.length) {
+            toast.show({
+              variant: "info",
+              message: "Add a favorite model to use this shortcut",
+              duration: 3000,
+            })
+            return
+          }
+          const current = currentModel()
+          let index = favorites.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
+          if (index === -1) {
+            index = direction === 1 ? 0 : favorites.length - 1
+          } else {
+            index += direction
+            if (index < 0) index = favorites.length - 1
+            if (index >= favorites.length) index = 0
+          }
+          const next = favorites[index]
+          if (!next) return
+          setModelStore("model", agent.current().name, { ...next })
+          const uniq = uniqueBy([next, ...modelStore.recent], (x) => x.providerID + x.modelID)
+          if (uniq.length > 10) uniq.pop()
+          setModelStore("recent", uniq)
+          save()
+        },
         set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
           batch(() => {
             if (!isModelValid(model)) {
@@ -219,15 +265,30 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
             setModelStore("model", agent.current().name, model)
             if (options?.recent) {
               const uniq = uniqueBy([model, ...modelStore.recent], (x) => x.providerID + x.modelID)
-              if (uniq.length > 5) uniq.pop()
+              if (uniq.length > 10) uniq.pop()
               setModelStore("recent", uniq)
-              Bun.write(
-                file,
-                JSON.stringify({
-                  recent: modelStore.recent,
-                }),
-              )
+              save()
+            }
+          })
+        },
+        toggleFavorite(model: { providerID: string; modelID: string }) {
+          batch(() => {
+            if (!isModelValid(model)) {
+              toast.show({
+                message: `Model ${model.providerID}/${model.modelID} is not valid`,
+                variant: "warning",
+                duration: 3000,
+              })
+              return
             }
+            const exists = modelStore.favorite.some(
+              (x) => x.providerID === model.providerID && x.modelID === model.modelID,
+            )
+            const next = exists
+              ? modelStore.favorite.filter((x) => x.providerID !== model.providerID || x.modelID !== model.modelID)
+              : [model, ...modelStore.favorite]
+            setModelStore("favorite", next)
+            save()
           })
         },
       }

+ 1 - 1
packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx

@@ -253,7 +253,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
           )}
         </For>
       </scrollbox>
-      <box paddingRight={2} paddingLeft={4} flexDirection="row" paddingBottom={1} gap={1}>
+      <box paddingRight={2} paddingLeft={4} flexDirection="row" paddingBottom={1} gap={2}>
         <For each={props.keybind ?? []}>
           {(item) => (
             <text>

+ 2 - 0
packages/opencode/src/config/config.ts

@@ -428,6 +428,8 @@ export namespace Config {
       model_list: z.string().optional().default("<leader>m").describe("List available models"),
       model_cycle_recent: z.string().optional().default("f2").describe("Next recently used model"),
       model_cycle_recent_reverse: z.string().optional().default("shift+f2").describe("Previous recently used model"),
+      model_cycle_favorite: z.string().optional().default("none").describe("Next favorite model"),
+      model_cycle_favorite_reverse: z.string().optional().default("none").describe("Previous favorite model"),
       command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
       agent_list: z.string().optional().default("<leader>a").describe("List agents"),
       agent_cycle: z.string().optional().default("tab").describe("Next agent"),

+ 6 - 0
packages/sdk/go/config.go

@@ -1935,6 +1935,10 @@ type KeybindsConfig struct {
 	ModelCycleRecent string `json:"model_cycle_recent"`
 	// Previous recent model
 	ModelCycleRecentReverse string `json:"model_cycle_recent_reverse"`
+	// Next favorite model
+	ModelCycleFavorite string `json:"model_cycle_favorite"`
+	// Previous favorite model
+	ModelCycleFavoriteReverse string `json:"model_cycle_favorite_reverse"`
 	// List available models
 	ModelList string `json:"model_list"`
 	// Create/update AGENTS.md
@@ -2008,6 +2012,8 @@ type keybindsConfigJSON struct {
 	MessagesUndo             apijson.Field
 	ModelCycleRecent         apijson.Field
 	ModelCycleRecentReverse  apijson.Field
+	ModelCycleFavorite       apijson.Field
+	ModelCycleFavoriteReverse apijson.Field
 	ModelList                apijson.Field
 	ProjectInit              apijson.Field
 	SessionChildCycle        apijson.Field

+ 8 - 0
packages/sdk/js/src/gen/types.gen.ts

@@ -811,6 +811,14 @@ export type KeybindsConfig = {
    * Previous recently used model
    */
   model_cycle_recent_reverse?: string
+  /**
+   * Next favorite model
+   */
+  model_cycle_favorite?: string
+  /**
+   * Previous favorite model
+   */
+  model_cycle_favorite_reverse?: string
   /**
    * List available commands
    */

+ 18 - 0
packages/sdk/python/src/opencode_ai/models/keybinds_config.py

@@ -43,6 +43,8 @@ class KeybindsConfig:
         model_list (Union[Unset, str]): List available models Default: '<leader>m'.
         model_cycle_recent (Union[Unset, str]): Next recent model Default: 'f2'.
         model_cycle_recent_reverse (Union[Unset, str]): Previous recent model Default: 'shift+f2'.
+        model_cycle_favorite (Union[Unset, str]): Next favorite model Default: 'none'.
+        model_cycle_favorite_reverse (Union[Unset, str]): Previous favorite model Default: 'none'.
         agent_list (Union[Unset, str]): List agents Default: '<leader>a'.
         agent_cycle (Union[Unset, str]): Next agent Default: 'tab'.
         agent_cycle_reverse (Union[Unset, str]): Previous agent Default: 'shift+tab'.
@@ -95,6 +97,8 @@ class KeybindsConfig:
     model_list: Union[Unset, str] = "<leader>m"
     model_cycle_recent: Union[Unset, str] = "f2"
     model_cycle_recent_reverse: Union[Unset, str] = "shift+f2"
+    model_cycle_favorite: Union[Unset, str] = "none"
+    model_cycle_favorite_reverse: Union[Unset, str] = "none"
     agent_list: Union[Unset, str] = "<leader>a"
     agent_cycle: Union[Unset, str] = "tab"
     agent_cycle_reverse: Union[Unset, str] = "shift+tab"
@@ -176,6 +180,10 @@ class KeybindsConfig:
 
         model_cycle_recent_reverse = self.model_cycle_recent_reverse
 
+        model_cycle_favorite = self.model_cycle_favorite
+
+        model_cycle_favorite_reverse = self.model_cycle_favorite_reverse
+
         agent_list = self.agent_list
 
         agent_cycle = self.agent_cycle
@@ -277,6 +285,10 @@ class KeybindsConfig:
             field_dict["model_cycle_recent"] = model_cycle_recent
         if model_cycle_recent_reverse is not UNSET:
             field_dict["model_cycle_recent_reverse"] = model_cycle_recent_reverse
+        if model_cycle_favorite is not UNSET:
+            field_dict["model_cycle_favorite"] = model_cycle_favorite
+        if model_cycle_favorite_reverse is not UNSET:
+            field_dict["model_cycle_favorite_reverse"] = model_cycle_favorite_reverse
         if agent_list is not UNSET:
             field_dict["agent_list"] = agent_list
         if agent_cycle is not UNSET:
@@ -381,6 +393,10 @@ class KeybindsConfig:
 
         model_cycle_recent_reverse = d.pop("model_cycle_recent_reverse", UNSET)
 
+        model_cycle_favorite = d.pop("model_cycle_favorite", UNSET)
+
+        model_cycle_favorite_reverse = d.pop("model_cycle_favorite_reverse", UNSET)
+
         agent_list = d.pop("agent_list", UNSET)
 
         agent_cycle = d.pop("agent_cycle", UNSET)
@@ -450,6 +466,8 @@ class KeybindsConfig:
             model_list=model_list,
             model_cycle_recent=model_cycle_recent,
             model_cycle_recent_reverse=model_cycle_recent_reverse,
+            model_cycle_favorite=model_cycle_favorite,
+            model_cycle_favorite_reverse=model_cycle_favorite_reverse,
             agent_list=agent_list,
             agent_cycle=agent_cycle,
             agent_cycle_reverse=agent_cycle_reverse,

+ 5 - 1
packages/ui/src/components/select-dialog.tsx

@@ -14,6 +14,7 @@ interface SelectDialogProps<T>
   emptyMessage?: string
   children: (item: T) => JSX.Element
   onSelect?: (value: T | undefined) => void
+  onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void
 }
 
 export function SelectDialog<T>(props: SelectDialogProps<T>) {
@@ -65,9 +66,12 @@ export function SelectDialog<T>(props: SelectDialogProps<T>) {
     setStore("mouseActive", false)
     if (e.key === "Escape") return
 
+    const all = flat()
+    const selected = all.find((x) => others.key(x) === active())
+    props.onKeyEvent?.(e, selected)
+
     if (e.key === "Enter") {
       e.preventDefault()
-      const selected = flat().find((x) => others.key(x) === active())
       if (selected) handleSelect(selected)
     } else {
       onKeyDown(e)

+ 2 - 0
packages/web/src/content/docs/keybinds.mdx

@@ -38,6 +38,8 @@ OpenCode has a list of keybinds that you can customize through the OpenCode conf
     "model_list": "<leader>m",
     "model_cycle_recent": "f2",
     "model_cycle_recent_reverse": "shift+f2",
+    "model_cycle_favorite": "none",
+    "model_cycle_favorite_reverse": "none",
     "command_list": "ctrl+p",
     "agent_list": "<leader>a",
     "agent_cycle": "tab",