diff options
| author | Adam <[email protected]> | 2025-10-22 17:31:44 -0500 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-10-22 17:31:49 -0500 |
| commit | 89b703c387aed3ee918d826b788b4be1729bdde9 (patch) | |
| tree | 9a22d26bfc5e921789924e2344d04113660b67a3 /packages/desktop/src/components/prompt-input.tsx | |
| parent | eff12cb48453e45590a53a7705c5044a0da9e7f7 (diff) | |
| download | opencode-89b703c387aed3ee918d826b788b4be1729bdde9.tar.gz opencode-89b703c387aed3ee918d826b788b4be1729bdde9.zip | |
wip: desktop work
Diffstat (limited to 'packages/desktop/src/components/prompt-input.tsx')
| -rw-r--r-- | packages/desktop/src/components/prompt-input.tsx | 428 |
1 files changed, 298 insertions, 130 deletions
diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 0f8fb5930..55a410516 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -1,63 +1,74 @@ -import { createEffect, on, Component, createMemo, Show } from "solid-js" +import { useLocal } from "@/context" +import { Button, Icon, IconButton, Select, SelectDialog, Tooltip } from "@opencode-ai/ui" +import { useFilteredList } from "@opencode-ai/ui/hooks" +import { createEffect, on, Component, createMemo, Show, Switch, Match, For } from "solid-js" import { createStore } from "solid-js/store" +import { FileIcon } from "@/ui" +import { getDirectory, getFilename } from "@/utils" +import { createFocusSignal } from "@solid-primitives/active-element" +import { TextSelection } from "@/context/local" +import { DateTime } from "luxon" -interface TextPart { - type: "text" +interface PartBase { content: string } -interface AttachmentPart { - type: "attachment" - fileId: string - name: string +interface TextPart extends PartBase { + type: "text" } -export type ContentPart = TextPart | AttachmentPart - -export interface AttachmentToAdd { - id: string - name: string +interface FileAttachmentPart extends PartBase { + type: "file" + path: string + selection?: TextSelection } -type AddAttachmentCallback = (attachment: AttachmentToAdd) => void - -export interface PopoverState { - isOpen: boolean - searchQuery: string - addAttachment: AddAttachmentCallback -} +export type ContentPart = TextPart | FileAttachmentPart interface PromptInputProps { onSubmit: (parts: ContentPart[]) => void - onShowAttachments?: (state: PopoverState | null) => void class?: string + ref?: (el: HTMLDivElement) => void } export const PromptInput: Component<PromptInputProps> = (props) => { - let editorRef: HTMLDivElement | undefined + const local = useLocal() + let editorRef!: HTMLDivElement const defaultParts = [{ type: "text", content: "" } as const] const [store, setStore] = createStore<{ contentParts: ContentPart[] - popover: { - isOpen: boolean - searchQuery: string - } + popoverIsOpen: boolean }>({ contentParts: defaultParts, - popover: { - isOpen: false, - searchQuery: "", - }, + popoverIsOpen: false, }) const isEmpty = createMemo(() => isEqual(store.contentParts, defaultParts)) + const isFocused = createFocusSignal(() => editorRef) + + createEffect(() => { + if (isFocused()) { + handleInput() + } else { + setStore("popoverIsOpen", false) + } + }) + + const { flat, active, onInput, onKeyDown } = useFilteredList<string>({ + items: local.file.search, + key: (x) => x, + onSelect: (path) => { + if (!path) return + addPart({ type: "file", path, content: "@" + getFilename(path) }) + setStore("popoverIsOpen", false) + }, + }) createEffect( on( () => store.contentParts, (currentParts) => { - if (!editorRef) return const domParts = parseFromDOM() if (isEqual(currentParts, domParts)) return @@ -70,14 +81,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => { editorRef.innerHTML = "" currentParts.forEach((part) => { if (part.type === "text") { - editorRef!.appendChild(document.createTextNode(part.content)) - } else if (part.type === "attachment") { + editorRef.appendChild(document.createTextNode(part.content)) + } else if (part.type === "file") { const pill = document.createElement("span") - pill.textContent = `@${part.name}` - pill.className = "attachment-pill" - pill.setAttribute("data-file-id", part.fileId) + pill.textContent = part.content + pill.setAttribute("data-type", "file") + pill.setAttribute("data-path", part.path) pill.setAttribute("contenteditable", "false") - editorRef!.appendChild(pill) + pill.style.userSelect = "text" + pill.style.cursor = "default" + editorRef.appendChild(pill) } }) @@ -88,30 +101,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => { ), ) - createEffect(() => { - if (store.popover.isOpen) { - props.onShowAttachments?.({ - isOpen: true, - searchQuery: store.popover.searchQuery, - addAttachment: addAttachment, - }) - } else { - props.onShowAttachments?.(null) - } - }) - const parseFromDOM = (): ContentPart[] => { - if (!editorRef) return [] const newParts: ContentPart[] = [] editorRef.childNodes.forEach((node) => { if (node.nodeType === Node.TEXT_NODE) { if (node.textContent) newParts.push({ type: "text", content: node.textContent }) - } else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.fileId) { - newParts.push({ - type: "attachment", - fileId: (node as HTMLElement).dataset.fileId!, - name: node.textContent!.substring(1), - }) + } else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type) { + switch ((node as HTMLElement).dataset.type) { + case "file": + newParts.push({ + type: "file", + path: (node as HTMLElement).dataset.path!, + content: node.textContent!, + }) + break + default: + break + } } }) if (newParts.length === 0) newParts.push(...defaultParts) @@ -120,96 +126,234 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const handleInput = () => { const rawParts = parseFromDOM() - const cursorPosition = getCursorPosition(editorRef!) - const rawText = rawParts.map((p) => (p.type === "text" ? p.content : `@${p.name}`)).join("") + const cursorPosition = getCursorPosition(editorRef) + const rawText = rawParts.map((p) => p.content).join("") const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/) if (atMatch) { - setStore("popover", { isOpen: true, searchQuery: atMatch[1] }) - } else if (store.popover.isOpen) { - setStore("popover", "isOpen", false) + onInput(atMatch[1]) + setStore("popoverIsOpen", true) + } else if (store.popoverIsOpen) { + setStore("popoverIsOpen", false) } setStore("contentParts", rawParts) } - const addAttachment: AddAttachmentCallback = (attachment) => { - const rawText = store.contentParts.map((p) => (p.type === "text" ? p.content : `@${p.name}`)).join("") - const cursorPosition = getCursorPosition(editorRef!) - + const addPart = (part: ContentPart) => { + const cursorPosition = getCursorPosition(editorRef) + const rawText = store.contentParts.map((p) => p.content).join("") const textBeforeCursor = rawText.substring(0, cursorPosition) const atMatch = textBeforeCursor.match(/@(\S*)$/) - if (!atMatch) return const startIndex = atMatch.index! + const endIndex = cursorPosition + + const { + parts: nextParts, + cursorIndex, + cursorOffset, + inserted, + } = store.contentParts.reduce( + (acc, item) => { + if (acc.inserted) { + acc.parts.push(item) + acc.runningIndex += item.content.length + return acc + } - // Create new structured content - const newParts: ContentPart[] = [] - const textBeforeTrigger = rawText.substring(0, startIndex) - if (textBeforeTrigger) newParts.push({ type: "text", content: textBeforeTrigger }) + const nextIndex = acc.runningIndex + item.content.length + if (nextIndex <= startIndex) { + acc.parts.push(item) + acc.runningIndex = nextIndex + return acc + } + + if (item.type !== "text") { + acc.parts.push(item) + acc.runningIndex = nextIndex + return acc + } + + const headLength = Math.max(0, startIndex - acc.runningIndex) + const tailLength = Math.max(0, endIndex - acc.runningIndex) + const head = item.content.slice(0, headLength) + const tail = item.content.slice(tailLength) - newParts.push({ type: "attachment", fileId: attachment.id, name: attachment.name }) + if (head) acc.parts.push({ type: "text", content: head }) - // Add a space after the pill for better UX - newParts.push({ type: "text", content: " " }) + acc.parts.push(part) + + const rest = /^\s/.test(tail) ? tail : ` ${tail}` + if (rest) { + acc.cursorIndex = acc.parts.length + acc.cursorOffset = Math.min(1, rest.length) + acc.parts.push({ type: "text", content: rest }) + } + + acc.inserted = true + acc.runningIndex = nextIndex + return acc + }, + { + parts: [] as ContentPart[], + runningIndex: 0, + inserted: false, + cursorIndex: null as number | null, + cursorOffset: 0, + }, + ) - const textAfterCursor = rawText.substring(cursorPosition) - if (textAfterCursor) newParts.push({ type: "text", content: textAfterCursor }) + if (!inserted || cursorIndex === null) return - setStore("contentParts", newParts) - setStore("popover", "isOpen", false) + setStore("contentParts", nextParts) + setStore("popoverIsOpen", false) - // Set cursor position after the newly added pill + space - // We need to wait for the DOM to update queueMicrotask(() => { - setCursorPosition(editorRef!, textBeforeTrigger.length + 1 + attachment.name.length + 1) + const node = editorRef.childNodes[cursorIndex] + if (node && node.nodeType === Node.TEXT_NODE) { + const range = document.createRange() + const selection = window.getSelection() + const length = node.textContent ? node.textContent.length : 0 + const offset = cursorOffset > length ? length : cursorOffset + range.setStart(node, offset) + range.collapse(true) + selection?.removeAllRanges() + selection?.addRange(range) + } }) } const handleKeyDown = (event: KeyboardEvent) => { - if (store.popover.isOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { - // In a real implementation, you'd prevent default and delegate this to the popover - console.log("Key press delegated to popover:", event.key) + if (store.popoverIsOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { + onKeyDown(event) event.preventDefault() return } - if (event.key === "Enter" && !event.shiftKey) { - event.preventDefault() - if (store.contentParts.length > 0) { - props.onSubmit([...store.contentParts]) - setStore("contentParts", defaultParts) - } + handleSubmit(event) + } + } + + const handleSubmit = (event: Event) => { + event.preventDefault() + if (store.contentParts.length > 0) { + props.onSubmit([...store.contentParts]) + setStore("contentParts", defaultParts) } } return ( - <div - classList={{ - "size-full max-w-xl bg-surface-raised-stronger-non-alpha border border-border-strong-base": true, - "rounded-2xl overflow-clip focus-within:shadow-xs-border-selected": true, - [props.class ?? ""]: !!props.class, - }} - > - <div class="p-3" /> - <div class="relative"> - <div - ref={editorRef} - contenteditable="true" - onInput={handleInput} - onKeyDown={handleKeyDown} - classList={{ - "w-full p-3 text-sm focus:outline-none": true, - }} - /> - <Show when={isEmpty()}> - <div class="absolute bottom-0 left-0 p-3 text-sm text-text-weak pointer-events-none"> - Plan and build anything + <div class="relative size-full max-w-[640px] _max-h-[320px] flex flex-col gap-3"> + <Show when={store.popoverIsOpen}> + <div class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-[252px] min-h-10 overflow-y-auto flex flex-col p-2 pb-0 rounded-2xl border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"> + <For each={flat()}> + {(i) => ( + <div + classList={{ + "w-full flex items-center justify-between rounded-md": true, + "bg-surface-raised-base-hover": active() === 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> + <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span> + </div> + </div> + <div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div> + </div> + )} + </For> + </div> + </Show> + <form + onSubmit={handleSubmit} + classList={{ + "bg-surface-raised-stronger-non-alpha border border-border-strong-base": true, + "rounded-2xl overflow-clip focus-within:shadow-xs-border-selected": true, + [props.class ?? ""]: !!props.class, + }} + > + <div class="relative max-h-[240px] overflow-y-auto"> + <div + ref={(el) => { + editorRef = el + props.ref?.(el) + }} + contenteditable="true" + onInput={handleInput} + onKeyDown={handleKeyDown} + classList={{ + "w-full p-3 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, + "[&>[data-type=file]]:text-icon-info-active": true, + }} + /> + <Show when={isEmpty()}> + <div class="absolute top-0 left-0 p-3 text-14-regular text-text-weak pointer-events-none"> + Plan and build anything + </div> + </Show> + </div> + <div class="p-3 flex items-center justify-between"> + <div class="flex items-center justify-start gap-1"> + <Select + options={local.agent.list().map((agent) => agent.name)} + current={local.agent.current().name} + onSelect={local.agent.set} + class="capitalize" + /> + <SelectDialog + title="Select model" + placeholder="Search models" + emptyMessage="No model results" + key={(x) => `${x.provider.id}:${x.id}`} + items={local.model.list()} + current={local.model.current()} + filterKeys={["provider.name", "name", "id"]} + groupBy={(x) => x.provider.name} + sortGroupsBy={(a, b) => { + const order = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"] + const aProvider = a.items[0].provider.id + const bProvider = b.items[0].provider.id + if (order.includes(aProvider) && !order.includes(bProvider)) return -1 + if (!order.includes(aProvider) && order.includes(bProvider)) return 1 + return order.indexOf(aProvider) - order.indexOf(bProvider) + }} + onSelect={(x) => local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined)} + trigger={ + <Button as="div" variant="ghost"> + {local.model.current()?.name ?? "Select model"} + <span class="ml-0.5 text-text-weak text-12-regular">{local.model.current()?.provider.name}</span> + <Icon name="chevron-down" size="small" /> + </Button> + } + > + {(i) => ( + <div class="w-full flex items-center justify-between gap-x-3"> + <div class="flex items-center gap-x-2.5 text-text-muted grow min-w-0"> + <img src={`https://models.dev/logos/${i.provider.id}.svg`} class="size-6 p-0.5 shrink-0 " /> + <div class="flex gap-x-3 items-baseline flex-[1_0_0]"> + <span class="text-14-medium text-text-strong overflow-hidden text-ellipsis">{i.name}</span> + <span class="text-12-medium text-text-weak overflow-hidden text-ellipsis truncate min-w-0"> + {DateTime.fromFormat(i.release_date, "yyyy-MM-dd").toFormat("LLL yyyy")} + </span> + </div> + </div> + <Show when={!i.cost || i.cost?.input === 0}> + <div class="overflow-hidden text-12-medium text-text-strong">Free</div> + </Show> + </div> + )} + </SelectDialog> </div> - </Show> - </div> - <div class="p-3" /> + <IconButton type="submit" disabled={isEmpty()} icon="arrow-up" variant="primary" /> + </div> + </form> </div> ) } @@ -223,7 +367,7 @@ function isEqual(arrA: ContentPart[], arrB: ContentPart[]): boolean { if (partA.type === "text" && partA.content !== (partB as TextPart).content) { return false } - if (partA.type === "attachment" && partA.fileId !== (partB as AttachmentPart).fileId) { + if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) { return false } } @@ -241,24 +385,48 @@ function getCursorPosition(parent: HTMLElement): number { } function setCursorPosition(parent: HTMLElement, position: number) { - let child = parent.firstChild - let offset = position - while (child) { - if (offset > child.textContent!.length) { - offset -= child.textContent!.length - child = child.nextSibling - } else { - try { - const range = document.createRange() - const sel = window.getSelection() - range.setStart(child, offset) - range.collapse(true) - sel?.removeAllRanges() - sel?.addRange(range) - } catch (e) { - console.error("Failed to set cursor position.", e) - } + let remaining = position + let node = parent.firstChild + while (node) { + const length = node.textContent ? node.textContent.length : 0 + const isText = node.nodeType === Node.TEXT_NODE + const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file" + + if (isText && remaining <= length) { + const range = document.createRange() + const selection = window.getSelection() + range.setStart(node, remaining) + range.collapse(true) + selection?.removeAllRanges() + selection?.addRange(range) return } + + if (isFile && remaining <= length) { + const range = document.createRange() + const selection = window.getSelection() + range.setStartAfter(node) + range.collapse(true) + selection?.removeAllRanges() + selection?.addRange(range) + return + } + + remaining -= length + node = node.nextSibling + } + + const fallbackRange = document.createRange() + const fallbackSelection = window.getSelection() + const last = parent.lastChild + if (last && last.nodeType === Node.TEXT_NODE) { + const len = last.textContent ? last.textContent.length : 0 + fallbackRange.setStart(last, len) + } + if (!last || last.nodeType !== Node.TEXT_NODE) { + fallbackRange.selectNodeContents(parent) } + fallbackRange.collapse(false) + fallbackSelection?.removeAllRanges() + fallbackSelection?.addRange(fallbackRange) } |
