|
|
@@ -1,12 +1,13 @@
|
|
|
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
|
|
-import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js"
|
|
|
-import { createStore } from "solid-js/store"
|
|
|
+import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js"
|
|
|
+import { createStore, produce } from "solid-js/store"
|
|
|
import { makePersisted } from "@solid-primitives/storage"
|
|
|
import { createFocusSignal } from "@solid-primitives/active-element"
|
|
|
import { useLocal } from "@/context/local"
|
|
|
-import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "@/context/session"
|
|
|
+import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt, ImageAttachmentPart } from "@/context/prompt"
|
|
|
+import { useLayout } from "@/context/layout"
|
|
|
import { useSDK } from "@/context/sdk"
|
|
|
-import { useNavigate } from "@solidjs/router"
|
|
|
+import { useNavigate, useParams } from "@solidjs/router"
|
|
|
import { useSync } from "@/context/sync"
|
|
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
|
|
import { Button } from "@opencode-ai/ui/button"
|
|
|
@@ -19,6 +20,10 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
|
import { DialogSelectModel } from "@/components/dialog-select-model"
|
|
|
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
|
|
import { useProviders } from "@/hooks/use-providers"
|
|
|
+import { useCommand, formatKeybind } from "@/context/command"
|
|
|
+
|
|
|
+const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
|
|
+const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
|
|
|
|
|
|
interface PromptInputProps {
|
|
|
class?: string
|
|
|
@@ -53,26 +58,54 @@ const PLACEHOLDERS = [
|
|
|
"How do environment variables work here?",
|
|
|
]
|
|
|
|
|
|
+interface SlashCommand {
|
|
|
+ id: string
|
|
|
+ trigger: string
|
|
|
+ title: string
|
|
|
+ description?: string
|
|
|
+ keybind?: string
|
|
|
+ type: "builtin" | "custom"
|
|
|
+}
|
|
|
+
|
|
|
export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
const navigate = useNavigate()
|
|
|
const sdk = useSDK()
|
|
|
const sync = useSync()
|
|
|
const local = useLocal()
|
|
|
- const session = useSession()
|
|
|
+ const prompt = usePrompt()
|
|
|
+ const layout = useLayout()
|
|
|
+ const params = useParams()
|
|
|
const dialog = useDialog()
|
|
|
const providers = useProviders()
|
|
|
+ const command = useCommand()
|
|
|
let editorRef!: HTMLDivElement
|
|
|
+ let fileInputRef!: HTMLInputElement
|
|
|
+
|
|
|
+ const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
|
|
+ const tabs = createMemo(() => layout.tabs(sessionKey()))
|
|
|
+ const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
|
|
+ const status = createMemo(
|
|
|
+ () =>
|
|
|
+ sync.data.session_status[params.id ?? ""] ?? {
|
|
|
+ type: "idle",
|
|
|
+ },
|
|
|
+ )
|
|
|
+ const working = createMemo(() => status()?.type !== "idle")
|
|
|
|
|
|
const [store, setStore] = createStore<{
|
|
|
- popoverIsOpen: boolean
|
|
|
+ popover: "file" | "slash" | null
|
|
|
historyIndex: number
|
|
|
savedPrompt: Prompt | null
|
|
|
placeholder: number
|
|
|
+ dragging: boolean
|
|
|
+ imageAttachments: ImageAttachmentPart[]
|
|
|
}>({
|
|
|
- popoverIsOpen: false,
|
|
|
+ popover: null,
|
|
|
historyIndex: -1,
|
|
|
savedPrompt: null,
|
|
|
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
|
|
|
+ dragging: false,
|
|
|
+ imageAttachments: [],
|
|
|
})
|
|
|
|
|
|
const MAX_HISTORY = 100
|
|
|
@@ -88,58 +121,48 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
)
|
|
|
|
|
|
const clonePromptParts = (prompt: Prompt): Prompt =>
|
|
|
- prompt.map((part) =>
|
|
|
- part.type === "text"
|
|
|
- ? { ...part }
|
|
|
- : {
|
|
|
- ...part,
|
|
|
- selection: part.selection ? { ...part.selection } : undefined,
|
|
|
- },
|
|
|
- )
|
|
|
+ prompt.map((part) => {
|
|
|
+ if (part.type === "text") return { ...part }
|
|
|
+ if (part.type === "image") return { ...part }
|
|
|
+ return {
|
|
|
+ ...part,
|
|
|
+ selection: part.selection ? { ...part.selection } : undefined,
|
|
|
+ }
|
|
|
+ })
|
|
|
|
|
|
- const promptLength = (prompt: Prompt) => prompt.reduce((len, part) => len + part.content.length, 0)
|
|
|
+ const promptLength = (prompt: Prompt) =>
|
|
|
+ prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
|
|
|
|
|
|
- const applyHistoryPrompt = (prompt: Prompt, position: "start" | "end") => {
|
|
|
- const length = position === "start" ? 0 : promptLength(prompt)
|
|
|
- session.prompt.set(prompt, length)
|
|
|
+ const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
|
|
|
+ const length = position === "start" ? 0 : promptLength(p)
|
|
|
+ prompt.set(p, length)
|
|
|
requestAnimationFrame(() => {
|
|
|
editorRef.focus()
|
|
|
setCursorPosition(editorRef, length)
|
|
|
})
|
|
|
}
|
|
|
|
|
|
- const getCaretLineState = () => {
|
|
|
+ const getCaretState = () => {
|
|
|
const selection = window.getSelection()
|
|
|
- if (!selection || selection.rangeCount === 0) return { collapsed: false, onFirstLine: false, onLastLine: false }
|
|
|
- const range = selection.getRangeAt(0)
|
|
|
- const rect = range.getBoundingClientRect()
|
|
|
- const editorRect = editorRef.getBoundingClientRect()
|
|
|
- const style = window.getComputedStyle(editorRef)
|
|
|
- const paddingTop = parseFloat(style.paddingTop) || 0
|
|
|
- const paddingBottom = parseFloat(style.paddingBottom) || 0
|
|
|
- let lineHeight = parseFloat(style.lineHeight)
|
|
|
- if (!Number.isFinite(lineHeight)) lineHeight = parseFloat(style.fontSize) || 16
|
|
|
- const scrollTop = editorRef.scrollTop
|
|
|
- let relativeTop = rect.top - editorRect.top - paddingTop + scrollTop
|
|
|
- if (!Number.isFinite(relativeTop)) relativeTop = scrollTop
|
|
|
- relativeTop = Math.max(0, relativeTop)
|
|
|
- let caretHeight = rect.height
|
|
|
- if (!caretHeight || !Number.isFinite(caretHeight)) caretHeight = lineHeight
|
|
|
- const relativeBottom = relativeTop + caretHeight
|
|
|
- const contentHeight = Math.max(caretHeight, editorRef.scrollHeight - paddingTop - paddingBottom)
|
|
|
- const threshold = Math.max(2, lineHeight / 2)
|
|
|
-
|
|
|
+ const textLength = promptLength(prompt.current())
|
|
|
+ if (!selection || selection.rangeCount === 0) {
|
|
|
+ return { collapsed: false, cursorPosition: 0, textLength }
|
|
|
+ }
|
|
|
+ const anchorNode = selection.anchorNode
|
|
|
+ if (!anchorNode || !editorRef.contains(anchorNode)) {
|
|
|
+ return { collapsed: false, cursorPosition: 0, textLength }
|
|
|
+ }
|
|
|
return {
|
|
|
collapsed: selection.isCollapsed,
|
|
|
- onFirstLine: relativeTop <= threshold,
|
|
|
- onLastLine: contentHeight - relativeBottom <= threshold,
|
|
|
+ cursorPosition: getCursorPosition(editorRef),
|
|
|
+ textLength,
|
|
|
}
|
|
|
}
|
|
|
|
|
|
createEffect(() => {
|
|
|
- session.id
|
|
|
+ params.id
|
|
|
editorRef.focus()
|
|
|
- if (session.id) return
|
|
|
+ if (params.id) return
|
|
|
const interval = setInterval(() => {
|
|
|
setStore("placeholder", (prev) => (prev + 1) % PLACEHOLDERS.length)
|
|
|
}, 6500)
|
|
|
@@ -148,14 +171,89 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
|
|
|
const isFocused = createFocusSignal(() => editorRef)
|
|
|
|
|
|
- const handlePaste = (event: ClipboardEvent) => {
|
|
|
+ const addImageAttachment = async (file: File) => {
|
|
|
+ if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
|
|
|
+
|
|
|
+ const reader = new FileReader()
|
|
|
+ reader.onload = () => {
|
|
|
+ const dataUrl = reader.result as string
|
|
|
+ const attachment: ImageAttachmentPart = {
|
|
|
+ type: "image",
|
|
|
+ id: crypto.randomUUID(),
|
|
|
+ filename: file.name,
|
|
|
+ mime: file.type,
|
|
|
+ dataUrl,
|
|
|
+ }
|
|
|
+ setStore(
|
|
|
+ produce((draft) => {
|
|
|
+ draft.imageAttachments.push(attachment)
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ }
|
|
|
+ reader.readAsDataURL(file)
|
|
|
+ }
|
|
|
+
|
|
|
+ const removeImageAttachment = (id: string) => {
|
|
|
+ setStore(
|
|
|
+ produce((draft) => {
|
|
|
+ draft.imageAttachments = draft.imageAttachments.filter((a) => a.id !== id)
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ const handlePaste = async (event: ClipboardEvent) => {
|
|
|
+ const clipboardData = event.clipboardData
|
|
|
+ if (!clipboardData) return
|
|
|
+
|
|
|
+ const items = Array.from(clipboardData.items)
|
|
|
+ const imageItems = items.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
|
|
|
+
|
|
|
+ if (imageItems.length > 0) {
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+ for (const item of imageItems) {
|
|
|
+ const file = item.getAsFile()
|
|
|
+ if (file) await addImageAttachment(file)
|
|
|
+ }
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
event.preventDefault()
|
|
|
event.stopPropagation()
|
|
|
- // @ts-expect-error
|
|
|
- const plainText = (event.clipboardData || window.clipboardData)?.getData("text/plain") ?? ""
|
|
|
+ const plainText = clipboardData.getData("text/plain") ?? ""
|
|
|
addPart({ type: "text", content: plainText, start: 0, end: 0 })
|
|
|
}
|
|
|
|
|
|
+ const handleDragOver = (event: DragEvent) => {
|
|
|
+ event.preventDefault()
|
|
|
+ const hasFiles = event.dataTransfer?.types.includes("Files")
|
|
|
+ if (hasFiles) {
|
|
|
+ setStore("dragging", true)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const handleDragLeave = (event: DragEvent) => {
|
|
|
+ const related = event.relatedTarget as Node | null
|
|
|
+ const form = event.currentTarget as HTMLElement
|
|
|
+ if (!related || !form.contains(related)) {
|
|
|
+ setStore("dragging", false)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const handleDrop = async (event: DragEvent) => {
|
|
|
+ event.preventDefault()
|
|
|
+ setStore("dragging", false)
|
|
|
+
|
|
|
+ const files = event.dataTransfer?.files
|
|
|
+ if (!files) return
|
|
|
+
|
|
|
+ for (const file of Array.from(files)) {
|
|
|
+ if (ACCEPTED_FILE_TYPES.includes(file.type)) {
|
|
|
+ await addImageAttachment(file)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
onMount(() => {
|
|
|
editorRef.addEventListener("paste", handlePaste)
|
|
|
})
|
|
|
@@ -167,7 +265,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
if (isFocused()) {
|
|
|
handleInput()
|
|
|
} else {
|
|
|
- setStore("popoverIsOpen", false)
|
|
|
+ setStore("popover", null)
|
|
|
}
|
|
|
})
|
|
|
|
|
|
@@ -182,9 +280,70 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
onSelect: handleFileSelect,
|
|
|
})
|
|
|
|
|
|
+ const slashCommands = createMemo<SlashCommand[]>(() => {
|
|
|
+ const builtin = command.options
|
|
|
+ .filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash)
|
|
|
+ .map((opt) => ({
|
|
|
+ id: opt.id,
|
|
|
+ trigger: opt.slash!,
|
|
|
+ title: opt.title,
|
|
|
+ description: opt.description,
|
|
|
+ keybind: opt.keybind,
|
|
|
+ type: "builtin" as const,
|
|
|
+ }))
|
|
|
+
|
|
|
+ const custom = sync.data.command.map((cmd) => ({
|
|
|
+ id: `custom.${cmd.name}`,
|
|
|
+ trigger: cmd.name,
|
|
|
+ title: cmd.name,
|
|
|
+ description: cmd.description,
|
|
|
+ type: "custom" as const,
|
|
|
+ }))
|
|
|
+
|
|
|
+ return [...custom, ...builtin]
|
|
|
+ })
|
|
|
+
|
|
|
+ const handleSlashSelect = (cmd: SlashCommand | undefined) => {
|
|
|
+ if (!cmd) return
|
|
|
+ setStore("popover", null)
|
|
|
+
|
|
|
+ if (cmd.type === "custom") {
|
|
|
+ const text = `/${cmd.trigger} `
|
|
|
+ editorRef.innerHTML = ""
|
|
|
+ editorRef.textContent = text
|
|
|
+ prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
|
|
|
+ requestAnimationFrame(() => {
|
|
|
+ editorRef.focus()
|
|
|
+ const range = document.createRange()
|
|
|
+ const sel = window.getSelection()
|
|
|
+ range.selectNodeContents(editorRef)
|
|
|
+ range.collapse(false)
|
|
|
+ sel?.removeAllRanges()
|
|
|
+ sel?.addRange(range)
|
|
|
+ })
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ editorRef.innerHTML = ""
|
|
|
+ prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
|
|
|
+ command.trigger(cmd.id, "slash")
|
|
|
+ }
|
|
|
+
|
|
|
+ const {
|
|
|
+ flat: slashFlat,
|
|
|
+ active: slashActive,
|
|
|
+ onInput: slashOnInput,
|
|
|
+ onKeyDown: slashOnKeyDown,
|
|
|
+ } = useFilteredList<SlashCommand>({
|
|
|
+ items: slashCommands,
|
|
|
+ key: (x) => x?.id,
|
|
|
+ filterKeys: ["trigger", "title", "description"],
|
|
|
+ onSelect: handleSlashSelect,
|
|
|
+ })
|
|
|
+
|
|
|
createEffect(
|
|
|
on(
|
|
|
- () => session.prompt.current(),
|
|
|
+ () => prompt.current(),
|
|
|
(currentParts) => {
|
|
|
const domParts = parseFromDOM()
|
|
|
if (isPromptEqual(currentParts, domParts)) return
|
|
|
@@ -253,14 +412,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
const handleInput = () => {
|
|
|
const rawParts = parseFromDOM()
|
|
|
const cursorPosition = getCursorPosition(editorRef)
|
|
|
- const rawText = rawParts.map((p) => p.content).join("")
|
|
|
+ const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("")
|
|
|
|
|
|
const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/)
|
|
|
+ const slashMatch = rawText.match(/^\/(\S*)$/)
|
|
|
+
|
|
|
if (atMatch) {
|
|
|
onInput(atMatch[1])
|
|
|
- setStore("popoverIsOpen", true)
|
|
|
- } else if (store.popoverIsOpen) {
|
|
|
- setStore("popoverIsOpen", false)
|
|
|
+ setStore("popover", "file")
|
|
|
+ } else if (slashMatch) {
|
|
|
+ slashOnInput(slashMatch[1])
|
|
|
+ setStore("popover", "slash")
|
|
|
+ } else {
|
|
|
+ setStore("popover", null)
|
|
|
}
|
|
|
|
|
|
if (store.historyIndex >= 0) {
|
|
|
@@ -268,7 +432,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
setStore("savedPrompt", null)
|
|
|
}
|
|
|
|
|
|
- session.prompt.set(rawParts, cursorPosition)
|
|
|
+ prompt.set(rawParts, cursorPosition)
|
|
|
}
|
|
|
|
|
|
const addPart = (part: ContentPart) => {
|
|
|
@@ -276,8 +440,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
if (!selection || selection.rangeCount === 0) return
|
|
|
|
|
|
const cursorPosition = getCursorPosition(editorRef)
|
|
|
- const prompt = session.prompt.current()
|
|
|
- const rawText = prompt.map((p) => p.content).join("")
|
|
|
+ const currentPrompt = prompt.current()
|
|
|
+ const rawText = currentPrompt.map((p) => ("content" in p ? p.content : "")).join("")
|
|
|
const textBeforeCursor = rawText.substring(0, cursorPosition)
|
|
|
const atMatch = textBeforeCursor.match(/@(\S*)$/)
|
|
|
|
|
|
@@ -294,8 +458,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
const range = selection.getRangeAt(0)
|
|
|
|
|
|
if (atMatch) {
|
|
|
- // let node: Node | null = range.startContainer
|
|
|
- // let offset = range.startOffset
|
|
|
let runningLength = 0
|
|
|
|
|
|
const walker = document.createTreeWalker(editorRef, NodeFilter.SHOW_TEXT, null)
|
|
|
@@ -335,17 +497,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
}
|
|
|
|
|
|
handleInput()
|
|
|
- setStore("popoverIsOpen", false)
|
|
|
+ setStore("popover", null)
|
|
|
}
|
|
|
|
|
|
const abort = () =>
|
|
|
sdk.client.session.abort({
|
|
|
- sessionID: session.id!,
|
|
|
+ sessionID: params.id!,
|
|
|
})
|
|
|
|
|
|
const addToHistory = (prompt: Prompt) => {
|
|
|
const text = prompt
|
|
|
- .map((p) => p.content)
|
|
|
+ .map((p) => ("content" in p ? p.content : ""))
|
|
|
.join("")
|
|
|
.trim()
|
|
|
if (!text) return
|
|
|
@@ -353,7 +515,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
const entry = clonePromptParts(prompt)
|
|
|
const lastEntry = history.entries[0]
|
|
|
if (lastEntry) {
|
|
|
- const lastText = lastEntry.map((p) => p.content).join("")
|
|
|
+ const lastText = lastEntry.map((p) => ("content" in p ? p.content : "")).join("")
|
|
|
if (lastText === text) return
|
|
|
}
|
|
|
|
|
|
@@ -367,7 +529,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
if (direction === "up") {
|
|
|
if (entries.length === 0) return false
|
|
|
if (current === -1) {
|
|
|
- setStore("savedPrompt", clonePromptParts(session.prompt.current()))
|
|
|
+ setStore("savedPrompt", clonePromptParts(prompt.current()))
|
|
|
setStore("historyIndex", 0)
|
|
|
applyHistoryPrompt(entries[0], "start")
|
|
|
return true
|
|
|
@@ -403,24 +565,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
}
|
|
|
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
|
- if (store.popoverIsOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
|
|
|
- onKeyDown(event)
|
|
|
+ if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
|
|
|
+ if (store.popover === "file") {
|
|
|
+ onKeyDown(event)
|
|
|
+ } else {
|
|
|
+ slashOnKeyDown(event)
|
|
|
+ }
|
|
|
event.preventDefault()
|
|
|
return
|
|
|
}
|
|
|
|
|
|
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
|
|
- const { collapsed, onFirstLine, onLastLine } = getCaretLineState()
|
|
|
+ if (event.altKey || event.ctrlKey || event.metaKey) return
|
|
|
+ const { collapsed, cursorPosition, textLength } = getCaretState()
|
|
|
if (!collapsed) return
|
|
|
- const cursorPos = getCursorPosition(editorRef)
|
|
|
- const textLength = promptLength(session.prompt.current())
|
|
|
const inHistory = store.historyIndex >= 0
|
|
|
- const isStart = cursorPos === 0
|
|
|
- const isEnd = cursorPos === textLength
|
|
|
- const atAbsoluteStart = onFirstLine && isStart
|
|
|
- const atAbsoluteEnd = onLastLine && isEnd
|
|
|
- const allowUp = (inHistory && isEnd) || atAbsoluteStart
|
|
|
- const allowDown = (inHistory && isStart) || atAbsoluteEnd
|
|
|
+ const atAbsoluteStart = cursorPosition === 0
|
|
|
+ const atAbsoluteEnd = cursorPosition === textLength
|
|
|
+ const allowUp = (inHistory && atAbsoluteEnd) || atAbsoluteStart
|
|
|
+ const allowDown = (inHistory && atAbsoluteStart) || atAbsoluteEnd
|
|
|
|
|
|
if (event.key === "ArrowUp") {
|
|
|
if (!allowUp) return
|
|
|
@@ -441,9 +604,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
handleSubmit(event)
|
|
|
}
|
|
|
if (event.key === "Escape") {
|
|
|
- if (store.popoverIsOpen) {
|
|
|
- setStore("popoverIsOpen", false)
|
|
|
- } else if (session.working()) {
|
|
|
+ if (store.popover) {
|
|
|
+ setStore("popover", null)
|
|
|
+ } else if (working()) {
|
|
|
abort()
|
|
|
}
|
|
|
}
|
|
|
@@ -451,18 +614,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
|
|
|
const handleSubmit = async (event: Event) => {
|
|
|
event.preventDefault()
|
|
|
- const prompt = session.prompt.current()
|
|
|
- const text = prompt.map((part) => part.content).join("")
|
|
|
- if (text.trim().length === 0) {
|
|
|
- if (session.working()) abort()
|
|
|
+ const currentPrompt = prompt.current()
|
|
|
+ const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
|
|
|
+ const hasImageAttachments = store.imageAttachments.length > 0
|
|
|
+ if (text.trim().length === 0 && !hasImageAttachments) {
|
|
|
+ if (working()) abort()
|
|
|
return
|
|
|
}
|
|
|
|
|
|
- addToHistory(prompt)
|
|
|
+ addToHistory(currentPrompt)
|
|
|
setStore("historyIndex", -1)
|
|
|
setStore("savedPrompt", null)
|
|
|
|
|
|
- let existing = session.info()
|
|
|
+ let existing = info()
|
|
|
if (!existing) {
|
|
|
const created = await sdk.client.session.create()
|
|
|
existing = created.data ?? undefined
|
|
|
@@ -470,32 +634,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
}
|
|
|
if (!existing) return
|
|
|
|
|
|
- // if (!session.id) {
|
|
|
- // session.layout.setOpenedTabs(
|
|
|
- // session.layout.copyTabs("", session.id)
|
|
|
- // }
|
|
|
-
|
|
|
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
|
|
|
- const attachments = prompt.filter((part) => part.type === "file")
|
|
|
-
|
|
|
- // const activeFile = local.context.active()
|
|
|
- // if (activeFile) {
|
|
|
- // registerAttachment(
|
|
|
- // activeFile.path,
|
|
|
- // activeFile.selection,
|
|
|
- // activeFile.name ?? formatAttachmentLabel(activeFile.path, activeFile.selection),
|
|
|
- // )
|
|
|
- // }
|
|
|
-
|
|
|
- // for (const contextFile of local.context.all()) {
|
|
|
- // registerAttachment(
|
|
|
- // contextFile.path,
|
|
|
- // contextFile.selection,
|
|
|
- // formatAttachmentLabel(contextFile.path, contextFile.selection),
|
|
|
- // )
|
|
|
- // }
|
|
|
-
|
|
|
- const attachmentParts = attachments.map((attachment) => {
|
|
|
+ const attachments = currentPrompt.filter(
|
|
|
+ (part) => part.type === "file",
|
|
|
+ ) as import("@/context/prompt").FileAttachmentPart[]
|
|
|
+
|
|
|
+ const fileAttachmentParts = attachments.map((attachment) => {
|
|
|
const absolute = toAbsolutePath(attachment.path)
|
|
|
const query = attachment.selection
|
|
|
? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
|
|
|
@@ -517,11 +661,33 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
}
|
|
|
})
|
|
|
|
|
|
- session.layout.setActiveTab(undefined)
|
|
|
- session.messages.setActive(undefined)
|
|
|
- // Clear the editor DOM directly to ensure it's empty
|
|
|
+ const imageAttachmentParts = store.imageAttachments.map((attachment) => ({
|
|
|
+ type: "file" as const,
|
|
|
+ mime: attachment.mime,
|
|
|
+ url: attachment.dataUrl,
|
|
|
+ filename: attachment.filename,
|
|
|
+ }))
|
|
|
+
|
|
|
+ tabs().setActive(undefined)
|
|
|
editorRef.innerHTML = ""
|
|
|
- session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
|
|
|
+ prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
|
|
|
+ setStore("imageAttachments", [])
|
|
|
+
|
|
|
+ if (text.startsWith("/")) {
|
|
|
+ const [cmdName, ...args] = text.split(" ")
|
|
|
+ const commandName = cmdName.slice(1)
|
|
|
+ const customCommand = sync.data.command.find((c) => c.name === commandName)
|
|
|
+ if (customCommand) {
|
|
|
+ sdk.client.session.command({
|
|
|
+ sessionID: existing.id,
|
|
|
+ command: commandName,
|
|
|
+ arguments: args.join(" "),
|
|
|
+ agent: local.agent.current()!.name,
|
|
|
+ model: `${local.model.current()!.provider.id}/${local.model.current()!.id}`,
|
|
|
+ })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
sdk.client.session.prompt({
|
|
|
sessionID: existing.id,
|
|
|
@@ -535,55 +701,136 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
type: "text",
|
|
|
text,
|
|
|
},
|
|
|
- ...attachmentParts,
|
|
|
+ ...fileAttachmentParts,
|
|
|
+ ...imageAttachmentParts,
|
|
|
],
|
|
|
})
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
<div class="relative size-full _max-h-[320px] flex flex-col gap-3">
|
|
|
- <Show when={store.popoverIsOpen}>
|
|
|
+ <Show when={store.popover}>
|
|
|
<div
|
|
|
- class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-[252px] min-h-10
|
|
|
- overflow-auto no-scrollbar flex flex-col p-2 pb-0 rounded-md
|
|
|
+ class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10
|
|
|
+ overflow-auto no-scrollbar flex flex-col p-2 rounded-md
|
|
|
border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
|
|
|
>
|
|
|
- <Show when={flat().length > 0} fallback={<div class="text-text-weak px-2">No matching files</div>}>
|
|
|
- <For each={flat()}>
|
|
|
- {(i) => (
|
|
|
- <button
|
|
|
- classList={{
|
|
|
- "w-full flex items-center justify-between rounded-md": true,
|
|
|
- "bg-surface-raised-base-hover": active() === i,
|
|
|
- }}
|
|
|
- onClick={() => handleFileSelect(i)}
|
|
|
- >
|
|
|
- <div class="flex items-center gap-x-2 grow min-w-0">
|
|
|
- <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
|
|
- <div class="flex items-center text-14-regular">
|
|
|
- <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
|
|
- {getDirectory(i)}
|
|
|
- </span>
|
|
|
- <Show when={!i.endsWith("/")}>
|
|
|
- <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
|
|
|
- </Show>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
|
|
|
- </button>
|
|
|
- )}
|
|
|
- </For>
|
|
|
- </Show>
|
|
|
+ <Switch>
|
|
|
+ <Match when={store.popover === "file"}>
|
|
|
+ <Show when={flat().length > 0} fallback={<div class="text-text-weak px-2 py-1">No matching files</div>}>
|
|
|
+ <For each={flat()}>
|
|
|
+ {(i) => (
|
|
|
+ <button
|
|
|
+ classList={{
|
|
|
+ "w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
|
|
|
+ "bg-surface-raised-base-hover": active() === i,
|
|
|
+ }}
|
|
|
+ onClick={() => handleFileSelect(i)}
|
|
|
+ >
|
|
|
+ <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
|
|
+ <div class="flex items-center text-14-regular min-w-0">
|
|
|
+ <span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(i)}</span>
|
|
|
+ <Show when={!i.endsWith("/")}>
|
|
|
+ <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
|
|
|
+ </Show>
|
|
|
+ </div>
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ </For>
|
|
|
+ </Show>
|
|
|
+ </Match>
|
|
|
+ <Match when={store.popover === "slash"}>
|
|
|
+ <Show
|
|
|
+ when={slashFlat().length > 0}
|
|
|
+ fallback={<div class="text-text-weak px-2 py-1">No matching commands</div>}
|
|
|
+ >
|
|
|
+ <For each={slashFlat()}>
|
|
|
+ {(cmd) => (
|
|
|
+ <button
|
|
|
+ classList={{
|
|
|
+ "w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
|
|
|
+ "bg-surface-raised-base-hover": slashActive() === cmd.id,
|
|
|
+ }}
|
|
|
+ onClick={() => handleSlashSelect(cmd)}
|
|
|
+ >
|
|
|
+ <div class="flex items-center gap-2 min-w-0">
|
|
|
+ <span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
|
|
|
+ <Show when={cmd.description}>
|
|
|
+ <span class="text-14-regular text-text-weak truncate">{cmd.description}</span>
|
|
|
+ </Show>
|
|
|
+ </div>
|
|
|
+ <div class="flex items-center gap-2 shrink-0">
|
|
|
+ <Show when={cmd.type === "custom"}>
|
|
|
+ <span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
|
|
|
+ custom
|
|
|
+ </span>
|
|
|
+ </Show>
|
|
|
+ <Show when={cmd.keybind}>
|
|
|
+ <span class="text-12-regular text-text-subtle">{formatKeybind(cmd.keybind!)}</span>
|
|
|
+ </Show>
|
|
|
+ </div>
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ </For>
|
|
|
+ </Show>
|
|
|
+ </Match>
|
|
|
+ </Switch>
|
|
|
</div>
|
|
|
</Show>
|
|
|
<form
|
|
|
onSubmit={handleSubmit}
|
|
|
+ onDragOver={handleDragOver}
|
|
|
+ onDragLeave={handleDragLeave}
|
|
|
+ onDrop={handleDrop}
|
|
|
classList={{
|
|
|
- "bg-surface-raised-stronger-non-alpha shadow-xs-border": true,
|
|
|
+ "bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true,
|
|
|
"rounded-md overflow-clip focus-within:shadow-xs-border": true,
|
|
|
+ "border-icon-info-active border-dashed": store.dragging,
|
|
|
[props.class ?? ""]: !!props.class,
|
|
|
}}
|
|
|
>
|
|
|
+ <Show when={store.dragging}>
|
|
|
+ <div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
|
|
|
+ <div class="flex flex-col items-center gap-2 text-text-weak">
|
|
|
+ <Icon name="photo" class="size-8" />
|
|
|
+ <span class="text-14-regular">Drop images or PDFs here</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </Show>
|
|
|
+ <Show when={store.imageAttachments.length > 0}>
|
|
|
+ <div class="flex flex-wrap gap-2 px-3 pt-3">
|
|
|
+ <For each={store.imageAttachments}>
|
|
|
+ {(attachment) => (
|
|
|
+ <div class="relative group">
|
|
|
+ <Show
|
|
|
+ when={attachment.mime.startsWith("image/")}
|
|
|
+ fallback={
|
|
|
+ <div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
|
|
|
+ <Icon name="folder" class="size-6 text-text-weak" />
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ >
|
|
|
+ <img
|
|
|
+ src={attachment.dataUrl}
|
|
|
+ alt={attachment.filename}
|
|
|
+ class="size-16 rounded-md object-cover border border-border-base"
|
|
|
+ />
|
|
|
+ </Show>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={() => removeImageAttachment(attachment.id)}
|
|
|
+ class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
|
|
|
+ >
|
|
|
+ <Icon name="close" class="size-3 text-text-weak" />
|
|
|
+ </button>
|
|
|
+ <div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md">
|
|
|
+ <span class="text-10-regular text-white truncate block">{attachment.filename}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </For>
|
|
|
+ </div>
|
|
|
+ </Show>
|
|
|
<div class="relative max-h-[240px] overflow-y-auto">
|
|
|
<div
|
|
|
ref={(el) => {
|
|
|
@@ -598,7 +845,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
"[&>[data-type=file]]:text-icon-info-active": true,
|
|
|
}}
|
|
|
/>
|
|
|
- <Show when={!session.prompt.dirty()}>
|
|
|
+ <Show when={!prompt.dirty() && store.imageAttachments.length === 0}>
|
|
|
<div class="absolute top-0 left-0 px-5 py-3 text-14-regular text-text-weak pointer-events-none">
|
|
|
Ask anything... "{PLACEHOLDERS[store.placeholder]}"
|
|
|
</div>
|
|
|
@@ -617,7 +864,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
as="div"
|
|
|
variant="ghost"
|
|
|
onClick={() =>
|
|
|
- dialog.push(() => (providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />))
|
|
|
+ dialog.show(() => (providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />))
|
|
|
}
|
|
|
>
|
|
|
{local.model.current()?.name ?? "Select model"}
|
|
|
@@ -625,34 +872,56 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
<Icon name="chevron-down" size="small" />
|
|
|
</Button>
|
|
|
</div>
|
|
|
- <Tooltip
|
|
|
- placement="top"
|
|
|
- inactive={!session.prompt.dirty() && !session.working()}
|
|
|
- value={
|
|
|
- <Switch>
|
|
|
- <Match when={session.working()}>
|
|
|
- <div class="flex items-center gap-2">
|
|
|
- <span>Stop</span>
|
|
|
- <span class="text-icon-base text-12-medium text-[10px]!">ESC</span>
|
|
|
- </div>
|
|
|
- </Match>
|
|
|
- <Match when={true}>
|
|
|
- <div class="flex items-center gap-2">
|
|
|
- <span>Send</span>
|
|
|
- <Icon name="enter" size="small" class="text-icon-base" />
|
|
|
- </div>
|
|
|
- </Match>
|
|
|
- </Switch>
|
|
|
- }
|
|
|
- >
|
|
|
- <IconButton
|
|
|
- type="submit"
|
|
|
- disabled={!session.prompt.dirty() && !session.working()}
|
|
|
- icon={session.working() ? "stop" : "arrow-up"}
|
|
|
- variant="primary"
|
|
|
- class="h-10 w-8 absolute right-2 bottom-2"
|
|
|
+ <div class="flex items-center gap-1 absolute right-2 bottom-2">
|
|
|
+ <input
|
|
|
+ ref={fileInputRef}
|
|
|
+ type="file"
|
|
|
+ accept={ACCEPTED_IMAGE_TYPES.join(",")}
|
|
|
+ class="hidden"
|
|
|
+ onChange={(e) => {
|
|
|
+ const file = e.currentTarget.files?.[0]
|
|
|
+ if (file) addImageAttachment(file)
|
|
|
+ e.currentTarget.value = ""
|
|
|
+ }}
|
|
|
/>
|
|
|
- </Tooltip>
|
|
|
+ <Tooltip placement="top" value="Attach image">
|
|
|
+ <IconButton
|
|
|
+ type="button"
|
|
|
+ icon="photo"
|
|
|
+ variant="ghost"
|
|
|
+ class="h-10 w-8"
|
|
|
+ onClick={() => fileInputRef.click()}
|
|
|
+ />
|
|
|
+ </Tooltip>
|
|
|
+ <Tooltip
|
|
|
+ placement="top"
|
|
|
+ inactive={!prompt.dirty() && !working()}
|
|
|
+ value={
|
|
|
+ <Switch>
|
|
|
+ <Match when={working()}>
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <span>Stop</span>
|
|
|
+ <span class="text-icon-base text-12-medium text-[10px]!">ESC</span>
|
|
|
+ </div>
|
|
|
+ </Match>
|
|
|
+ <Match when={true}>
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <span>Send</span>
|
|
|
+ <Icon name="enter" size="small" class="text-icon-base" />
|
|
|
+ </div>
|
|
|
+ </Match>
|
|
|
+ </Switch>
|
|
|
+ }
|
|
|
+ >
|
|
|
+ <IconButton
|
|
|
+ type="submit"
|
|
|
+ disabled={!prompt.dirty() && store.imageAttachments.length === 0 && !working()}
|
|
|
+ icon={working() ? "stop" : "arrow-up"}
|
|
|
+ variant="primary"
|
|
|
+ class="h-10 w-8"
|
|
|
+ />
|
|
|
+ </Tooltip>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</form>
|
|
|
</div>
|