import { useFilteredList } from "@opencode-ai/ui/hooks" import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo, createSignal, } from "solid-js" import { createStore, produce } from "solid-js/store" import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" import { useFile, type FileSelection } from "@/context/file" import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt, ImageAttachmentPart, AgentPart, FileAttachmentPart, } 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, TooltipKeybind } 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 { ImagePreview } from "@opencode-ai/ui/image-preview" import { ModelSelectorPopover } 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 { Persist, persisted } from "@/utils/persist" import { Identifier } from "@/utils/id" import { SessionContextUsage } from "@/components/session-context-usage" import { usePermission } from "@/context/permission" import { useGlobalSync } from "@/context/global-sync" import { usePlatform } from "@/context/platform" import { createOpencodeClient, type Message, type Part } from "@opencode-ai/sdk/v2/client" import { Binary } from "@opencode-ai/util/binary" import { showToast } from "@opencode-ai/ui/toast" import { base64Encode } from "@opencode-ai/util/encode" 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 newSessionWorktree?: string onNewSessionWorktreeReset?: () => 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 globalSync = useGlobalSync() const platform = usePlatform() const local = useLocal() const files = useFile() const prompt = usePrompt() const layout = useLayout() const params = useParams() const dialog = useDialog() const providers = useProviders() const command = useCommand() const permission = usePermission() let editorRef!: HTMLDivElement let fileInputRef!: HTMLInputElement let scrollRef!: HTMLDivElement let slashPopoverRef!: HTMLDivElement const scrollCursorIntoView = () => { const container = scrollRef const selection = window.getSelection() if (!container || !selection || selection.rangeCount === 0) return const range = selection.getRangeAt(0) if (!editorRef.contains(range.startContainer)) return const rect = range.getBoundingClientRect() if (!rect.height) return const containerRect = container.getBoundingClientRect() const top = rect.top - containerRect.top + container.scrollTop const bottom = rect.bottom - containerRect.top + container.scrollTop const padding = 12 if (top < container.scrollTop + padding) { container.scrollTop = Math.max(0, top - padding) return } if (bottom > container.scrollTop + container.clientHeight - padding) { container.scrollTop = bottom - container.clientHeight + padding } } const queueScroll = () => { requestAnimationFrame(scrollCursorIntoView) } const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey())) const activeFile = createMemo(() => { const tab = tabs().active() if (!tab) return return files.pathFromTab(tab) }) 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 imageAttachments = createMemo( () => prompt.current().filter((part) => part.type === "image") as ImageAttachmentPart[], ) const [store, setStore] = createStore<{ popover: "at" | "slash" | null historyIndex: number savedPrompt: Prompt | null placeholder: number dragging: boolean mode: "normal" | "shell" applyingHistory: boolean }>({ popover: null, historyIndex: -1, savedPrompt: null, placeholder: Math.floor(Math.random() * PLACEHOLDERS.length), dragging: false, mode: "normal", applyingHistory: false, }) const MAX_HISTORY = 100 const [history, setHistory] = persisted( Persist.global("prompt-history", ["prompt-history.v1"]), createStore<{ entries: Prompt[] }>({ entries: [], }), ) const [shellHistory, setShellHistory] = persisted( Persist.global("prompt-history-shell", ["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 } if (part.type === "agent") 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) queueScroll() }) } 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, } } const isFocused = createFocusSignal(() => editorRef) createEffect(() => { params.id editorRef.focus() if (params.id) return const interval = setInterval(() => { setStore("placeholder", (prev) => (prev + 1) % PLACEHOLDERS.length) }, 6500) onCleanup(() => clearInterval(interval)) }) const [composing, setComposing] = createSignal(false) const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 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, } const cursorPosition = prompt.cursor() ?? getCursorPosition(editorRef) prompt.set([...prompt.current(), attachment], cursorPosition) } reader.readAsDataURL(file) } const removeImageAttachment = (id: string) => { const current = prompt.current() const next = current.filter((part) => part.type !== "image" || part.id !== id) prompt.set(next, prompt.cursor()) } const handlePaste = async (event: ClipboardEvent) => { if (!isFocused()) return const clipboardData = event.clipboardData if (!clipboardData) return event.preventDefault() event.stopPropagation() const items = Array.from(clipboardData.items) const imageItems = items.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type)) if (imageItems.length > 0) { for (const item of imageItems) { const file = item.getAsFile() if (file) await addImageAttachment(file) } return } const plainText = clipboardData.getData("text/plain") ?? "" addPart({ type: "text", content: plainText, start: 0, end: 0 }) } const handleGlobalDragOver = (event: DragEvent) => { if (dialog.active) return event.preventDefault() const hasFiles = event.dataTransfer?.types.includes("Files") if (hasFiles) { setStore("dragging", true) } } const handleGlobalDragLeave = (event: DragEvent) => { if (dialog.active) return // relatedTarget is null when leaving the document window if (!event.relatedTarget) { setStore("dragging", false) } } const handleGlobalDrop = async (event: DragEvent) => { if (dialog.active) return event.preventDefault() setStore("dragging", false) const dropped = event.dataTransfer?.files if (!dropped) return for (const file of Array.from(dropped)) { if (ACCEPTED_FILE_TYPES.includes(file.type)) { await addImageAttachment(file) } } } onMount(() => { document.addEventListener("dragover", handleGlobalDragOver) document.addEventListener("dragleave", handleGlobalDragLeave) document.addEventListener("drop", handleGlobalDrop) }) onCleanup(() => { document.removeEventListener("dragover", handleGlobalDragOver) document.removeEventListener("dragleave", handleGlobalDragLeave) document.removeEventListener("drop", handleGlobalDrop) }) createEffect(() => { if (!isFocused()) setStore("popover", null) }) type AtOption = { type: "agent"; name: string; display: string } | { type: "file"; path: string; display: string } const agentList = createMemo(() => sync.data.agent .filter((agent) => !agent.hidden && agent.mode !== "primary") .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })), ) const handleAtSelect = (option: AtOption | undefined) => { if (!option) return if (option.type === "agent") { addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 }) } else { addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 }) } } const atKey = (x: AtOption | undefined) => { if (!x) return "" return x.type === "agent" ? `agent:${x.name}` : `file:${x.path}` } const { flat: atFlat, active: atActive, setActive: setAtActive, onInput: atOnInput, onKeyDown: atOnKeyDown, } = useFilteredList({ items: async (query) => { const agents = agentList() const paths = await files.searchFilesAndDirectories(query) const fileOptions: AtOption[] = paths.map((path) => ({ type: "file", path, display: path })) return [...agents, ...fileOptions] }, key: atKey, filterKeys: ["display"], onSelect: handleAtSelect, }) 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, setActive: setSlashActive, onInput: slashOnInput, onKeyDown: slashOnKeyDown, refetch: slashRefetch, } = useFilteredList({ items: slashCommands, key: (x) => x?.id, filterKeys: ["trigger", "title", "description"], onSelect: handleSlashSelect, }) const createPill = (part: FileAttachmentPart | AgentPart) => { const pill = document.createElement("span") pill.textContent = part.content pill.setAttribute("data-type", part.type) if (part.type === "file") pill.setAttribute("data-path", part.path) if (part.type === "agent") pill.setAttribute("data-name", part.name) pill.setAttribute("contenteditable", "false") pill.style.userSelect = "text" pill.style.cursor = "default" return pill } const isNormalizedEditor = () => Array.from(editorRef.childNodes).every((node) => { if (node.nodeType === Node.TEXT_NODE) { const text = node.textContent ?? "" if (!text.includes("\u200B")) return true if (text !== "\u200B") return false const prev = node.previousSibling const next = node.nextSibling const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR" const nextIsBr = next?.nodeType === Node.ELEMENT_NODE && (next as HTMLElement).tagName === "BR" if (!prevIsBr && !nextIsBr) return false if (nextIsBr && !prevIsBr && prev) return false return true } if (node.nodeType !== Node.ELEMENT_NODE) return false const el = node as HTMLElement if (el.dataset.type === "file") return true if (el.dataset.type === "agent") return true return el.tagName === "BR" }) const renderEditor = (parts: Prompt) => { editorRef.innerHTML = "" for (const part of parts) { if (part.type === "text") { editorRef.appendChild(createTextFragment(part.content)) continue } if (part.type === "file" || part.type === "agent") { editorRef.appendChild(createPill(part)) } } } createEffect( on( () => sync.data.command, () => slashRefetch(), { defer: true }, ), ) // Auto-scroll active command into view when navigating with keyboard createEffect(() => { const activeId = slashActive() if (!activeId || !slashPopoverRef) return requestAnimationFrame(() => { const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`) element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) }) }) createEffect( on( () => prompt.current(), (currentParts) => { const inputParts = currentParts.filter((part) => part.type !== "image") as Prompt const domParts = parseFromDOM() if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return const selection = window.getSelection() let cursorPosition: number | null = null if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) { cursorPosition = getCursorPosition(editorRef) } renderEditor(inputParts) 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").replace(/\u200B/g, "") 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 pushAgent = (agent: HTMLElement) => { const content = agent.textContent ?? "" parts.push({ type: "agent", name: agent.dataset.name!, 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.dataset.type === "agent") { flushText() pushAgent(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 images = imageAttachments() 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 && images.length === 0 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) } queueScroll() return } const shellMode = store.mode === "shell" if (!shellMode) { const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/) const slashMatch = rawText.match(/^\/(\S*)$/) if (atMatch) { atOnInput(atMatch[1]) setStore("popover", "at") } 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, ...images], cursorPosition) queueScroll() } const setRangeEdge = (range: Range, edge: "start" | "end", offset: number) => { let remaining = offset const nodes = Array.from(editorRef.childNodes) for (const node of nodes) { const length = getNodeLength(node) const isText = node.nodeType === Node.TEXT_NODE const isPill = node.nodeType === Node.ELEMENT_NODE && ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent") const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" if (isText && remaining <= length) { if (edge === "start") range.setStart(node, remaining) if (edge === "end") range.setEnd(node, remaining) return } if ((isPill || isBreak) && 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 } } 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" || part.type === "agent") { const pill = createPill(part) const gap = document.createTextNode(" ") const range = selection.getRangeAt(0) if (atMatch) { const start = atMatch.index ?? cursorPosition - atMatch[0].length setRangeEdge(range, "start", start) setRangeEdge(range, "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 range = selection.getRangeAt(0) const fragment = createTextFragment(part.content) const last = fragment.lastChild range.deleteContents() range.insertNode(fragment) if (last) { if (last.nodeType === Node.TEXT_NODE) { const text = last.textContent ?? "" if (text === "\u200B") { range.setStart(last, 0) } if (text !== "\u200B") { range.setStart(last, text.length) } } if (last.nodeType !== Node.TEXT_NODE) { range.setStartAfter(last) } } range.collapse(true) selection.removeAllRanges() selection.addRange(range) } handleInput() setStore("popover", null) } const abort = () => sdk.client.session .abort({ sessionID: params.id!, }) .catch(() => {}) const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { const text = prompt .map((p) => ("content" in p ? p.content : "")) .join("") .trim() const hasImages = prompt.some((part) => part.type === "image") if (!text && !hasImages) return const entry = clonePromptParts(prompt) const currentHistory = mode === "shell" ? shellHistory : history const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory const lastEntry = currentHistory.entries[0] if (lastEntry && isPromptEqual(lastEntry, entry)) 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 === "Backspace") { const selection = window.getSelection() if (selection && selection.isCollapsed) { const node = selection.anchorNode const offset = selection.anchorOffset if (node && node.nodeType === Node.TEXT_NODE) { const text = node.textContent ?? "" if (/^\u200B+$/.test(text) && offset > 0) { const range = document.createRange() range.setStart(node, 0) range.collapse(true) selection.removeAllRanges() selection.addRange(range) } } } } 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 (event.key === "Enter" && isImeComposing(event)) { return } if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { if (store.popover === "at") { atOnKeyDown(event) } else { slashOnKeyDown(event) } event.preventDefault() return } const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey if (ctrl && event.code === "KeyG") { if (store.popover) { setStore("popover", null) event.preventDefault() return } if (working()) { abort() 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 = prompt .current() .map((part) => ("content" in part ? part.content : "")) .join("") 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 images = imageAttachments().slice() const mode = store.mode if (text.trim().length === 0 && images.length === 0) { if (working()) abort() return } const currentModel = local.model.current() const currentAgent = local.agent.current() if (!currentModel || !currentAgent) { showToast({ title: "Select an agent and model", description: "Choose an agent and model before sending a prompt.", }) return } const errorMessage = (err: unknown) => { if (err && typeof err === "object" && "data" in err) { const data = (err as { data?: { message?: string } }).data if (data?.message) return data.message } if (err instanceof Error) return err.message return "Request failed" } addToHistory(currentPrompt, mode) setStore("historyIndex", -1) setStore("savedPrompt", null) const projectDirectory = sdk.directory const isNewSession = !params.id const worktreeSelection = props.newSessionWorktree ?? "main" let sessionDirectory = projectDirectory let client = sdk.client if (isNewSession) { if (worktreeSelection === "create") { const createdWorktree = await client.worktree .create({ directory: projectDirectory }) .then((x) => x.data) .catch((err) => { showToast({ title: "Failed to create worktree", description: errorMessage(err), }) return undefined }) if (!createdWorktree?.directory) { showToast({ title: "Failed to create worktree", description: "Request failed", }) return } sessionDirectory = createdWorktree.directory } if (worktreeSelection !== "main" && worktreeSelection !== "create") { sessionDirectory = worktreeSelection } if (sessionDirectory !== projectDirectory) { client = createOpencodeClient({ baseUrl: sdk.url, fetch: platform.fetch, directory: sessionDirectory, throwOnError: true, }) globalSync.child(sessionDirectory) } props.onNewSessionWorktreeReset?.() } let session = info() if (!session && isNewSession) { session = await client.session.create().then((x) => x.data ?? undefined) if (session) navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) } if (!session) return const model = { modelID: currentModel.id, providerID: currentModel.provider.id, } const agent = currentAgent.name const variant = local.model.variant.current() const clearInput = () => { prompt.reset() setStore("mode", "normal") setStore("popover", null) } const restoreInput = () => { prompt.set(currentPrompt, promptLength(currentPrompt)) setStore("mode", mode) setStore("popover", null) requestAnimationFrame(() => { editorRef.focus() setCursorPosition(editorRef, promptLength(currentPrompt)) queueScroll() }) } if (mode === "shell") { clearInput() client.session .shell({ sessionID: session.id, agent, model, command: text, }) .catch((err) => { showToast({ title: "Failed to send shell command", description: errorMessage(err), }) restoreInput() }) 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) { clearInput() client.session .command({ sessionID: session.id, command: commandName, arguments: args.join(" "), agent, model: `${model.providerID}/${model.modelID}`, variant, parts: images.map((attachment) => ({ id: Identifier.ascending("part"), type: "file" as const, mime: attachment.mime, url: attachment.dataUrl, filename: attachment.filename, })), }) .catch((err) => { showToast({ title: "Failed to send command", description: errorMessage(err), }) restoreInput() }) return } } const toAbsolutePath = (path: string) => path.startsWith("/") ? path : (sessionDirectory + "/" + path).replace("//", "/") const fileAttachments = currentPrompt.filter((part) => part.type === "file") as FileAttachmentPart[] const agentAttachments = currentPrompt.filter((part) => part.type === "agent") as AgentPart[] const fileAttachmentParts = fileAttachments.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 agentAttachmentParts = agentAttachments.map((attachment) => ({ id: Identifier.ascending("part"), type: "agent" as const, name: attachment.name, source: { value: attachment.content, start: attachment.start, end: attachment.end, }, })) const usedUrls = new Set(fileAttachmentParts.map((part) => part.url)) const contextFileParts: Array<{ id: string type: "file" mime: string url: string filename?: string }> = [] const addContextFile = (path: string, selection?: FileSelection) => { const absolute = toAbsolutePath(path) const query = selection ? `?start=${selection.startLine}&end=${selection.endLine}` : "" const url = `file://${absolute}${query}` if (usedUrls.has(url)) return usedUrls.add(url) contextFileParts.push({ id: Identifier.ascending("part"), type: "file", mime: "text/plain", url, filename: getFilename(path), }) } const activePath = activeFile() if (activePath && prompt.context.activeTab()) { addContextFile(activePath) } for (const item of prompt.context.items()) { if (item.type !== "file") continue addContextFile(item.path, item.selection) } const imageAttachmentParts = images.map((attachment) => ({ id: Identifier.ascending("part"), type: "file" as const, mime: attachment.mime, url: attachment.dataUrl, filename: attachment.filename, })) const messageID = Identifier.ascending("message") const textPart = { id: Identifier.ascending("part"), type: "text" as const, text, } const requestParts = [ textPart, ...fileAttachmentParts, ...contextFileParts, ...agentAttachmentParts, ...imageAttachmentParts, ] const optimisticParts = requestParts.map((part) => ({ ...part, sessionID: session.id, messageID, })) as unknown as Part[] const optimisticMessage: Message = { id: messageID, sessionID: session.id, role: "user", time: { created: Date.now() }, agent, model, } const setSyncStore = sessionDirectory === projectDirectory ? sync.set : globalSync.child(sessionDirectory)[1] const addOptimisticMessage = () => { setSyncStore( produce((draft) => { const messages = draft.message[session.id] if (!messages) { draft.message[session.id] = [optimisticMessage] } else { const result = Binary.search(messages, messageID, (m) => m.id) messages.splice(result.index, 0, optimisticMessage) } draft.part[messageID] = optimisticParts .filter((p) => !!p?.id) .slice() .sort((a, b) => a.id.localeCompare(b.id)) }), ) } const removeOptimisticMessage = () => { setSyncStore( produce((draft) => { const messages = draft.message[session.id] if (messages) { const result = Binary.search(messages, messageID, (m) => m.id) if (result.found) messages.splice(result.index, 1) } delete draft.part[messageID] }), ) } clearInput() addOptimisticMessage() client.session .prompt({ sessionID: session.id, agent, model, messageID, parts: requestParts, variant, }) .catch((err) => { showToast({ title: "Failed to send prompt", description: errorMessage(err), }) removeOptimisticMessage() restoreInput() }) } return (
{ if (store.popover === "slash") slashPopoverRef = el }} class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10 overflow-auto no-scrollbar flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md" onMouseDown={(e) => e.preventDefault()} > 0} fallback={
No matching results
} > {(item) => ( )}
0} fallback={
No matching commands
} > {(cmd) => ( )}
Drop images or PDFs here
0 || !!activeFile())}>
{(path) => (
{getDirectory(path())} {getFilename(path())} active
prompt.context.removeActive()} />
)}
{(item) => (
{getDirectory(item.path)} {getFilename(item.path)} {(sel) => ( {sel().startLine === sel().endLine ? `:${sel().startLine}` : `:${sel().startLine}-${sel().endLine}`} )}
prompt.context.remove(item.key)} />
)}
0}>
{(attachment) => (
} > {attachment.filename} dialog.show(() => ) } />
{attachment.filename}
)}
(scrollRef = el)}>
{ editorRef = el props.ref?.(el) }} contenteditable="true" onInput={handleInput} onPaste={handlePaste} onCompositionStart={() => setComposing(true)} onCompositionEnd={() => setComposing(false)} onKeyDown={handleKeyDown} classList={{ "select-text": true, "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-syntax-property": true, "[&_[data-type=agent]]:text-syntax-type": true, "font-mono!": store.mode === "shell", }} />
{store.mode === "shell" ? "Enter shell command..." : `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
Shell esc to exit
{ const file = e.currentTarget.files?.[0] if (file) addImageAttachment(file) e.currentTarget.value = "" }} />
Stop ESC
Send
} >
) } function createTextFragment(content: string): DocumentFragment { const fragment = document.createDocumentFragment() const segments = content.split("\n") segments.forEach((segment, index) => { if (segment) { fragment.appendChild(document.createTextNode(segment)) } else if (segments.length > 1) { fragment.appendChild(document.createTextNode("\u200B")) } if (index < segments.length - 1) { fragment.appendChild(document.createElement("br")) } }) return fragment } function getNodeLength(node: Node): number { if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1 return (node.textContent ?? "").replace(/\u200B/g, "").length } function getTextLength(node: Node): number { if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1 let length = 0 for (const child of Array.from(node.childNodes)) { length += getTextLength(child) } return length } function getCursorPosition(parent: HTMLElement): number { const selection = window.getSelection() if (!selection || selection.rangeCount === 0) return 0 const range = selection.getRangeAt(0) if (!parent.contains(range.startContainer)) return 0 const preCaretRange = range.cloneRange() preCaretRange.selectNodeContents(parent) preCaretRange.setEnd(range.startContainer, range.startOffset) return getTextLength(preCaretRange.cloneContents()) } function setCursorPosition(parent: HTMLElement, position: number) { let remaining = position let node = parent.firstChild while (node) { const length = getNodeLength(node) const isText = node.nodeType === Node.TEXT_NODE const isPill = node.nodeType === Node.ELEMENT_NODE && ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent") const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" 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 ((isPill || isBreak) && remaining <= length) { const range = document.createRange() const selection = window.getSelection() if (remaining === 0) { range.setStartBefore(node) } if (remaining > 0 && isPill) { range.setStartAfter(node) } if (remaining > 0 && isBreak) { const next = node.nextSibling if (next && next.nodeType === Node.TEXT_NODE) { range.setStart(next, 0) } if (!next || next.nodeType !== Node.TEXT_NODE) { 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) }