Compare commits
5 Commits
codenomad/
...
codenomad/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d1f702597 | ||
|
|
2d93d82611 | ||
|
|
4e0f064c3a | ||
|
|
e4e10cc630 | ||
|
|
8f6d4c8b09 |
@@ -1,8 +1,8 @@
|
||||
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 { addAttachment, getAttachments } from "../../stores/attachments"
|
||||
import { createAgentAttachment, createFileAttachment } from "../../types/attachment"
|
||||
import { addAttachment, getAttachments, removeAttachment } from "../../stores/attachments"
|
||||
import type { PickerMode } from "./types"
|
||||
import type { PickerSelectAction } from "../unified-picker"
|
||||
|
||||
@@ -204,30 +204,15 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
||||
}
|
||||
|
||||
const folderMention =
|
||||
relativePath === "." || relativePath === "" || relativePath === "./"
|
||||
? "./"
|
||||
: (relativePath.startsWith("./") ? relativePath.replace(/\/+$/, "") + "/" : relativePath.replace(/^\.\//, "").replace(/\/+$/, "") + "/")
|
||||
relativePath === "." || relativePath === ""
|
||||
? "/"
|
||||
: relativePath.replace(/\/+$/, "") + "/"
|
||||
|
||||
const normalizedFolderPath = (() => {
|
||||
const trimmed = relativePath.replace(/\/+$/, "")
|
||||
// If it's root "./", just return "./"
|
||||
if (trimmed === "" || trimmed === ".") return "./"
|
||||
// Otherwise remove any leading ./ and add ./ prefix
|
||||
return "./" + trimmed.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.
|
||||
@@ -239,14 +224,12 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
||||
const mentionText = `@${folderMention}`
|
||||
|
||||
if (action === "shiftEnter") {
|
||||
// SHIFT+ENTER on directory: keep @path in prompt, add text attachment, remove @ when sending
|
||||
// Always prefix with ./ for consistency
|
||||
const normalizedFolderPathWithPrefix = normalizedFolderPath.startsWith("./") ? normalizedFolderPath : "./" + normalizedFolderPath
|
||||
addPathOnlyAttachment(normalizedFolderPathWithPrefix)
|
||||
// SHIFT+ENTER on directory: keep @path in prompt (BACKSPACE works), remove @ when sending
|
||||
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 dirLabel =
|
||||
normalizedFolderPath === "." ? "/" : normalizedFolderPath.split("/").pop() || normalizedFolderPath
|
||||
const dirFilename = dirLabel.endsWith("/") ? dirLabel : `${dirLabel}/`
|
||||
|
||||
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
||||
@@ -255,6 +238,20 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
||||
)
|
||||
|
||||
if (!alreadyAttached) {
|
||||
// Remove any parent/child directory attachments that overlap with this one
|
||||
// (e.g., if "docs/" is attached and user selects "docs/screenshots/", replace parent with child)
|
||||
for (const att of existingAttachments) {
|
||||
if (
|
||||
att.source.type === "file" &&
|
||||
att.source.mime === "inode/directory" &&
|
||||
(normalizedFolderPath.startsWith(att.source.path + "/") || // new is child of existing
|
||||
att.source.path.startsWith(normalizedFolderPath + "/")) // new is parent of existing
|
||||
) {
|
||||
// Remove the overlapping directory attachment
|
||||
removeAttachment(options.instanceId(), options.sessionId(), att.id)
|
||||
}
|
||||
}
|
||||
|
||||
const attachment = createFileAttachment(
|
||||
normalizedFolderPath,
|
||||
dirFilename,
|
||||
@@ -278,15 +275,10 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
||||
}
|
||||
|
||||
if (action === "shiftEnter") {
|
||||
// SHIFT+ENTER on file: keep @path in prompt, add text attachment, remove @ when sending
|
||||
// Always prefix with ./ for consistency
|
||||
const normalizedPathWithPrefix = normalizedPath.startsWith("./") ? normalizedPath : "./" + normalizedPath
|
||||
addPathOnlyAttachment(normalizedPathWithPrefix)
|
||||
replaceMentionToken(`@${normalizedPathWithPrefix}`, { trailingSpace: true })
|
||||
// SHIFT+ENTER on file: keep @path in prompt (BACKSPACE works), remove @ when sending
|
||||
replaceMentionToken(`@${normalizedPath}`, { trailingSpace: true })
|
||||
} else {
|
||||
// ENTER/click on file: attach file (existing behavior).
|
||||
// Always prefix with ./ for consistency
|
||||
const normalizedPathWithPrefix = normalizedPath.startsWith("./") ? normalizedPath : "./" + normalizedPath
|
||||
const pathSegments = normalizedPath.split("/")
|
||||
const filename = (() => {
|
||||
const candidate = pathSegments[pathSegments.length - 1] || normalizedPath
|
||||
@@ -295,12 +287,12 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
||||
|
||||
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
||||
const alreadyAttached = existingAttachments.some(
|
||||
(att) => att.source.type === "file" && att.source.path === normalizedPathWithPrefix,
|
||||
(att) => att.source.type === "file" && att.source.path === normalizedPath,
|
||||
)
|
||||
|
||||
if (!alreadyAttached) {
|
||||
const attachment = createFileAttachment(
|
||||
normalizedPathWithPrefix,
|
||||
normalizedPath,
|
||||
filename,
|
||||
"text/plain",
|
||||
undefined,
|
||||
@@ -309,7 +301,7 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
||||
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||
}
|
||||
|
||||
replaceMentionToken(`@${normalizedPathWithPrefix}`, { trailingSpace: true })
|
||||
replaceMentionToken(`@${normalizedPath}`, { trailingSpace: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -342,9 +334,6 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
||||
nextTextarea.setSelectionRange(pos, pos)
|
||||
}
|
||||
}, 0)
|
||||
|
||||
// Clear ignoredAtPositions so typing @ again will work
|
||||
setIgnoredAtPositions(new Set<number>())
|
||||
}
|
||||
}
|
||||
setShowPicker(false)
|
||||
|
||||
@@ -51,7 +51,9 @@ function normalizeQuery(rawQuery: string) {
|
||||
if (!trimmed) {
|
||||
return ""
|
||||
}
|
||||
// Don't normalize "." - it's used for workspace root
|
||||
if (trimmed === "." || trimmed === "./") {
|
||||
return ""
|
||||
}
|
||||
return trimmed.replace(/^(\.\/)+/, "").replace(/^\/+/, "")
|
||||
}
|
||||
|
||||
@@ -348,22 +350,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
return items
|
||||
}
|
||||
|
||||
// Add root directory as first item only when query is EXACTLY "." or "./" (not "./docs/")
|
||||
const isExactRootQuery = props.searchQuery === "." || props.searchQuery === "./"
|
||||
if (mode() === "mention" && isExactRootQuery) {
|
||||
const rootFile: FileItem = {
|
||||
path: ".",
|
||||
relativePath: ".",
|
||||
isDirectory: true,
|
||||
isGitFile: false,
|
||||
}
|
||||
items.push({ type: "file", file: rootFile })
|
||||
}
|
||||
|
||||
// Don't show agents for exact root path queries
|
||||
if (!isExactRootQuery) {
|
||||
filteredAgents().forEach((agent) => items.push({ type: "agent", agent }))
|
||||
}
|
||||
filteredAgents().forEach((agent) => items.push({ type: "agent", agent }))
|
||||
files().forEach((file) => items.push({ type: "file", file }))
|
||||
return items
|
||||
}
|
||||
@@ -487,7 +474,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
</For>
|
||||
</Show>
|
||||
|
||||
<Show when={mode() === "mention" && agentCount() > 0 && !(props.searchQuery === "." || props.searchQuery === "./")}>
|
||||
<Show when={mode() === "mention" && agentCount() > 0}>
|
||||
<div class="dropdown-section-header">
|
||||
{t("unifiedPicker.sections.agents")}
|
||||
</div>
|
||||
@@ -542,39 +529,10 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
</For>
|
||||
</Show>
|
||||
|
||||
<Show when={mode() === "mention" && (fileCount() > 0 || props.searchQuery === "." || props.searchQuery === "./")}>
|
||||
<Show when={mode() === "mention" && fileCount() > 0}>
|
||||
<div class="dropdown-section-header">
|
||||
{t("unifiedPicker.sections.files")}
|
||||
</div>
|
||||
<Show when={props.searchQuery === "." || props.searchQuery === "./"}>
|
||||
<div
|
||||
class={`dropdown-item py-1.5 ${
|
||||
selectedIndex() === 0 ? "dropdown-item-highlight" : ""
|
||||
}`}
|
||||
data-picker-selected={selectedIndex() === 0}
|
||||
onClick={() => {
|
||||
const rootFile: FileItem = {
|
||||
path: ".",
|
||||
relativePath: ".",
|
||||
isDirectory: true,
|
||||
isGitFile: false,
|
||||
}
|
||||
props.onSelect({ type: "file", file: rootFile }, "click")
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<svg class="dropdown-icon h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-mono">. {t("unifiedPicker.sections.workspaceRoot")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<For each={files()}>
|
||||
{(file) => {
|
||||
const itemIndex = allItems().findIndex(
|
||||
|
||||
@@ -158,7 +158,6 @@ export const commandMessages = {
|
||||
"unifiedPicker.sections.commands": "COMMANDS",
|
||||
"unifiedPicker.sections.agents": "AGENTS",
|
||||
"unifiedPicker.sections.files": "FILES",
|
||||
"unifiedPicker.sections.workspaceRoot": "WORKSPACE ROOT",
|
||||
"unifiedPicker.badge.subagent": "subagent",
|
||||
"unifiedPicker.footer.navigate": "navigate",
|
||||
"unifiedPicker.footer.select": "select",
|
||||
|
||||
@@ -5,49 +5,90 @@ export function resolvePastedPlaceholders(prompt: string, attachments: Attachmen
|
||||
return prompt
|
||||
}
|
||||
|
||||
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 is Attachment & { source: FileSource } => a.source.type === "file")
|
||||
.map((a) => a.source.path),
|
||||
)
|
||||
|
||||
const pathAttachments = new Set(
|
||||
attachments
|
||||
.filter((a) => a.source.type === "text" && typeof a.display === "string" && a.display.startsWith("path:"))
|
||||
.map((a) => (a.source as { value: string }).value),
|
||||
)
|
||||
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
|
||||
|
||||
let result = prompt
|
||||
// Root-level files (no `/`) still commonly have an extension.
|
||||
const ext = value.split(".").pop()?.toLowerCase()
|
||||
if (!ext || ext === value.toLowerCase()) return false
|
||||
|
||||
// Step 1: Handle root paths FIRST using unique placeholders
|
||||
// Replace longer pattern first to avoid partial match issues
|
||||
result = result.replace(/@(\.\/)/g, "___ROOT___")
|
||||
result = result.replace(/@(\.)(?!\.)/g, "___ROOT_NOSLASH___")
|
||||
// Note: The regex @(\.)(?!\.) means @. NOT followed by another .
|
||||
|
||||
// Step 2: Build set of non-root paths
|
||||
const allPaths = new Set<string>()
|
||||
for (const p of fileAttachments) {
|
||||
if (p && p !== "." && p !== "./") allPaths.add(p)
|
||||
}
|
||||
for (const p of pathAttachments) {
|
||||
if (p && p !== "." && p !== "./") allPaths.add(p)
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Step 3: Replace @path with ./path for non-root paths
|
||||
for (const path of allPaths) {
|
||||
if (!path) continue
|
||||
const withoutPrefix = path.startsWith("./") ? path.slice(2) : path
|
||||
const withPrefix = path.startsWith("./") ? path : "./" + path
|
||||
result = result.replace("@" + withoutPrefix, withPrefix)
|
||||
result = result.replace("@" + withoutPrefix + "/", withPrefix + "/")
|
||||
const stripTrailingPunctuation = (value: string) => {
|
||||
const match = value.match(/^(.*?)([)\]}.,!?:;]+)?$/)
|
||||
if (!match) return { core: value, trailing: "" }
|
||||
return { core: match[1] ?? value, trailing: match[2] ?? "" }
|
||||
}
|
||||
|
||||
// Step 4: Convert placeholders back to ./
|
||||
result = result.replace("___ROOT___", "./")
|
||||
result = result.replace("___ROOT_NOSLASH___", "./")
|
||||
let result = prompt.replace(/(^|[\s([{"'`])@([^\s@]+)/g, (full, prefix, rawToken) => {
|
||||
const { core, trailing } = stripTrailingPunctuation(String(rawToken))
|
||||
if (!core) return full
|
||||
|
||||
// Step 5: Resolve [pasted #N] placeholders
|
||||
// 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}`
|
||||
}
|
||||
|
||||
// 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
|
||||
if (!result.includes("[pasted #")) {
|
||||
return result
|
||||
}
|
||||
@@ -62,7 +103,7 @@ export function resolvePastedPlaceholders(prompt: string, attachments: Attachmen
|
||||
const source = attachment?.source
|
||||
if (!source || source.type !== "text") continue
|
||||
const display = attachment?.display
|
||||
const value = (source as { value?: string }).value
|
||||
const value = source.value
|
||||
if (typeof display !== "string" || typeof value !== "string") continue
|
||||
const match = display.match(/pasted #(\d+)/)
|
||||
if (!match) continue
|
||||
|
||||
@@ -140,11 +140,8 @@ async function sendMessage(
|
||||
const display: string | undefined = att.display
|
||||
const value: unknown = source.value
|
||||
const isPastedPlaceholder = typeof display === "string" && /^pasted #\d+/.test(display)
|
||||
const isPathPlaceholder = typeof display === "string" && /^path:/.test(display)
|
||||
|
||||
// Skip path: attachments from being sent as separate parts (content is already in prompt)
|
||||
// Skip pasted placeholders too (already resolved in prompt)
|
||||
if (isPastedPlaceholder || isPathPlaceholder || typeof value !== "string") {
|
||||
if (isPastedPlaceholder || typeof value !== "string") {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user