import { createSignal, Show, onMount, For } from "solid-js" import AgentSelector from "./agent-selector" import ModelSelector from "./model-selector" import FilePicker from "./file-picker" import { addToHistory, getHistory } from "../stores/message-history" import { getAttachments, addAttachment, clearAttachments, removeAttachment } from "../stores/attachments" import { createFileAttachment, createTextAttachment } from "../types/attachment" import type { Attachment } from "../types/attachment" import Kbd from "./kbd" import HintRow from "./hint-row" import { getActiveInstance } from "../stores/instances" 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 } export default function PromptInput(props: PromptInputProps) { const [prompt, setPrompt] = createSignal("") const [sending, setSending] = createSignal(false) const [history, setHistory] = createSignal([]) const [historyIndex, setHistoryIndex] = createSignal(-1) const [isFocused, setIsFocused] = createSignal(false) const [showFilePicker, setShowFilePicker] = createSignal(false) const [fileSearchQuery, setFileSearchQuery] = createSignal("") const [atPosition, setAtPosition] = createSignal(null) const [isDragging, setIsDragging] = createSignal(false) const [ignoredAtPositions, setIgnoredAtPositions] = createSignal>(new Set()) const [pasteCount, setPasteCount] = createSignal(0) let textareaRef: HTMLTextAreaElement | undefined let containerRef: HTMLDivElement | undefined const attachments = () => getAttachments(props.instanceId, props.sessionId) function handleRemoveAttachment(attachmentId: string) { removeAttachment(props.instanceId, props.sessionId, attachmentId) const currentAttachments = attachments() const attachment = currentAttachments.find((a) => a.id === attachmentId) if (attachment && attachment.source.type === "file") { const filename = attachment.filename const currentPrompt = prompt() const newPrompt = currentPrompt.replace(`@${filename}`, "").replace(/\s+/g, " ").trim() setPrompt(newPrompt) if (textareaRef) { textareaRef.style.height = "auto" textareaRef.style.height = Math.min(textareaRef.scrollHeight, 200) + "px" } } } function handlePaste(e: ClipboardEvent) { 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) } } onMount(async () => { const loaded = await getHistory(props.instanceFolder) setHistory(loaded) }) function handleKeyDown(e: KeyboardEvent) { if (e.key === "Enter" && !e.shiftKey && !showFilePicker()) { e.preventDefault() handleSend() return } const textarea = textareaRef if (!textarea) return const atStart = textarea.selectionStart === 0 && textarea.selectionEnd === 0 const currentHistory = history() if (e.key === "ArrowUp" && !showFilePicker() && 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" && !showFilePicker() && 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 || sending() || props.disabled) return setSending(true) try { await addToHistory(props.instanceFolder, text) const updated = await getHistory(props.instanceFolder) setHistory(updated) setHistoryIndex(-1) await props.onSend(text, currentAttachments) setPrompt("") clearAttachments(props.instanceId, props.sessionId) setIgnoredAtPositions(new Set()) setPasteCount(0) if (textareaRef) { textareaRef.style.height = "auto" } } catch (error) { console.error("Failed to send message:", error) alert("Failed to send message: " + (error instanceof Error ? error.message : String(error))) } finally { setSending(false) 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 (previousAtPosition !== null && 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) setFileSearchQuery(textAfterAt) setShowFilePicker(true) } return } } setShowFilePicker(false) setAtPosition(null) } function handleFileSelect(path: string) { const filename = path.split("/").pop() || path 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) } setShowFilePicker(false) setAtPosition(null) setFileSearchQuery("") textareaRef?.focus() } function handleFilePickerClose() { const pos = atPosition() if (pos !== null) { setIgnoredAtPositions((prev) => new Set(prev).add(pos)) } setShowFilePicker(false) setAtPosition(null) setFileSearchQuery("") setTimeout(() => textareaRef?.focus(), 0) } function handleFilePickerNavigate(_direction: "up" | "down") {} 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) && !sending() && !props.disabled const instance = () => getActiveInstance() return (
0}>
{(attachment) => (
} > {attachment.source.type === "text" ? attachment.display : attachment.filename}
)}