Compare commits

..

5 Commits

Author SHA1 Message Date
Shantur Rathore
2d1f702597 fix(ui): hide download action for local file attachments
Avoid showing a misleading download icon for file:// attachments in the message stream.
2026-02-14 10:52:15 +00:00
Shantur Rathore
2d93d82611 fix(ui): hide synthetic helper text in user messages
Avoid rendering tool trace/read output parts on user messages while keeping the primary prompt text visible.
2026-02-14 09:53:40 +00:00
Shantur Rathore
4e0f064c3a fix(ui): avoid stripping non-path @mentions 2026-02-14 08:18:35 +00:00
VooDisss
e4e10cc630 fix(ui): replace parent directory when selecting child directory 2026-02-14 07:39:39 +02:00
VooDisss
8f6d4c8b09 fix(ui): improve picker deletion, ESC cancel, and SHIFT+ENTER path handling 2026-02-14 06:53:31 +02:00
5 changed files with 108 additions and 124 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, 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)

View File

@@ -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(

View File

@@ -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",

View File

@@ -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

View File

@@ -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
}