summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-15 04:09:57 -0600
committerAdam <[email protected]>2025-12-15 10:20:15 -0600
commite9b95b2e9199bfd94d6ad2f67a12ef5ae60f4f21 (patch)
treefd2b4241924c4df8495f380dd074b163c8272ec0
parent56dde2cc835f509f77cbd800d080d6dbb2b8edc6 (diff)
downloadopencode-e9b95b2e9199bfd94d6ad2f67a12ef5ae60f4f21.tar.gz
opencode-e9b95b2e9199bfd94d6ad2f67a12ef5ae60f4f21.zip
wip(desktop): progress
-rw-r--r--packages/desktop/src/app.tsx45
-rw-r--r--packages/desktop/src/components/prompt-input.tsx203
-rw-r--r--packages/desktop/src/context/command.tsx255
-rw-r--r--packages/desktop/src/pages/session.tsx117
4 files changed, 475 insertions, 145 deletions
diff --git a/packages/desktop/src/app.tsx b/packages/desktop/src/app.tsx
index a49dac9aa..6414d0d49 100644
--- a/packages/desktop/src/app.tsx
+++ b/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>
diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx
index 0c1be77db..6ab280fa6 100644
--- a/packages/desktop/src/components/prompt-input.tsx
+++ b/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
diff --git a/packages/desktop/src/context/command.tsx b/packages/desktop/src/context/command.tsx
new file mode 100644
index 000000000..b17a98270
--- /dev/null
+++ b/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()
+ },
+ }
+ },
+})
diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx
index bef12fbd8..e3cac4842 100644
--- a/packages/desktop/src/pages/session.tsx
+++ b/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)