Ver Fonte

#6 Favorites models

paviko há 6 dias atrás
pai
commit
58ef39a509

+ 1 - 1
packages/opencode/src/server/server.ts

@@ -44,7 +44,7 @@ import { websocket } from "hono/bun"
 import { HTTPException } from "hono/http-exception"
 import { errors } from "./error"
 import { Pty } from "@/pty"
-import * as State from "@/webgui/state/state"
+
 import path from "path"
 import * as fs from "fs"
 import { fileURLToPath } from "url"

+ 113 - 93
packages/opencode/src/webgui/server/webgui.ts

@@ -3,8 +3,8 @@ import { Hono } from "hono"
 import { validator, describeRoute, resolver } from "hono-openapi"
 import { stream } from "hono/streaming"
 import { z } from "zod"
-import * as State from "@/webgui/state/state.ts"
-import { StateSchema } from "@/webgui/state/state.ts"
+import path from "path"
+import { Global } from "../../global"
 import { ModelsDev } from "../../provider/models"
 import { Auth } from "../../auth"
 import { Instance } from "../../project/instance"
@@ -13,8 +13,6 @@ import { Provider } from "@/provider/provider.ts"
 import { SessionPrompt } from "../../session/prompt"
 import { MessageV2 } from "../../session/message-v2"
 
