Compare commits

...

11 Commits

Author SHA1 Message Date
Shantur Rathore
e16c5752ed Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-02-16 09:01:25 +00:00
Shantur Rathore
375f92410e Merge pull request #169 from NeuralNomadsAI/codenomad/issue-136
feat(ui): unify picker Tab/Enter/Shift+Enter and allow directory attachments
2026-02-16 09:00:22 +00:00
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
Shantur Rathore
eb6701185b Min version 0.11.1 2026-02-15 23:36:32 +00:00
Shantur Rathore
d948ad8e35 Bump version to 0.11.1 2026-02-15 23:34:26 +00: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
Shantur Rathore
1ef01da019 feat(ui): improve picker actions and directory attach 2026-02-13 22:52:42 +00:00
16 changed files with 372 additions and 115 deletions

12
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.10.3", "version": "0.11.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.10.3", "version": "0.11.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
@@ -11985,7 +11985,7 @@
}, },
"packages/electron-app": { "packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.10.3", "version": "0.11.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codenomad/ui": "file:../ui", "@codenomad/ui": "file:../ui",
@@ -12021,7 +12021,7 @@
}, },
"packages/server": { "packages/server": {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.10.3", "version": "0.11.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
@@ -12062,7 +12062,7 @@
}, },
"packages/tauri-app": { "packages/tauri-app": {
"name": "@codenomad/tauri-app", "name": "@codenomad/tauri-app",
"version": "0.10.3", "version": "0.11.1",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.9.4" "@tauri-apps/cli": "^2.9.4"
@@ -12070,7 +12070,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.10.3", "version": "0.11.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@git-diff-view/solid": "^0.0.8", "@git-diff-view/solid": "^0.0.8",

View File

@@ -1,6 +1,6 @@
{ {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.10.3", "version": "0.11.1",
"private": true, "private": true,
"description": "CodeNomad monorepo workspace", "description": "CodeNomad monorepo workspace",
"license": "MIT", "license": "MIT",

View File

@@ -1,4 +1,4 @@
{ {
"minServerVersion": "0.10.3", "minServerVersion": "0.11.1",
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest" "latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.10.3", "version": "0.11.1",
"description": "CodeNomad - AI coding assistant", "description": "CodeNomad - AI coding assistant",
"license": "MIT", "license": "MIT",
"author": { "author": {

View File

@@ -1,12 +1,12 @@
{ {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.10.3", "version": "0.11.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.10.3", "version": "0.11.1",
"dependencies": { "dependencies": {
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0", "@fastify/reply-from": "^9.8.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.10.3", "version": "0.11.1",
"description": "CodeNomad Server", "description": "CodeNomad Server",
"license": "MIT", "license": "MIT",
"author": { "author": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@codenomad/tauri-app", "name": "@codenomad/tauri-app",
"version": "0.10.3", "version": "0.11.1",
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.10.3", "version": "0.11.1",
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",

View File

@@ -1,5 +1,5 @@
import { For, Show, createSignal } from "solid-js" import { For, Show, createSignal } from "solid-js"
import { Copy, Split, Trash2, Undo } from "lucide-solid" import { Copy, ExternalLink, Split, Trash2, Undo } from "lucide-solid"
import type { MessageInfo, ClientPart } from "../types/message" import type { MessageInfo, ClientPart } from "../types/message"
import { partHasRenderableText } from "../types/message" import { partHasRenderableText } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types" import type { MessageRecord } from "../stores/message-v2/types"
@@ -8,6 +8,7 @@ import { copyToClipboard } from "../lib/clipboard"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import { showAlertDialog } from "../stores/alerts" import { showAlertDialog } from "../stores/alerts"
import { deleteMessagePart } from "../stores/session-actions" import { deleteMessagePart } from "../stores/session-actions"
import { isTauriHost } from "../lib/runtime-env"
interface MessageItemProps { interface MessageItemProps {
record: MessageRecord record: MessageRecord
@@ -45,6 +46,15 @@ export default function MessageItem(props: MessageItemProps) {
const messageParts = () => props.parts const messageParts = () => props.parts
// User messages can temporarily include synthetic helper parts (e.g. tool traces / file reads).
// We only want to display the primary prompt text for the user message; other synthetic text
// parts should be hidden.
const primaryUserTextPartId = () => {
if (!isUser()) return null
const firstText = messageParts().find((part) => part?.type === "text") as { id?: string } | undefined
return typeof firstText?.id === "string" ? firstText.id : null
}
const fileAttachments = () => const fileAttachments = () =>
messageParts().filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string") messageParts().filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")
@@ -96,7 +106,8 @@ export default function MessageItem(props: MessageItemProps) {
} }
if (url.startsWith("file://")) { if (url.startsWith("file://")) {
window.open(url, "_blank", "noopener") // Local filesystem URLs are not reliably downloadable from the message stream.
// We hide the download action for these chips.
return return
} }
@@ -373,6 +384,7 @@ export default function MessageItem(props: MessageItemProps) {
messageType={props.record.role} messageType={props.record.role}
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
primaryUserTextPartId={primaryUserTextPartId()}
onRendered={props.onContentRendered} onRendered={props.onContentRendered}
/> />
)} )}
@@ -399,17 +411,20 @@ export default function MessageItem(props: MessageItemProps) {
<img src={attachment.url} alt={name} class="h-5 w-5 rounded object-cover" /> <img src={attachment.url} alt={name} class="h-5 w-5 rounded object-cover" />
</Show> </Show>
<span class="truncate max-w-[180px]">{name}</span> <span class="truncate max-w-[180px]">{name}</span>
<button <Show when={!attachment.url?.startsWith("file://")}>
type="button" <button
onClick={() => void handleAttachmentDownload(attachment)} type="button"
class="attachment-download" onClick={() => void handleAttachmentDownload(attachment)}
aria-label={t("messageItem.attachment.downloadAriaLabel", { name })} class="attachment-download"
> aria-label={t("messageItem.attachment.downloadAriaLabel", { name })}
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"> title={t("messageItem.attachment.downloadAriaLabel", { name })}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" /> >
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" /> <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
</svg> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
</button> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
</svg>
</button>
</Show>
<button <button
type="button" type="button"

View File

@@ -13,9 +13,12 @@ interface MessagePartProps {
messageType?: "user" | "assistant" messageType?: "user" | "assistant"
instanceId: string instanceId: string
sessionId: string sessionId: string
// For user messages, keep the primary prompt text visible even when synthetic (optimistic).
// Other synthetic text parts (tool traces, read outputs, etc.) should be hidden.
primaryUserTextPartId?: string | null
onRendered?: () => void onRendered?: () => void
} }
export default function MessagePart(props: MessagePartProps) { export default function MessagePart(props: MessagePartProps) {
const { isDark } = useTheme() const { isDark } = useTheme()
const { preferences } = useConfig() const { preferences } = useConfig()
@@ -28,8 +31,19 @@ interface MessagePartProps {
const shouldHideTextPart = () => { const shouldHideTextPart = () => {
const part = props.part const part = props.part
if (!part || part.type !== "text") return false if (!part || part.type !== "text") return false
// Keep optimistic user prompts visible; hide synthetic assistant text.
return Boolean((part as any).synthetic) && props.messageType !== "user" const isSynthetic = Boolean((part as any).synthetic)
if (!isSynthetic) return false
// Keep optimistic user prompts visible; hide other synthetic user helper parts.
if (props.messageType === "user") {
const primaryId = props.primaryUserTextPartId
if (!primaryId) return false
return part.id !== primaryId
}
// Hide synthetic assistant text.
return true
} }

View File

@@ -183,9 +183,25 @@ export function usePromptKeyDown(options: UsePromptKeyDownOptions) {
if (isDeletingFromEnd || isDeletingFromStart || isSelected) { if (isDeletingFromEnd || isDeletingFromStart || isSelected) {
const currentAttachments = options.getAttachments() const currentAttachments = options.getAttachments()
const attachment = currentAttachments.find( const attachment = currentAttachments.find((a) => {
(a) => (a.source.type === "file" || a.source.type === "agent") && a.filename === name, if (a.source.type === "agent") {
) return a.filename === name
}
if (a.source.type === "file") {
// Match either by filename (basename) or by path (for full paths like @docs/file.txt)
return (
a.filename === name ||
a.source.path === name ||
a.source.path.endsWith("/" + name) ||
a.source.path === name.replace(/\/$/, "")
)
}
if (a.source.type === "text") {
// For text attachments (path-only mentions), match by value
return a.source.value === name || a.source.value.endsWith("/" + name)
}
return false
})
if (attachment) { if (attachment) {
e.preventDefault() e.preventDefault()
@@ -205,6 +221,14 @@ export function usePromptKeyDown(options: UsePromptKeyDownOptions) {
textarea.setSelectionRange(mentionStart, mentionStart) textarea.setSelectionRange(mentionStart, mentionStart)
}, 0) }, 0)
// Check if there are any @ remaining in the text - if not, close the picker
if (!newText.includes("@") && options.isPickerOpen()) {
options.closePicker()
// Clear ignoredAtPositions since we deleted the entire @mention
// This ensures typing @ again will open the picker
options.setIgnoredAtPositions(new Set())
}
return return
} }
} }

View File

@@ -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,152 @@ 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 === "./"
? "./"
: (relativePath.startsWith("./") ? relativePath.replace(/\/+$/, "") + "/" : relativePath.replace(/^\.\//, "").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(/^\.\//, "")
})()
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: 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 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: 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
return candidate === "." ? "/" : candidate
})()
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
const alreadyAttached = existingAttachments.some(
(att) => att.source.type === "file" && att.source.path === normalizedPathWithPrefix,
)
if (!alreadyAttached) {
const attachment = createFileAttachment(
normalizedPathWithPrefix,
filename,
"text/plain",
undefined,
options.instanceFolder(),
)
addAttachment(options.instanceId(), options.sessionId(), attachment)
}
replaceMentionToken(`@${normalizedPathWithPrefix}`, { trailingSpace: true })
}
}
} }
setShowPicker(false) setShowPicker(false)
@@ -245,6 +324,28 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
const pos = atPosition() const pos = atPosition()
if (pickerMode() === "mention" && pos !== null) { if (pickerMode() === "mention" && pos !== null) {
setIgnoredAtPositions((prev) => new Set(prev).add(pos)) setIgnoredAtPositions((prev) => new Set(prev).add(pos))
// Remove the partial @mention text from the textarea when ESC is pressed
const textarea = options.getTextarea()
if (textarea) {
const currentPrompt = options.prompt()
const cursorPos = textarea.selectionStart
// Remove text from @ position to cursor position
const before = currentPrompt.substring(0, pos)
const after = currentPrompt.substring(cursorPos)
options.setPrompt(before + after)
// Restore cursor position to where @ was
setTimeout(() => {
const nextTextarea = options.getTextarea()
if (nextTextarea) {
nextTextarea.setSelectionRange(pos, pos)
}
}, 0)
// Clear ignoredAtPositions so typing @ again will work
setIgnoredAtPositions(new Set<number>())
}
} }
setShowPicker(false) setShowPicker(false)
setAtPosition(null) setAtPosition(null)

View File

@@ -51,9 +51,7 @@ function normalizeQuery(rawQuery: string) {
if (!trimmed) { if (!trimmed) {
return "" return ""
} }
if (trimmed === "." || trimmed === "./") { // Don't normalize "." - it's used for workspace root
return ""
}
return trimmed.replace(/^(\.\/)+/, "").replace(/^\/+/, "") return trimmed.replace(/^(\.\/)+/, "").replace(/^\/+/, "")
} }
@@ -74,10 +72,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[]
@@ -266,6 +266,13 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
const workspaceChanged = lastWorkspaceId !== props.workspaceId const workspaceChanged = lastWorkspaceId !== props.workspaceId
const queryChanged = lastQuery !== props.searchQuery const queryChanged = lastQuery !== props.searchQuery
if (queryChanged) {
// Reset selectedIndex to 0 when query changes to avoid ghost state
// This ensures proper highlighting when navigating back to root or changing queries
setSelectedIndex(0)
resetScrollPosition()
}
if (!isInitialized() || workspaceChanged || queryChanged) { if (!isInitialized() || workspaceChanged || queryChanged) {
setIsInitialized(true) setIsInitialized(true)
lastWorkspaceId = props.workspaceId lastWorkspaceId = props.workspaceId
@@ -341,7 +348,22 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
return items 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 })) files().forEach((file) => items.push({ type: "file", file }))
return items return items
} }
@@ -356,7 +378,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 +401,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 +466,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">
@@ -464,7 +487,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
</For> </For>
</Show> </Show>
<Show when={mode() === "mention" && agentCount() > 0}> <Show when={mode() === "mention" && agentCount() > 0 && !(props.searchQuery === "." || props.searchQuery === "./")}>
<div class="dropdown-section-header"> <div class="dropdown-section-header">
{t("unifiedPicker.sections.agents")} {t("unifiedPicker.sections.agents")}
</div> </div>
@@ -479,7 +502,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
@@ -519,10 +542,39 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
</For> </For>
</Show> </Show>
<Show when={mode() === "mention" && fileCount() > 0}> <Show when={mode() === "mention" && (fileCount() > 0 || props.searchQuery === "." || props.searchQuery === "./")}>
<div class="dropdown-section-header"> <div class="dropdown-section-header">
{t("unifiedPicker.sections.files")} {t("unifiedPicker.sections.files")}
</div> </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()}> <For each={files()}>
{(file) => { {(file) => {
const itemIndex = allItems().findIndex( const itemIndex = allItems().findIndex(
@@ -535,7 +587,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

View File

@@ -164,6 +164,7 @@ export const commandMessages = {
"unifiedPicker.sections.commands": "COMMANDS", "unifiedPicker.sections.commands": "COMMANDS",
"unifiedPicker.sections.agents": "AGENTS", "unifiedPicker.sections.agents": "AGENTS",
"unifiedPicker.sections.files": "FILES", "unifiedPicker.sections.files": "FILES",
"unifiedPicker.sections.workspaceRoot": "WORKSPACE ROOT",
"unifiedPicker.badge.subagent": "subagent", "unifiedPicker.badge.subagent": "subagent",
"unifiedPicker.footer.navigate": "navigate", "unifiedPicker.footer.navigate": "navigate",
"unifiedPicker.footer.select": "select", "unifiedPicker.footer.select": "select",

View File

@@ -1,12 +1,59 @@
import type { Attachment } from "../types/attachment" import type { Attachment, FileSource } from "../types/attachment"
export function resolvePastedPlaceholders(prompt: string, attachments: Attachment[] = []): string { export function resolvePastedPlaceholders(prompt: string, attachments: Attachment[] = []): string {
if (!prompt || !prompt.includes("[pasted #")) { if (!prompt) {
return prompt return prompt
} }
const fileAttachments = 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),
)
let result = prompt
// 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)
}
// 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 + "/")
}
// Step 4: Convert placeholders back to ./
result = result.replace("___ROOT___", "./")
result = result.replace("___ROOT_NOSLASH___", "./")
// Step 5: Resolve [pasted #N] placeholders
if (!result.includes("[pasted #")) {
return result
}
if (!attachments || attachments.length === 0) { if (!attachments || attachments.length === 0) {
return prompt return result
} }
const lookup = new Map<string, string>() const lookup = new Map<string, string>()
@@ -15,7 +62,7 @@ export function resolvePastedPlaceholders(prompt: string, attachments: Attachmen
const source = attachment?.source const source = attachment?.source
if (!source || source.type !== "text") continue if (!source || source.type !== "text") continue
const display = attachment?.display const display = attachment?.display
const value = source.value const value = (source as { value?: string }).value
if (typeof display !== "string" || typeof value !== "string") continue if (typeof display !== "string" || typeof value !== "string") continue
const match = display.match(/pasted #(\d+)/) const match = display.match(/pasted #(\d+)/)
if (!match) continue if (!match) continue
@@ -26,10 +73,10 @@ export function resolvePastedPlaceholders(prompt: string, attachments: Attachmen
} }
if (lookup.size === 0) { if (lookup.size === 0) {
return prompt return result
} }
return prompt.replace(/\[pasted #(\d+)\]/g, (fullMatch) => { return result.replace(/\[pasted #(\d+)\]/g, (fullMatch) => {
const replacement = lookup.get(fullMatch) const replacement = lookup.get(fullMatch)
return typeof replacement === "string" ? replacement : fullMatch return typeof replacement === "string" ? replacement : fullMatch
}) })

View File

@@ -140,8 +140,11 @@ async function sendMessage(
const display: string | undefined = att.display const display: string | undefined = att.display
const value: unknown = source.value const value: unknown = source.value
const isPastedPlaceholder = typeof display === "string" && /^pasted #\d+/.test(display) 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 continue
} }