diff options
| author | Adam <[email protected]> | 2025-12-15 04:09:57 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-15 10:20:15 -0600 |
| commit | e9b95b2e9199bfd94d6ad2f67a12ef5ae60f4f21 (patch) | |
| tree | fd2b4241924c4df8495f380dd074b163c8272ec0 /packages/desktop/src/components | |
| parent | 56dde2cc835f509f77cbd800d080d6dbb2b8edc6 (diff) | |
| download | opencode-e9b95b2e9199bfd94d6ad2f67a12ef5ae60f4f21.tar.gz opencode-e9b95b2e9199bfd94d6ad2f67a12ef5ae60f4f21.zip | |
wip(desktop): progress
Diffstat (limited to 'packages/desktop/src/components')
| -rw-r--r-- | packages/desktop/src/components/prompt-input.tsx | 203 |
1 files changed, 138 insertions, 65 deletions
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 |
