Browse Source

#9 Add command execution support to message input

paviko 2 months ago
parent
commit
e9d66785cf

+ 2 - 5
hosts/jetbrains-plugin/src/main/kotlin/paviko/opencode/ui/ChatToolWindowFactory.kt

@@ -43,12 +43,9 @@ class ChatToolWindowFactory : ToolWindowFactory, DumbAware {
     }
 
     private fun pluginVersion(): String {
-        // Use pluginDescriptor.version without hardcoding plugin id.
-        // PluginUtil ties a classloader back to the hosting plugin.
-        val pluginId = PluginUtil.getPluginId(javaClass.classLoader) ?: return "dev"
-        val descriptor = PluginManagerCore.getPlugin(pluginId) ?: return "dev"
-        return descriptor.version
+        return javaClass.`package`?.implementationVersion ?: java.time.LocalDate.now().toString()
     }
+
     private fun withCacheBuster(url: String, version: String): String {
         val encodedVersion = URLEncoder.encode(version, StandardCharsets.UTF_8)
         val sep = if (url.contains("?")) "&" else "?"

+ 2 - 0
packages/opencode/webgui/src/components/MessageInput/EditorContent.tsx

@@ -5,6 +5,7 @@ import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"
 import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"
 import { MentionPlugin } from "../mention/MentionPlugin"
 import { AttachmentPlugin } from "../attachment/AttachmentPlugin"
+import { CommandPlugin } from "../command/CommandPlugin"
 import type { EditorState } from "lexical"
 
 interface EditorContentProps {
@@ -37,6 +38,7 @@ export function EditorContent({ contentEditableRef, containerRef, onEditorChange
         <OnChangePlugin onChange={onEditorChange} />
         <HistoryPlugin />
         <MentionPlugin />
+        <CommandPlugin />
         <AttachmentPlugin />
       </div>
     </div>

+ 76 - 44
packages/opencode/webgui/src/components/MessageInput/hooks/useMessageInput.ts

@@ -45,7 +45,6 @@ export function useMessageInput({
     setIsSending(true)
     setIsIdle(false)
 
-    // Save message content before clearing (for potential restore on error)
     let savedMessage = ""
     editor.getEditorState().read(() => {
       const root = $getRoot()
@@ -53,14 +52,9 @@ export function useMessageInput({
     })
 
     try {
-      // Extract parts from editor
-      const parts = extractMessageParts()
+      const trimmedMessage = savedMessage.trim()
+      const isCommand = trimmedMessage.startsWith("/")
 
-      if (parts.length === 0) {
-        throw new Error("No message content")
-      }
-
-      // If this is a virtual session, materialize it first
       let actualSessionID = sessionID
       if (isVirtualSession) {
         console.log("[MessageInput] Materializing virtual session before sending message...")
@@ -72,28 +66,6 @@ export function useMessageInput({
         console.log("[MessageInput] Virtual session materialized:", actualSessionID)
       }
 
-      // Build request body
-      const requestBody: any = {
-        parts,
-      }
-
-      // Add model if selected
-      if (selectedProviderId && selectedModelId) {
-        requestBody.model = {
-          providerID: selectedProviderId,
-          modelID: selectedModelId,
-        }
-      }
-
-      // Always include agent (defaults to 'build')
-      requestBody.agent = selectedAgent
-
-      // Add variant if selected
-      if (selectedVariant) {
-        requestBody.variant = selectedVariant
-      }
-
-      // Clear editor immediately (optimistic UI)
       editor.update(() => {
         const root = $getRoot()
         root.clear()
@@ -108,21 +80,81 @@ export function useMessageInput({
         editor.focus()
       }, 0)
 
-      // Send message (this may take minutes, but UI is already cleared)
-      const response = await sdk.session.prompt({
-        path: { id: actualSessionID },
-        body: requestBody,
-      })
+      if (isCommand) {
+        const commandParts = trimmedMessage.slice(1).split(/\s+/)
+        const commandName = commandParts[0]
+        const commandArgs = commandParts.slice(1).join(" ")
+
+        const requestBody: any = {
+          command: commandName,
+          // Server schema requires `arguments` even if empty
+          arguments: commandArgs,
+        }
+
+        if (selectedProviderId && selectedModelId) {
+          requestBody.model = `${selectedProviderId}/${selectedModelId}`
+        }
+
+        requestBody.agent = selectedAgent
+
+        if (selectedVariant) {
+          requestBody.variant = selectedVariant
+        }
 
-      if (response.error) {
-        const errorMsg =
-          "data" in response.error &&
-          response.error.data &&
-          typeof response.error.data === "object" &&
-          "message" in response.error.data
-            ? String(response.error.data.message)
-            : "Failed to send message"
-        throw new Error(errorMsg)
+        const response = await sdk.session.command({
+          path: { id: actualSessionID },
+          body: requestBody,
+        })
+
+        if (response.error) {
+          const errorMsg =
+            "data" in response.error &&
+            response.error.data &&
+            typeof response.error.data === "object" &&
+            "message" in response.error.data
+              ? String(response.error.data.message)
+              : "Failed to execute command"
+          throw new Error(errorMsg)
+        }
+      } else {
+        const parts = extractMessageParts()
+
+        if (parts.length === 0) {
+          throw new Error("No message content")
+        }
+
+        const requestBody: any = {
+          parts,
+        }
+
+        if (selectedProviderId && selectedModelId) {
+          requestBody.model = {
+            providerID: selectedProviderId,
+            modelID: selectedModelId,
+          }
+        }
+
+        requestBody.agent = selectedAgent
+
+        if (selectedVariant) {
+          requestBody.variant = selectedVariant
+        }
+
+        const response = await sdk.session.prompt({
+          path: { id: actualSessionID },
+          body: requestBody,
+        })
+
+        if (response.error) {
+          const errorMsg =
+            "data" in response.error &&
+            response.error.data &&
+            typeof response.error.data === "object" &&
+            "message" in response.error.data
+              ? String(response.error.data.message)
+              : "Failed to send message"
+          throw new Error(errorMsg)
+        }
       }
     } catch (err) {
       const error = err instanceof Error ? err : new Error("Failed to send message")

+ 48 - 0
packages/opencode/webgui/src/components/command/CommandPlugin/CommandDetector.tsx

@@ -0,0 +1,48 @@
+import { useCallback, useRef } from "react"
+import type { LexicalEditor } from "lexical"
+import { extractCommandQuery, updatePopoverPosition } from "./utils"
+
+export function useCommandDetector(
+  editor: LexicalEditor,
+  setQuery: (query: string) => void,
+  setShowPopover: (show: boolean) => void,
+  setCommandStartOffset: (offset: number | null) => void,
+  setPosition: (pos: { top: number; left: number; placement: "top" | "bottom" }) => void,
+) {
+  const leftRef = useRef<{ left: number; width: number } | null>(null)
+
+  const handlePositionUpdate = useCallback(() => {
+    updatePopoverPosition(editor, leftRef, setPosition)
+  }, [editor, setPosition])
+
+  const handleTextChange = useCallback(() => {
+    editor.getEditorState().read(() => {
+      const commandQuery = extractCommandQuery(setCommandStartOffset)
+
+      if (commandQuery === null) {
+        leftRef.current = null
+        setShowPopover(false)
+        setQuery("")
+        setCommandStartOffset(null)
+        return
+      }
+
+      setQuery(commandQuery)
+      setShowPopover(true)
+      handlePositionUpdate()
+    })
+  }, [editor, setQuery, setShowPopover, setCommandStartOffset, handlePositionUpdate])
+
+  const resetState = useCallback(() => {
+    setShowPopover(false)
+    setQuery("")
+    setCommandStartOffset(null)
+    leftRef.current = null
+  }, [setShowPopover, setQuery, setCommandStartOffset])
+
+  return {
+    handleTextChange,
+    handlePositionUpdate,
+    resetState,
+  }
+}

+ 63 - 0
packages/opencode/webgui/src/components/command/CommandPlugin/CommandHandler.tsx

@@ -0,0 +1,63 @@
+import { useCallback } from "react"
+import type { LexicalEditor } from "lexical"
+import { $getSelection, $isRangeSelection, $isTextNode, TextNode } from "lexical"
+
+export interface CommandMetadata {
+  name: string
+  description?: string
+  source?: "command" | "mcp" | "skill"
+}
+
+export function useCommandHandler(
+  editor: LexicalEditor,
+  commandStartOffset: number | null,
+  resetState: () => void,
+) {
+
+  const insertCommand = useCallback(
+    (metadata: CommandMetadata) => {
+      const res = { inserted: false }
+
+      editor.update(() => {
+        const selection = $getSelection()
+        if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
+          return
+        }
+
+        const anchor = selection.anchor
+        const node = anchor.getNode()
+        if (!$isTextNode(node)) {
+          return
+        }
+
+        const offset = anchor.offset
+
+        const textContent = node.getTextContent()
+        const beforeCursor = textContent.slice(0, offset)
+        const start = commandStartOffset ?? beforeCursor.lastIndexOf("/")
+        if (start === -1) {
+          return
+        }
+
+        const before = textContent.slice(0, start)
+        const after = textContent.slice(offset)
+
+        const commandText = `/${metadata.name}`
+        const newText = before + commandText + after
+        const textNode = node as TextNode
+        textNode.setTextContent(newText)
+
+        const newOffset = start + commandText.length
+        textNode.select(newOffset, newOffset)
+        res.inserted = true
+
+        resetState()
+      })
+
+      if (res.inserted) return
+    },
+    [editor, commandStartOffset, resetState],
+  )
+
+  return { insertCommand }
+}

+ 114 - 0
packages/opencode/webgui/src/components/command/CommandPlugin/index.tsx

@@ -0,0 +1,114 @@
+import { useEffect, useState } from "react"
+import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
+import {
+  COMMAND_PRIORITY_LOW,
+  KEY_ARROW_DOWN_COMMAND,
+  KEY_ARROW_UP_COMMAND,
+  KEY_ENTER_COMMAND,
+  KEY_ESCAPE_COMMAND,
+  KEY_TAB_COMMAND,
+} from "lexical"
+import { CommandPopover } from "../CommandPopover"
+import { createPortal } from "react-dom"
+import { useCommandDetector } from "./CommandDetector"
+import { useCommandHandler } from "./CommandHandler"
+
+export function CommandPlugin() {
+  const [editor] = useLexicalComposerContext()
+  const [showPopover, setShowPopover] = useState(false)
+  const [query, setQuery] = useState("")
+  const [position, setPosition] = useState<{ top: number; left: number; placement: "top" | "bottom" }>({
+    top: 0,
+    left: 0,
+    placement: "top",
+  })
+  const [commandStartOffset, setCommandStartOffset] = useState<number | null>(null)
+
+  const { handleTextChange, handlePositionUpdate, resetState } = useCommandDetector(
+    editor,
+    setQuery,
+    setShowPopover,
+    setCommandStartOffset,
+    setPosition,
+  )
+
+  const { insertCommand } = useCommandHandler(editor, commandStartOffset, resetState)
+
+  useEffect(() => {
+    return editor.registerUpdateListener(({ editorState }) => {
+      editorState.read(() => {
+        handleTextChange()
+      })
+    })
+  }, [editor, handleTextChange])
+
+  useEffect(() => {
+    if (!showPopover) return
+
+    const frame = requestAnimationFrame(() => {
+      handlePositionUpdate()
+    })
+
+    return () => cancelAnimationFrame(frame)
+  }, [showPopover, query, handlePositionUpdate])
+
+  useEffect(() => {
+    if (!showPopover) return
+
+    const removeArrowDownCommand = editor.registerCommand(
+      KEY_ARROW_DOWN_COMMAND,
+      () => {
+        return true
+      },
+      COMMAND_PRIORITY_LOW,
+    )
+
+    const removeArrowUpCommand = editor.registerCommand(
+      KEY_ARROW_UP_COMMAND,
+      () => {
+        return true
+      },
+      COMMAND_PRIORITY_LOW,
+    )
+
+    const removeEnterCommand = editor.registerCommand(
+      KEY_ENTER_COMMAND,
+      () => {
+        return true
+      },
+      COMMAND_PRIORITY_LOW,
+    )
+
+    const removeTabCommand = editor.registerCommand(
+      KEY_TAB_COMMAND,
+      () => {
+        return true
+      },
+      COMMAND_PRIORITY_LOW,
+    )
+
+    const removeEscapeCommand = editor.registerCommand(
+      KEY_ESCAPE_COMMAND,
+      () => {
+        resetState()
+        return true
+      },
+      COMMAND_PRIORITY_LOW,
+    )
+
+    return () => {
+      removeArrowDownCommand()
+      removeArrowUpCommand()
+      removeEnterCommand()
+      removeTabCommand()
+      removeEscapeCommand()
+    }
+  }, [editor, showPopover, resetState])
+
+  return showPopover
+    ? createPortal(
+        <CommandPopover query={query} position={position} onSelect={insertCommand} onClose={resetState} onReposition={handlePositionUpdate} />,
+        document.body,
+      )
+    : null
+}

+ 139 - 0
packages/opencode/webgui/src/components/command/CommandPlugin/utils.ts

@@ -0,0 +1,139 @@
+import { $getRoot, $getSelection, $isElementNode, $isRangeSelection, $isTextNode, type TextNode } from "lexical"
+
+export const TRIGGER_CHAR = "/"
+const WHITESPACE_REGEX = /\s/
+const ZERO_WIDTH_REGEX = /\u200B|\u200C|\u200D|\u2060|\uFEFF/g
+
+export function extractCommandQuery(setCommandStartOffset: (offset: number | null) => void): string | null {
+  const selection = $getSelection()
+  if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
+    return null
+  }
+
+  const anchor = selection.anchor
+  const node = anchor.getNode()
+  if (!$isTextNode(node)) {
+    return null
+  }
+
+  const offset = anchor.offset
+
+  const beforeCursor = node.getTextContent().slice(0, offset)
+  const lastSlashIndex = beforeCursor.lastIndexOf(TRIGGER_CHAR)
+
+  if (lastSlashIndex === -1) {
+    return null
+  }
+
+  const hasLeadingContent = hasNonWhitespaceBeforeOffset(node, lastSlashIndex)
+  if (hasLeadingContent) {
+    return null
+  }
+
+  const query = beforeCursor.slice(lastSlashIndex + 1)
+  if (WHITESPACE_REGEX.test(query)) {
+    return null
+  }
+
+  setCommandStartOffset(lastSlashIndex)
+
+  return query
+}
+
+export function updatePopoverPosition(
+  editor: { getRootElement: () => HTMLElement | null },
+  leftRef: React.MutableRefObject<{ left: number; width: number } | null>,
+  setPosition: (pos: { top: number; left: number; placement: "top" | "bottom" }) => void,
+) {
+  const root = editor.getRootElement()
+  if (!root) return
+
+  const selection = root.ownerDocument.getSelection()
+  if (!selection) return
+
+  if (selection.rangeCount === 0) return
+
+  const range = selection.getRangeAt(0)
+  if (!root.contains(range.startContainer)) return
+
+  const rect = range.getBoundingClientRect()
+  const gap = 8
+
+  const viewportHeight = window.innerHeight
+  const popover = root.ownerDocument.querySelector<HTMLElement>("[data-command-popover]")
+  const popoverRect = popover ? popover.getBoundingClientRect() : null
+  const rawHeight = popoverRect && popoverRect.height > 0 ? Math.ceil(popoverRect.height) : 280
+  const desiredHeight = Math.min(rawHeight, viewportHeight - gap * 2)
+  const below = viewportHeight - rect.bottom - gap
+  const above = rect.top - gap
+  const placement =
+    above >= desiredHeight ? "top" : below >= desiredHeight ? "bottom" : above >= below ? "top" : "bottom"
+
+  const viewportWidth = window.innerWidth
+  const rawWidth = popoverRect && popoverRect.width > 0 ? Math.ceil(popoverRect.width) : 500
+  const desiredWidth = Math.min(rawWidth, viewportWidth - gap * 2)
+  const minLeft = window.scrollX + gap
+  const maxLeft = window.scrollX + viewportWidth - desiredWidth - gap
+
+  const clamp = (value: number) => {
+    if (maxLeft <= minLeft) return minLeft
+    if (value < minLeft) return minLeft
+    if (value > maxLeft) return maxLeft
+    return value
+  }
+
+  const anchorLeft = rect.left + window.scrollX
+  const stored = leftRef.current
+  const shouldReanchor = stored === null || Math.abs(stored.width - desiredWidth) > 8
+  const openedLeft = (() => {
+    const viewportRight = window.scrollX + viewportWidth - gap
+    const start = anchorLeft
+    const end = anchorLeft - desiredWidth
+
+    if (start + desiredWidth <= viewportRight) return start
+    if (end >= minLeft) return end
+    return clamp(start)
+  })()
+
+  const preferred = shouldReanchor ? openedLeft : stored.left
+  const left = clamp(preferred)
+
+  leftRef.current = { left, width: desiredWidth }
+
+  const top = placement === "top" ? rect.top + window.scrollY - gap : rect.bottom + window.scrollY + gap
+
+  setPosition({
+    top,
+    left,
+    placement,
+  })
+}
+
+function hasNonWhitespaceBeforeOffset(node: TextNode, offset: number): boolean {
+  const root = $getRoot()
+  const paragraphs = root.getChildren()
+
+  for (const paragraph of paragraphs) {
+    if (!$isElementNode(paragraph)) continue
+
+    const children = paragraph.getChildren()
+    const inParagraph = children.includes(node)
+
+    for (const child of children) {
+      if (child === node) {
+        const text = node.getTextContent().slice(0, offset)
+        return text.replace(ZERO_WIDTH_REGEX, "").trim().length > 0
+      }
+
+      const raw = child.getTextContent ? child.getTextContent() : ""
+      const normalized = raw.replace(ZERO_WIDTH_REGEX, "")
+      if (normalized.trim().length > 0) return true
+    }
+
+    if (inParagraph) {
+      return false
+    }
+  }
+
+  return false
+}

+ 158 - 0
packages/opencode/webgui/src/components/command/CommandPopover.tsx

@@ -0,0 +1,158 @@
+import { useLayoutEffect, useMemo, useRef } from "react"
+import { useCommandSearch, type CommandResult } from "../../hooks/useCommandSearch"
+import { useMentionNavigation } from "../../hooks/useMentionNavigation"
+import type { CommandMetadata } from "./CommandPlugin/CommandHandler"
+
+interface CommandPopoverProps {
+  query: string
+  position: { top: number; left: number; placement: "top" | "bottom" }
+  onSelect: (metadata: CommandMetadata) => void
+  onClose: () => void
+  onReposition?: () => void
+}
+
+export function CommandPopover({ query, position, onSelect, onClose, onReposition }: CommandPopoverProps) {
+  const { results, isLoading } = useCommandSearch(query)
+  const rootRef = useRef<HTMLDivElement>(null)
+
+  const transform = useMemo(
+    () => (position.placement === "top" ? "translateY(-100%)" : "translateY(0)"),
+    [position.placement],
+  )
+
+  useLayoutEffect(() => {
+    if (!onReposition) return
+    const node = rootRef.current
+    if (!node) return
+
+    onReposition()
+    const frame = requestAnimationFrame(() => onReposition())
+
+    if (typeof ResizeObserver === "undefined") {
+      return () => cancelAnimationFrame(frame)
+    }
+
+    const observer = new ResizeObserver(() => onReposition())
+    observer.observe(node)
+
+    return () => {
+      cancelAnimationFrame(frame)
+      observer.disconnect()
+    }
+  }, [onReposition, isLoading, results.length])
+
+  const handleSelect = (index: number) => {
+    if (results[index]) {
+      onSelect(results[index].metadata)
+    }
+  }
+
+  const { selectedIndex, setSelectedIndex, listRef } = useMentionNavigation({
+    itemCount: results.length,
+    onSelect: handleSelect,
+    onClose,
+    isOpen: true,
+  })
+
+  if (results.length === 0 && !isLoading) {
+    return (
+      <div
+        ref={rootRef}
+        className="absolute z-50 bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded shadow-lg"
+        style={{ top: position.top, left: position.left, transform, maxWidth: "calc(100vw - 16px)" }}
+        data-command-popover
+      >
+        <div className="px-2 py-1 text-xs text-gray-500 dark:text-gray-400">
+          {query ? "No commands found" : "Type to search commands..."}
+        </div>
+      </div>
+    )
+  }
+
+  return (
+    <div
+      ref={rootRef}
+      className="absolute z-50 bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded shadow-lg"
+      style={{ top: position.top, left: position.left, transform, maxWidth: "calc(100vw - 16px)" }}
+      data-command-popover
+    >
+      <div ref={listRef} className="max-h-64 overflow-y-auto" style={{ maxWidth: "calc(100vw - 16px)" }}>
+        {isLoading && results.length === 0 ? (
+          <div className="px-2 py-1 text-xs text-gray-500 dark:text-gray-400">Loading commands...</div>
+        ) : (
+          <div className="py-0.5">
+            {results.map((result, index) => (
+              <CommandItem
+                key={result.id}
+                result={result}
+                isSelected={index === selectedIndex}
+                index={index}
+                onClick={() => onSelect(result.metadata)}
+                onMouseEnter={() => setSelectedIndex(index)}
+              />
+            ))}
+          </div>
+        )}
+      </div>
+    </div>
+  )
+}
+
+interface CommandItemProps {
+  result: CommandResult
+  isSelected: boolean
+  index: number
+  onClick: () => void
+  onMouseEnter: () => void
+}
+
+function CommandItem({ result, isSelected, index, onClick, onMouseEnter }: CommandItemProps) {
+  const { metadata } = result
+  const typeLabel = metadata.source === "skill" ? "Skill" : metadata.source === "mcp" ? "MCP" : "Command"
+  const badgeClass = metadata.source === "skill"
+    ? "text-emerald-600 dark:text-emerald-400"
+    : metadata.source === "mcp"
+      ? "text-indigo-600 dark:text-indigo-400"
+      : "text-amber-600 dark:text-amber-400"
+
+  return (
+    <div
+      data-index={index}
+      className={`px-2 py-1 cursor-pointer flex items-center gap-1.5 ${
+        isSelected ? "bg-blue-50 dark:bg-blue-950" : "hover:bg-gray-50 dark:hover:bg-gray-800"
+      }`}
+      onClick={onClick}
+      onMouseEnter={onMouseEnter}
+    >
+      <div
+        className={`flex-shrink-0 ${
+          isSelected ? "text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-400"
+        }`}
+      >
+        <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+          <path
+            strokeLinecap="round"
+            strokeLinejoin="round"
+            strokeWidth={2}
+            d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
+          />
+        </svg>
+      </div>
+      <div className="flex-1 min-w-0">
+        <div className="flex items-center gap-1.5">
+          <span
+            className={`text-xs truncate ${
+              isSelected ? "text-gray-900 dark:text-gray-100 font-medium" : "text-gray-900 dark:text-gray-100"
+            }`}
+          >
+            /{metadata.name}
+          </span>
+          <span className={`text-xs font-medium ${badgeClass}`}>{typeLabel}</span>
+        </div>
+        {metadata.description && (
+          <div className="text-[10px] text-gray-500 dark:text-gray-400 truncate">{metadata.description}</div>
+        )}
+      </div>
+    </div>
+  )
+}

+ 95 - 0
packages/opencode/webgui/src/hooks/useCommandSearch.ts

@@ -0,0 +1,95 @@
+import { useState, useEffect, useMemo } from "react"
+import { sdk } from "../lib/api/sdkClient"
+import type { Command } from "@opencode-ai/sdk/client"
+
+type CommandWithSource = Command & { source?: "command" | "mcp" | "skill" }
+
+export interface CommandResult {
+  id: string
+  metadata: {
+    name: string
+    description?: string
+    source?: "command" | "mcp" | "skill"
+  }
+}
+
+let commandsCache: CommandWithSource[] | null = null
+let commandsPromise: Promise<CommandWithSource[]> | null = null
+
+async function loadCommands() {
+  if (commandsCache) {
+    return commandsCache
+  }
+
+  if (commandsPromise) {
+    return commandsPromise
+  }
+
+  commandsPromise = (async () => {
+    try {
+      const response = await sdk.command.list()
+      commandsCache = (response.data ?? []) as CommandWithSource[]
+      return commandsCache
+    } catch (err) {
+      console.error("[useCommandSearch] Failed to load commands:", err)
+      return []
+    }
+  })()
+
+  return commandsPromise.finally(() => {
+    commandsPromise = null
+  })
+}
+
+export function useCommandSearch(query: string) {
+  const [isLoading, setIsLoading] = useState(!commandsCache)
+  const [commands, setCommands] = useState<CommandWithSource[]>(() => commandsCache ?? [])
+
+  useEffect(() => {
+    let cancelled = false
+    if (commandsCache) {
+      setIsLoading(false)
+      setCommands(commandsCache)
+      return () => {
+        cancelled = true
+      }
+    }
+
+    setIsLoading(true)
+    loadCommands()
+      .then((loaded) => {
+        if (cancelled) return
+        setCommands(loaded)
+      })
+      .finally(() => {
+        if (cancelled) return
+        setIsLoading(false)
+      })
+
+    return () => {
+      cancelled = true
+    }
+  }, [])
+
+  const results = useMemo(() => {
+    if (commands.length === 0) return []
+
+    const lowerQuery = query.toLowerCase()
+    const filtered = commands.filter((cmd) => {
+      const nameMatch = cmd.name.toLowerCase().includes(lowerQuery)
+      const descMatch = cmd.description?.toLowerCase().includes(lowerQuery)
+      return nameMatch || descMatch
+    })
+
+    return filtered.map((cmd) => ({
+      id: `${cmd.source ?? "command"}:${cmd.name}`,
+      metadata: {
+        name: cmd.name,
+        description: cmd.description,
+        source: cmd.source,
+      },
+    }))
+  }, [commands, query])
+
+  return { results, isLoading }
+}