import { createSignal, Show, onMount, For, onCleanup } from "solid-js" import AgentSelector from "./agent-selector" import ModelSelector from "./model-selector" import UnifiedPicker from "./unified-picker" import { addToHistory, getHistory } from "../stores/message-history" import { getAttachments, addAttachment, clearAttachments, removeAttachment } from "../stores/attachments" import { createFileAttachment, createTextAttachment, createAgentAttachment } from "../types/attachment" import type { Attachment } from "../types/attachment" import Kbd from "./kbd" import HintRow from "./hint-row" import { getActiveInstance } from "../stores/instances" import { agents } from "../stores/sessions" interface PromptInputProps { instanceId: string instanceFolder: string sessionId: string onSend: (prompt: string, attachments: Attachment[]) => Promise disabled?: boolean agent: string model: { providerId: string; modelId: string } onAgentChange: (agent: string) => Promise onModelChange: (model: { providerId: string; modelId: string }) => Promise escapeInDebounce?: boolean } export default function PromptInput(props: PromptInputProps) { const [prompt, setPrompt] = createSignal("") const [history, setHistory] = createSignal([]) const [historyIndex, setHistoryIndex] = createSignal(-1) const [isFocused, setIsFocused] = createSignal(false) const [showPicker, setShowPicker] = createSignal(false) const [searchQuery, setSearchQuery] = createSignal("") const [atPosition, setAtPosition] = createSignal(null) const [isDragging, setIsDragging] = createSignal(false) const [ignoredAtPositions, setIgnoredAtPositions] = createSignal>(new Set()) const [pasteCount, setPasteCount] = createSignal(0) const [imageCount, setImageCount] = createSignal(0) let textareaRef: HTMLTextAreaElement | undefined let containerRef: HTMLDivElement | undefined const attachments = () => getAttachments(props.instanceId, props.sessionId) const instanceAgents = () => agents().get(props.instanceId) || [] function handleRemoveAttachment(attachmentId: string) { const currentAttachments = attachments() const attachment = currentAttachments.find((a) => a.id === attachmentId) removeAttachment(props.instanceId, props.sessionId, attachmentId) if (attachment) { const currentPrompt = prompt() let newPrompt = currentPrompt if (attachment.source.type === "file") { if (attachment.mediaType.startsWith("image/")) { const imageMatch = attachment.display.match(/\[Image #(\d+)\]/) if (imageMatch) { const placeholder = `[Image #${imageMatch[1]}]` newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim() } } else { const filename = attachment.filename newPrompt = currentPrompt.replace(`@${filename}`, "").replace(/\s+/g, " ").trim() } } else if (attachment.source.type === "agent") { const agentName = attachment.filename newPrompt = currentPrompt.replace(`@${agentName}`, "").replace(/\s+/g, " ").trim() } else if (attachment.source.type === "text") { const placeholderMatch = attachment.display.match(/pasted #(\d+)/) if (placeholderMatch) { const placeholder = `[pasted #${placeholderMatch[1]}]` newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim() } } setPrompt(newPrompt) if (textareaRef) { textareaRef.style.height = "auto" textareaRef.style.height = Math.min(textareaRef.scrollHeight, 200) + "px" } } } async function handlePaste(e: ClipboardEvent) { const items = e.clipboardData?.items if (!items) return for (let i = 0; i < items.length; i++) { const item = items[i] if (item.type.startsWith("image/")) { e.preventDefault() const blob = item.getAsFile() if (!blob) continue const count = imageCount() + 1 setImageCount(count) const reader = new FileReader() reader.onload = () => { const base64Data = (reader.result as string).split(",")[1] const display = `[Image #${count}]` const filename = `image-${count}.png` const attachment = createFileAttachment( filename, filename, "image/png", new TextEncoder().encode(base64Data), props.instanceFolder, ) attachment.url = `data:image/png;base64,${base64Data}` attachment.display = display addAttachment(props.instanceId, props.sessionId, attachment) const textarea = textareaRef if (textarea) { const start = textarea.selectionStart const end = textarea.selectionEnd const currentText = prompt() const placeholder = `[Image #${count}]` const newText = currentText.substring(0, start) + placeholder + currentText.substring(end) setPrompt(newText) setTimeout(() => { const newCursorPos = start + placeholder.length textarea.setSelectionRange(newCursorPos, newCursorPos) textarea.style.height = "auto" textarea.style.height = Math.min(textarea.scrollHeight, 200) + "px" textarea.focus() }, 0) } } reader.readAsDataURL(blob) return } } const pastedText = e.clipboardData?.getData("text/plain") if (!pastedText) return const lineCount = pastedText.split("\n").length const charCount = pastedText.length const isLongPaste = charCount > 150 || lineCount > 3 if (isLongPaste) { e.preventDefault() const count = pasteCount() + 1 setPasteCount(count) const summary = lineCount > 1 ? `${lineCount} lines` : `${charCount} chars` const display = `pasted #${count} (${summary})` const filename = `paste-${count}.txt` const attachment = createTextAttachment(pastedText, display, filename) addAttachment(props.instanceId, props.sessionId, attachment) const textarea = textareaRef if (textarea) { const start = textarea.selectionStart const end = textarea.selectionEnd const currentText = prompt() const placeholder = `[pasted #${count}]` const newText = currentText.substring(0, start) + placeholder + currentText.substring(end) setPrompt(newText) setTimeout(() => { const newCursorPos = start + placeholder.length textarea.setSelectionRange(newCursorPos, newCursorPos) textarea.style.height = "auto" textarea.style.height = Math.min(textarea.scrollHeight, 200) + "px" textarea.focus() }, 0) } } } onMount(async () => { const loaded = await getHistory(props.instanceFolder) setHistory(loaded) const handleGlobalKeyDown = (e: KeyboardEvent) => { const activeElement = document.activeElement as HTMLElement const isInputElement = activeElement?.tagName === "INPUT" || activeElement?.tagName === "TEXTAREA" || activeElement?.tagName === "SELECT" || activeElement?.isContentEditable if (isInputElement) return const isModifierKey = e.ctrlKey || e.metaKey || e.altKey if (isModifierKey) return const isSpecialKey = e.key === "Tab" || e.key === "Enter" || e.key.startsWith("Arrow") || e.key === "Backspace" || e.key === "Delete" if (isSpecialKey) return if (e.key.length === 1 && textareaRef && !props.disabled) { textareaRef.focus() } } document.addEventListener("keydown", handleGlobalKeyDown) onCleanup(() => { document.removeEventListener("keydown", handleGlobalKeyDown) }) }) function handleKeyDown(e: KeyboardEvent) { const textarea = textareaRef if (!textarea) return if (e.key === "Backspace" || e.key === "Delete") { const cursorPos = textarea.selectionStart const text = prompt() const pastePlaceholderRegex = /\[pasted #(\d+)\]/g let pasteMatch while ((pasteMatch = pastePlaceholderRegex.exec(text)) !== null) { const placeholderStart = pasteMatch.index const placeholderEnd = pasteMatch.index + pasteMatch[0].length const pasteNumber = pasteMatch[1] const isDeletingFromEnd = e.key === "Backspace" && cursorPos === placeholderEnd const isDeletingFromStart = e.key === "Delete" && cursorPos === placeholderStart const isSelected = textarea.selectionStart <= placeholderStart && textarea.selectionEnd >= placeholderEnd && textarea.selectionStart !== textarea.selectionEnd if (isDeletingFromEnd || isDeletingFromStart || isSelected) { e.preventDefault() const currentAttachments = attachments() const attachment = currentAttachments.find( (a) => a.source.type === "text" && a.display.includes(`pasted #${pasteNumber}`), ) if (attachment) { removeAttachment(props.instanceId, props.sessionId, attachment.id) } const newText = text.substring(0, placeholderStart) + text.substring(placeholderEnd) setPrompt(newText) setTimeout(() => { textarea.setSelectionRange(placeholderStart, placeholderStart) textarea.style.height = "auto" textarea.style.height = Math.min(textarea.scrollHeight, 200) + "px" }, 0) return } } const imagePlaceholderRegex = /\[Image #(\d+)\]/g let imageMatch while ((imageMatch = imagePlaceholderRegex.exec(text)) !== null) { const placeholderStart = imageMatch.index const placeholderEnd = imageMatch.index + imageMatch[0].length const imageNumber = imageMatch[1] const isDeletingFromEnd = e.key === "Backspace" && cursorPos === placeholderEnd const isDeletingFromStart = e.key === "Delete" && cursorPos === placeholderStart const isSelected = textarea.selectionStart <= placeholderStart && textarea.selectionEnd >= placeholderEnd && textarea.selectionStart !== textarea.selectionEnd if (isDeletingFromEnd || isDeletingFromStart || isSelected) { e.preventDefault() const currentAttachments = attachments() const attachment = currentAttachments.find( (a) => a.source.type === "file" && a.mediaType.startsWith("image/") && a.display.includes(`Image #${imageNumber}`), ) if (attachment) { removeAttachment(props.instanceId, props.sessionId, attachment.id) } const newText = text.substring(0, placeholderStart) + text.substring(placeholderEnd) setPrompt(newText) setTimeout(() => { textarea.setSelectionRange(placeholderStart, placeholderStart) textarea.style.height = "auto" textarea.style.height = Math.min(textarea.scrollHeight, 200) + "px" }, 0) return } } const mentionRegex = /@(\S+)/g let mentionMatch while ((mentionMatch = mentionRegex.exec(text)) !== null) { const mentionStart = mentionMatch.index const mentionEnd = mentionMatch.index + mentionMatch[0].length const name = mentionMatch[1] const isDeletingFromEnd = e.key === "Backspace" && cursorPos === mentionEnd const isDeletingFromStart = e.key === "Delete" && cursorPos === mentionStart const isSelected = textarea.selectionStart <= mentionStart && textarea.selectionEnd >= mentionEnd && textarea.selectionStart !== textarea.selectionEnd if (isDeletingFromEnd || isDeletingFromStart || isSelected) { const currentAttachments = attachments() const attachment = currentAttachments.find( (a) => (a.source.type === "file" || a.source.type === "agent") && a.filename === name, ) if (attachment) { e.preventDefault() removeAttachment(props.instanceId, props.sessionId, attachment.id) setIgnoredAtPositions((prev) => { const next = new Set(prev) next.delete(mentionStart) return next }) const newText = text.substring(0, mentionStart) + text.substring(mentionEnd) setPrompt(newText) setTimeout(() => { textarea.setSelectionRange(mentionStart, mentionStart) textarea.style.height = "auto" textarea.style.height = Math.min(textarea.scrollHeight, 200) + "px" }, 0) return } } } } if (e.key === "Enter" && !e.shiftKey && !showPicker()) { e.preventDefault() handleSend() return } const atStart = textarea.selectionStart === 0 && textarea.selectionEnd === 0 const currentHistory = history() if (e.key === "ArrowUp" && !showPicker() && atStart && currentHistory.length > 0) { e.preventDefault() const newIndex = historyIndex() === -1 ? 0 : Math.min(historyIndex() + 1, currentHistory.length - 1) setHistoryIndex(newIndex) setPrompt(currentHistory[newIndex]) setTimeout(() => { textarea.style.height = "auto" textarea.style.height = Math.min(textarea.scrollHeight, 200) + "px" }, 0) return } if (e.key === "ArrowDown" && !showPicker() && historyIndex() >= 0) { e.preventDefault() const newIndex = historyIndex() - 1 if (newIndex >= 0) { setHistoryIndex(newIndex) setPrompt(currentHistory[newIndex]) } else { setHistoryIndex(-1) setPrompt("") } setTimeout(() => { textarea.style.height = "auto" textarea.style.height = Math.min(textarea.scrollHeight, 200) + "px" }, 0) return } } async function handleSend() { const text = prompt().trim() const currentAttachments = attachments() if (!text || props.disabled) return setPrompt("") clearAttachments(props.instanceId, props.sessionId) setIgnoredAtPositions(new Set()) setPasteCount(0) setImageCount(0) if (textareaRef) { textareaRef.style.height = "auto" } try { await addToHistory(props.instanceFolder, text) const updated = await getHistory(props.instanceFolder) setHistory(updated) setHistoryIndex(-1) await props.onSend(text, currentAttachments) } catch (error) { console.error("Failed to send message:", error) alert("Failed to send message: " + (error instanceof Error ? error.message : String(error))) } finally { textareaRef?.focus() } } function handleInput(e: Event) { const target = e.target as HTMLTextAreaElement const value = target.value setPrompt(value) setHistoryIndex(-1) target.style.height = "auto" target.style.height = Math.min(target.scrollHeight, 200) + "px" const cursorPos = target.selectionStart const textBeforeCursor = value.substring(0, cursorPos) const lastAtIndex = textBeforeCursor.lastIndexOf("@") const previousAtPosition = atPosition() if (lastAtIndex === -1) { setIgnoredAtPositions(new Set()) } else if (previousAtPosition !== null && lastAtIndex !== previousAtPosition) { setIgnoredAtPositions((prev) => { const next = new Set(prev) next.delete(previousAtPosition) return next }) } if (lastAtIndex !== -1) { const textAfterAt = value.substring(lastAtIndex + 1, cursorPos) const hasSpace = textAfterAt.includes(" ") || textAfterAt.includes("\n") if (!hasSpace && cursorPos === lastAtIndex + textAfterAt.length + 1) { if (!ignoredAtPositions().has(lastAtIndex)) { setAtPosition(lastAtIndex) setSearchQuery(textAfterAt) setShowPicker(true) } return } } setShowPicker(false) setAtPosition(null) } function handlePickerSelect(item: { type: "agent"; agent: any } | { type: "file"; file: any }) { if (item.type === "agent") { const agentName = item.agent.name const existingAttachments = attachments() const alreadyAttached = existingAttachments.some( (att) => att.source.type === "agent" && att.source.name === agentName, ) if (!alreadyAttached) { const attachment = createAgentAttachment(agentName) addAttachment(props.instanceId, props.sessionId, attachment) } const currentPrompt = prompt() const pos = atPosition() const cursorPos = textareaRef?.selectionStart || 0 if (pos !== null) { const before = currentPrompt.substring(0, pos) const after = currentPrompt.substring(cursorPos) const attachmentText = `@${agentName}` const newPrompt = before + attachmentText + " " + after setPrompt(newPrompt) setTimeout(() => { if (textareaRef) { const newCursorPos = pos + attachmentText.length + 1 textareaRef.setSelectionRange(newCursorPos, newCursorPos) textareaRef.style.height = "auto" textareaRef.style.height = Math.min(textareaRef.scrollHeight, 200) + "px" } }, 0) } } else if (item.type === "file") { const path = item.file.path const isFolder = path.endsWith("/") const filename = path.split("/").pop() || path if (isFolder) { const currentPrompt = prompt() const pos = atPosition() const cursorPos = textareaRef?.selectionStart || 0 if (pos !== null) { const before = currentPrompt.substring(0, pos + 1) const after = currentPrompt.substring(cursorPos) const newPrompt = before + path + after setPrompt(newPrompt) setSearchQuery(path) setTimeout(() => { if (textareaRef) { const newCursorPos = pos + 1 + path.length textareaRef.setSelectionRange(newCursorPos, newCursorPos) textareaRef.style.height = "auto" textareaRef.style.height = Math.min(textareaRef.scrollHeight, 200) + "px" textareaRef.focus() } }, 0) } return } const existingAttachments = attachments() const alreadyAttached = existingAttachments.some((att) => att.source.type === "file" && att.source.path === path) if (!alreadyAttached) { const attachment = createFileAttachment(path, filename, "text/plain", undefined, props.instanceFolder) addAttachment(props.instanceId, props.sessionId, attachment) } const currentPrompt = prompt() const pos = atPosition() const cursorPos = textareaRef?.selectionStart || 0 if (pos !== null) { const before = currentPrompt.substring(0, pos) const after = currentPrompt.substring(cursorPos) const attachmentText = `@${filename}` const newPrompt = before + attachmentText + " " + after setPrompt(newPrompt) setTimeout(() => { if (textareaRef) { const newCursorPos = pos + attachmentText.length + 1 textareaRef.setSelectionRange(newCursorPos, newCursorPos) textareaRef.style.height = "auto" textareaRef.style.height = Math.min(textareaRef.scrollHeight, 200) + "px" } }, 0) } } setShowPicker(false) setAtPosition(null) setSearchQuery("") textareaRef?.focus() } function handlePickerClose() { const pos = atPosition() if (pos !== null) { setIgnoredAtPositions((prev) => new Set(prev).add(pos)) } setShowPicker(false) setAtPosition(null) setSearchQuery("") setTimeout(() => textareaRef?.focus(), 0) } function handleDragOver(e: DragEvent) { e.preventDefault() e.stopPropagation() setIsDragging(true) } function handleDragLeave(e: DragEvent) { e.preventDefault() e.stopPropagation() setIsDragging(false) } function handleDrop(e: DragEvent) { e.preventDefault() e.stopPropagation() setIsDragging(false) const files = e.dataTransfer?.files if (!files || files.length === 0) return for (let i = 0; i < files.length; i++) { const file = files[i] const path = (file as any).path || file.name const filename = file.name const mime = file.type || "text/plain" const attachment = createFileAttachment(path, filename, mime, undefined, props.instanceFolder) addAttachment(props.instanceId, props.sessionId, attachment) } textareaRef?.focus() } const canSend = () => (prompt().trim().length > 0 || attachments().length > 0) && !props.disabled const instance = () => getActiveInstance() return (
0}>
{(attachment) => { const isImage = attachment.mediaType.startsWith("image/") return (
} > } > } > {attachment.filename} {attachment.source.type === "text" ? attachment.display : attachment.filename}
) }}