feat(ui): improve picker actions and directory attach
This commit is contained in:
@@ -1,9 +1,10 @@
|
|||||||
import { createSignal, type Accessor, type Setter } from "solid-js"
|
import { createSignal, type Accessor, type Setter } from "solid-js"
|
||||||
import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
|
import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
|
||||||
import type { Agent } from "../../types/session"
|
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 { addAttachment, getAttachments } from "../../stores/attachments"
|
||||||
import type { PickerMode } from "./types"
|
import type { PickerMode } from "./types"
|
||||||
|
import type { PickerSelectAction } from "../unified-picker"
|
||||||
|
|
||||||
type PickerItem =
|
type PickerItem =
|
||||||
| { type: "agent"; agent: Agent }
|
| { type: "agent"; agent: Agent }
|
||||||
@@ -37,7 +38,7 @@ type PromptPickerController = {
|
|||||||
setIgnoredAtPositions: Setter<Set<number>>
|
setIgnoredAtPositions: Setter<Set<number>>
|
||||||
|
|
||||||
handleInput: (e: Event) => void
|
handleInput: (e: Event) => void
|
||||||
handlePickerSelect: (item: PickerItem) => void
|
handlePickerSelect: (item: PickerItem, action: PickerSelectAction) => void
|
||||||
handlePickerClose: () => void
|
handlePickerClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,10 +104,11 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
setAtPosition(null)
|
setAtPosition(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePickerSelect(item: PickerItem) {
|
function handlePickerSelect(item: PickerItem, action: PickerSelectAction) {
|
||||||
const textarea = options.getTextarea()
|
const textarea = options.getTextarea()
|
||||||
|
|
||||||
if (item.type === "command") {
|
if (item.type === "command") {
|
||||||
|
// For commands, Tab/Enter/Shift+Enter/click all mean "select".
|
||||||
const name = item.command.name
|
const name = item.command.name
|
||||||
const currentPrompt = options.prompt()
|
const currentPrompt = options.prompt()
|
||||||
|
|
||||||
@@ -128,6 +130,7 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
}
|
}
|
||||||
}, 0)
|
}, 0)
|
||||||
} else if (item.type === "agent") {
|
} else if (item.type === "agent") {
|
||||||
|
// For agents, Tab/Enter/Shift+Enter/click all mean "select".
|
||||||
const agentName = item.agent.name
|
const agentName = item.agent.name
|
||||||
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
||||||
const alreadyAttached = existingAttachments.some(
|
const alreadyAttached = existingAttachments.some(
|
||||||
@@ -163,76 +166,144 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
const relativePath = item.file.relativePath ?? displayPath
|
const relativePath = item.file.relativePath ?? displayPath
|
||||||
const isFolder = item.file.isDirectory ?? displayPath.endsWith("/")
|
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 pos = atPosition()
|
||||||
const cursorPos = textarea?.selectionStart || 0
|
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 before = currentPrompt.substring(0, pos)
|
||||||
const after = currentPrompt.substring(cursorPos)
|
const after = currentPrompt.substring(cursorPos)
|
||||||
const attachmentText = `@${normalizedPath}`
|
const suffix = opts?.trailingSpace ? " " : ""
|
||||||
const newPrompt = before + attachmentText + " " + after
|
const nextPrompt = before + mentionText + suffix + after
|
||||||
options.setPrompt(newPrompt)
|
options.setPrompt(nextPrompt)
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const nextTextarea = options.getTextarea()
|
const nextTextarea = options.getTextarea()
|
||||||
if (nextTextarea) {
|
if (!nextTextarea) return
|
||||||
const newCursorPos = pos + attachmentText.length + 1
|
const nextCursorPos = pos + mentionText.length + suffix.length
|
||||||
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
|
nextTextarea.setSelectionRange(nextCursorPos, nextCursorPos)
|
||||||
}
|
|
||||||
}, 0)
|
}, 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)
|
setShowPicker(false)
|
||||||
|
|||||||
@@ -74,10 +74,12 @@ type PickerItem =
|
|||||||
| { type: "file"; file: FileItem }
|
| { type: "file"; file: FileItem }
|
||||||
| { type: "command"; command: SDKCommand }
|
| { type: "command"; command: SDKCommand }
|
||||||
|
|
||||||
|
export type PickerSelectAction = "click" | "tab" | "enter" | "shiftEnter"
|
||||||
|
|
||||||
interface UnifiedPickerProps {
|
interface UnifiedPickerProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
mode?: "mention" | "command"
|
mode?: "mention" | "command"
|
||||||
onSelect: (item: PickerItem) => void
|
onSelect: (item: PickerItem, action: PickerSelectAction) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
agents: Agent[]
|
agents: Agent[]
|
||||||
commands?: SDKCommand[]
|
commands?: SDKCommand[]
|
||||||
@@ -356,7 +358,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSelect(item: PickerItem) {
|
function handleSelect(item: PickerItem) {
|
||||||
props.onSelect(item)
|
props.onSelect(item, "click")
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
@@ -379,7 +381,8 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
const selected = items[selectedIndex()]
|
const selected = items[selectedIndex()]
|
||||||
if (selected) {
|
if (selected) {
|
||||||
handleSelect(selected)
|
const action: PickerSelectAction = e.key === "Tab" ? "tab" : e.shiftKey ? "shiftEnter" : "enter"
|
||||||
|
props.onSelect(selected, action)
|
||||||
}
|
}
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -443,7 +446,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
<div
|
<div
|
||||||
class={`dropdown-item ${isSelected() ? "dropdown-item-highlight" : ""}`}
|
class={`dropdown-item ${isSelected() ? "dropdown-item-highlight" : ""}`}
|
||||||
data-picker-selected={isSelected()}
|
data-picker-selected={isSelected()}
|
||||||
onClick={() => handleSelect({ type: "command", command })}
|
onClick={() => props.onSelect({ type: "command", command }, "click")}
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<svg class="dropdown-icon-accent h-4 w-4 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="dropdown-icon-accent h-4 w-4 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
@@ -479,7 +482,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
itemIndex === selectedIndex() ? "dropdown-item-highlight" : ""
|
itemIndex === selectedIndex() ? "dropdown-item-highlight" : ""
|
||||||
}`}
|
}`}
|
||||||
data-picker-selected={itemIndex === selectedIndex()}
|
data-picker-selected={itemIndex === selectedIndex()}
|
||||||
onClick={() => handleSelect({ type: "agent", agent })}
|
onClick={() => props.onSelect({ type: "agent", agent }, "click")}
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<svg
|
<svg
|
||||||
@@ -535,7 +538,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
itemIndex === selectedIndex() ? "dropdown-item-highlight" : ""
|
itemIndex === selectedIndex() ? "dropdown-item-highlight" : ""
|
||||||
}`}
|
}`}
|
||||||
data-picker-selected={itemIndex === selectedIndex()}
|
data-picker-selected={itemIndex === selectedIndex()}
|
||||||
onClick={() => handleSelect({ type: "file", file })}
|
onClick={() => props.onSelect({ type: "file", file }, "click")}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 text-sm">
|
<div class="flex items-center gap-2 text-sm">
|
||||||
<Show
|
<Show
|
||||||
|
|||||||
Reference in New Issue
Block a user