-const StatePatchSchema = StateSchema.partial()
-
 type AuthSession = {
   status: "pending" | "success" | "failed"
   result?: any
@@ -396,53 +394,76 @@ export const WebGuiRoute = new Hono()
     },
   )
   .get(
-    "/state",
+    "/kv",
+    describeRoute({
+      description: "Get key-value preferences from kv.json (shared with CLI)",
+      operationId: "kv.get",
+      responses: {
+        200: {
+          description: "KV store contents",
+          content: {
+            "application/json": {
+              schema: resolver(z.record(z.string(), z.any())),
+            },
+          },
+        },
+      },
+    }),
+    async (c) => {
+      const file = Bun.file(path.join(Global.Path.state, "kv.json"))
+      if (!(await file.exists())) return c.json({})
+      try {
+        return c.json(await file.json())
+      } catch {
+        return c.json({})
+      }
+    },
+  )
+  .patch(
+    "/kv",
+    describeRoute({
+      description: "Update key-value preferences in kv.json (shared with CLI)",
+      operationId: "kv.update",
+      responses: {
+        200: {
+          description: "Updated KV store",
+          content: {
+            "application/json": {
+              schema: resolver(z.record(z.string(), z.any())),
+            },
+          },
+        },
+      },
+    }),
+    validator("json", z.record(z.string(), z.any())),
+    async (c) => {
+      const partial = c.req.valid("json")
+      const file = Bun.file(path.join(Global.Path.state, "kv.json"))
+      let existing: Record<string, any> = {}
+      try {
+        if (await file.exists()) existing = await file.json()
+      } catch {}
+      const merged = { ...existing, ...partial }
+      await Bun.write(file, JSON.stringify(merged, null, 2))
+      return c.json(merged)
+    },
+  )
+  .get(
+    "/model",
     describeRoute({
-      description: "Get TUI state (theme, model, agent preferences)",
-      operationId: "state.get",
+      description: "Get model preferences (recent, favorite, variant) from model.json",
+      operationId: "model.get",
       responses: {
         200: {
-          description: "TUI state",
+          description: "Model preferences",
           content: {
             "application/json": {
               schema: resolver(
-                z
-                  .object({
-                    theme: z.string().optional(),
-                    agent_model: z
-                      .record(
-                        z.string(),
-                        z.object({
-                          provider_id: z.string(),
-                          model_id: z.string(),
-                        }),
-                      )
-                      .optional(),
-                    provider: z.string().optional(),
-                    model: z.string().optional(),
-                    agent: z.string().optional(),
-                    variant: z.record(z.string(), z.string()).optional(),
-                    recently_used_models: z
-                      .array(
-                        z.object({
-                          provider_id: z.string(),
-                          model_id: z.string(),
-                          last_used: z.string(),
-                        }),
-                      )
-                      .optional(),
-                    recently_used_agents: z
-                      .array(
-                        z.object({
-                          agent_name: z.string(),
-                          last_used: z.string(),
-                        }),
-                      )
-                      .optional(),
-                    show_tool_details: z.boolean().optional(),
-                    show_thinking_blocks: z.boolean().optional(),
-                  })
-                  .meta({ ref: "State" }),
+                z.object({
+                  recent: z.array(z.object({ providerID: z.string(), modelID: z.string() })),
+                  favorite: z.array(z.object({ providerID: z.string(), modelID: z.string() })),
+                  variant: z.record(z.string(), z.string()).optional(),
+                }),
               ),
             },
           },
@@ -450,71 +471,70 @@ export const WebGuiRoute = new Hono()
       },
     }),
     async (c) => {
-      const state = await State.read()
-      return c.json(state)
+      const file = Bun.file(path.join(Global.Path.state, "model.json"))
+      const exists = await file.exists()
+      if (!exists) return c.json({ recent: [], favorite: [], variant: {} })
+      try {
+        const data = await file.json()
+        return c.json({
+          recent: Array.isArray(data.recent) ? data.recent : [],
+          favorite: Array.isArray(data.favorite) ? data.favorite : [],
+          variant: typeof data.variant === "object" && data.variant !== null ? data.variant : {},
+        })
+      } catch {
+        return c.json({ recent: [], favorite: [], variant: {} })
+      }
     },
   )
   .patch(
-    "/state",
+    "/model",
     describeRoute({
-      description: "Update TUI state (merge with existing)",
-      operationId: "state.update",
+      description: "Update model preferences (recent, favorite, variant) in model.json",
+      operationId: "model.update",
       responses: {
         200: {
-          description: "Successfully updated state",
+          description: "Updated model preferences",
           content: {
             "application/json": {
               schema: resolver(
-                z
-                  .object({
-                    theme: z.string().optional(),
-                    agent_model: z
-                      .record(
-                        z.string(),
-                        z.object({
-                          provider_id: z.string(),
-                          model_id: z.string(),
-                        }),
-                      )
-                      .optional(),
-                    provider: z.string().optional(),
-                    model: z.string().optional(),
-                    agent: z.string().optional(),
-                    variant: z.record(z.string(), z.string()).optional(),
-                    recently_used_models: z
-                      .array(
-                        z.object({
-                          provider_id: z.string(),
-                          model_id: z.string(),
-                          last_used: z.string(),
-                        }),
-                      )
-                      .optional(),
-                    recently_used_agents: z
-                      .array(
-                        z.object({
-                          agent_name: z.string(),
-                          last_used: z.string(),
-                        }),
-                      )
-                      .optional(),
-                    show_tool_details: z.boolean().optional(),
-                    show_thinking_blocks: z.boolean().optional(),
-                  })
-                  .meta({ ref: "State" }),
+                z.object({
+                  recent: z.array(z.object({ providerID: z.string(), modelID: z.string() })),
+                  favorite: z.array(z.object({ providerID: z.string(), modelID: z.string() })),
+                  variant: z.record(z.string(), z.string()).optional(),
+                }),
               ),
             },
           },
         },
-        ...errors(400),
       },
     }),
-    validator("json", StatePatchSchema),
+    validator(
+      "json",
+      z.object({
+        recent: z.array(z.object({ providerID: z.string(), modelID: z.string() })).optional(),
+        favorite: z.array(z.object({ providerID: z.string(), modelID: z.string() })).optional(),
+        variant: z.record(z.string(), z.string()).optional(),
+      }),
+    ),
     async (c) => {
       const partial = c.req.valid("json")
-      await State.write(partial)
-      const updated = await State.read()
-      return c.json(updated)
+      const file = Bun.file(path.join(Global.Path.state, "model.json"))
+      let existing = { recent: [] as any[], favorite: [] as any[], variant: {} as Record<string, string> }
+      try {
+        if (await file.exists()) {
+          const data = await file.json()
+          existing = {
+            recent: Array.isArray(data.recent) ? data.recent : [],
+            favorite: Array.isArray(data.favorite) ? data.favorite : [],
+            variant: typeof data.variant === "object" && data.variant !== null ? data.variant : {},
+          }
+        }
+      } catch {}
+      if (partial.recent !== undefined) existing.recent = partial.recent
+      if (partial.favorite !== undefined) existing.favorite = partial.favorite
+      if (partial.variant !== undefined) existing.variant = { ...existing.variant, ...partial.variant }
+      await Bun.write(file, JSON.stringify(existing))
+      return c.json(existing)
     },
   )
   .post(

+ 0 - 159
packages/opencode/src/webgui/state/state.ts

@@ -1,159 +0,0 @@
-import path from "path"
-import { Global } from "../../global"
-import { z } from "zod"
-import TOML from "@iarna/toml"
-
-/**
- * State management for TUI preferences
- * Syncs with ~/.local/state/tui TOML file
- */
-
-export const ModelUsageSchema = z.object({
-  provider_id: z.string(),
-  model_id: z.string(),
-  last_used: z.string(),
-})
-
-export const AgentUsageSchema = z.object({
-  agent_name: z.string(),
-  last_used: z.string(),
-})
-
-export const AgentModelSchema = z.object({
-  provider_id: z.string(),
-  model_id: z.string(),
-})
-
-export const StateSchema = z.object({
-  theme: z.string().optional(),
-  agent_model: z.record(z.string(), AgentModelSchema).optional(),
-  provider: z.string().optional(),
-  model: z.string().optional(),
-  agent: z.string().optional(),
-  variant: z.record(z.string(), z.string()).optional(),
-  recently_used_models: z.array(ModelUsageSchema).optional(),
-  recently_used_agents: z.array(AgentUsageSchema).optional(),
-  show_tool_details: z.boolean().optional(),
-  show_thinking_blocks: z.boolean().optional(),
-})
-
-export type State = z.infer<typeof StateSchema>
-export type ModelUsage = z.infer<typeof ModelUsageSchema>
-export type AgentUsage = z.infer<typeof AgentUsageSchema>
-export type AgentModel = z.infer<typeof AgentModelSchema>
-
-type RawTomlState = { [key: string]: unknown }
-
-const STATE_FILE_PATH = path.join(Global.Path.state, "tui")
-
-function defaultState(): State {
-  return {
-    theme: "opencode",
-    agent: "build",
-    agent_model: {},
-    recently_used_models: [],
-    recently_used_agents: [],
-  }
-}
-
-/**
- * Read state from TOML file
- */
-export async function read(): Promise<State> {
-  try {
-    const file = Bun.file(STATE_FILE_PATH)
-    const exists = await file.exists()
-
-    if (!exists) {
-      // Return default state if file doesn't exist
-      return defaultState()
-    }
-
-    const content = await file.text()
-
-    // Fix unquoted RFC3339 timestamps that Bun.TOML can't parse
-    // Replace patterns like: last_used = 2025-11-04T13:25:25.427920869+01:00
-    // with: last_used = "2025-11-04T13:25:25.427920869+01:00"
-    const fixedContent = content.replace(/(\b(?:last_used)\s*=\s*)(\d{4}-\d{2}-\d{2}T[^\s\n]+)/g, '$1"$2"')
-
-    const parsed = TOML.parse(fixedContent) as unknown
-
-    // Normalize to a plain JSON-serializable object to strip TOML metadata (e.g. symbol keys)
-    const plain = JSON.parse(JSON.stringify(parsed))
-
-    // Validate the parsed data (unknown keys are preserved in raw TOML during writes,
-    // but stripped from the typed State view)
-    const validated = StateSchema.parse(plain)
-    return validated
-  } catch (error) {
-    console.error("Failed to read state file:", error)
-    // Return default state on error
-    return defaultState()
-  }
-}
-
-/**
- * Write state to TOML file (merges with existing state)
- */
-export async function write(partial: Partial<State>): Promise<void> {
-  try {
-    const file = Bun.file(STATE_FILE_PATH)
-    const exists = await file.exists()
-
-    let raw: RawTomlState
-
-    if (!exists) {
-      raw = { ...defaultState() }
-    } else {
-      const content = await file.text()
-      const fixedContent = content.replace(/(\b(?:last_used)\s*=\s*)(\d{4}-\d{2}-\d{2}T[^\s\n]+)/g, '$1"$2"')
-      try {
-        raw = TOML.parse(fixedContent) as RawTomlState
-      } catch (parseError) {
-        console.error("Failed to parse state file for write:", parseError)
-        raw = { ...defaultState() }
-      }
-    }
-
-    if (partial.theme !== undefined) {
-      ;(raw as any).theme = partial.theme
-    }
-    if (partial.provider !== undefined) {
-      ;(raw as any).provider = partial.provider
-    }
-    if (partial.model !== undefined) {
-      ;(raw as any).model = partial.model
-    }
-    if (partial.agent !== undefined) {
-      ;(raw as any).agent = partial.agent
-    }
-    if (partial.show_tool_details !== undefined) {
-      ;(raw as any).show_tool_details = partial.show_tool_details
-    }
-    if (partial.show_thinking_blocks !== undefined) {
-      ;(raw as any).show_thinking_blocks = partial.show_thinking_blocks
-    }
-    if (partial.recently_used_models !== undefined) {
-      ;(raw as any).recently_used_models = partial.recently_used_models
-    }
-    if (partial.recently_used_agents !== undefined) {
-      ;(raw as any).recently_used_agents = partial.recently_used_agents
-    }
-
-    if (partial.agent_model) {
-      const existingAgentModel = ((raw as any).agent_model as Record<string, AgentModel> | undefined) || {}
-      ;(raw as any).agent_model = { ...existingAgentModel, ...partial.agent_model }
-    }
-
-    if (partial.variant) {
-      const existingVariant = ((raw as any).variant as Record<string, string> | undefined) || {}
-      ;(raw as any).variant = { ...existingVariant, ...partial.variant }
-    }
-
-    const toml = TOML.stringify(raw as any)
-    await Bun.write(STATE_FILE_PATH, toml)
-  } catch (error) {
-    console.error("Failed to write state file:", error)
-    throw new Error("Failed to write state file")
-  }
-}

+ 160 - 145
packages/opencode/webgui/src/components/ModelSelector.tsx

@@ -1,8 +1,7 @@
-import { useState, useEffect } from "react"
+import { useState, useEffect, useCallback } from "react"
 import { sdk } from "../lib/api/sdkClient"
 import type { Provider } from "@opencode-ai/sdk/client"
 import { useDropdown } from "../hooks/useDropdown"
-import { formatDate } from "../utils/formatting"
 
 interface ModelSelectorProps {
   selectedProviderId?: string
@@ -11,112 +10,179 @@ interface ModelSelectorProps {
   disabled?: boolean
 }
 
+interface ModelEntry {
+  providerID: string
+  modelID: string
+}
+
+const MAX_RECENT = 10
+
+function StarIcon({ filled, onClick }: { filled: boolean; onClick: (e: React.MouseEvent) => void }) {
+  return (
+    <button
+      onClick={onClick}
+      className="flex-shrink-0 p-0.5 hover:scale-110 transition-transform"
+      title={filled ? "Remove from favorites" : "Add to favorites"}
+    >
+      <svg className="w-3.5 h-3.5" viewBox="0 0 20 20" fill={filled ? "#eab308" : "none"} stroke={filled ? "#eab308" : "currentColor"} strokeWidth={1.5}>
+        <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
+      </svg>
+    </button>
+  )
+}
+
 export function ModelSelector({ selectedProviderId, selectedModelId, onSelect, disabled }: ModelSelectorProps) {
   const { isOpen, searchTerm, setSearchTerm, dropdownRef, close, toggle } = useDropdown()
   const [providers, setProviders] = useState<Provider[]>([])
   const [defaultIds, setDefaultIds] = useState<{ [key: string]: string }>({})
   const [isLoading, setIsLoading] = useState(true)
-  const [recent, setRecent] = useState<Array<{ provider_id: string; model_id: string; last_used: string }>>([])
+  const [recent, setRecent] = useState<ModelEntry[]>([])
+  const [favorite, setFavorite] = useState<ModelEntry[]>([])
+
+  const isFavorite = useCallback(
+    (providerID: string, modelID: string) => favorite.some((f) => f.providerID === providerID && f.modelID === modelID),
+    [favorite],
+  )
 
-  // Load providers on mount
   useEffect(() => {
     let active = true
 
-    async function loadProviders() {
+    async function load() {
       setIsLoading(true)
       try {
-        const response = await sdk.config.providers()
+        const [provRes, modelRes] = await Promise.all([sdk.config.providers(), sdk.model.get()])
 
         if (!active) return
 
-        if (response.error) {
-          console.error("[ModelSelector] Failed to load providers:", response.error)
+        if (provRes.error) {
+          console.error("[ModelSelector] Failed to load providers:", provRes.error)
           setIsLoading(false)
           return
         }
 
-        if (response.data) {
-          setProviders(response.data.providers)
-          setDefaultIds(response.data.default)
+        if (provRes.data) {
+          setProviders(provRes.data.providers)
+          setDefaultIds(provRes.data.default)
         }
 
-        // Load recent models from state for the "Recent" group
-        const stateRes = await sdk.state.get()
-        if (stateRes.data?.recently_used_models) {
-          const list = [...stateRes.data.recently_used_models]
-            .sort((a, b) => new Date(b.last_used).getTime() - new Date(a.last_used).getTime())
-            .slice(0, 2)
-          setRecent(list)
-        } else {
-          setRecent([])
+        if (modelRes.data) {
+          setRecent(modelRes.data.recent.slice(0, MAX_RECENT))
+          setFavorite(modelRes.data.favorite)
         }
       } catch (err) {
-        if (active) {
-          console.error("[ModelSelector] Failed to load providers:", err)
-        }
+        if (active) console.error("[ModelSelector] Failed to load:", err)
       } finally {
-        if (active) {
-          setIsLoading(false)
-        }
+        if (active) setIsLoading(false)
       }
     }
 
-    loadProviders()
-    return () => {
-      active = false
-    }
+    load()
+    return () => { active = false }
   }, [])
 
-  // Get current selection display
   const getCurrentDisplay = () => {
-    const effectiveProviderId = selectedProviderId || defaultIds.provider
-    const effectiveModelId = selectedModelId || defaultIds.model
-
-    if (!effectiveProviderId || !effectiveModelId) {
-      return "Select Model"
-    }
-
-    const provider = providers.find((p) => p.id === effectiveProviderId)
+    const pid = selectedProviderId || defaultIds.provider
+    const mid = selectedModelId || defaultIds.model
+    if (!pid || !mid) return "Select Model"
+    const provider = providers.find((p) => p.id === pid)
     if (!provider) return "Select Model"
-
-    const model = provider.models[effectiveModelId]
-    return model?.name || "Select Model"
+    return provider.models[mid]?.name || "Select Model"
   }
 
-  const handleSelect = async (providerId: string, modelId: string) => {
-    await onSelect(providerId, modelId)
+  const handleSelect = async (providerID: string, modelID: string) => {
+    await onSelect(providerID, modelID)
 
-    try {
-      const stateRes = await sdk.state.get()
-      if (stateRes.data?.recently_used_models) {
-        const list = [...stateRes.data.recently_used_models]
-          .sort((a, b) => new Date(b.last_used).getTime() - new Date(a.last_used).getTime())
-          .slice(0, 2)
-        setRecent(list)
-      }
-    } catch (err) {
-      console.error("[ModelSelector] Failed to refresh recent models:", err)
-    }
+    const entry = { providerID, modelID }
+    const deduped = [entry, ...recent.filter((r) => r.providerID !== providerID || r.modelID !== modelID)]
+    if (deduped.length > MAX_RECENT) deduped.length = MAX_RECENT
+    setRecent(deduped)
+
+    sdk.model.update({ body: { recent: deduped } }).catch((err) =>
+      console.error("[ModelSelector] Failed to update recent:", err),
+    )
 
     close()
   }
 
-  // Filter models based on search term
+  const toggleFavorite = async (providerID: string, modelID: string, e: React.MouseEvent) => {
+    e.stopPropagation()
+    const exists = isFavorite(providerID, modelID)
+    const next = exists
+      ? favorite.filter((f) => f.providerID !== providerID || f.modelID !== modelID)
+      : [{ providerID, modelID }, ...favorite]
+    setFavorite(next)
+
+    sdk.model.update({ body: { favorite: next } }).catch((err) =>
+      console.error("[ModelSelector] Failed to update favorites:", err),
+    )
+  }
+
   const filterModels = (provider: Provider) => {
     if (!searchTerm) return Object.entries(provider.models)
-
-    const lowerSearch = searchTerm.toLowerCase()
+    const q = searchTerm.toLowerCase()
     return Object.entries(provider.models).filter(
-      ([, model]) =>
-        model.name.toLowerCase().includes(lowerSearch) || provider.name.toLowerCase().includes(lowerSearch),
+      ([, model]) => model.name.toLowerCase().includes(q) || provider.name.toLowerCase().includes(q),
     )
   }
 
-  // Filter recent items based on search term
-  const filterRecent = () => {
-    if (!searchTerm) return recent
+  const filteredFavorites = () => {
+    if (!searchTerm) return favorite
+    const q = searchTerm.toLowerCase()
+    return favorite.filter((f) => {
+      const provider = providers.find((p) => p.id === f.providerID)
+      const name = provider?.models[f.modelID]?.name || f.modelID
+      return name.toLowerCase().includes(q) || f.providerID.toLowerCase().includes(q)
+    })
+  }
+
+  const filteredRecent = () => {
+    const list = recent.filter((r) => !isFavorite(r.providerID, r.modelID))
+    if (!searchTerm) return list
     const q = searchTerm.toLowerCase()
-    return recent.filter((r) => r.model_id.toLowerCase().includes(q) || r.provider_id.toLowerCase().includes(q))
+    return list.filter((r) => {
+      const provider = providers.find((p) => p.id === r.providerID)
+      const name = provider?.models[r.modelID]?.name || r.modelID
+      return name.toLowerCase().includes(q) || r.providerID.toLowerCase().includes(q)
+    })
+  }
+
+  const renderModelRow = (providerID: string, modelID: string, extraLabel?: React.ReactNode) => {
+    const isSelected = selectedProviderId === providerID && selectedModelId === modelID
+    const provider = providers.find((p) => p.id === providerID)
+    const model = provider?.models[modelID]
+    const name = model?.name || modelID
+
+    return (
+      <button
+        key={`${providerID}:${modelID}`}
+        onClick={() => handleSelect(providerID, modelID)}
+        className={`w-full px-3 py-2 text-xs text-left hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-between ${
+          isSelected
+            ? "bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400"
+            : "text-gray-900 dark:text-gray-100"
+        }`}
+      >
+        <div className="flex items-center gap-2 flex-1 min-w-0">
+          <span className="font-medium truncate">{name}</span>
+          {extraLabel}
+          <span className="text-[10px] text-gray-400 dark:text-gray-500 truncate max-w-[6rem]">
+            {provider?.name || providerID}
+          </span>
+        </div>
+        <div className="flex items-center gap-1 flex-shrink-0">
+          <StarIcon filled={isFavorite(providerID, modelID)} onClick={(e) => toggleFavorite(providerID, modelID, e)} />
+          {isSelected && (
+            <svg className="w-4 h-4 ml-1" fill="currentColor" viewBox="0 0 20 20">
+              <path
+                fillRule="evenodd"
+                d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
+                clipRule="evenodd"
+              />
+            </svg>
+          )}
+        </div>
+      </button>
+    )
   }
 
   return (
@@ -136,7 +202,6 @@ export function ModelSelector({ selectedProviderId, selectedModelId, onSelect, d
 
       {isOpen && (
         <div className="absolute bottom-full left-0 mb-1 min-w-[300px] w-max max-w-[500px] bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-50 max-h-96 overflow-hidden flex flex-col">
-          {/* Search input */}
           <div className="p-2 border-b border-gray-200 dark:border-gray-700">
             <input
               type="text"
@@ -148,7 +213,6 @@ export function ModelSelector({ selectedProviderId, selectedModelId, onSelect, d
             />
           </div>
 
-          {/* Models list */}
           <div className="overflow-y-auto flex-1">
             {isLoading ? (
               <div className="p-4 text-xs text-gray-500 dark:text-gray-400 text-center">Loading models...</div>
@@ -156,103 +220,54 @@ export function ModelSelector({ selectedProviderId, selectedModelId, onSelect, d
               <div className="p-4 text-xs text-gray-500 dark:text-gray-400 text-center">No providers configured</div>
             ) : (
               <>
-                {/* Recent group */}
-                {filterRecent().length > 0 && (
+                {/* Favorites group */}
+                {filteredFavorites().length > 0 && (
+                  <div className="border-b border-gray-100 dark:border-gray-800">
+                    <div className="px-3 py-1.5 text-xs font-medium text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800">
+                      Favorites
+                    </div>
+                    {filteredFavorites().map((item) => renderModelRow(item.providerID, item.modelID))}
+                  </div>
+                )}
+
+                {/* Recent group (excluding favorites) */}
+                {filteredRecent().length > 0 && (
                   <div className="border-b border-gray-100 dark:border-gray-800">
                     <div className="px-3 py-1.5 text-xs font-medium text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800">
                       Recent
                     </div>
-                    {filterRecent().map((item) => {
-                      const isSelected = selectedProviderId === item.provider_id && selectedModelId === item.model_id
-
-                      // Find model name from providers list
-                      const provider = providers.find((p) => p.id === item.provider_id)
-                      const modelName = provider?.models[item.model_id]?.name || item.model_id
-
-                      return (
-                        <button
-                          key={`${item.provider_id}:${item.model_id}:${item.last_used}`}
-                          onClick={() => handleSelect(item.provider_id, item.model_id)}
-                          className={`w-full px-3 py-2 text-xs text-left hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-between ${
-                            isSelected
-                              ? "bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400"
-                              : "text-gray-900 dark:text-gray-100"
-                          }`}
-                        >
-                          <div className="flex items-center gap-2 flex-1 min-w-0">
-                            <span className="font-medium truncate">{modelName}</span>
-                            <span className="text-[10px] text-gray-400 dark:text-gray-500 flex-shrink-0">
-                              {formatDate(item.last_used)}
-                            </span>
-                            <span className="text-[10px] text-gray-400 dark:text-gray-500 truncate max-w-[6rem]">
-                              {item.provider_id}
-                            </span>
-                          </div>
-                          {isSelected && (
-                            <svg className="w-4 h-4 ml-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
-                              <path
-                                fillRule="evenodd"
-                                d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
-                                clipRule="evenodd"
-                              />
-                            </svg>
-                          )}
-                        </button>
-                      )
-                    })}
+                    {filteredRecent().map((item) => renderModelRow(item.providerID, item.modelID))}
                   </div>
                 )}
 
                 {/* Provider groups */}
                 {providers.map((provider) => {
-                  const filteredModels = filterModels(provider)
-                  if (filteredModels.length === 0) return null
+                  const filtered = filterModels(provider)
+                  if (filtered.length === 0) return null
 
                   return (
                     <div key={provider.id} className="border-b border-gray-100 dark:border-gray-800 last:border-0">
                       <div className="px-3 py-1.5 text-xs font-medium text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800">
                         {provider.name}
                       </div>
-                      {filteredModels.map(([modelId, model]) => {
-                        const isSelected = selectedProviderId === provider.id && selectedModelId === modelId
+                      {filtered.map(([modelId, model]) => {
                         const isDefault = defaultIds.provider === provider.id && defaultIds.model === modelId
 
-                        return (
-                          <button
-                            key={modelId}
-                            onClick={() => handleSelect(provider.id, modelId)}
-                            className={`w-full px-3 py-2 text-xs text-left hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-between ${
-                              isSelected
-                                ? "bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400"
-                                : "text-gray-900 dark:text-gray-100"
-                            }`}
-                          >
-                            <div className="flex items-center gap-2 flex-1 min-w-0">
-                              <span className="font-medium truncate">{model.name}</span>
-                              <div className="flex items-center gap-1.5 text-[10px] text-gray-400 dark:text-gray-500 flex-shrink-0">
-                                {/*<span>{formatDate(model.release_date)}</span>*/}
-                                {model.capabilities.reasoning && (
-                                  <span className="px-1 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded text-[9px] leading-none">
-                                    reasoning
-                                  </span>
-                                )}
-                                {isDefault && (
-                                  <span className="px-1 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded text-[9px] leading-none">
-                                    default
-                                  </span>
-                                )}
-                              </div>
-                            </div>
-                            {isSelected && (
-                              <svg className="w-4 h-4 ml-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
-                                <path
-                                  fillRule="evenodd"
-                                  d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
-                                  clipRule="evenodd"
-                                />
-                              </svg>
+                        return renderModelRow(
+                          provider.id,
+                          modelId,
+                          <div className="flex items-center gap-1.5 text-[10px] text-gray-400 dark:text-gray-500 flex-shrink-0">
+                            {model.capabilities.reasoning && (
+                              <span className="px-1 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded text-[9px] leading-none">
+                                reasoning
+                              </span>
+                            )}
+                            {isDefault && (
+                              <span className="px-1 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded text-[9px] leading-none">
+                                default
+                              </span>
                             )}
-                          </button>
+                          </div>,
                         )
                       })}
                     </div>

+ 56 - 35
packages/opencode/webgui/src/lib/api/sdkClient.ts

@@ -9,34 +9,22 @@ import { createOpencodeClient, type Provider } from "@opencode-ai/sdk/client"
 // The server runs on the same origin, so we use '/' for relative requests
 const baseClient = createOpencodeClient({ baseUrl: "/" })
 
-/**
- * State API response types
- */
-interface StateResponse {
-  theme?: string
-  agent_model?: Record<string, { provider_id: string; model_id: string }>
-  provider?: string
-  model?: string
-  agent?: string
-  variant?: Record<string, string>
-  recently_used_models?: Array<{
-    provider_id: string
-    model_id: string
-    last_used: string // RFC3339 timestamp
-  }>
-  recently_used_agents?: Array<{
-    agent_name: string
-    last_used: string // RFC3339 timestamp
-  }>
-  show_tool_details?: boolean
-  show_thinking_blocks?: boolean
-}
-
 interface ProvidersResponse {
   providers: Provider[]
   default: Record<string, string>
 }
 
+interface ModelEntry {
+  providerID: string
+  modelID: string
+}
+
+interface ModelPreferences {
+  recent: ModelEntry[]
+  favorite: ModelEntry[]
+  variant?: Record<string, string>
+}
+
 interface PathResponse {
   state: string
   config: string
@@ -219,36 +207,69 @@ export const sdk = {
       return { data, error: null }
     },
   },
-  state: {
+  model: {
     get: async () => {
       try {
-        const response = await fetch("/app/api/state", {
+        const response = await fetch("/app/api/model", {
           method: "GET",
           headers: { "Content-Type": "application/json" },
         })
         if (!response.ok) {
-          return { error: { message: "Failed to fetch state" }, data: null }
+          return { error: { message: "Failed to fetch model preferences" }, data: null as ModelPreferences | null }
         }
-        const data = (await response.json()) as StateResponse
-        return { data, error: null }
+        const data = (await response.json()) as ModelPreferences
+        return { data, error: null as { message: string } | null }
       } catch (error) {
-        return { error: { message: error instanceof Error ? error.message : "Unknown error" }, data: null }
+        return { error: { message: error instanceof Error ? error.message : "Unknown error" }, data: null as ModelPreferences | null }
       }
     },
-    update: async (options: { body: Partial<StateResponse> }) => {
+    update: async (options: { body: Partial<ModelPreferences> }) => {
       try {
-        const response = await fetch("/app/api/state", {
+        const response = await fetch("/app/api/model", {
           method: "PATCH",
           headers: { "Content-Type": "application/json" },
           body: JSON.stringify(options.body),
         })
         if (!response.ok) {
-          return { error: { message: "Failed to update state" }, data: null }
+          return { error: { message: "Failed to update model preferences" }, data: null as ModelPreferences | null }
         }
-        const data = (await response.json()) as StateResponse
-        return { data, error: null }
+        const data = (await response.json()) as ModelPreferences
+        return { data, error: null as { message: string } | null }
+      } catch (error) {
+        return { error: { message: error instanceof Error ? error.message : "Unknown error" }, data: null as ModelPreferences | null }
+      }
+    },
+  },
+  kv: {
+    get: async () => {
+      try {
+        const response = await fetch("/app/api/kv", {
+          method: "GET",
+          headers: { "Content-Type": "application/json" },
+        })
+        if (!response.ok) {
+          return { error: { message: "Failed to fetch kv" }, data: null as Record<string, any> | null }
+        }
+        const data = (await response.json()) as Record<string, any>
+        return { data, error: null as { message: string } | null }
+      } catch (error) {
+        return { error: { message: error instanceof Error ? error.message : "Unknown error" }, data: null as Record<string, any> | null }
+      }
+    },
+    update: async (options: { body: Record<string, any> }) => {
+      try {
+        const response = await fetch("/app/api/kv", {
+          method: "PATCH",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify(options.body),
+        })
+        if (!response.ok) {
+          return { error: { message: "Failed to update kv" }, data: null as Record<string, any> | null }
+        }
+        const data = (await response.json()) as Record<string, any>
+        return { data, error: null as { message: string } | null }
       } catch (error) {
-        return { error: { message: error instanceof Error ? error.message : "Unknown error" }, data: null }
+        return { error: { message: error instanceof Error ? error.message : "Unknown error" }, data: null as Record<string, any> | null }
       }
     },
   },

+ 74 - 72
packages/opencode/webgui/src/state/SessionContext.tsx

@@ -175,68 +175,71 @@ export function SessionProvider({ children }: SessionProviderProps) {
   useEffect(() => {
     const initializeState = async () => {
       try {
-        // Fetch state from server
-        const stateResponse = await sdk.state.get()
-
-        if (stateResponse.data) {
-          const state = stateResponse.data
+        // Fetch preferences from kv.json and model.json (shared with CLI)
+        const [kvRes, modelRes] = await Promise.all([sdk.kv.get(), sdk.model.get()])
+        const kv = kvRes.data ?? {}
+        const modelPrefs = modelRes.data
+
+        // Cache agent_model map from kv
+        if (kv.webgui_agent_model) {
+          setAgentModelMap(kv.webgui_agent_model)
+        }
 
-          // Cache agent_model map
-          if (state.agent_model) {
-            setAgentModelMap(state.agent_model)
-          }
+        // Load variant map from model.json
+        if (modelPrefs?.variant) {
+          setVariantMap(modelPrefs.variant as Record<string, string>)
+        }
 
-          // Load variant map from server state
-          if (state.variant) {
-            setVariantMap(state.variant)
-          }
+        // Set agent (default to 'build' if not set)
+        const agent = kv.webgui_agent || "build"
+        setSelectedAgentState(agent)
+        localStorage.setItem("opencode_selected_agent", agent)
 
-          // Set agent (default to 'build' if not set)
-          const agent = state.agent || "build"
-          setSelectedAgentState(agent)
-          localStorage.setItem("opencode_selected_agent", agent)
+        // Check if there's a per-agent model preference
+        let providerId = kv.webgui_provider as string | undefined
+        let modelId = kv.webgui_model as string | undefined
 
-          // Check if there's a per-agent model preference
-          let providerId = state.provider
-          let modelId = state.model
+        if (kv.webgui_agent_model?.[agent]) {
+          providerId = kv.webgui_agent_model[agent].provider_id
+          modelId = kv.webgui_agent_model[agent].model_id
+        }
 
-          if (state.agent_model && state.agent_model[agent]) {
-            // Use per-agent model preference
-            providerId = state.agent_model[agent].provider_id
-            modelId = state.agent_model[agent].model_id
+        // If no kv state, try recent list from model.json, then config fallback
+        if (!providerId || !modelId) {
+          const recent = modelPrefs?.recent ?? []
+          if (recent.length > 0) {
+            providerId = recent[0].providerID
+            modelId = recent[0].modelID
           }
+        }
 
-          // If no state, try config fallback
-          if (!providerId || !modelId) {
-            const configResponse = await sdk.config.get()
+        if (!providerId || !modelId) {
+          const configResponse = await sdk.config.get()
 
-            if (configResponse.data?.model) {
-              // Parse "provider/model" format
-              const parts = configResponse.data.model.split("/")
-              if (parts.length === 2) {
-                providerId = parts[0]
-                modelId = parts[1]
-              }
+          if (configResponse.data?.model) {
+            const parts = configResponse.data.model.split("/")
+            if (parts.length === 2) {
+              providerId = parts[0]
+              modelId = parts[1]
             }
           }
+        }
 
-          // Set model/provider if we have them
-          if (providerId && modelId) {
-            setSelectedProviderId(providerId)
-            setSelectedModelId(modelId)
-            localStorage.setItem("opencode_selected_provider", providerId)
-            localStorage.setItem("opencode_selected_model", modelId)
-
-            // Compute initial variant for the selected model
-            if (state.variant) {
-              const modelKey = `${providerId}/${modelId}`
-              setSelectedVariantState(state.variant[modelKey])
-            }
+        // Set model/provider if we have them
+        if (providerId && modelId) {
+          setSelectedProviderId(providerId)
+          setSelectedModelId(modelId)
+          localStorage.setItem("opencode_selected_provider", providerId)
+          localStorage.setItem("opencode_selected_model", modelId)
+
+          // Compute initial variant for the selected model
+          if (modelPrefs?.variant) {
+            const modelKey = `${providerId}/${modelId}`
+            setSelectedVariantState((modelPrefs.variant as Record<string, string>)[modelKey])
           }
         }
       } catch (err) {
         console.error("[SessionContext] Failed to load state from server, using localStorage fallback:", err)
-        // Fallback to localStorage if server state fails
         const savedProvider = localStorage.getItem("opencode_selected_provider")
         const savedModel = localStorage.getItem("opencode_selected_model")
         const savedAgent = localStorage.getItem("opencode_selected_agent")
@@ -296,23 +299,22 @@ export function SessionProvider({ children }: SessionProviderProps) {
 
           setAgentModelMap(updatedAgentModel)
 
-          const stateResponse = await sdk.state.get()
-          const existingRecent = stateResponse.data?.recently_used_models ?? []
-          const now = new Date().toISOString()
-          const filtered = existingRecent.filter(
-            (item) => !(item.provider_id === providerId && item.model_id === modelId),
-          )
-          const nextRecent = [{ provider_id: providerId, model_id: modelId, last_used: now }, ...filtered].slice(0, 2)
-
-          // Update server state
-          await sdk.state.update({
+          // Persist to kv.json (webgui-specific keys)
+          await sdk.kv.update({
             body: {
-              provider: providerId,
-              model: modelId,
-              agent_model: updatedAgentModel,
-              recently_used_models: nextRecent,
+              webgui_provider: providerId,
+              webgui_model: modelId,
+              webgui_agent_model: updatedAgentModel,
             },
           })
+
+          // Update model.json recent list (shared with CLI)
+          const modelRes = await sdk.model.get()
+          const existing = modelRes.data?.recent ?? []
+          const entry = { providerID: providerId, modelID: modelId }
+          const deduped = [entry, ...existing.filter((r) => r.providerID !== providerId || r.modelID !== modelId)]
+          if (deduped.length > 10) deduped.length = 10
+          await sdk.model.update({ body: { recent: deduped } })
         } catch (err) {
           console.error("[SessionContext] Failed to save model preference to server:", err)
         }
@@ -342,9 +344,9 @@ export function SessionProvider({ children }: SessionProviderProps) {
         }
         setVariantMap(updatedVariantMap)
 
-        // Persist to server
+        // Persist variant to model.json (shared with CLI)
         try {
-          await sdk.state.update({
+          await sdk.model.update({
             body: {
               variant: updatedVariantMap,
             },
@@ -364,14 +366,14 @@ export function SessionProvider({ children }: SessionProviderProps) {
   const setSelectedAgent = useCallback(
     async (newAgent: string) => {
       try {
-        // Fetch current state to get agent_model map
-        const stateResponse = await sdk.state.get()
+        // Fetch current kv to get agent_model map
+        const kvRes = await sdk.kv.get()
         const currentAgent = selectedAgent
         const currentProvider = selectedProviderId
         const currentModel = selectedModelId
 
         // Save current model for current agent if we have one
-        let agentModel = stateResponse.data?.agent_model || {}
+        let agentModel = kvRes.data?.webgui_agent_model || {}
         if (currentAgent && currentProvider && currentModel) {
           agentModel = {
             ...agentModel,
@@ -405,13 +407,13 @@ export function SessionProvider({ children }: SessionProviderProps) {
           if (newModel) localStorage.setItem("opencode_selected_model", newModel)
         }
 
-        // Persist to server (save both agent and agent_model map)
-        await sdk.state.update({
+        // Persist to kv.json (webgui-specific keys)
+        await sdk.kv.update({
           body: {
-            agent: newAgent,
-            agent_model: agentModel,
-            provider: newProvider,
-            model: newModel,
+            webgui_agent: newAgent,
+            webgui_agent_model: agentModel,
+            webgui_provider: newProvider,
+            webgui_model: newModel,
           },
         })
         console.log("[SessionContext] Agent and model preferences saved to server")