Adam 3 місяців тому
батько
коміт
e9b95b2e91

+ 24 - 21
packages/desktop/src/app.tsx

@@ -12,6 +12,7 @@ import { GlobalSDKProvider } from "@/context/global-sdk"
 import { SessionProvider } from "@/context/session"
 import { NotificationProvider } from "@/context/notification"
 import { DialogProvider } from "@opencode-ai/ui/context/dialog"
+import { CommandProvider } from "@/context/command"
 import Layout from "@/pages/layout"
 import Home from "@/pages/home"
 import DirectoryLayout from "@/pages/directory-layout"
@@ -40,27 +41,29 @@ export function App() {
           <GlobalSyncProvider>
             <LayoutProvider>
               <DialogProvider>
-                <NotificationProvider>
-                  <MetaProvider>
-                    <Font />
-                    <Router root={Layout}>
-                      <Route path="/" component={Home} />
-                      <Route path="/:dir" component={DirectoryLayout}>
-                        <Route path="/" component={() => <Navigate href="session" />} />
-                        <Route
-                          path="/session/:id?"
-                          component={(p) => (
-                            <Show when={p.params.id || true} keyed>
-                              <SessionProvider>
-                                <Session />
-                              </SessionProvider>
-                            </Show>
-                          )}
-                        />
-                      </Route>
-                    </Router>
-                  </MetaProvider>
-                </NotificationProvider>
+                <CommandProvider>
+                  <NotificationProvider>
+                    <MetaProvider>
+                      <Font />
+                      <Router root={Layout}>
+                        <Route path="/" component={Home} />
+                        <Route path="/:dir" component={DirectoryLayout}>
+                          <Route path="/" component={() => <Navigate href="session" />} />
+                          <Route
+                            path="/session/:id?"
+                            component={(p) => (
+                              <Show when={p.params.id || true} keyed>
+                                <SessionProvider>
+                                  <Session />
+                                </SessionProvider>
+                              </Show>
+                            )}
+                          />
+                        </Route>
+                      </Router>
+                    </MetaProvider>
+                  </NotificationProvider>
+                </CommandProvider>
               </DialogProvider>
             </LayoutProvider>
           </GlobalSyncProvider>

+ 138 - 65
packages/desktop/src/components/prompt-input.tsx

@@ -1,5 +1,5 @@
 import { useFilteredList } from "@opencode-ai/ui/hooks"
-import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js"
+import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js"
 import { createStore } from "solid-js/store"
 import { makePersisted } from "@solid-primitives/storage"
 import { createFocusSignal } from "@solid-primitives/active-element"
@@ -19,6 +19,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { DialogSelectModel } from "@/components/dialog-select-model"
 import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
 import { useProviders } from "@/hooks/use-providers"
+import { useCommand, formatKeybind } from "@/context/command"
 
 interface PromptInputProps {
   class?: string
@@ -53,6 +54,14 @@ const PLACEHOLDERS = [
   "How do environment variables work here?",
 ]
 
+interface SlashCommand {
+  id: string
+  trigger: string
+  title: string
+  description?: string
+  keybind?: string
+}
+
 export const PromptInput: Component<PromptInputProps> = (props) => {
   const navigate = useNavigate()
   const sdk = useSDK()
@@ -61,18 +70,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const session = useSession()
   const dialog = useDialog()
   const providers = useProviders()
+  const command = useCommand()
   let editorRef!: HTMLDivElement
 
   const [store, setStore] = createStore<{
-    popoverIsOpen: boolean
+    popover: "file" | "slash" | null
     historyIndex: number
     savedPrompt: Prompt | null
     placeholder: number
+    slashFilter: string
   }>({
-    popoverIsOpen: false,
+    popover: null,
     historyIndex: -1,
     savedPrompt: null,
     placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
+    slashFilter: "",
   })
 
   const MAX_HISTORY = 100
@@ -157,17 +169,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   }
 
   onMount(() => {
-    editorRef.addEventListener("paste", handlePaste)
+    editorRef?.addEventListener("paste", handlePaste)
   })
   onCleanup(() => {
-    editorRef.removeEventListener("paste", handlePaste)
+    editorRef?.removeEventListener("paste", handlePaste)
   })
 
   createEffect(() => {
     if (isFocused()) {
       handleInput()
     } else {
-      setStore("popoverIsOpen", false)
+      setStore("popover", null)
     }
   })
 
@@ -182,6 +194,53 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     onSelect: handleFileSelect,
   })
 
