- Detect pastes >150 chars or >3 lines - Convert long pastes to text attachments - Show clipboard icon for text attachments - Display summary (e.g., 'pasted #1 (10 lines)') - Auto-increment paste counter - Reset counter on message send - Short pastes insert normally
412 lines
14 KiB
TypeScript
412 lines
14 KiB
TypeScript
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<void>
|
|
disabled?: boolean
|
|
agent: string
|
|
model: { providerId: string; modelId: string }
|
|
onAgentChange: (agent: string) => Promise<void>
|
|
onModelChange: (model: { providerId: string; modelId: string }) => Promise<void>
|
|
}
|
|
|
|
export default function PromptInput(props: PromptInputProps) {
|
|
const [prompt, setPrompt] = createSignal("")
|
|
const [sending, setSending] = createSignal(false)
|
|
const [history, setHistory] = createSignal<string[]>([])
|
|
const [historyIndex, setHistoryIndex] = createSignal(-1)
|
|
const [isFocused, setIsFocused] = createSignal(false)
|
|
const [showFilePicker, setShowFilePicker] = createSignal(false)
|
|
const [fileSearchQuery, setFileSearchQuery] = createSignal("")
|
|
const [atPosition, setAtPosition] = createSignal<number | null>(null)
|
|
const [isDragging, setIsDragging] = createSignal(false)
|
|
const [ignoredAtPositions, setIgnoredAtPositions] = createSignal<Set<number>>(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<number>())
|
|
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<number>())
|
|
} 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 (
|
|
<div class="prompt-input-container">
|
|
<div
|
|
ref={containerRef}
|
|
class={`prompt-input-wrapper relative ${isDragging() ? "border-2 border-blue-500 bg-blue-50 dark:bg-blue-900/10" : ""}`}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
>
|
|
<Show when={showFilePicker() && instance()}>
|
|
<FilePicker
|
|
open={showFilePicker()}
|
|
onClose={handleFilePickerClose}
|
|
onSelect={handleFileSelect}
|
|
onNavigate={handleFilePickerNavigate}
|
|
instanceClient={instance()!.client}
|
|
searchQuery={fileSearchQuery()}
|
|
textareaRef={textareaRef}
|
|
/>
|
|
</Show>
|
|
|
|
<div class="flex flex-1 flex-col">
|
|
<Show when={attachments().length > 0}>
|
|
<div class="flex flex-wrap gap-1.5 border-b border-gray-200 pb-2 dark:border-gray-700">
|
|
<For each={attachments()}>
|
|
{(attachment) => (
|
|
<div class="inline-flex items-center gap-1.5 rounded-md bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10 dark:bg-blue-500/10 dark:text-blue-400 dark:ring-blue-500/20">
|
|
<Show
|
|
when={attachment.source.type === "text"}
|
|
fallback={
|
|
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
/>
|
|
</svg>
|
|
}
|
|
>
|
|
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
|
/>
|
|
</svg>
|
|
</Show>
|
|
<span>{attachment.source.type === "text" ? attachment.display : attachment.filename}</span>
|
|
<button
|
|
onClick={() => handleRemoveAttachment(attachment.id)}
|
|
class="ml-0.5 flex h-4 w-4 items-center justify-center rounded hover:bg-blue-100 dark:hover:bg-blue-500/20"
|
|
aria-label="Remove attachment"
|
|
>
|
|
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</Show>
|
|
<textarea
|
|
ref={textareaRef}
|
|
class="prompt-input"
|
|
placeholder="Type your message, @file, or /command..."
|
|
value={prompt()}
|
|
onInput={handleInput}
|
|
onKeyDown={handleKeyDown}
|
|
onPaste={handlePaste}
|
|
onFocus={() => setIsFocused(true)}
|
|
onBlur={() => setIsFocused(false)}
|
|
disabled={sending() || props.disabled}
|
|
rows={1}
|
|
style={attachments().length > 0 ? { "padding-top": "8px" } : {}}
|
|
/>
|
|
</div>
|
|
|
|
<button class="send-button" onClick={handleSend} disabled={!canSend()} aria-label="Send message">
|
|
<Show when={sending()} fallback={<span class="send-icon">▶</span>}>
|
|
<span class="spinner-small" />
|
|
</Show>
|
|
</button>
|
|
</div>
|
|
<div class="prompt-input-hints">
|
|
<HintRow>
|
|
<Kbd>Enter</Kbd> to send • <Kbd>Shift+Enter</Kbd> for new line • <Kbd>@</Kbd> for files • <Kbd>↑↓</Kbd> for
|
|
history
|
|
<Show when={attachments().length > 0}>
|
|
<span class="ml-2 text-xs text-gray-500">• {attachments().length} file(s) attached</span>
|
|
</Show>
|
|
</HintRow>
|
|
<div class="flex items-center gap-2">
|
|
<AgentSelector
|
|
instanceId={props.instanceId}
|
|
sessionId={props.sessionId}
|
|
currentAgent={props.agent}
|
|
onAgentChange={props.onAgentChange}
|
|
/>
|
|
<ModelSelector
|
|
instanceId={props.instanceId}
|
|
sessionId={props.sessionId}
|
|
currentModel={props.model}
|
|
onModelChange={props.onModelChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|