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 PartBase { content: string } interface TextPart extends PartBase { type: "text" } interface FileAttachmentPart extends PartBase { type: "file" path: string selection?: TextSelection } export type ContentPart = TextPart | FileAttachmentPart interface PromptInputProps { onSubmit: (parts: ContentPart[]) => void class?: string ref?: (el: HTMLDivElement) => void } export const PromptInput: Component = (props) => { const local = useLocal() let editorRef!: HTMLDivElement const defaultParts = [{ type: "text", content: "" } as const] const [store, setStore] = createStore<{ contentParts: ContentPart[] popoverIsOpen: boolean }>({ contentParts: defaultParts, 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({ 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) => { const domParts = parseFromDOM() if (isEqual(currentParts, domParts)) return const selection = window.getSelection() let cursorPosition: number | null = null if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) { cursorPosition = getCursorPosition(editorRef) } editorRef.innerHTML = "" currentParts.forEach((part) => { if (part.type === "text") { editorRef.appendChild(document.createTextNode(part.content)) } else if (part.type === "file") { const pill = document.createElement("span") pill.textContent = part.content pill.setAttribute("data-type", "file") pill.setAttribute("data-path", part.path) pill.setAttribute("contenteditable", "false") pill.style.userSelect = "text" pill.style.cursor = "default" editorRef.appendChild(pill) } }) if (cursorPosition !== null) { setCursorPosition(editorRef, cursorPosition) } }, ), ) const parseFromDOM = (): ContentPart[] => { 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.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) return newParts } const handleInput = () => { const rawParts = parseFromDOM() const cursorPosition = getCursorPosition(editorRef) const rawText = rawParts.map((p) => p.content).join("") const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/) if (atMatch) { onInput(atMatch[1]) setStore("popoverIsOpen", true) } else if (store.popoverIsOpen) { setStore("popoverIsOpen", false) } setStore("contentParts", rawParts) } 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 } 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) if (head) acc.parts.push({ type: "text", content: head }) 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, }, ) if (!inserted || cursorIndex === null) return setStore("contentParts", nextParts) setStore("popoverIsOpen", false) queueMicrotask(() => { 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.popoverIsOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { onKeyDown(event) event.preventDefault() return } if (event.key === "Enter" && !event.shiftKey) { handleSubmit(event) } } const handleSubmit = (event: Event) => { event.preventDefault() if (store.contentParts.length > 0) { props.onSubmit([...store.contentParts]) setStore("contentParts", defaultParts) } } return (
{(i) => (
{getDirectory(i)}/ {getFilename(i)}
)}
{ 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, }} />
Plan and build anything