+  // Get slash commands from registered commands (only those with explicit slash trigger)
+  const slashCommands = createMemo<SlashCommand[]>(() =>
+    command.options
+      .filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash)
+      .map((opt) => ({
+        id: opt.id,
+        trigger: opt.slash!,
+        title: opt.title,
+        description: opt.description,
+        keybind: opt.keybind,
+      })),
+  )
+
+  const handleSlashSelect = (cmd: SlashCommand | undefined) => {
+    if (!cmd) return
+    // Since slash commands only trigger from start, just clear the input
+    editorRef.innerHTML = ""
+    session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
+    setStore("popover", null)
+    command.trigger(cmd.id, "slash")
+  }
+
+  const {
+    flat: slashFlat,
+    active: slashActive,
+    onInput: slashOnInput,
+    onKeyDown: slashOnKeyDown,
+  } = useFilteredList<SlashCommand>({
+    items: () => {
+      const filter = store.slashFilter.toLowerCase()
+      return slashCommands().filter(
+        (cmd) =>
+          cmd.trigger.toLowerCase().includes(filter) ||
+          cmd.title.toLowerCase().includes(filter) ||
+          cmd.description?.toLowerCase().includes(filter) ||
+          false,
+      )
+    },
+    key: (x) => x?.id,
+    onSelect: handleSlashSelect,
+  })
+
+  // Update slash filter when store changes
+  createEffect(() => {
+    slashOnInput(store.slashFilter)
+  })
+
   createEffect(
     on(
       () => session.prompt.current(),
@@ -256,11 +315,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     const rawText = rawParts.map((p) => p.content).join("")
 
     const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/)
+    // Slash commands only trigger when / is at the start of input
+    const slashMatch = rawText.match(/^\/(\S*)$/)
+
     if (atMatch) {
       onInput(atMatch[1])
-      setStore("popoverIsOpen", true)
-    } else if (store.popoverIsOpen) {
-      setStore("popoverIsOpen", false)
+      setStore("popover", "file")
+    } else if (slashMatch) {
+      setStore("slashFilter", slashMatch[1])
+      setStore("popover", "slash")
+    } else {
+      setStore("popover", null)
     }
 
     if (store.historyIndex >= 0) {
@@ -294,8 +359,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       const range = selection.getRangeAt(0)
 
       if (atMatch) {
-        // let node: Node | null = range.startContainer
-        // let offset = range.startOffset
         let runningLength = 0
 
         const walker = document.createTreeWalker(editorRef, NodeFilter.SHOW_TEXT, null)
@@ -335,7 +398,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     }
 
     handleInput()
-    setStore("popoverIsOpen", false)
+    setStore("popover", null)
   }
 
   const abort = () =>
@@ -403,8 +466,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   }
 
   const handleKeyDown = (event: KeyboardEvent) => {
-    if (store.popoverIsOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
-      onKeyDown(event)
+    // Handle popover navigation
+    if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
+      if (store.popover === "file") {
+        onKeyDown(event)
+      } else {
+        slashOnKeyDown(event)
+      }
       event.preventDefault()
       return
     }
@@ -441,8 +509,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       handleSubmit(event)
     }
     if (event.key === "Escape") {
-      if (store.popoverIsOpen) {
-        setStore("popoverIsOpen", false)
+      if (store.popover) {
+        setStore("popover", null)
       } else if (session.working()) {
         abort()
       }
@@ -470,31 +538,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     }
     if (!existing) return
 
-    // if (!session.id) {
-    // session.layout.setOpenedTabs(
-    // session.layout.copyTabs("", session.id)
-    // }
-
     const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
     const attachments = prompt.filter((part) => part.type === "file")
 
-    // const activeFile = local.context.active()
-    // if (activeFile) {
-    //   registerAttachment(
-    //     activeFile.path,
-    //     activeFile.selection,
-    //     activeFile.name ?? formatAttachmentLabel(activeFile.path, activeFile.selection),
-    //   )
-    // }
-
-    // for (const contextFile of local.context.all()) {
-    //   registerAttachment(
-    //     contextFile.path,
-    //     contextFile.selection,
-    //     formatAttachmentLabel(contextFile.path, contextFile.selection),
-    //   )
-    // }
-
     const attachmentParts = attachments.map((attachment) => {
       const absolute = toAbsolutePath(attachment.path)
       const query = attachment.selection
@@ -519,7 +565,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
     session.layout.setActiveTab(undefined)
     session.messages.setActive(undefined)
-    // Clear the editor DOM directly to ensure it's empty
     editorRef.innerHTML = ""
     session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
 
@@ -542,38 +587,66 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
   return (
     <div class="relative size-full _max-h-[320px] flex flex-col gap-3">
-      <Show when={store.popoverIsOpen}>
+      {/* Popover for file mentions and slash commands */}
+      <Show when={store.popover}>
         <div
           class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-[252px] min-h-10
                  overflow-auto no-scrollbar flex flex-col p-2 pb-0 rounded-md
                  border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
         >
-          <Show when={flat().length > 0} fallback={<div class="text-text-weak px-2">No matching files</div>}>
-            <For each={flat()}>
-              {(i) => (
-                <button
-                  classList={{
-                    "w-full flex items-center justify-between rounded-md": true,
-                    "bg-surface-raised-base-hover": active() === i,
-                  }}
-                  onClick={() => handleFileSelect(i)}
-                >
-                  <div class="flex items-center gap-x-2 grow min-w-0">
-                    <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
-                    <div class="flex items-center text-14-regular">
-                      <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
-                        {getDirectory(i)}
-                      </span>
-                      <Show when={!i.endsWith("/")}>
-                        <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
+          <Switch>
+            <Match when={store.popover === "file"}>
+              <Show when={flat().length > 0} fallback={<div class="text-text-weak px-2 py-1">No matching files</div>}>
+                <For each={flat()}>
+                  {(i) => (
+                    <button
+                      classList={{
+                        "w-full flex items-center gap-x-2 rounded-md px-2 py-1": true,
+                        "bg-surface-raised-base-hover": active() === i,
+                      }}
+                      onClick={() => handleFileSelect(i)}
+                    >
+                      <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
+                      <div class="flex items-center text-14-regular min-w-0">
+                        <span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(i)}</span>
+                        <Show when={!i.endsWith("/")}>
+                          <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
+                        </Show>
+                      </div>
+                    </button>
+                  )}
+                </For>
+              </Show>
+            </Match>
+            <Match when={store.popover === "slash"}>
+              <Show
+                when={slashFlat().length > 0}
+                fallback={<div class="text-text-weak px-2 py-1">No matching commands</div>}
+              >
+                <For each={slashFlat()}>
+                  {(cmd) => (
+                    <button
+                      classList={{
+                        "w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
+                        "bg-surface-raised-base-hover": slashActive() === cmd.id,
+                      }}
+                      onClick={() => handleSlashSelect(cmd)}
+                    >
+                      <div class="flex items-center gap-2 min-w-0">
+                        <span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
+                        <Show when={cmd.description}>
+                          <span class="text-14-regular text-text-weak truncate">{cmd.description}</span>
+                        </Show>
+                      </div>
+                      <Show when={cmd.keybind}>
+                        <span class="text-12-regular text-text-subtle shrink-0">{formatKeybind(cmd.keybind!)}</span>
                       </Show>
-                    </div>
-                  </div>
-                  <div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
-                </button>
-              )}
-            </For>
-          </Show>
+                    </button>
+                  )}
+                </For>
+              </Show>
+            </Match>
+          </Switch>
         </div>
       </Show>
       <form

+ 255 - 0
packages/desktop/src/context/command.tsx

@@ -0,0 +1,255 @@
+import { createMemo, createSignal, onCleanup, onMount, Show, type Accessor } from "solid-js"
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { List } from "@opencode-ai/ui/list"
+
+const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
+
+/**
+ * Keybind configuration type.
+ * Format: "mod+key" where mod can be ctrl, alt, shift, meta (or cmd on mac)
+ * Multiple keybinds can be separated by comma: "mod+p,ctrl+shift+p"
+ * Use "mod" for platform-appropriate modifier (cmd on mac, ctrl elsewhere)
+ */
+export type KeybindConfig = string
+
+export interface Keybind {
+  key: string
+  ctrl: boolean
+  meta: boolean
+  shift: boolean
+  alt: boolean
+}
+
+export interface CommandOption {
+  /** Unique identifier for the command */
+  id: string
+  /** Display title in the command palette */
+  title: string
+  /** Optional description */
+  description?: string
+  /** Category for grouping in the palette */
+  category?: string
+  /** Keybind string (e.g., "mod+p", "ctrl+shift+t") */
+  keybind?: KeybindConfig
+  /** Slash command trigger (e.g., "models" for /models) */
+  slash?: string
+  /** Whether to show in the "Suggested" section */
+  suggested?: boolean
+  /** Whether the command is disabled */
+  disabled?: boolean
+  /** Handler when command is selected */
+  onSelect?: (source?: "palette" | "keybind" | "slash") => void
+}
+
+export function parseKeybind(config: string): Keybind[] {
+  if (!config || config === "none") return []
+
+  return config.split(",").map((combo) => {
+    const parts = combo.trim().toLowerCase().split("+")
+    const keybind: Keybind = {
+      key: "",
+      ctrl: false,
+      meta: false,
+      shift: false,
+      alt: false,
+    }
+
+    for (const part of parts) {
+      switch (part) {
+        case "ctrl":
+        case "control":
+          keybind.ctrl = true
+          break
+        case "meta":
+        case "cmd":
+        case "command":
+          keybind.meta = true
+          break
+        case "mod":
+          if (IS_MAC) keybind.meta = true
+          else keybind.ctrl = true
+          break
+        case "alt":
+        case "option":
+          keybind.alt = true
+          break
+        case "shift":
+          keybind.shift = true
+          break
+        default:
+          keybind.key = part
+          break
+      }
+    }
+
+    return keybind
+  })
+}
+
+export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean {
+  const eventKey = event.key.toLowerCase()
+
+  for (const kb of keybinds) {
+    const keyMatch = kb.key === eventKey
+    const ctrlMatch = kb.ctrl === (event.ctrlKey || false)
+    const metaMatch = kb.meta === (event.metaKey || false)
+    const shiftMatch = kb.shift === (event.shiftKey || false)
+    const altMatch = kb.alt === (event.altKey || false)
+
+    if (keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) {
+      return true
+    }
+  }
+
+  return false
+}
+
+export function formatKeybind(config: string): string {
+  if (!config || config === "none") return ""
+
+  const keybinds = parseKeybind(config)
+  if (keybinds.length === 0) return ""
+
+  const kb = keybinds[0]
+  const parts: string[] = []
+
+  if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl")
+  if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt")
+  if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift")
+  if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta")
+
+  if (kb.key) {
+    const displayKey = kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1)
+    parts.push(displayKey)
+  }
+
+  return IS_MAC ? parts.join("") : parts.join("+")
+}
+
+function DialogCommand(props: { options: CommandOption[] }) {
+  const dialog = useDialog()
+
+  return (
+    <Dialog title="Commands">
+      <List
+        class="px-2.5"
+        search={{ placeholder: "Search commands", autofocus: true }}
+        emptyMessage="No commands found"
+        items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)}
+        key={(x) => x.id}
+        groupBy={(x) => x.category ?? ""}
+        onSelect={(option) => {
+          if (option) {
+            dialog.clear()
+            option.onSelect?.("palette")
+          }
+        }}
+      >
+        {(option) => (
+          <div class="w-full flex items-center justify-between gap-4">
+            <div class="flex items-center gap-2 min-w-0">
+              <span class="text-14-regular text-text-strong whitespace-nowrap">{option.title}</span>
+              <Show when={option.description}>
+                <span class="text-14-regular text-text-weak truncate">{option.description}</span>
+              </Show>
+            </div>
+            <Show when={option.keybind}>
+              <span class="text-12-regular text-text-subtle shrink-0">{formatKeybind(option.keybind!)}</span>
+            </Show>
+          </div>
+        )}
+      </List>
+    </Dialog>
+  )
+}
+
+export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
+  name: "Command",
+  init: () => {
+    const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
+    const [suspendCount, setSuspendCount] = createSignal(0)
+    const dialog = useDialog()
+
+    const options = createMemo(() => {
+      const all = registrations().flatMap((x) => x())
+      const suggested = all.filter((x) => x.suggested && !x.disabled)
+      return [
+        ...suggested.map((x) => ({
+          ...x,
+          id: "suggested." + x.id,
+          category: "Suggested",
+        })),
+        ...all,
+      ]
+    })
+
+    const suspended = () => suspendCount() > 0
+
+    const showPalette = () => {
+      if (dialog.stack.length === 0) {
+        dialog.replace(() => <DialogCommand options={options().filter((x) => !x.disabled)} />)
+      }
+    }
+
+    const handleKeyDown = (event: KeyboardEvent) => {
+      if (suspended()) return
+
+      // Check for command palette keybind (mod+shift+p)
+      const paletteKeybinds = parseKeybind("mod+shift+p")
+      if (matchKeybind(paletteKeybinds, event)) {
+        event.preventDefault()
+        showPalette()
+        return
+      }
+
+      // Check registered command keybinds
+      for (const option of options()) {
+        if (option.disabled) continue
+        if (!option.keybind) continue
+
+        const keybinds = parseKeybind(option.keybind)
+        if (matchKeybind(keybinds, event)) {
+          event.preventDefault()
+          option.onSelect?.("keybind")
+          return
+        }
+      }
+    }
+
+    onMount(() => {
+      document.addEventListener("keydown", handleKeyDown)
+    })
+
+    onCleanup(() => {
+      document.removeEventListener("keydown", handleKeyDown)
+    })
+
+    return {
+      register(cb: () => CommandOption[]) {
+        const results = createMemo(cb)
+        setRegistrations((arr) => [results, ...arr])
+        onCleanup(() => {
+          setRegistrations((arr) => arr.filter((x) => x !== results))
+        })
+      },
+      trigger(id: string, source?: "palette" | "keybind" | "slash") {
+        for (const option of options()) {
+          if (option.id === id || option.id === "suggested." + id) {
+            option.onSelect?.(source)
+            return
+          }
+        }
+      },
+      show: showPalette,
+      keybinds(enabled: boolean) {
+        setSuspendCount((count) => count + (enabled ? -1 : 1))
+      },
+      suspended,
+      get options() {
+        return options()
+      },
+    }
+  },
+})

