diff --git a/packages/ui/src/components/prompt-input/usePromptPicker.ts b/packages/ui/src/components/prompt-input/usePromptPicker.ts index ada32cc9..98e53bc6 100644 --- a/packages/ui/src/components/prompt-input/usePromptPicker.ts +++ b/packages/ui/src/components/prompt-input/usePromptPicker.ts @@ -1,9 +1,10 @@ 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 } from "../../types/attachment" +import { createAgentAttachment, createFileAttachment, createTextAttachment } from "../../types/attachment" import { addAttachment, getAttachments } from "../../stores/attachments" import type { PickerMode } from "./types" +import type { PickerSelectAction } from "../unified-picker" type PickerItem = | { type: "agent"; agent: Agent } @@ -37,7 +38,7 @@ type PromptPickerController = { setIgnoredAtPositions: Setter> handleInput: (e: Event) => void - handlePickerSelect: (item: PickerItem) => void + handlePickerSelect: (item: PickerItem, action: PickerSelectAction) => void handlePickerClose: () => void } @@ -103,10 +104,11 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr setAtPosition(null) } - function handlePickerSelect(item: PickerItem) { + function handlePickerSelect(item: PickerItem, action: PickerSelectAction) { const textarea = options.getTextarea() if (item.type === "command") { + // For commands, Tab/Enter/Shift+Enter/click all mean "select". const name = item.command.name const currentPrompt = options.prompt() @@ -128,6 +130,7 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr } }, 0) } else if (item.type === "agent") { + // For agents, Tab/Enter/Shift+Enter/click all mean "select". const agentName = item.agent.name const existingAttachments = getAttachments(options.instanceId(), options.sessionId()) const alreadyAttached = existingAttachments.some( @@ -163,76 +166,144 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr const relativePath = item.file.relativePath ?? displayPath const isFolder = item.file.isDirectory ?? displayPath.endsWith("/") - if (isFolder) { - const currentPrompt = options.prompt() - const pos = atPosition() - const cursorPos = textarea?.selectionStart || 0 - const folderMention = - relativePath === "." || relativePath === "" - ? "/" - : relativePath.replace(/\/+$/, "") + "/" - - if (pos !== null) { - const before = currentPrompt.substring(0, pos + 1) - const after = currentPrompt.substring(cursorPos) - const newPrompt = before + folderMention + after - options.setPrompt(newPrompt) - setSearchQuery(folderMention) - - setTimeout(() => { - const nextTextarea = options.getTextarea() - if (nextTextarea) { - const newCursorPos = pos + 1 + folderMention.length - nextTextarea.setSelectionRange(newCursorPos, newCursorPos) - } - }, 0) - } - - return - } - - const normalizedPath = relativePath.replace(/\/+$/, "") || relativePath - const pathSegments = normalizedPath.split("/") - const filename = (() => { - const candidate = pathSegments[pathSegments.length - 1] || normalizedPath - return candidate === "." ? "/" : candidate - })() - - const existingAttachments = getAttachments(options.instanceId(), options.sessionId()) - const alreadyAttached = existingAttachments.some( - (att) => att.source.type === "file" && att.source.path === normalizedPath, - ) - - if (!alreadyAttached) { - const attachment = createFileAttachment( - normalizedPath, - filename, - "text/plain", - undefined, - options.instanceFolder(), - ) - addAttachment(options.instanceId(), options.sessionId(), attachment) - } - - const currentPrompt = options.prompt() const pos = atPosition() const cursorPos = textarea?.selectionStart || 0 - if (pos !== null) { + const replaceMentionToken = (mentionText: string, opts?: { trailingSpace?: boolean }) => { + if (pos === null) return + const currentPrompt = options.prompt() const before = currentPrompt.substring(0, pos) const after = currentPrompt.substring(cursorPos) - const attachmentText = `@${normalizedPath}` - const newPrompt = before + attachmentText + " " + after - options.setPrompt(newPrompt) + const suffix = opts?.trailingSpace ? " " : "" + const nextPrompt = before + mentionText + suffix + after + options.setPrompt(nextPrompt) setTimeout(() => { const nextTextarea = options.getTextarea() - if (nextTextarea) { - const newCursorPos = pos + attachmentText.length + 1 - nextTextarea.setSelectionRange(newCursorPos, newCursorPos) - } + if (!nextTextarea) return + const nextCursorPos = pos + mentionText.length + suffix.length + nextTextarea.setSelectionRange(nextCursorPos, nextCursorPos) }, 0) } + + const replaceMentionQueryAfterAt = (value: string) => { + // Replaces only the query after '@' (keeps the '@' itself). Used for directory navigation. + if (pos === null) return + const currentPrompt = options.prompt() + const before = currentPrompt.substring(0, pos + 1) + const after = currentPrompt.substring(cursorPos) + const nextPrompt = before + value + after + options.setPrompt(nextPrompt) + + setTimeout(() => { + const nextTextarea = options.getTextarea() + if (!nextTextarea) return + const nextCursorPos = pos + 1 + value.length + nextTextarea.setSelectionRange(nextCursorPos, nextCursorPos) + }, 0) + } + + const folderMention = + relativePath === "." || relativePath === "" + ? "/" + : relativePath.replace(/\/+$/, "") + "/" + + const normalizedFolderPath = (() => { + const trimmed = relativePath.replace(/\/+$/, "") + 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. + replaceMentionQueryAfterAt(folderMention) + setSearchQuery(folderMention) + return + } + + const mentionText = `@${folderMention}` + + if (action === "shiftEnter") { + // SHIFT+ENTER on directory: attach path as text only. + addPathOnlyAttachment(folderMention) + replaceMentionToken(mentionText, { trailingSpace: true }) + } else { + // ENTER/click on directory: attach as a file part pointing at a file:// directory URL. + const dirLabel = + normalizedFolderPath === "." ? "/" : normalizedFolderPath.split("/").pop() || normalizedFolderPath + const dirFilename = dirLabel.endsWith("/") ? dirLabel : `${dirLabel}/` + + const existingAttachments = getAttachments(options.instanceId(), options.sessionId()) + const alreadyAttached = existingAttachments.some( + (att) => att.source.type === "file" && att.source.path === normalizedFolderPath && att.source.mime === "inode/directory", + ) + + if (!alreadyAttached) { + const attachment = createFileAttachment( + normalizedFolderPath, + dirFilename, + "inode/directory", + undefined, + options.instanceFolder(), + ) + addAttachment(options.instanceId(), options.sessionId(), attachment) + } + + replaceMentionToken(mentionText, { trailingSpace: true }) + } + } else { + const normalizedPath = relativePath.replace(/\/+$/, "") || relativePath + + if (action === "tab") { + // TAB on file: autocomplete the file path but do not attach. + replaceMentionToken(`@${normalizedPath}`) + setSearchQuery(normalizedPath) + return + } + + if (action === "shiftEnter") { + // SHIFT+ENTER on file: attach path as text only. + addPathOnlyAttachment(normalizedPath) + replaceMentionToken(`@${normalizedPath}`, { trailingSpace: true }) + } else { + // ENTER/click on file: attach file (existing behavior). + const pathSegments = normalizedPath.split("/") + const filename = (() => { + const candidate = pathSegments[pathSegments.length - 1] || normalizedPath + return candidate === "." ? "/" : candidate + })() + + const existingAttachments = getAttachments(options.instanceId(), options.sessionId()) + const alreadyAttached = existingAttachments.some( + (att) => att.source.type === "file" && att.source.path === normalizedPath, + ) + + if (!alreadyAttached) { + const attachment = createFileAttachment( + normalizedPath, + filename, + "text/plain", + undefined, + options.instanceFolder(), + ) + addAttachment(options.instanceId(), options.sessionId(), attachment) + } + + replaceMentionToken(`@${normalizedPath}`, { trailingSpace: true }) + } + } } setShowPicker(false) diff --git a/packages/ui/src/components/unified-picker.tsx b/packages/ui/src/components/unified-picker.tsx index 0abc00be..7284d145 100644 --- a/packages/ui/src/components/unified-picker.tsx +++ b/packages/ui/src/components/unified-picker.tsx @@ -74,10 +74,12 @@ type PickerItem = | { type: "file"; file: FileItem } | { type: "command"; command: SDKCommand } +export type PickerSelectAction = "click" | "tab" | "enter" | "shiftEnter" + interface UnifiedPickerProps { open: boolean mode?: "mention" | "command" - onSelect: (item: PickerItem) => void + onSelect: (item: PickerItem, action: PickerSelectAction) => void onClose: () => void agents: Agent[] commands?: SDKCommand[] @@ -356,7 +358,7 @@ const UnifiedPicker: Component = (props) => { } function handleSelect(item: PickerItem) { - props.onSelect(item) + props.onSelect(item, "click") } function handleKeyDown(e: KeyboardEvent) { @@ -379,7 +381,8 @@ const UnifiedPicker: Component = (props) => { e.stopPropagation() const selected = items[selectedIndex()] if (selected) { - handleSelect(selected) + const action: PickerSelectAction = e.key === "Tab" ? "tab" : e.shiftKey ? "shiftEnter" : "enter" + props.onSelect(selected, action) } } else if (e.key === "Escape") { e.preventDefault() @@ -443,7 +446,7 @@ const UnifiedPicker: Component = (props) => {
handleSelect({ type: "command", command })} + onClick={() => props.onSelect({ type: "command", command }, "click")} >
@@ -479,7 +482,7 @@ const UnifiedPicker: Component = (props) => { itemIndex === selectedIndex() ? "dropdown-item-highlight" : "" }`} data-picker-selected={itemIndex === selectedIndex()} - onClick={() => handleSelect({ type: "agent", agent })} + onClick={() => props.onSelect({ type: "agent", agent }, "click")} >
= (props) => { itemIndex === selectedIndex() ? "dropdown-item-highlight" : "" }`} data-picker-selected={itemIndex === selectedIndex()} - onClick={() => handleSelect({ type: "file", file })} + onClick={() => props.onSelect({ type: "file", file }, "click")} >