diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index 77ce1aeb..87eace66 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -1,8 +1,8 @@ -import { createSignal, Show, onMount, onCleanup, createEffect, on, untrack } from "solid-js" +import { createSignal, Show, onMount, onCleanup, createEffect, on } from "solid-js" import { ArrowBigUp, ArrowBigDown } from "lucide-solid" import UnifiedPicker from "./unified-picker" import ExpandButton from "./expand-button" -import { getAttachments, clearAttachments, removeAttachment } from "../stores/attachments" +import { clearAttachments, removeAttachment } from "../stores/attachments" import { resolvePastedPlaceholders } from "../lib/prompt-placeholders" import Kbd from "./kbd" import { getActiveInstance } from "../stores/instances" @@ -63,6 +63,7 @@ export default function PromptInput(props: PromptInputProps) { handleDrop, syncAttachmentCounters, handleExpandTextAttachment, + handleRemoveAttachment, } = usePromptAttachments({ instanceId: () => props.instanceId, sessionId: () => props.sessionId, @@ -87,6 +88,9 @@ export default function PromptInput(props: PromptInputProps) { if (!attachment) return handleExpandTextAttachment(attachment) }, + removeAttachment: (attachmentId: string) => { + handleRemoveAttachment(attachmentId) + }, setPromptText: (text: string, opts?: { focus?: boolean }) => { const textarea = textareaRef if (textarea) { @@ -166,10 +170,7 @@ export default function PromptInput(props: PromptInputProps) { setAtPosition(null) setSearchQuery("") - const instanceId = props.instanceId - const sessionId = props.sessionId - const currentAttachments = untrack(() => getAttachments(instanceId, sessionId)) - syncAttachmentCounters(prompt(), currentAttachments) + syncAttachmentCounters(prompt()) }, { defer: true }, ), @@ -238,10 +239,10 @@ export default function PromptInput(props: PromptInputProps) { // Ignore attachments for slash commands, but keep them for next prompt. if (!isKnownSlashCommand) { clearAttachments(props.instanceId, props.sessionId) - syncAttachmentCounters("", []) + syncAttachmentCounters("") setIgnoredAtPositions(new Set()) } else { - syncAttachmentCounters("", currentAttachments) + syncAttachmentCounters("") setIgnoredAtPositions(new Set()) } diff --git a/packages/ui/src/components/prompt-input/attachmentPlaceholders.ts b/packages/ui/src/components/prompt-input/attachmentPlaceholders.ts index 3139cc06..1e009761 100644 --- a/packages/ui/src/components/prompt-input/attachmentPlaceholders.ts +++ b/packages/ui/src/components/prompt-input/attachmentPlaceholders.ts @@ -1,5 +1,3 @@ -import type { Attachment } from "../../types/attachment" - export function formatPastedPlaceholder(value: string | number) { return `[pasted #${value}]` } @@ -9,27 +7,27 @@ export function formatImagePlaceholder(value: string | number) { } export function createPastedPlaceholderRegex() { - return /\[pasted #(\d+)\]/g + return /\[\s*pasted\s*#\s*(\d+)\s*\]/gi } export function createImagePlaceholderRegex() { - return /\[Image #(\d+)\]/g + return /\[\s*Image\s*#\s*(\d+)\s*\]/gi } export function createMentionRegex() { return /@(\S+)/g } -export const pastedDisplayCounterRegex = /pasted #(\d+)/ -export const imageDisplayCounterRegex = /Image #(\d+)/ -export const bracketedImageDisplayCounterRegex = /\[Image #(\d+)\]/ +export const pastedDisplayCounterRegex = /pasted #(\d+)/i +export const imageDisplayCounterRegex = /Image #(\d+)/i +export const bracketedImageDisplayCounterRegex = /\[\s*Image\s*#\s*(\d+)\s*\]/i export function parseCounter(value: string) { const parsed = Number.parseInt(value, 10) return Number.isNaN(parsed) ? null : parsed } -export function findHighestAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) { +export function findHighestAttachmentCounters(currentPrompt: string) { let highestPaste = 0 let highestImage = 0 @@ -40,27 +38,6 @@ export function findHighestAttachmentCounters(currentPrompt: string, sessionAtta } } - for (const attachment of sessionAttachments) { - if (attachment.source.type === "text") { - const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex) - if (placeholderMatch) { - const parsed = parseCounter(placeholderMatch[1]) - if (parsed !== null) { - highestPaste = Math.max(highestPaste, parsed) - } - } - } - if (attachment.source.type === "file" && attachment.mediaType.startsWith("image/")) { - const imageMatch = attachment.display.match(imageDisplayCounterRegex) - if (imageMatch) { - const parsed = parseCounter(imageMatch[1]) - if (parsed !== null) { - highestImage = Math.max(highestImage, parsed) - } - } - } - } - for (const match of currentPrompt.matchAll(createImagePlaceholderRegex())) { const parsed = parseCounter(match[1]) if (parsed !== null) { diff --git a/packages/ui/src/components/prompt-input/types.ts b/packages/ui/src/components/prompt-input/types.ts index b3ff1a39..54757793 100644 --- a/packages/ui/src/components/prompt-input/types.ts +++ b/packages/ui/src/components/prompt-input/types.ts @@ -8,6 +8,7 @@ export type PromptInsertMode = "quote" | "code" export interface PromptInputApi { insertSelection(text: string, mode: PromptInsertMode): void expandTextAttachment(attachmentId: string): void + removeAttachment(attachmentId: string): void setPromptText(text: string, opts?: { focus?: boolean }): void focus(): void } diff --git a/packages/ui/src/components/prompt-input/usePromptAttachments.ts b/packages/ui/src/components/prompt-input/usePromptAttachments.ts index fa833cb2..d4a53b4e 100644 --- a/packages/ui/src/components/prompt-input/usePromptAttachments.ts +++ b/packages/ui/src/components/prompt-input/usePromptAttachments.ts @@ -1,4 +1,4 @@ -import { createSignal, type Accessor } from "solid-js" +import { createEffect, createSignal, type Accessor } from "solid-js" import { addAttachment, getAttachments, removeAttachment } from "../../stores/attachments" import { createFileAttachment, createTextAttachment } from "../../types/attachment" import type { Attachment } from "../../types/attachment" @@ -7,6 +7,7 @@ import { findHighestAttachmentCounters, formatImagePlaceholder, formatPastedPlaceholder, + imageDisplayCounterRegex, pastedDisplayCounterRegex, } from "./attachmentPlaceholders" @@ -23,7 +24,7 @@ type PromptAttachments = { attachments: Accessor pasteCount: Accessor imageCount: Accessor - syncAttachmentCounters: (promptText: string, sessionAttachments: Attachment[]) => void + syncAttachmentCounters: (promptText: string) => void handlePaste: (e: ClipboardEvent) => Promise isDragging: Accessor @@ -41,45 +42,106 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA const [pasteCount, setPasteCount] = createSignal(0) const [imageCount, setImageCount] = createSignal(0) - function syncAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) { - const { highestPaste, highestImage } = findHighestAttachmentCounters(currentPrompt, sessionAttachments) + function syncAttachmentCounters(currentPrompt: string) { + const { highestPaste, highestImage } = findHighestAttachmentCounters(currentPrompt) setPasteCount(highestPaste) setImageCount(highestImage) } + const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + + function removeTokenFromPrompt(currentPrompt: string, tokenRegex: RegExp) { + const next = currentPrompt.replace(tokenRegex, "") + if (next === currentPrompt) return currentPrompt + + return next + .replace(/[ \t]{2,}/g, " ") + .replace(/[ \t]+\n/g, "\n") + .replace(/\n[ \t]+/g, "\n") + .trim() + } + + const createLooseImagePlaceholderRegex = (counter: string | number) => + new RegExp(`\\[\\s*Image\\s*#\\s*${counter}\\s*\\]`, "i") + const createLoosePastedPlaceholderRegex = (counter: string | number) => + new RegExp(`\\[\\s*pasted\\s*#\\s*${counter}\\s*\\]`, "i") + + // Keep placeholder-backed attachments in sync with prompt text. + // If the placeholder token disappears from the prompt, the attachment should disappear too. + createEffect(() => { + const currentPrompt = options.prompt() + const currentAttachments = attachments() + + const toRemove: string[] = [] + + for (const attachment of currentAttachments) { + if (attachment.source.type === "text") { + const match = attachment.display.match(pastedDisplayCounterRegex) + if (!match) continue + const counter = match[1] + if (!createLoosePastedPlaceholderRegex(counter).test(currentPrompt)) { + toRemove.push(attachment.id) + } + continue + } + + if (attachment.source.type === "file" && attachment.mediaType.startsWith("image/")) { + const match = + attachment.display.match(bracketedImageDisplayCounterRegex) || attachment.display.match(imageDisplayCounterRegex) + if (!match) continue + const counter = match[1] + if (!createLooseImagePlaceholderRegex(counter).test(currentPrompt)) { + toRemove.push(attachment.id) + } + } + } + + for (const attachmentId of toRemove) { + removeAttachment(options.instanceId(), options.sessionId(), attachmentId) + } + }) + function handleRemoveAttachment(attachmentId: string) { const currentAttachments = attachments() const attachment = currentAttachments.find((a) => a.id === attachmentId) + // Always remove from store. removeAttachment(options.instanceId(), options.sessionId(), attachmentId) - if (attachment) { - const currentPrompt = options.prompt() - let newPrompt = currentPrompt - - if (attachment.source.type === "file") { - if (attachment.mediaType.startsWith("image/")) { - const imageMatch = attachment.display.match(bracketedImageDisplayCounterRegex) - if (imageMatch) { - const placeholder = formatImagePlaceholder(imageMatch[1]) - newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim() - } - } else { - const filename = attachment.filename - newPrompt = currentPrompt.replace(`@${filename}`, "").replace(/\s+/g, " ").trim() + if (!attachment) return + + const currentPrompt = options.prompt() + let nextPrompt = currentPrompt + + if (attachment.source.type === "file") { + if (attachment.mediaType.startsWith("image/")) { + const imageMatch = + attachment.display.match(bracketedImageDisplayCounterRegex) || attachment.display.match(imageDisplayCounterRegex) + if (imageMatch) { + nextPrompt = removeTokenFromPrompt(currentPrompt, createLooseImagePlaceholderRegex(imageMatch[1])) } - } 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(pastedDisplayCounterRegex) - if (placeholderMatch) { - const placeholder = formatPastedPlaceholder(placeholderMatch[1]) - newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim() + } else { + // For file mentions we insert `@`, but the chip might display `@`. + const candidates = [attachment.source.path, attachment.filename] + for (const candidate of candidates) { + if (!candidate) continue + const mentionRegex = new RegExp(`@${escapeRegExp(candidate)}(?=\\s|$)`, "i") + nextPrompt = removeTokenFromPrompt(nextPrompt, mentionRegex) } } + } else if (attachment.source.type === "agent") { + const agentName = attachment.filename + const mentionRegex = new RegExp(`@${escapeRegExp(agentName)}(?=\\s|$)`, "i") + nextPrompt = removeTokenFromPrompt(currentPrompt, mentionRegex) + } else if (attachment.source.type === "text") { + const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex) + if (placeholderMatch) { + nextPrompt = removeTokenFromPrompt(currentPrompt, createLoosePastedPlaceholderRegex(placeholderMatch[1])) + } + } - options.setPrompt(newPrompt) + if (nextPrompt !== currentPrompt) { + options.setPrompt(nextPrompt) } } @@ -143,13 +205,32 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA const blob = item.getAsFile() if (!blob) continue - const count = imageCount() + 1 + const { highestImage } = findHighestAttachmentCounters(options.prompt()) + const count = highestImage + 1 setImageCount(count) + const placeholder = formatImagePlaceholder(count) + const textarea = options.getTextarea() + + if (textarea) { + const start = textarea.selectionStart + const end = textarea.selectionEnd + const currentText = options.prompt() + const newText = currentText.substring(0, start) + placeholder + currentText.substring(end) + options.setPrompt(newText) + + setTimeout(() => { + const newCursorPos = start + placeholder.length + textarea.setSelectionRange(newCursorPos, newCursorPos) + textarea.focus() + }, 0) + } else { + options.setPrompt(options.prompt() + placeholder) + } + const reader = new FileReader() reader.onload = () => { const base64Data = (reader.result as string).split(",")[1] - const display = formatImagePlaceholder(count) const filename = `image-${count}.png` const attachment = createFileAttachment( @@ -160,24 +241,8 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA options.instanceFolder(), ) attachment.url = `data:image/png;base64,${base64Data}` - attachment.display = display + attachment.display = placeholder addAttachment(options.instanceId(), options.sessionId(), attachment) - - const textarea = options.getTextarea() - if (textarea) { - const start = textarea.selectionStart - const end = textarea.selectionEnd - const currentText = options.prompt() - const placeholder = formatImagePlaceholder(count) - const newText = currentText.substring(0, start) + placeholder + currentText.substring(end) - options.setPrompt(newText) - - setTimeout(() => { - const newCursorPos = start + placeholder.length - textarea.setSelectionRange(newCursorPos, newCursorPos) - textarea.focus() - }, 0) - } } reader.readAsDataURL(blob) @@ -196,7 +261,8 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA if (isLongPaste) { e.preventDefault() - const count = pasteCount() + 1 + const { highestPaste } = findHighestAttachmentCounters(options.prompt()) + const count = highestPaste + 1 setPasteCount(count) const summary = lineCount > 1 ? `${lineCount} lines` : `${charCount} chars` @@ -204,14 +270,12 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA const filename = `paste-${count}.txt` const attachment = createTextAttachment(pastedText, display, filename) - addAttachment(options.instanceId(), options.sessionId(), attachment) - + const placeholder = formatPastedPlaceholder(count) const textarea = options.getTextarea() if (textarea) { const start = textarea.selectionStart const end = textarea.selectionEnd const currentText = options.prompt() - const placeholder = formatPastedPlaceholder(count) const newText = currentText.substring(0, start) + placeholder + currentText.substring(end) options.setPrompt(newText) @@ -220,7 +284,11 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA textarea.setSelectionRange(newCursorPos, newCursorPos) textarea.focus() }, 0) + } else { + options.setPrompt(options.prompt() + placeholder) } + + addAttachment(options.instanceId(), options.sessionId(), attachment) } } diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index cce1b454..e0de2457 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -299,13 +299,19 @@ export const SessionView: Component = (props) => { /> - 0}> - removeAttachment(props.instanceId, props.sessionId, attachmentId)} - onExpandTextAttachment={(attachmentId) => promptInputApi?.expandTextAttachment(attachmentId)} - /> - + 0}> + { + if (promptInputApi) { + promptInputApi.removeAttachment(attachmentId) + return + } + removeAttachment(props.instanceId, props.sessionId, attachmentId) + }} + onExpandTextAttachment={(attachmentId) => promptInputApi?.expandTextAttachment(attachmentId)} + /> +