+ 58 - 59
packages/desktop/src/pages/session.tsx

@@ -34,6 +34,7 @@ import { Terminal } from "@/components/terminal"
 import { checksum } from "@opencode-ai/util/encode"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { DialogSelectFile } from "@/components/dialog-select-file"
+import { useCommand } from "@/context/command"
 
 export default function Page() {
   const layout = useLayout()
@@ -41,6 +42,7 @@ export default function Page() {
   const sync = useSync()
   const session = useSession()
   const dialog = useDialog()
+  const command = useCommand()
   const [store, setStore] = createStore({
     clickTimer: undefined as number | undefined,
     activeDraggable: undefined as string | undefined,
@@ -48,16 +50,6 @@ export default function Page() {
   })
   let inputRef!: HTMLDivElement
 
-  const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
-
-  onMount(() => {
-    document.addEventListener("keydown", handleKeyDown)
-  })
-
-  onCleanup(() => {
-    document.removeEventListener("keydown", handleKeyDown)
-  })
-
   createEffect(() => {
     if (layout.terminal.opened()) {
       if (session.terminal.all().length === 0) {
@@ -66,35 +58,54 @@ export default function Page() {
     }
   })
 
-  const handleKeyDown = (event: KeyboardEvent) => {
-    if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") {
-      event.preventDefault()
-      return
-    }
-    if (event.getModifierState(MOD) && event.key.toLowerCase() === "p") {
-      event.preventDefault()
-      dialog.replace(() => <DialogSelectFile />)
-      return
-    }
-    if (event.ctrlKey && event.key.toLowerCase() === "t") {
-      event.preventDefault()
-      const currentTheme = localStorage.getItem("theme") ?? "oc-1"
-      const themes = ["oc-1", "oc-2-paper"]
-      const nextTheme = themes[(themes.indexOf(currentTheme) + 1) % themes.length]
-      localStorage.setItem("theme", nextTheme)
-      document.documentElement.setAttribute("data-theme", nextTheme)
-      return
-    }
-    if (event.ctrlKey && event.key.toLowerCase() === "`") {
-      event.preventDefault()
-      if (event.shiftKey) {
-        session.terminal.new()
-        return
-      }
-      layout.terminal.toggle()
-      return
-    }
+  // Register commands for this page
+  command.register(() => [
+    {
+      id: "file.open",
+      title: "Open file",
+      description: "Search and open a file",
+      category: "File",
+      keybind: "mod+p",
+      slash: "open",
+      onSelect: () => dialog.replace(() => <DialogSelectFile />),
+    },
+    {
+      id: "theme.toggle",
+      title: "Toggle theme",
+      description: "Switch between themes",
+      category: "View",
+      keybind: "ctrl+t",
+      slash: "theme",
+      onSelect: () => {
+        const currentTheme = localStorage.getItem("theme") ?? "oc-1"
+        const themes = ["oc-1", "oc-2-paper"]
+        const nextTheme = themes[(themes.indexOf(currentTheme) + 1) % themes.length]
+        localStorage.setItem("theme", nextTheme)
+        document.documentElement.setAttribute("data-theme", nextTheme)
+      },
+    },
+    {
+      id: "terminal.toggle",
+      title: "Toggle terminal",
+      description: "Show or hide the terminal",
+      category: "View",
+      keybind: "ctrl+`",
+      slash: "terminal",
+      onSelect: () => layout.terminal.toggle(),
+    },
+    {
+      id: "terminal.new",
+      title: "New terminal",
+      description: "Create a new terminal tab",
+      category: "Terminal",
+      keybind: "ctrl+shift+`",
+      onSelect: () => session.terminal.new(),
+    },
+  ])
 
+  // Handle keyboard events that aren't commands
+  const handleKeyDown = (event: KeyboardEvent) => {
+    // Don't interfere with terminal
     // @ts-expect-error
     if (document.activeElement?.dataset?.component === "terminal") {
       return
@@ -108,32 +119,20 @@ export default function Page() {
       return
     }
 
-    // if (local.file.active()) {
-    //   const active = local.file.active()!
-    //   if (event.key === "Enter" && active.selection) {
-    //     local.context.add({
-    //       type: "file",
-    //       path: active.path,
-    //       selection: { ...active.selection },
-    //     })
-    //     return
-    //   }
-    //
-    //   if (event.getModifierState(MOD)) {
-    //     if (event.key.toLowerCase() === "a") {
-    //       return
-    //     }
-    //     if (event.key.toLowerCase() === "c") {
-    //       return
-    //     }
-    //   }
-    // }
-
+    // Focus input when typing characters
     if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
       inputRef?.focus()
     }
   }
 
+  onMount(() => {
+    document.addEventListener("keydown", handleKeyDown)
+  })
+
+  onCleanup(() => {
+    document.removeEventListener("keydown", handleKeyDown)
+  })
+
   const resetClickTimer = () => {
     if (!store.clickTimer) return
     clearTimeout(store.clickTimer)