Compare commits

..

6 Commits

Author SHA1 Message Date
Shantur Rathore
53f1dd4150 Merge pull request #171 from VooDisss/codenomad/issue-136
fix(ui): improve picker deletion, ESC cancel, and SHIFT+ENTER path handling
2026-02-16 08:59:17 +00:00
VooDisss
b7f638f07d fix(i18n): add workspace root translation key 2026-02-16 05:21:22 +02:00
VooDisss
32113ea100 fix(ui): resolve root path @. and @./ correctly 2026-02-16 05:03:27 +02:00
VooDisss
b31135f622 fix(ui): fix ./ path prefix for SHIFT+ENTER 2026-02-16 04:29:24 +02:00
VooDisss
f58267dd30 fix(ui): always strip @ for SHIFT+ENTER paths regardless of file attachment 2026-02-16 01:23:24 +02:00
VooDisss
95c747923c fix(ui): improve picker actions, directory navigation, @ handling, and message display 2026-02-16 01:11:53 +02:00
5 changed files with 124 additions and 108 deletions

View File

@@ -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 } from "../../types/attachment"
import { addAttachment, getAttachments, removeAttachment } from "../../stores/attachments"
import { createAgentAttachment, createFileAttachment, createTextAttachment } from "../../types/attachment"
import { addAttachment, getAttachments } from "../../stores/attachments"
import type { PickerMode } from "./types"
import type { PickerSelectAction } from "../unified-picker"
@@ -204,15 +204,30 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
}
const folderMention =
relativePath === "." || relativePath === ""
? "/"
: relativePath.replace(/\/+$/, "") + "/"
relativePath === "." || relativePath === "" || relativePath === "./"
? "./"
: (relativePath.startsWith("./") ? relativePath.replace(/\/+$/, "") + "/" : relativePath.replace(/^\.\//, "").replace(/\/+$/, "") + "/")
const normalizedFolderPath = (() => {
const trimmed = relativePath.replace(/\/+$/, "")
return trimmed.length > 0 ? trimmed : "."
// If it's root "./", just return "./"
if (trimmed === "" || trimmed === ".") return "./"
// Otherwise remove any leading ./ and add ./ prefix
return "./" + trimmed.replace(/^\.\//, "")
})()
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.
@@ -224,12 +239,14 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
const mentionText = `@${folderMention}`
if (action === "shiftEnter") {
// SHIFT+ENTER on directory: keep @path in prompt (BACKSPACE works), remove @ when sending
// 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)
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())
@@ -238,20 +255,6 @@ 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,
@@ -275,10 +278,15 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
}
if (action === "shiftEnter") {
// SHIFT+ENTER on file: keep @path in prompt (BACKSPACE works), remove @ when sending
replaceMentionToken(`@${normalizedPath}`, { trailingSpace: true })
// 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 })
} 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
@@ -287,12 +295,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 === normalizedPath,
(att) => att.source.type === "file" && att.source.path === normalizedPathWithPrefix,
)
if (!alreadyAttached) {
const attachment = createFileAttachment(
normalizedPath,
normalizedPathWithPrefix,
filename,
"text/plain",
undefined,
@@ -301,7 +309,7 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
addAttachment(options.instanceId(), options.sessionId(), attachment)
}
replaceMentionToken(`@${normalizedPath}`, { trailingSpace: true })
replaceMentionToken(`@${normalizedPathWithPrefix}`, { trailingSpace: true })
}
}
}
@@ -334,6 +342,9 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
nextTextarea.setSelectionRange(pos, pos)
}
}, 0)
// Clear ignoredAtPositions so typing @ again will work
setIgnoredAtPositions(new Set<number>())
}
}
setShowPicker(false)

View File

@@ -51,9 +51,7 @@ function normalizeQuery(rawQuery: string) {
if (!trimmed) {
return ""
}
if (trimmed === "." || trimmed === "./") {
return ""
}
// Don't normalize "." - it's used for workspace root
return trimmed.replace(/^(\.\/)+/, "").replace(/^\/+/, "")
}
@@ -350,7 +348,22 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
return items
}
filteredAgents().forEach((agent) => items.push({ type: "agent", agent }))
// 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 }))
}
files().forEach((file) => items.push({ type: "file", file }))
return items
}
@@ -474,7 +487,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
</For>
</Show>
<Show when={mode() === "mention" && agentCount() > 0}>
<Show when={mode() === "mention" && agentCount() > 0 && !(props.searchQuery === "." || props.searchQuery === "./")}>
<div class="dropdown-section-header">
{t("unifiedPicker.sections.agents")}
</div>
@@ -529,10 +542,39 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
</For>
</Show>
<Show when={mode() === "mention" && fileCount() > 0}>
<Show when={mode() === "mention" && (fileCount() > 0 || props.searchQuery === "." || props.searchQuery === "./")}>
<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(

View File

@@ -158,6 +158,7 @@ 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",

View File

@@ -5,90 +5,49 @@ export function resolvePastedPlaceholders(prompt: string, attachments: Attachmen
return prompt
}
// 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(
const fileAttachments = new Set(
attachments
.filter((a): a is Attachment & { source: FileSource } => a.source.type === "file")
.map((a) => a.source.path),
)
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
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),
)
// Root-level files (no `/`) still commonly have an extension.
const ext = value.split(".").pop()?.toLowerCase()
if (!ext || ext === value.toLowerCase()) return false
let result = prompt
// 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 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)
}
const stripTrailingPunctuation = (value: string) => {
const match = value.match(/^(.*?)([)\]}.,!?:;]+)?$/)
if (!match) return { core: value, trailing: "" }
return { core: match[1] ?? value, trailing: match[2] ?? "" }
// 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 + "/")
}
let result = prompt.replace(/(^|[\s([{"'`])@([^\s@]+)/g, (full, prefix, rawToken) => {
const { core, trailing } = stripTrailingPunctuation(String(rawToken))
if (!core) return full
// Step 4: Convert placeholders back to ./
result = result.replace("___ROOT___", "./")
result = result.replace("___ROOT_NOSLASH___", "./")
// 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
// Step 5: Resolve [pasted #N] placeholders
if (!result.includes("[pasted #")) {
return result
}
@@ -103,7 +62,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.value
const value = (source as { value?: string }).value
if (typeof display !== "string" || typeof value !== "string") continue
const match = display.match(/pasted #(\d+)/)
if (!match) continue

View File

@@ -140,8 +140,11 @@ 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)
if (isPastedPlaceholder || typeof value !== "string") {
// 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") {
continue
}