diff --git a/packages/ui/src/components/prompt-input/usePromptPicker.ts b/packages/ui/src/components/prompt-input/usePromptPicker.ts index 191138b0..0b4c0df2 100644 --- a/packages/ui/src/components/prompt-input/usePromptPicker.ts +++ b/packages/ui/src/components/prompt-input/usePromptPicker.ts @@ -1,7 +1,7 @@ import { createSignal, type Accessor, type Setter } from "solid-js" import type { Command as SDKCommand } from "@opencode-ai/sdk/v2" import type { Agent } from "../../types/session" -import { createAgentAttachment, createFileAttachment, createTextAttachment } from "../../types/attachment" +import { createAgentAttachment, createFileAttachment } from "../../types/attachment" import { addAttachment, getAttachments, removeAttachment } from "../../stores/attachments" import type { PickerMode } from "./types" import type { PickerSelectAction } from "../unified-picker" @@ -213,18 +213,6 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr return trimmed.length > 0 ? trimmed : "." })() - const addPathOnlyAttachment = (value: string) => { - const display = `path: ${value}` - const filename = value - const existing = getAttachments(options.instanceId(), options.sessionId()) - const alreadyAttached = existing.some( - (att) => att.source.type === "text" && att.source.value === value && att.display === display, - ) - if (!alreadyAttached) { - addAttachment(options.instanceId(), options.sessionId(), createTextAttachment(value, display, filename)) - } - } - if (isFolder) { if (action === "tab") { // TAB on directory: autocomplete directory name and show its contents. diff --git a/packages/ui/src/lib/prompt-placeholders.ts b/packages/ui/src/lib/prompt-placeholders.ts index 594c6b4a..25fe12f6 100644 --- a/packages/ui/src/lib/prompt-placeholders.ts +++ b/packages/ui/src/lib/prompt-placeholders.ts @@ -5,21 +5,87 @@ export function resolvePastedPlaceholders(prompt: string, attachments: Attachmen return prompt } - // First, strip @ from file/directory paths that don't have file attachments - // This handles SHIFT+ENTER case where @path should become path - const fileAttachments = new Set( + // First, strip `@` from path-like mentions that do NOT have a backing file attachment. + // This is intended for SHIFT+ENTER selection where we keep `@path` in the textarea for + // easy deletion, but send `path` to the API. + // + // IMPORTANT: avoid rewriting plain `@mentions` or email addresses. + const fileAttachmentPaths = new Set( attachments - .filter((a) => a.source.type === "file" && "path" in a.source) - .map((a) => (a.source as { path: string }).path), + .filter((a) => a.source.type === "file") + .map((a) => a.source.path), ) - let result = prompt.replace(/@([^\s@]+)/g, (match, path) => { - // If this path has a file attachment, keep the @ (attachment is sent separately) - if (fileAttachments.has(path) || fileAttachments.has(path.replace(/\/$/, ""))) { - return match + const isPathLike = (value: string) => { + if (!value) return false + if (value.includes("/") || value.includes("\\")) return true + if (value.startsWith("./") || value.startsWith("../")) return true + if (value.startsWith("~")) return true + if (value.endsWith("/")) return true + + // Root-level files (no `/`) still commonly have an extension. + const ext = value.split(".").pop()?.toLowerCase() + if (!ext || ext === value.toLowerCase()) return false + + // Keep this list intentionally small and code-focused to avoid matching domains like `example.com`. + const allowedExts = new Set([ + "ts", + "tsx", + "js", + "jsx", + "mjs", + "cjs", + "json", + "md", + "txt", + "yml", + "yaml", + "toml", + "css", + "html", + "htm", + "svg", + "png", + "jpg", + "jpeg", + "gif", + "pdf", + "rs", + "go", + "py", + "java", + "kt", + "swift", + "sh", + "bash", + "zsh", + "sql", + "lock", + ]) + return allowedExts.has(ext) + } + + const stripTrailingPunctuation = (value: string) => { + const match = value.match(/^(.*?)([)\]}.,!?:;]+)?$/) + if (!match) return { core: value, trailing: "" } + return { core: match[1] ?? value, trailing: match[2] ?? "" } + } + + let result = prompt.replace(/(^|[\s([{"'`])@([^\s@]+)/g, (full, prefix, rawToken) => { + const { core, trailing } = stripTrailingPunctuation(String(rawToken)) + if (!core) return full + + // If this path has a file attachment, keep the `@` (attachment is sent separately). + if (fileAttachmentPaths.has(core) || fileAttachmentPaths.has(core.replace(/\/$/, ""))) { + return `${prefix}@${core}${trailing}` } - // Otherwise (SHIFT+ENTER case), strip the @ - return path + + // Only strip for path-like tokens; leave plain `@mentions` intact. + if (!isPathLike(core)) { + return `${prefix}@${core}${trailing}` + } + + return `${prefix}${core}${trailing}` }) // Then, resolve [pasted #N] placeholders