import { useFilteredList } from "@opencode-ai/ui/hooks" import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js" import { createStore, produce } from "solid-js/store" import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt, ImageAttachmentPart } from "@/context/prompt" import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" import { useNavigate, useParams } from "@solidjs/router" import { useSync } from "@/context/sync" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { Tooltip } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" import { getDirectory, getFilename } from "@opencode-ai/util/path" 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 } from "@/context/command" import { persisted } from "@/utils/persist" import { Identifier } from "@/utils/id" import { SessionContextUsage } from "@/components/session-context-usage" const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"] interface PromptInputProps { class?: string ref?: (el: HTMLDivElement) => void } const PLACEHOLDERS = [ "Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests", "Explain how authentication works", "Find and fix security vulnerabilities", "Add unit tests for the user service", "Refactor this function to be more readable", "What does this error mean?", "Help me debug this issue", "Generate API documentation", "Optimize database queries", "Add input validation", "Create a new component for...", "How do I deploy this project?", "Review my code for best practices", "Add error handling to this function", "Explain this regex pattern", "Convert this to TypeScript", "Add logging throughout the codebase", "What dependencies are outdated?", "Help me write a migration script", "Implement caching for this endpoint", "Add pagination to this list", "Create a CLI command for...", "How do environment variables work here?", ] interface SlashCommand { id: string trigger: string title: string description?: string keybind?: string type: "builtin" | "custom" } export const PromptInput: Component = (props) => { const navigate = useNavigate() const sdk = useSDK() const sync = useSync() const local = useLocal() const prompt = usePrompt() const layout = useLayout() const params = useParams() const dialog = useDialog() const providers = useProviders() const command = useCommand() let editorRef!: HTMLDivElement let fileInputRef!: HTMLInputElement const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey())) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const status = createMemo( () => sync.data.session_status[params.id ?? ""] ?? { type: "idle", }, ) const working = createMemo(() => status()?.type !== "idle") const [store, setStore] = createStore<{ popover: "file" | "slash" | null historyIndex: number savedPrompt: Prompt | null placeholder: number dragging: boolean imageAttachments: ImageAttachmentPart[] mode: "normal" | "shell" applyingHistory: boolean }>({ popover: null, historyIndex: -1, savedPrompt: null, placeholder: Math.floor(Math.random() * PLACEHOLDERS.length), dragging: false, imageAttachments: [], mode: "normal", applyingHistory: false, }) const MAX_HISTORY = 100 const [history, setHistory] = persisted( "prompt-history.v1", createStore<{ entries: Prompt[] }>({ entries: [], }), ) const [shellHistory, setShellHistory] = persisted( "prompt-history-shell.v1", createStore<{ entries: Prompt[] }>({ entries: [], }), ) const clonePromptParts = (prompt: Prompt): Prompt => prompt.map((part) => { if (part.type === "text") return { ...part } if (part.type === "image") return { ...part } return { ...part, selection: part.selection ? { ...part.selection } : undefined, } }) const promptLength = (prompt: Prompt) => prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0) const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => { const length = position === "start" ? 0 : promptLength(p) setStore("applyingHistory", true) prompt.set(p, length) requestAnimationFrame(() => { editorRef.focus() setCursorPosition(editorRef, length) setStore("applyingHistory", false) }) } const getCaretState = () => { const selection = window.getSelection() const textLength = promptLength(prompt.current()) if (!selection || selection.rangeCount === 0) { return { collapsed: false, cursorPosition: 0, textLength } } const anchorNode = selection.anchorNode if (!anchorNode || !editorRef.contains(anchorNode)) { return { collapsed: false, cursorPosition: 0, textLength } } return { collapsed: selection.isCollapsed, cursorPosition: getCursorPosition(editorRef), textLength, } } createEffect(() => { params.id editorRef.focus() if (params.id) return const interval = setInterval(() => { setStore("placeholder", (prev) => (prev + 1) % PLACEHOLDERS.length) }, 6500) onCleanup(() => clearInterval(interval)) }) const isFocused = createFocusSignal(() => editorRef) const addImageAttachment = async (file: File) => { if (!ACCEPTED_FILE_TYPES.includes(file.type)) return const reader = new FileReader() reader.onload = () => { const dataUrl = reader.result as string const attachment: ImageAttachmentPart = { type: "image", id: crypto.randomUUID(), filename: file.name, mime: file.type, dataUrl, } setStore( produce((draft) => { draft.imageAttachments.push(attachment) }), ) } reader.readAsDataURL(file) } const removeImageAttachment = (id: string) => { setStore( produce((draft) => { draft.imageAttachments = draft.imageAttachments.filter((a) => a.id !== id) }), ) } const handlePaste = async (event: ClipboardEvent) => { const clipboardData = event.clipboardData if (!clipboardData) return const items = Array.from(clipboardData.items) const imageItems = items.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type)) if (imageItems.length > 0) { event.preventDefault() event.stopPropagation() for (const item of imageItems) { const file = item.getAsFile() if (file) await addImageAttachment(file) } return } event.preventDefault() event.stopPropagation() const plainText = clipboardData.getData("text/plain") ?? "" addPart({ type: "text", content: plainText, start: 0, end: 0 }) } const handleDragOver = (event: DragEvent) => { event.preventDefault() const hasFiles = event.dataTransfer?.types.includes("Files") if (hasFiles) { setStore("dragging", true) } } const handleDragLeave = (event: DragEvent) => { const related = event.relatedTarget as Node | null const form = event.currentTarget as HTMLElement if (!related || !form.contains(related)) { setStore("dragging", false) } } const handleDrop = async (event: DragEvent) => { event.preventDefault() setStore("dragging", false) const files = event.dataTransfer?.files if (!files) return for (const file of Array.from(files)) { if (ACCEPTED_FILE_TYPES.includes(file.type)) { await addImageAttachment(file) } } } onMount(() => { editorRef.addEventListener("paste", handlePaste) }) onCleanup(() => { editorRef.removeEventListener("paste", handlePaste) }) createEffect(() => { if (!isFocused()) setStore("popover", null) }) const handleFileSelect = (path: string | undefined) => { if (!path) return addPart({ type: "file", path, content: "@" + path, start: 0, end: 0 }) } const { flat, active, onInput, onKeyDown } = useFilteredList({ items: local.file.searchFilesAndDirectories, key: (x) => x, onSelect: handleFileSelect, }) const slashCommands = createMemo(() => { const builtin = 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, type: "builtin" as const, })) const custom = sync.data.command.map((cmd) => ({ id: `custom.${cmd.name}`, trigger: cmd.name, title: cmd.name, description: cmd.description, type: "custom" as const, })) return [...custom, ...builtin] }) const handleSlashSelect = (cmd: SlashCommand | undefined) => { if (!cmd) return setStore("popover", null) if (cmd.type === "custom") { const text = `/${cmd.trigger} ` editorRef.innerHTML = "" editorRef.textContent = text prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) requestAnimationFrame(() => { editorRef.focus() const range = document.createRange() const sel = window.getSelection() range.selectNodeContents(editorRef) range.collapse(false) sel?.removeAllRanges() sel?.addRange(range) }) return } editorRef.innerHTML = "" prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) command.trigger(cmd.id, "slash") } const { flat: slashFlat, active: slashActive, onInput: slashOnInput, onKeyDown: slashOnKeyDown, } = useFilteredList({ items: slashCommands, key: (x) => x?.id, filterKeys: ["trigger", "title", "description"], onSelect: handleSlashSelect, }) createEffect( on( () => prompt.current(), (currentParts) => { const domParts = parseFromDOM() const normalized = Array.from(editorRef.childNodes).every((node) => { if (node.nodeType === Node.TEXT_NODE) return true if (node.nodeType !== Node.ELEMENT_NODE) return false return (node as HTMLElement).dataset.type === "file" }) if (normalized && isPromptEqual(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 = (): Prompt => { const parts: Prompt = [] let position = 0 let buffer = "" const flushText = () => { const content = buffer.replace(/\r\n?/g, "\n") buffer = "" if (!content) return parts.push({ type: "text", content, start: position, end: position + content.length }) position += content.length } const pushFile = (file: HTMLElement) => { const content = file.textContent ?? "" parts.push({ type: "file", path: file.dataset.path!, content, start: position, end: position + content.length, }) position += content.length } const visit = (node: Node) => { if (node.nodeType === Node.TEXT_NODE) { buffer += node.textContent ?? "" return } if (node.nodeType !== Node.ELEMENT_NODE) return const el = node as HTMLElement if (el.dataset.type === "file") { flushText() pushFile(el) return } if (el.tagName === "BR") { buffer += "\n" return } for (const child of Array.from(el.childNodes)) { visit(child) } } const children = Array.from(editorRef.childNodes) children.forEach((child, index) => { const isBlock = child.nodeType === Node.ELEMENT_NODE && ["DIV", "P"].includes((child as HTMLElement).tagName) visit(child) if (isBlock && index < children.length - 1) { buffer += "\n" } }) flushText() if (parts.length === 0) parts.push(...DEFAULT_PROMPT) return parts } const handleInput = () => { const rawParts = parseFromDOM() const cursorPosition = getCursorPosition(editorRef) const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("") const trimmed = rawText.replace(/\u200B/g, "").trim() const hasNonText = rawParts.some((part) => part.type !== "text") const shouldReset = trimmed.length === 0 && !hasNonText if (shouldReset) { setStore("popover", null) if (store.historyIndex >= 0 && !store.applyingHistory) { setStore("historyIndex", -1) setStore("savedPrompt", null) } if (prompt.dirty()) { prompt.set(DEFAULT_PROMPT, 0) } return } const shellMode = store.mode === "shell" if (!shellMode) { const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/) const slashMatch = rawText.match(/^\/(\S*)$/) if (atMatch) { onInput(atMatch[1]) setStore("popover", "file") } else if (slashMatch) { slashOnInput(slashMatch[1]) setStore("popover", "slash") } else { setStore("popover", null) } } else { setStore("popover", null) } if (store.historyIndex >= 0 && !store.applyingHistory) { setStore("historyIndex", -1) setStore("savedPrompt", null) } prompt.set(rawParts, cursorPosition) } const addPart = (part: ContentPart) => { const selection = window.getSelection() if (!selection || selection.rangeCount === 0) return const cursorPosition = getCursorPosition(editorRef) const currentPrompt = prompt.current() const rawText = currentPrompt.map((p) => ("content" in p ? p.content : "")).join("") const textBeforeCursor = rawText.substring(0, cursorPosition) const atMatch = textBeforeCursor.match(/@(\S*)$/) 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" const gap = document.createTextNode(" ") const range = selection.getRangeAt(0) const setEdge = (edge: "start" | "end", offset: number) => { let remaining = offset const nodes = Array.from(editorRef.childNodes) for (const node of nodes) { const length = 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) { if (edge === "start") range.setStart(node, remaining) if (edge === "end") range.setEnd(node, remaining) return } if (isFile && remaining <= length) { if (edge === "start" && remaining === 0) range.setStartBefore(node) if (edge === "start" && remaining > 0) range.setStartAfter(node) if (edge === "end" && remaining === 0) range.setEndBefore(node) if (edge === "end" && remaining > 0) range.setEndAfter(node) return } remaining -= length } } if (atMatch) { const start = atMatch.index ?? cursorPosition - atMatch[0].length setEdge("start", start) setEdge("end", cursorPosition) } range.deleteContents() range.insertNode(gap) range.insertNode(pill) range.setStartAfter(gap) range.collapse(true) selection.removeAllRanges() selection.addRange(range) } else if (part.type === "text") { const textNode = document.createTextNode(part.content) const range = selection.getRangeAt(0) range.deleteContents() range.insertNode(textNode) range.setStartAfter(textNode) range.collapse(true) selection.removeAllRanges() selection.addRange(range) } handleInput() setStore("popover", null) } const abort = () => sdk.client.session.abort({ sessionID: params.id!, }) const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { const text = prompt .map((p) => ("content" in p ? p.content : "")) .join("") .trim() if (!text) return const entry = clonePromptParts(prompt) const currentHistory = mode === "shell" ? shellHistory : history const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory const lastEntry = currentHistory.entries[0] if (lastEntry) { const lastText = lastEntry.map((p) => ("content" in p ? p.content : "")).join("") if (lastText === text) return } setCurrentHistory("entries", (entries) => [entry, ...entries].slice(0, MAX_HISTORY)) } const navigateHistory = (direction: "up" | "down") => { const entries = store.mode === "shell" ? shellHistory.entries : history.entries const current = store.historyIndex if (direction === "up") { if (entries.length === 0) return false if (current === -1) { setStore("savedPrompt", clonePromptParts(prompt.current())) setStore("historyIndex", 0) applyHistoryPrompt(entries[0], "start") return true } if (current < entries.length - 1) { const next = current + 1 setStore("historyIndex", next) applyHistoryPrompt(entries[next], "start") return true } return false } if (current > 0) { const next = current - 1 setStore("historyIndex", next) applyHistoryPrompt(entries[next], "end") return true } if (current === 0) { setStore("historyIndex", -1) const saved = store.savedPrompt if (saved) { applyHistoryPrompt(saved, "end") setStore("savedPrompt", null) return true } applyHistoryPrompt(DEFAULT_PROMPT, "end") return true } return false } const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "!" && store.mode === "normal") { const cursorPosition = getCursorPosition(editorRef) if (cursorPosition === 0) { setStore("mode", "shell") setStore("popover", null) event.preventDefault() return } } if (store.mode === "shell") { const { collapsed, cursorPosition, textLength } = getCaretState() if (event.key === "Escape") { setStore("mode", "normal") event.preventDefault() return } if (event.key === "Backspace" && collapsed && cursorPosition === 0 && textLength === 0) { setStore("mode", "normal") event.preventDefault() return } } if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { if (store.popover === "file") { onKeyDown(event) } else { slashOnKeyDown(event) } event.preventDefault() return } if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (event.altKey || event.ctrlKey || event.metaKey) return const { collapsed } = getCaretState() if (!collapsed) return const cursorPosition = getCursorPosition(editorRef) const textLength = promptLength(prompt.current()) const textContent = editorRef.textContent ?? "" const isEmpty = textContent.trim() === "" || textLength <= 1 const hasNewlines = textContent.includes("\n") const inHistory = store.historyIndex >= 0 const atStart = cursorPosition <= (isEmpty ? 1 : 0) const atEnd = cursorPosition >= (isEmpty ? textLength - 1 : textLength) const allowUp = isEmpty || atStart || (!hasNewlines && !inHistory) || (inHistory && atEnd) const allowDown = isEmpty || atEnd || (!hasNewlines && !inHistory) || (inHistory && atStart) if (event.key === "ArrowUp") { if (!allowUp) return if (navigateHistory("up")) { event.preventDefault() } return } if (!allowDown) return if (navigateHistory("down")) { event.preventDefault() } return } if (event.key === "Enter" && event.shiftKey) { addPart({ type: "text", content: "\n", start: 0, end: 0 }) event.preventDefault() return } if (event.key === "Enter" && !event.shiftKey) { handleSubmit(event) } if (event.key === "Escape") { if (store.popover) { setStore("popover", null) } else if (working()) { abort() } } } const handleSubmit = async (event: Event) => { event.preventDefault() const currentPrompt = prompt.current() const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("") const hasImageAttachments = store.imageAttachments.length > 0 if (text.trim().length === 0 && !hasImageAttachments) { if (working()) abort() return } addToHistory(currentPrompt, store.mode) setStore("historyIndex", -1) setStore("savedPrompt", null) let existing = info() if (!existing) { const created = await sdk.client.session.create() existing = created.data ?? undefined if (existing) navigate(existing.id) } if (!existing) return const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path)) const attachments = currentPrompt.filter( (part) => part.type === "file", ) as import("@/context/prompt").FileAttachmentPart[] const fileAttachmentParts = attachments.map((attachment) => { const absolute = toAbsolutePath(attachment.path) const query = attachment.selection ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}` : "" return { id: Identifier.ascending("part"), type: "file" as const, mime: "text/plain", url: `file://${absolute}${query}`, filename: getFilename(attachment.path), source: { type: "file" as const, text: { value: attachment.content, start: attachment.start, end: attachment.end, }, path: absolute, }, } }) const imageAttachmentParts = store.imageAttachments.map((attachment) => ({ id: Identifier.ascending("part"), type: "file" as const, mime: attachment.mime, url: attachment.dataUrl, filename: attachment.filename, })) const isShellMode = store.mode === "shell" tabs().setActive(undefined) editorRef.innerHTML = "" prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) setStore("imageAttachments", []) setStore("mode", "normal") const model = { modelID: local.model.current()!.id, providerID: local.model.current()!.provider.id, } const agent = local.agent.current()!.name if (isShellMode) { sdk.client.session.shell({ sessionID: existing.id, agent, model, command: text, }) return } if (text.startsWith("/")) { const [cmdName, ...args] = text.split(" ") const commandName = cmdName.slice(1) const customCommand = sync.data.command.find((c) => c.name === commandName) if (customCommand) { sdk.client.session.command({ sessionID: existing.id, command: commandName, arguments: args.join(" "), agent, model: `${model.providerID}/${model.modelID}`, }) return } } const messageID = Identifier.ascending("message") const textPart = { id: Identifier.ascending("part"), type: "text" as const, text, } const requestParts = [textPart, ...fileAttachmentParts, ...imageAttachmentParts] const optimisticParts = requestParts.map((part) => ({ ...part, sessionID: existing.id, messageID, })) sync.session.addOptimisticMessage({ sessionID: existing.id, messageID, parts: optimisticParts, agent, model, }) sdk.client.session.prompt({ sessionID: existing.id, agent, model, messageID, parts: requestParts, }) } return (
0} fallback={
No matching files
}> {(i) => ( )}
0} fallback={
No matching commands
} > {(cmd) => ( )}
Drop images or PDFs here
0}>
{(attachment) => (
} > {attachment.filename}
{attachment.filename}
)}
{ editorRef = el props.ref?.(el) }} contenteditable="true" onInput={handleInput} onKeyDown={handleKeyDown} classList={{ "w-full px-5 py-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, "[&_[data-type=file]]:text-icon-info-active": true, "font-mono!": store.mode === "shell", }} />
{store.mode === "shell" ? "Enter shell command..." : `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
Shell esc to exit
Cycle agent {command.keybind("agent.cycle")}
} > { const file = e.currentTarget.files?.[0] if (file) addImageAttachment(file) e.currentTarget.value = "" }} /> fileInputRef.click()} />
Stop ESC
Send
} >
) } function getCursorPosition(parent: HTMLElement): number { const selection = window.getSelection() if (!selection || selection.rangeCount === 0) return 0 const range = selection.getRangeAt(0) const preCaretRange = range.cloneRange() preCaretRange.selectNodeContents(parent) preCaretRange.setEnd(range.startContainer, range.startOffset) return preCaretRange.toString().length } function setCursorPosition(parent: HTMLElement, position: number) { 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) }