Compare commits
14 Commits
v0.10.3-de
...
codenomad/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53f1dd4150 | ||
|
|
b7f638f07d | ||
|
|
32113ea100 | ||
|
|
b31135f622 | ||
|
|
f58267dd30 | ||
|
|
95c747923c | ||
|
|
1ef01da019 | ||
|
|
e9f281a69d | ||
|
|
36baac06b8 | ||
|
|
3678214e69 | ||
|
|
338e3d9d38 | ||
|
|
0c0f397db0 | ||
|
|
da70cc9944 | ||
|
|
ba418a8518 |
3
.github/workflows/dev-release.yml
vendored
3
.github/workflows/dev-release.yml
vendored
@@ -34,7 +34,8 @@ jobs:
|
||||
uses: ./.github/workflows/reusable-release.yml
|
||||
with:
|
||||
version_suffix: ${{ needs.prepare.outputs.version_suffix }}
|
||||
dist_tag: dev
|
||||
npm_package_name: "@neuralnomads/codenomad-dev"
|
||||
dist_tag: latest
|
||||
prerelease: true
|
||||
release_ui: false
|
||||
secrets: inherit
|
||||
|
||||
20
.github/workflows/manual-npm-publish.yml
vendored
20
.github/workflows/manual-npm-publish.yml
vendored
@@ -12,6 +12,11 @@ on:
|
||||
required: false
|
||||
default: dev
|
||||
type: string
|
||||
package_name:
|
||||
description: "Package name to publish (e.g. @neuralnomads/codenomad-dev)"
|
||||
required: false
|
||||
default: "@neuralnomads/codenomad"
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
@@ -21,6 +26,10 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: dev
|
||||
package_name:
|
||||
required: false
|
||||
type: string
|
||||
default: "@neuralnomads/codenomad"
|
||||
secrets:
|
||||
NPM_TOKEN:
|
||||
required: false
|
||||
@@ -54,7 +63,7 @@ jobs:
|
||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||
|
||||
- name: Build server package (includes UI bundling)
|
||||
run: npm run build --workspace @neuralnomads/codenomad
|
||||
run: npm run build --workspace packages/server
|
||||
|
||||
- name: Set publish metadata
|
||||
shell: bash
|
||||
@@ -65,10 +74,17 @@ jobs:
|
||||
fi
|
||||
echo "VERSION=$VERSION_INPUT" >> "$GITHUB_ENV"
|
||||
echo "DIST_TAG=${{ inputs.dist_tag || 'dev' }}" >> "$GITHUB_ENV"
|
||||
echo "PACKAGE_NAME=${{ inputs.package_name }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Bump package version for publish
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Set server package name for publish
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node -e "const fs=require('fs'); const path=require('path'); const p=path.join('packages','server','package.json'); const j=JSON.parse(fs.readFileSync(p,'utf8')); j.name=process.env.PACKAGE_NAME || j.name; fs.writeFileSync(p, JSON.stringify(j, null, 2)+'\n'); console.log('Publishing as', j.name);"
|
||||
|
||||
- name: Publish server package with provenance
|
||||
env:
|
||||
# Optional: when present, npm will use token auth.
|
||||
@@ -85,4 +101,4 @@ jobs:
|
||||
else
|
||||
echo "Using NPM_TOKEN authentication"
|
||||
fi
|
||||
npm publish --workspace @neuralnomads/codenomad --access public --tag ${DIST_TAG} --provenance
|
||||
npm publish --workspace packages/server --access public --tag ${DIST_TAG} --provenance
|
||||
|
||||
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
@@ -14,4 +14,5 @@ jobs:
|
||||
uses: ./.github/workflows/reusable-release.yml
|
||||
with:
|
||||
dist_tag: latest
|
||||
npm_package_name: "@neuralnomads/codenomad"
|
||||
secrets: inherit
|
||||
|
||||
6
.github/workflows/reusable-release.yml
vendored
6
.github/workflows/reusable-release.yml
vendored
@@ -13,6 +13,11 @@ on:
|
||||
required: false
|
||||
default: dev
|
||||
type: string
|
||||
npm_package_name:
|
||||
description: "npm package name to publish (defaults to server package name)"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
prerelease:
|
||||
description: "Create GitHub prerelease"
|
||||
required: false
|
||||
@@ -100,4 +105,5 @@ jobs:
|
||||
with:
|
||||
version: ${{ needs.prepare-release.outputs.version }}
|
||||
dist_tag: ${{ inputs.dist_tag }}
|
||||
package_name: ${{ inputs.npm_package_name }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -47,7 +47,7 @@ npx @neuralnomads/codenomad --launch
|
||||
For dev version
|
||||
|
||||
```bash
|
||||
npx @neuralnomads/codenomad@dev --launch
|
||||
npx @neuralnomads/codenomad-dev --launch
|
||||
```
|
||||
|
||||
Dev builds are published as GitHub pre-releases:
|
||||
|
||||
@@ -3,10 +3,15 @@ import { Component, JSX } from "solid-js"
|
||||
interface HintRowProps {
|
||||
children: JSX.Element
|
||||
class?: string
|
||||
ariaHidden?: boolean
|
||||
}
|
||||
|
||||
const HintRow: Component<HintRowProps> = (props) => {
|
||||
return <span class={`text-xs text-muted ${props.class || ""}`}>{props.children}</span>
|
||||
return (
|
||||
<span aria-hidden={props.ariaHidden} class={`text-xs text-muted ${props.class || ""}`}>
|
||||
{props.children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default HintRow
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Show, type Accessor, type Component } from "solid-js"
|
||||
import type { SessionThread } from "../../../stores/session-state"
|
||||
import type { Session } from "../../../types/session"
|
||||
import type { KeyboardShortcut } from "../../../lib/keyboard-registry"
|
||||
import { keyboardRegistry, type KeyboardShortcut } from "../../../lib/keyboard-registry"
|
||||
import type { DrawerViewState } from "./types"
|
||||
|
||||
import { PlusSquare, Search } from "lucide-solid"
|
||||
@@ -13,7 +13,6 @@ import InfoOutlinedIcon from "@suid/icons-material/InfoOutlined"
|
||||
|
||||
import SessionList from "../../session-list"
|
||||
import KeyboardHint from "../../keyboard-hint"
|
||||
import Kbd from "../../kbd"
|
||||
import WorktreeSelector from "../../worktree-selector"
|
||||
import AgentSelector from "../../agent-selector"
|
||||
import ModelSelector from "../../model-selector"
|
||||
@@ -166,11 +165,17 @@ const SessionSidebar: Component<SessionSidebarProps> = (props) => (
|
||||
|
||||
<ThinkingSelector instanceId={props.instanceId} currentModel={activeSession().model} />
|
||||
|
||||
<div class="session-sidebar-selector-hints" aria-hidden="true">
|
||||
<Kbd shortcut="cmd+shift+a" />
|
||||
<Kbd shortcut="cmd+shift+m" />
|
||||
<Kbd shortcut="cmd+shift+t" />
|
||||
</div>
|
||||
<KeyboardHint
|
||||
class="session-sidebar-selector-hints"
|
||||
ariaHidden={true}
|
||||
shortcuts={[
|
||||
keyboardRegistry.get("open-agent-selector"),
|
||||
keyboardRegistry.get("focus-model"),
|
||||
keyboardRegistry.get("focus-variant"),
|
||||
].filter((shortcut): shortcut is KeyboardShortcut => Boolean(shortcut))}
|
||||
separator=" "
|
||||
showDescription={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, JSX, For } from "solid-js"
|
||||
import useMediaQuery from "@suid/material/useMediaQuery"
|
||||
import { isMac } from "../lib/keyboard-utils"
|
||||
|
||||
interface KbdProps {
|
||||
@@ -27,6 +28,9 @@ const SPECIAL_KEY_LABELS: Record<string, string> = {
|
||||
}
|
||||
|
||||
const Kbd: Component<KbdProps> = (props) => {
|
||||
const desktopQuery = useMediaQuery("(min-width: 1280px)")
|
||||
if (!desktopQuery()) return null
|
||||
|
||||
const parts = () => {
|
||||
if (props.children) return [{ text: props.children, isModifier: false }]
|
||||
if (!props.shortcut) return []
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { Component, For } from "solid-js"
|
||||
import { formatShortcut, isMac } from "../lib/keyboard-utils"
|
||||
import useMediaQuery from "@suid/material/useMediaQuery"
|
||||
import type { KeyboardShortcut } from "../lib/keyboard-registry"
|
||||
import Kbd from "./kbd"
|
||||
import HintRow from "./hint-row"
|
||||
|
||||
const KeyboardHint: Component<{
|
||||
shortcuts: KeyboardShortcut[]
|
||||
separator?: string
|
||||
separator?: string | null
|
||||
showDescription?: boolean
|
||||
class?: string
|
||||
ariaHidden?: boolean
|
||||
}> = (props) => {
|
||||
// Centralize layout gating here so call sites don't need to.
|
||||
// We only show keyboard hint UI on desktop layouts.
|
||||
const desktopQuery = useMediaQuery("(min-width: 1280px)")
|
||||
|
||||
function buildShortcutString(shortcut: KeyboardShortcut): string {
|
||||
const parts: string[] = []
|
||||
|
||||
@@ -26,12 +32,14 @@ const KeyboardHint: Component<{
|
||||
return parts.join("+")
|
||||
}
|
||||
|
||||
if (!desktopQuery()) return null
|
||||
|
||||
return (
|
||||
<HintRow>
|
||||
<HintRow class={props.class} ariaHidden={props.ariaHidden}>
|
||||
<For each={props.shortcuts}>
|
||||
{(shortcut, i) => (
|
||||
<>
|
||||
{i() > 0 && <span class="mx-1">{props.separator || "•"}</span>}
|
||||
{i() > 0 && props.separator !== null && <span class="mx-1">{props.separator ?? "•"}</span>}
|
||||
{props.showDescription !== false && <span class="mr-1">{shortcut.description}</span>}
|
||||
<Kbd shortcut={buildShortcutString(shortcut)} />
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { partHasRenderableText } from "../types/message"
|
||||
import type { MessageRecord } from "../stores/message-v2/types"
|
||||
@@ -8,6 +8,7 @@ import { copyToClipboard } from "../lib/clipboard"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { showAlertDialog } from "../stores/alerts"
|
||||
import { deleteMessagePart } from "../stores/session-actions"
|
||||
import { isTauriHost } from "../lib/runtime-env"
|
||||
|
||||
interface MessageItemProps {
|
||||
record: MessageRecord
|
||||
@@ -45,6 +46,15 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
|
||||
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 = () =>
|
||||
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://")) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -372,6 +383,7 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
messageType={props.record.role}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
primaryUserTextPartId={primaryUserTextPartId()}
|
||||
onRendered={props.onContentRendered}
|
||||
/>
|
||||
)}
|
||||
@@ -398,17 +410,20 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
<img src={attachment.url} alt={name} class="h-5 w-5 rounded object-cover" />
|
||||
</Show>
|
||||
<span class="truncate max-w-[180px]">{name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleAttachmentDownload(attachment)}
|
||||
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">
|
||||
<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>
|
||||
</button>
|
||||
<Show when={!attachment.url?.startsWith("file://")}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleAttachmentDownload(attachment)}
|
||||
class="attachment-download"
|
||||
aria-label={t("messageItem.attachment.downloadAriaLabel", { name })}
|
||||
title={t("messageItem.attachment.downloadAriaLabel", { name })}
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -13,9 +13,12 @@ interface MessagePartProps {
|
||||
messageType?: "user" | "assistant"
|
||||
instanceId: 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
|
||||
}
|
||||
export default function MessagePart(props: MessagePartProps) {
|
||||
export default function MessagePart(props: MessagePartProps) {
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const { preferences } = useConfig()
|
||||
@@ -28,8 +31,19 @@ interface MessagePartProps {
|
||||
const shouldHideTextPart = () => {
|
||||
const part = props.part
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createSignal, Show, onMount, onCleanup, createEffect, on, untrack } from "solid-js"
|
||||
import { createSignal, Show, onMount, onCleanup, createEffect, on } from "solid-js"
|
||||
import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
|
||||
import UnifiedPicker from "./unified-picker"
|
||||
import ExpandButton from "./expand-button"
|
||||
import { getAttachments, clearAttachments, removeAttachment } from "../stores/attachments"
|
||||
import { clearAttachments, removeAttachment } from "../stores/attachments"
|
||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||
import Kbd from "./kbd"
|
||||
import { getActiveInstance } from "../stores/instances"
|
||||
@@ -63,6 +63,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
handleDrop,
|
||||
syncAttachmentCounters,
|
||||
handleExpandTextAttachment,
|
||||
handleRemoveAttachment,
|
||||
} = usePromptAttachments({
|
||||
instanceId: () => props.instanceId,
|
||||
sessionId: () => props.sessionId,
|
||||
@@ -87,6 +88,9 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
if (!attachment) return
|
||||
handleExpandTextAttachment(attachment)
|
||||
},
|
||||
removeAttachment: (attachmentId: string) => {
|
||||
handleRemoveAttachment(attachmentId)
|
||||
},
|
||||
setPromptText: (text: string, opts?: { focus?: boolean }) => {
|
||||
const textarea = textareaRef
|
||||
if (textarea) {
|
||||
@@ -166,10 +170,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
setAtPosition(null)
|
||||
setSearchQuery("")
|
||||
|
||||
const instanceId = props.instanceId
|
||||
const sessionId = props.sessionId
|
||||
const currentAttachments = untrack(() => getAttachments(instanceId, sessionId))
|
||||
syncAttachmentCounters(prompt(), currentAttachments)
|
||||
syncAttachmentCounters(prompt())
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
@@ -238,10 +239,10 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
// Ignore attachments for slash commands, but keep them for next prompt.
|
||||
if (!isKnownSlashCommand) {
|
||||
clearAttachments(props.instanceId, props.sessionId)
|
||||
syncAttachmentCounters("", [])
|
||||
syncAttachmentCounters("")
|
||||
setIgnoredAtPositions(new Set<number>())
|
||||
} else {
|
||||
syncAttachmentCounters("", currentAttachments)
|
||||
syncAttachmentCounters("")
|
||||
setIgnoredAtPositions(new Set<number>())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { Attachment } from "../../types/attachment"
|
||||
|
||||
export function formatPastedPlaceholder(value: string | number) {
|
||||
return `[pasted #${value}]`
|
||||
}
|
||||
@@ -9,27 +7,27 @@ export function formatImagePlaceholder(value: string | number) {
|
||||
}
|
||||
|
||||
export function createPastedPlaceholderRegex() {
|
||||
return /\[pasted #(\d+)\]/g
|
||||
return /\[\s*pasted\s*#\s*(\d+)\s*\]/gi
|
||||
}
|
||||
|
||||
export function createImagePlaceholderRegex() {
|
||||
return /\[Image #(\d+)\]/g
|
||||
return /\[\s*Image\s*#\s*(\d+)\s*\]/gi
|
||||
}
|
||||
|
||||
export function createMentionRegex() {
|
||||
return /@(\S+)/g
|
||||
}
|
||||
|
||||
export const pastedDisplayCounterRegex = /pasted #(\d+)/
|
||||
export const imageDisplayCounterRegex = /Image #(\d+)/
|
||||
export const bracketedImageDisplayCounterRegex = /\[Image #(\d+)\]/
|
||||
export const pastedDisplayCounterRegex = /pasted #(\d+)/i
|
||||
export const imageDisplayCounterRegex = /Image #(\d+)/i
|
||||
export const bracketedImageDisplayCounterRegex = /\[\s*Image\s*#\s*(\d+)\s*\]/i
|
||||
|
||||
export function parseCounter(value: string) {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
return Number.isNaN(parsed) ? null : parsed
|
||||
}
|
||||
|
||||
export function findHighestAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) {
|
||||
export function findHighestAttachmentCounters(currentPrompt: string) {
|
||||
let highestPaste = 0
|
||||
let highestImage = 0
|
||||
|
||||
@@ -40,27 +38,6 @@ export function findHighestAttachmentCounters(currentPrompt: string, sessionAtta
|
||||
}
|
||||
}
|
||||
|
||||
for (const attachment of sessionAttachments) {
|
||||
if (attachment.source.type === "text") {
|
||||
const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex)
|
||||
if (placeholderMatch) {
|
||||
const parsed = parseCounter(placeholderMatch[1])
|
||||
if (parsed !== null) {
|
||||
highestPaste = Math.max(highestPaste, parsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (attachment.source.type === "file" && attachment.mediaType.startsWith("image/")) {
|
||||
const imageMatch = attachment.display.match(imageDisplayCounterRegex)
|
||||
if (imageMatch) {
|
||||
const parsed = parseCounter(imageMatch[1])
|
||||
if (parsed !== null) {
|
||||
highestImage = Math.max(highestImage, parsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const match of currentPrompt.matchAll(createImagePlaceholderRegex())) {
|
||||
const parsed = parseCounter(match[1])
|
||||
if (parsed !== null) {
|
||||
|
||||
@@ -8,6 +8,7 @@ export type PromptInsertMode = "quote" | "code"
|
||||
export interface PromptInputApi {
|
||||
insertSelection(text: string, mode: PromptInsertMode): void
|
||||
expandTextAttachment(attachmentId: string): void
|
||||
removeAttachment(attachmentId: string): void
|
||||
setPromptText(text: string, opts?: { focus?: boolean }): void
|
||||
focus(): void
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSignal, type Accessor } from "solid-js"
|
||||
import { createEffect, createSignal, type Accessor } from "solid-js"
|
||||
import { addAttachment, getAttachments, removeAttachment } from "../../stores/attachments"
|
||||
import { createFileAttachment, createTextAttachment } from "../../types/attachment"
|
||||
import type { Attachment } from "../../types/attachment"
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
findHighestAttachmentCounters,
|
||||
formatImagePlaceholder,
|
||||
formatPastedPlaceholder,
|
||||
imageDisplayCounterRegex,
|
||||
pastedDisplayCounterRegex,
|
||||
} from "./attachmentPlaceholders"
|
||||
|
||||
@@ -23,7 +24,7 @@ type PromptAttachments = {
|
||||
attachments: Accessor<Attachment[]>
|
||||
pasteCount: Accessor<number>
|
||||
imageCount: Accessor<number>
|
||||
syncAttachmentCounters: (promptText: string, sessionAttachments: Attachment[]) => void
|
||||
syncAttachmentCounters: (promptText: string) => void
|
||||
|
||||
handlePaste: (e: ClipboardEvent) => Promise<void>
|
||||
isDragging: Accessor<boolean>
|
||||
@@ -41,45 +42,106 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
|
||||
const [pasteCount, setPasteCount] = createSignal(0)
|
||||
const [imageCount, setImageCount] = createSignal(0)
|
||||
|
||||
function syncAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) {
|
||||
const { highestPaste, highestImage } = findHighestAttachmentCounters(currentPrompt, sessionAttachments)
|
||||
function syncAttachmentCounters(currentPrompt: string) {
|
||||
const { highestPaste, highestImage } = findHighestAttachmentCounters(currentPrompt)
|
||||
setPasteCount(highestPaste)
|
||||
setImageCount(highestImage)
|
||||
}
|
||||
|
||||
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
|
||||
function removeTokenFromPrompt(currentPrompt: string, tokenRegex: RegExp) {
|
||||
const next = currentPrompt.replace(tokenRegex, "")
|
||||
if (next === currentPrompt) return currentPrompt
|
||||
|
||||
return next
|
||||
.replace(/[ \t]{2,}/g, " ")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n[ \t]+/g, "\n")
|
||||
.trim()
|
||||
}
|
||||
|
||||
const createLooseImagePlaceholderRegex = (counter: string | number) =>
|
||||
new RegExp(`\\[\\s*Image\\s*#\\s*${counter}\\s*\\]`, "i")
|
||||
const createLoosePastedPlaceholderRegex = (counter: string | number) =>
|
||||
new RegExp(`\\[\\s*pasted\\s*#\\s*${counter}\\s*\\]`, "i")
|
||||
|
||||
// Keep placeholder-backed attachments in sync with prompt text.
|
||||
// If the placeholder token disappears from the prompt, the attachment should disappear too.
|
||||
createEffect(() => {
|
||||
const currentPrompt = options.prompt()
|
||||
const currentAttachments = attachments()
|
||||
|
||||
const toRemove: string[] = []
|
||||
|
||||
for (const attachment of currentAttachments) {
|
||||
if (attachment.source.type === "text") {
|
||||
const match = attachment.display.match(pastedDisplayCounterRegex)
|
||||
if (!match) continue
|
||||
const counter = match[1]
|
||||
if (!createLoosePastedPlaceholderRegex(counter).test(currentPrompt)) {
|
||||
toRemove.push(attachment.id)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (attachment.source.type === "file" && attachment.mediaType.startsWith("image/")) {
|
||||
const match =
|
||||
attachment.display.match(bracketedImageDisplayCounterRegex) || attachment.display.match(imageDisplayCounterRegex)
|
||||
if (!match) continue
|
||||
const counter = match[1]
|
||||
if (!createLooseImagePlaceholderRegex(counter).test(currentPrompt)) {
|
||||
toRemove.push(attachment.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const attachmentId of toRemove) {
|
||||
removeAttachment(options.instanceId(), options.sessionId(), attachmentId)
|
||||
}
|
||||
})
|
||||
|
||||
function handleRemoveAttachment(attachmentId: string) {
|
||||
const currentAttachments = attachments()
|
||||
const attachment = currentAttachments.find((a) => a.id === attachmentId)
|
||||
|
||||
// Always remove from store.
|
||||
removeAttachment(options.instanceId(), options.sessionId(), attachmentId)
|
||||
|
||||
if (attachment) {
|
||||
const currentPrompt = options.prompt()
|
||||
let newPrompt = currentPrompt
|
||||
if (!attachment) return
|
||||
|
||||
if (attachment.source.type === "file") {
|
||||
if (attachment.mediaType.startsWith("image/")) {
|
||||
const imageMatch = attachment.display.match(bracketedImageDisplayCounterRegex)
|
||||
if (imageMatch) {
|
||||
const placeholder = formatImagePlaceholder(imageMatch[1])
|
||||
newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim()
|
||||
}
|
||||
} else {
|
||||
const filename = attachment.filename
|
||||
newPrompt = currentPrompt.replace(`@${filename}`, "").replace(/\s+/g, " ").trim()
|
||||
const currentPrompt = options.prompt()
|
||||
let nextPrompt = currentPrompt
|
||||
|
||||
if (attachment.source.type === "file") {
|
||||
if (attachment.mediaType.startsWith("image/")) {
|
||||
const imageMatch =
|
||||
attachment.display.match(bracketedImageDisplayCounterRegex) || attachment.display.match(imageDisplayCounterRegex)
|
||||
if (imageMatch) {
|
||||
nextPrompt = removeTokenFromPrompt(currentPrompt, createLooseImagePlaceholderRegex(imageMatch[1]))
|
||||
}
|
||||
} else if (attachment.source.type === "agent") {
|
||||
const agentName = attachment.filename
|
||||
newPrompt = currentPrompt.replace(`@${agentName}`, "").replace(/\s+/g, " ").trim()
|
||||
} else if (attachment.source.type === "text") {
|
||||
const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex)
|
||||
if (placeholderMatch) {
|
||||
const placeholder = formatPastedPlaceholder(placeholderMatch[1])
|
||||
newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim()
|
||||
} else {
|
||||
// For file mentions we insert `@<path>`, but the chip might display `@<filename>`.
|
||||
const candidates = [attachment.source.path, attachment.filename]
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue
|
||||
const mentionRegex = new RegExp(`@${escapeRegExp(candidate)}(?=\\s|$)`, "i")
|
||||
nextPrompt = removeTokenFromPrompt(nextPrompt, mentionRegex)
|
||||
}
|
||||
}
|
||||
} else if (attachment.source.type === "agent") {
|
||||
const agentName = attachment.filename
|
||||
const mentionRegex = new RegExp(`@${escapeRegExp(agentName)}(?=\\s|$)`, "i")
|
||||
nextPrompt = removeTokenFromPrompt(currentPrompt, mentionRegex)
|
||||
} else if (attachment.source.type === "text") {
|
||||
const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex)
|
||||
if (placeholderMatch) {
|
||||
nextPrompt = removeTokenFromPrompt(currentPrompt, createLoosePastedPlaceholderRegex(placeholderMatch[1]))
|
||||
}
|
||||
}
|
||||
|
||||
options.setPrompt(newPrompt)
|
||||
if (nextPrompt !== currentPrompt) {
|
||||
options.setPrompt(nextPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,13 +205,32 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
|
||||
const blob = item.getAsFile()
|
||||
if (!blob) continue
|
||||
|
||||
const count = imageCount() + 1
|
||||
const { highestImage } = findHighestAttachmentCounters(options.prompt())
|
||||
const count = highestImage + 1
|
||||
setImageCount(count)
|
||||
|
||||
const placeholder = formatImagePlaceholder(count)
|
||||
const textarea = options.getTextarea()
|
||||
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const currentText = options.prompt()
|
||||
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
|
||||
options.setPrompt(newText)
|
||||
|
||||
setTimeout(() => {
|
||||
const newCursorPos = start + placeholder.length
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||
textarea.focus()
|
||||
}, 0)
|
||||
} else {
|
||||
options.setPrompt(options.prompt() + placeholder)
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const base64Data = (reader.result as string).split(",")[1]
|
||||
const display = formatImagePlaceholder(count)
|
||||
const filename = `image-${count}.png`
|
||||
|
||||
const attachment = createFileAttachment(
|
||||
@@ -160,24 +241,8 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
|
||||
options.instanceFolder(),
|
||||
)
|
||||
attachment.url = `data:image/png;base64,${base64Data}`
|
||||
attachment.display = display
|
||||
attachment.display = placeholder
|
||||
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||
|
||||
const textarea = options.getTextarea()
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const currentText = options.prompt()
|
||||
const placeholder = formatImagePlaceholder(count)
|
||||
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
|
||||
options.setPrompt(newText)
|
||||
|
||||
setTimeout(() => {
|
||||
const newCursorPos = start + placeholder.length
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||
textarea.focus()
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
reader.readAsDataURL(blob)
|
||||
|
||||
@@ -196,7 +261,8 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
|
||||
if (isLongPaste) {
|
||||
e.preventDefault()
|
||||
|
||||
const count = pasteCount() + 1
|
||||
const { highestPaste } = findHighestAttachmentCounters(options.prompt())
|
||||
const count = highestPaste + 1
|
||||
setPasteCount(count)
|
||||
|
||||
const summary = lineCount > 1 ? `${lineCount} lines` : `${charCount} chars`
|
||||
@@ -204,14 +270,12 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
|
||||
const filename = `paste-${count}.txt`
|
||||
|
||||
const attachment = createTextAttachment(pastedText, display, filename)
|
||||
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||
|
||||
const placeholder = formatPastedPlaceholder(count)
|
||||
const textarea = options.getTextarea()
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const currentText = options.prompt()
|
||||
const placeholder = formatPastedPlaceholder(count)
|
||||
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
|
||||
options.setPrompt(newText)
|
||||
|
||||
@@ -220,7 +284,11 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||
textarea.focus()
|
||||
}, 0)
|
||||
} else {
|
||||
options.setPrompt(options.prompt() + placeholder)
|
||||
}
|
||||
|
||||
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -183,9 +183,25 @@ export function usePromptKeyDown(options: UsePromptKeyDownOptions) {
|
||||
|
||||
if (isDeletingFromEnd || isDeletingFromStart || isSelected) {
|
||||
const currentAttachments = options.getAttachments()
|
||||
const attachment = currentAttachments.find(
|
||||
(a) => (a.source.type === "file" || a.source.type === "agent") && a.filename === name,
|
||||
)
|
||||
const attachment = currentAttachments.find((a) => {
|
||||
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) {
|
||||
e.preventDefault()
|
||||
@@ -205,6 +221,14 @@ export function usePromptKeyDown(options: UsePromptKeyDownOptions) {
|
||||
textarea.setSelectionRange(mentionStart, mentionStart)
|
||||
}, 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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 { createAgentAttachment, createFileAttachment, createTextAttachment } from "../../types/attachment"
|
||||
import { addAttachment, getAttachments } from "../../stores/attachments"
|
||||
import type { PickerMode } from "./types"
|
||||
import type { PickerSelectAction } from "../unified-picker"
|
||||
|
||||
type PickerItem =
|
||||
| { type: "agent"; agent: Agent }
|
||||
@@ -37,7 +38,7 @@ type PromptPickerController = {
|
||||
setIgnoredAtPositions: Setter<Set<number>>
|
||||
|
||||
handleInput: (e: Event) => void
|
||||
handlePickerSelect: (item: PickerItem) => void
|
||||
handlePickerSelect: (item: PickerItem, action: PickerSelectAction) => void
|
||||
handlePickerClose: () => void
|
||||
}
|
||||
|
||||
@@ -103,10 +104,11 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
||||
setAtPosition(null)
|
||||
}
|
||||
|
||||
function handlePickerSelect(item: PickerItem) {
|
||||
function handlePickerSelect(item: PickerItem, action: PickerSelectAction) {
|
||||
const textarea = options.getTextarea()
|
||||
|
||||
if (item.type === "command") {
|
||||
// For commands, Tab/Enter/Shift+Enter/click all mean "select".
|
||||
const name = item.command.name
|
||||
const currentPrompt = options.prompt()
|
||||
|
||||
@@ -128,6 +130,7 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
||||
}
|
||||
}, 0)
|
||||
} else if (item.type === "agent") {
|
||||
// For agents, Tab/Enter/Shift+Enter/click all mean "select".
|
||||
const agentName = item.agent.name
|
||||
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
||||
const alreadyAttached = existingAttachments.some(
|
||||
@@ -163,76 +166,152 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
||||
const relativePath = item.file.relativePath ?? displayPath
|
||||
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 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 after = currentPrompt.substring(cursorPos)
|
||||
const attachmentText = `@${normalizedPath}`
|
||||
const newPrompt = before + attachmentText + " " + after
|
||||
options.setPrompt(newPrompt)
|
||||
const suffix = opts?.trailingSpace ? " " : ""
|
||||
const nextPrompt = before + mentionText + suffix + after
|
||||
options.setPrompt(nextPrompt)
|
||||
|
||||
setTimeout(() => {
|
||||
const nextTextarea = options.getTextarea()
|
||||
if (nextTextarea) {
|
||||
const newCursorPos = pos + attachmentText.length + 1
|
||||
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||
}
|
||||
if (!nextTextarea) return
|
||||
const nextCursorPos = pos + mentionText.length + suffix.length
|
||||
nextTextarea.setSelectionRange(nextCursorPos, nextCursorPos)
|
||||
}, 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)
|
||||
@@ -245,6 +324,28 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
||||
const pos = atPosition()
|
||||
if (pickerMode() === "mention" && pos !== null) {
|
||||
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)
|
||||
setAtPosition(null)
|
||||
|
||||
@@ -299,13 +299,19 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
/>
|
||||
|
||||
|
||||
<Show when={attachments().length > 0}>
|
||||
<PromptAttachmentsBar
|
||||
attachments={attachments()}
|
||||
onRemoveAttachment={(attachmentId) => removeAttachment(props.instanceId, props.sessionId, attachmentId)}
|
||||
onExpandTextAttachment={(attachmentId) => promptInputApi?.expandTextAttachment(attachmentId)}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={attachments().length > 0}>
|
||||
<PromptAttachmentsBar
|
||||
attachments={attachments()}
|
||||
onRemoveAttachment={(attachmentId) => {
|
||||
if (promptInputApi) {
|
||||
promptInputApi.removeAttachment(attachmentId)
|
||||
return
|
||||
}
|
||||
removeAttachment(props.instanceId, props.sessionId, attachmentId)
|
||||
}}
|
||||
onExpandTextAttachment={(attachmentId) => promptInputApi?.expandTextAttachment(attachmentId)}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<PromptInput
|
||||
instanceId={props.instanceId}
|
||||
|
||||
@@ -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(/^\/+/, "")
|
||||
}
|
||||
|
||||
@@ -74,10 +72,12 @@ type PickerItem =
|
||||
| { type: "file"; file: FileItem }
|
||||
| { type: "command"; command: SDKCommand }
|
||||
|
||||
export type PickerSelectAction = "click" | "tab" | "enter" | "shiftEnter"
|
||||
|
||||
interface UnifiedPickerProps {
|
||||
open: boolean
|
||||
mode?: "mention" | "command"
|
||||
onSelect: (item: PickerItem) => void
|
||||
onSelect: (item: PickerItem, action: PickerSelectAction) => void
|
||||
onClose: () => void
|
||||
agents: Agent[]
|
||||
commands?: SDKCommand[]
|
||||
@@ -266,6 +266,13 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
const workspaceChanged = lastWorkspaceId !== props.workspaceId
|
||||
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) {
|
||||
setIsInitialized(true)
|
||||
lastWorkspaceId = props.workspaceId
|
||||
@@ -341,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
|
||||
}
|
||||
@@ -356,7 +378,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
}
|
||||
|
||||
function handleSelect(item: PickerItem) {
|
||||
props.onSelect(item)
|
||||
props.onSelect(item, "click")
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
@@ -379,7 +401,8 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
e.stopPropagation()
|
||||
const selected = items[selectedIndex()]
|
||||
if (selected) {
|
||||
handleSelect(selected)
|
||||
const action: PickerSelectAction = e.key === "Tab" ? "tab" : e.shiftKey ? "shiftEnter" : "enter"
|
||||
props.onSelect(selected, action)
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
@@ -443,7 +466,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
<div
|
||||
class={`dropdown-item ${isSelected() ? "dropdown-item-highlight" : ""}`}
|
||||
data-picker-selected={isSelected()}
|
||||
onClick={() => handleSelect({ type: "command", command })}
|
||||
onClick={() => props.onSelect({ type: "command", command }, "click")}
|
||||
>
|
||||
<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">
|
||||
@@ -464,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>
|
||||
@@ -479,7 +502,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
itemIndex === selectedIndex() ? "dropdown-item-highlight" : ""
|
||||
}`}
|
||||
data-picker-selected={itemIndex === selectedIndex()}
|
||||
onClick={() => handleSelect({ type: "agent", agent })}
|
||||
onClick={() => props.onSelect({ type: "agent", agent }, "click")}
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<svg
|
||||
@@ -519,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(
|
||||
@@ -535,7 +587,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
itemIndex === selectedIndex() ? "dropdown-item-highlight" : ""
|
||||
}`}
|
||||
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">
|
||||
<Show
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
if (!prompt || !prompt.includes("[pasted #")) {
|
||||
if (!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) {
|
||||
return prompt
|
||||
return result
|
||||
}
|
||||
|
||||
const lookup = new Map<string, string>()
|
||||
@@ -15,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
|
||||
@@ -26,10 +73,10 @@ export function resolvePastedPlaceholders(prompt: string, attachments: Attachmen
|
||||
}
|
||||
|
||||
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)
|
||||
return typeof replacement === "string" ? replacement : fullMatch
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user