From e9b95b2e9199bfd94d6ad2f67a12ef5ae60f4f21 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 04:09:57 -0600 Subject: wip(desktop): progress --- packages/desktop/src/components/prompt-input.tsx | 203 +++++++++++++++-------- 1 file changed, 138 insertions(+), 65 deletions(-) (limited to 'packages/desktop/src/components') 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 = (props) => { const navigate = useNavigate() const sdk = useSDK() @@ -61,18 +70,21 @@ export const PromptInput: Component = (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 = (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 = (props) => { onSelect: handleFileSelect, }) + // Get slash commands from registered commands (only those with explicit slash trigger) + const slashCommands = createMemo(() => + 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({ + 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 = (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 = (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 = (props) => { } handleInput() - setStore("popoverIsOpen", false) + setStore("popover", null) } const abort = () => @@ -403,8 +466,13 @@ export const PromptInput: Component = (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 = (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 = (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 = (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 = (props) => { return (
- + {/* Popover for file mentions and slash commands */} +
- 0} fallback={
No matching files
}> - - {(i) => ( - + )} + +
+ + + 0} + fallback={
No matching commands
} + > + + {(cmd) => ( +
-
-
- - )} - - + + )} + + + +