|
|
@@ -1,21 +1,9 @@
|
|
|
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
|
|
-import {
|
|
|
- createEffect,
|
|
|
- on,
|
|
|
- Component,
|
|
|
- Show,
|
|
|
- For,
|
|
|
- onMount,
|
|
|
- onCleanup,
|
|
|
- Switch,
|
|
|
- Match,
|
|
|
- createMemo,
|
|
|
- createSignal,
|
|
|
-} from "solid-js"
|
|
|
-import { createStore, produce } from "solid-js/store"
|
|
|
+import { createEffect, on, Component, Show, For, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
|
|
|
+import { createStore } from "solid-js/store"
|
|
|
import { createFocusSignal } from "@solid-primitives/active-element"
|
|
|
import { useLocal } from "@/context/local"
|
|
|
-import { useFile, type FileSelection } from "@/context/file"
|
|
|
+import { useFile } from "@/context/file"
|
|
|
import {
|
|
|
ContentPart,
|
|
|
DEFAULT_PROMPT,
|
|
|
@@ -28,7 +16,7 @@ import {
|
|
|
} from "@/context/prompt"
|
|
|
import { useLayout } from "@/context/layout"
|
|
|
import { useSDK } from "@/context/sdk"
|
|
|
-import { useNavigate, useParams } from "@solidjs/router"
|
|
|
+import { useParams } from "@solidjs/router"
|
|
|
import { useSync } from "@/context/sync"
|
|
|
import { useComments } from "@/context/comments"
|
|
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
|
|
@@ -47,27 +35,13 @@ import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid
|
|
|
import { useProviders } from "@/hooks/use-providers"
|
|
|
import { useCommand } from "@/context/command"
|
|
|
import { Persist, persisted } from "@/utils/persist"
|
|
|
-import { Identifier } from "@/utils/id"
|
|
|
-import { Worktree as WorktreeState } from "@/utils/worktree"
|
|
|
import { SessionContextUsage } from "@/components/session-context-usage"
|
|
|
import { usePermission } from "@/context/permission"
|
|
|
import { useLanguage } from "@/context/language"
|
|
|
-import { useGlobalSync } from "@/context/global-sync"
|
|
|
-import { usePlatform } from "@/context/platform"
|
|
|
-import { createOpencodeClient, type Message, type Part } from "@opencode-ai/sdk/v2/client"
|
|
|
-import { Binary } from "@opencode-ai/util/binary"
|
|
|
-import { showToast } from "@opencode-ai/ui/toast"
|
|
|
-import { base64Encode } from "@opencode-ai/util/encode"
|
|
|
-
|
|
|
-const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
|
|
-const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
|
|
|
-
|
|
|
-type PendingPrompt = {
|
|
|
- abort: AbortController
|
|
|
- cleanup: VoidFunction
|
|
|
-}
|
|
|
-
|
|
|
-const pending = new Map<string, PendingPrompt>()
|
|
|
+import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
|
|
+import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
|
|
|
+import { navigatePromptHistory, prependHistoryEntry, promptLength } from "./prompt-input/history"
|
|
|
+import { createPromptSubmit } from "./prompt-input/submit"
|
|
|
|
|
|
interface PromptInputProps {
|
|
|
class?: string
|
|
|
@@ -116,11 +90,8 @@ interface SlashCommand {
|
|
|
}
|
|
|
|
|
|
export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
- const navigate = useNavigate()
|
|
|
const sdk = useSDK()
|
|
|
const sync = useSync()
|
|
|
- const globalSync = useGlobalSync()
|
|
|
- const platform = usePlatform()
|
|
|
const local = useLocal()
|
|
|
const files = useFile()
|
|
|
const prompt = usePrompt()
|
|
|
@@ -272,20 +243,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
}),
|
|
|
)
|
|
|
|
|
|
- const clonePromptParts = (prompt: Prompt): Prompt =>
|
|
|
- prompt.map((part) => {
|
|
|
- if (part.type === "text") return { ...part }
|
|
|
- if (part.type === "image") return { ...part }
|
|
|
- if (part.type === "agent") return { ...part }
|
|
|
- return {
|
|
|
- ...part,
|
|
|
- selection: part.selection ? { ...part.selection } : undefined,
|
|
|
- }
|
|
|
- })
|
|
|
-
|
|
|
- const promptLength = (prompt: Prompt) =>
|
|
|
- prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
|
|
|
-
|
|
|
const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
|
|
|
const length = position === "start" ? 0 : promptLength(p)
|
|
|
setStore("applyingHistory", true)
|
|
|
@@ -329,110 +286,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
const [composing, setComposing] = createSignal(false)
|
|
|
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
|
|
|
|
|
|
- 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,
|
|
|
- }
|
|
|
- const cursorPosition = prompt.cursor() ?? getCursorPosition(editorRef)
|
|
|
- prompt.set([...prompt.current(), attachment], cursorPosition)
|
|
|
- }
|
|
|
- reader.readAsDataURL(file)
|
|
|
- }
|
|
|
-
|
|
|
- const removeImageAttachment = (id: string) => {
|
|
|
- const current = prompt.current()
|
|
|
- const next = current.filter((part) => part.type !== "image" || part.id !== id)
|
|
|
- prompt.set(next, prompt.cursor())
|
|
|
- }
|
|
|
-
|
|
|
- const handlePaste = async (event: ClipboardEvent) => {
|
|
|
- if (!isFocused()) return
|
|
|
- const clipboardData = event.clipboardData
|
|
|
- if (!clipboardData) return
|
|
|
-
|
|
|
- event.preventDefault()
|
|
|
- event.stopPropagation()
|
|
|
-
|
|
|
- const items = Array.from(clipboardData.items)
|
|
|
- const fileItems = items.filter((item) => item.kind === "file")
|
|
|
- const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
|
|
|
-
|
|
|
- if (imageItems.length > 0) {
|
|
|
- for (const item of imageItems) {
|
|
|
- const file = item.getAsFile()
|
|
|
- if (file) await addImageAttachment(file)
|
|
|
- }
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- if (fileItems.length > 0) {
|
|
|
- showToast({
|
|
|
- title: language.t("prompt.toast.pasteUnsupported.title"),
|
|
|
- description: language.t("prompt.toast.pasteUnsupported.description"),
|
|
|
- })
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- const plainText = clipboardData.getData("text/plain") ?? ""
|
|
|
- if (!plainText) return
|
|
|
- addPart({ type: "text", content: plainText, start: 0, end: 0 })
|
|
|
- }
|
|
|
-
|
|
|
- const handleGlobalDragOver = (event: DragEvent) => {
|
|
|
- if (dialog.active) return
|
|
|
-
|
|
|
- event.preventDefault()
|
|
|
- const hasFiles = event.dataTransfer?.types.includes("Files")
|
|
|
- if (hasFiles) {
|
|
|
- setStore("dragging", true)
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- const handleGlobalDragLeave = (event: DragEvent) => {
|
|
|
- if (dialog.active) return
|
|
|
-
|
|
|
- // relatedTarget is null when leaving the document window
|
|
|
- if (!event.relatedTarget) {
|
|
|
- setStore("dragging", false)
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- const handleGlobalDrop = async (event: DragEvent) => {
|
|
|
- if (dialog.active) return
|
|
|
-
|
|
|
- event.preventDefault()
|
|
|
- setStore("dragging", false)
|
|
|
-
|
|
|
- const dropped = event.dataTransfer?.files
|
|
|
- if (!dropped) return
|
|
|
-
|
|
|
- for (const file of Array.from(dropped)) {
|
|
|
- if (ACCEPTED_FILE_TYPES.includes(file.type)) {
|
|
|
- await addImageAttachment(file)
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- onMount(() => {
|
|
|
- document.addEventListener("dragover", handleGlobalDragOver)
|
|
|
- document.addEventListener("dragleave", handleGlobalDragLeave)
|
|
|
- document.addEventListener("drop", handleGlobalDrop)
|
|
|
- })
|
|
|
- onCleanup(() => {
|
|
|
- document.removeEventListener("dragover", handleGlobalDragOver)
|
|
|
- document.removeEventListener("dragleave", handleGlobalDragLeave)
|
|
|
- document.removeEventListener("drop", handleGlobalDrop)
|
|
|
- })
|
|
|
-
|
|
|
createEffect(() => {
|
|
|
if (!isFocused()) setStore("popover", null)
|
|
|
})
|
|
|
@@ -826,36 +679,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
queueScroll()
|
|
|
}
|
|
|
|
|
|
- const setRangeEdge = (range: Range, edge: "start" | "end", offset: number) => {
|
|
|
- let remaining = offset
|
|
|
- const nodes = Array.from(editorRef.childNodes)
|
|
|
-
|
|
|
- for (const node of nodes) {
|
|
|
- const length = getNodeLength(node)
|
|
|
- const isText = node.nodeType === Node.TEXT_NODE
|
|
|
- const isPill =
|
|
|
- node.nodeType === Node.ELEMENT_NODE &&
|
|
|
- ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
|
|
|
- const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
|
|
|
-
|
|
|
- if (isText && remaining <= length) {
|
|
|
- if (edge === "start") range.setStart(node, remaining)
|
|
|
- if (edge === "end") range.setEnd(node, remaining)
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- if ((isPill || isBreak) && remaining <= length) {
|
|
|
- if (edge === "start" && remaining === 0) range.setStartBefore(node)
|
|
|
- if (edge === "start" && remaining > 0) range.setStartAfter(node)
|
|
|
- if (edge === "end" && remaining === 0) range.setEndBefore(node)
|
|
|
- if (edge === "end" && remaining > 0) range.setEndAfter(node)
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- remaining -= length
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
const addPart = (part: ContentPart) => {
|
|
|
const selection = window.getSelection()
|
|
|
if (!selection || selection.rangeCount === 0) return
|
|
|
@@ -873,8 +696,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
|
|
|
if (atMatch) {
|
|
|
const start = atMatch.index ?? cursorPosition - atMatch[0].length
|
|
|
- setRangeEdge(range, "start", start)
|
|
|
- setRangeEdge(range, "end", cursorPosition)
|
|
|
+ setRangeEdge(editorRef, range, "start", start)
|
|
|
+ setRangeEdge(editorRef, range, "end", cursorPosition)
|
|
|
}
|
|
|
|
|
|
range.deleteContents()
|
|
|
@@ -913,81 +736,57 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
setStore("popover", null)
|
|
|
}
|
|
|
|
|
|
- const abort = async () => {
|
|
|
- const sessionID = params.id
|
|
|
- if (!sessionID) return Promise.resolve()
|
|
|
- const queued = pending.get(sessionID)
|
|
|
- if (queued) {
|
|
|
- queued.abort.abort()
|
|
|
- queued.cleanup()
|
|
|
- pending.delete(sessionID)
|
|
|
- return Promise.resolve()
|
|
|
- }
|
|
|
- return sdk.client.session
|
|
|
- .abort({
|
|
|
- sessionID,
|
|
|
- })
|
|
|
- .catch(() => {})
|
|
|
- }
|
|
|
-
|
|
|
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
|
|
|
- const text = prompt
|
|
|
- .map((p) => ("content" in p ? p.content : ""))
|
|
|
- .join("")
|
|
|
- .trim()
|
|
|
- const hasImages = prompt.some((part) => part.type === "image")
|
|
|
- if (!text && !hasImages) return
|
|
|
-
|
|
|
- const entry = clonePromptParts(prompt)
|
|
|
const currentHistory = mode === "shell" ? shellHistory : history
|
|
|
const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
|
|
|
- const lastEntry = currentHistory.entries[0]
|
|
|
- if (lastEntry && isPromptEqual(lastEntry, entry)) return
|
|
|
-
|
|
|
- setCurrentHistory("entries", (entries) => [entry, ...entries].slice(0, MAX_HISTORY))
|
|
|
+ const next = prependHistoryEntry(currentHistory.entries, prompt)
|
|
|
+ if (next === currentHistory.entries) return
|
|
|
+ setCurrentHistory("entries", next)
|
|
|
}
|
|
|
|
|
|
const navigateHistory = (direction: "up" | "down") => {
|
|
|
- const entries = store.mode === "shell" ? shellHistory.entries : history.entries
|
|
|
- const current = store.historyIndex
|
|
|
-
|
|
|
- if (direction === "up") {
|
|
|
- if (entries.length === 0) return false
|
|
|
- if (current === -1) {
|
|
|
- setStore("savedPrompt", clonePromptParts(prompt.current()))
|
|
|
- setStore("historyIndex", 0)
|
|
|
- applyHistoryPrompt(entries[0], "start")
|
|
|
- return true
|
|
|
- }
|
|
|
- if (current < entries.length - 1) {
|
|
|
- const next = current + 1
|
|
|
- setStore("historyIndex", next)
|
|
|
- applyHistoryPrompt(entries[next], "start")
|
|
|
- return true
|
|
|
- }
|
|
|
- return false
|
|
|
- }
|
|
|
+ const result = navigatePromptHistory({
|
|
|
+ direction,
|
|
|
+ entries: store.mode === "shell" ? shellHistory.entries : history.entries,
|
|
|
+ historyIndex: store.historyIndex,
|
|
|
+ currentPrompt: prompt.current(),
|
|
|
+ savedPrompt: store.savedPrompt,
|
|
|
+ })
|
|
|
+ if (!result.handled) return false
|
|
|
+ setStore("historyIndex", result.historyIndex)
|
|
|
+ setStore("savedPrompt", result.savedPrompt)
|
|
|
+ applyHistoryPrompt(result.prompt, result.cursor)
|
|
|
+ return true
|
|
|
+ }
|
|
|
|
|
|
- if (current > 0) {
|
|
|
- const next = current - 1
|
|
|
- setStore("historyIndex", next)
|
|
|
- applyHistoryPrompt(entries[next], "end")
|
|
|
- return true
|
|
|
- }
|
|
|
- if (current === 0) {
|
|
|
- setStore("historyIndex", -1)
|
|
|
- const saved = store.savedPrompt
|
|
|
- if (saved) {
|
|
|
- applyHistoryPrompt(saved, "end")
|
|
|
- setStore("savedPrompt", null)
|
|
|
- return true
|
|
|
- }
|
|
|
- applyHistoryPrompt(DEFAULT_PROMPT, "end")
|
|
|
- return true
|
|
|
- }
|
|
|
+ const { addImageAttachment, removeImageAttachment, handlePaste } = createPromptAttachments({
|
|
|
+ editor: () => editorRef,
|
|
|
+ isFocused,
|
|
|
+ isDialogActive: () => !!dialog.active,
|
|
|
+ setDragging: (value) => setStore("dragging", value),
|
|
|
+ addPart,
|
|
|
+ })
|
|
|
|
|
|
- return false
|
|
|
- }
|
|
|
+ const { abort, handleSubmit } = createPromptSubmit({
|
|
|
+ info,
|
|
|
+ imageAttachments,
|
|
|
+ commentCount,
|
|
|
+ mode: () => store.mode,
|
|
|
+ working,
|
|
|
+ editor: () => editorRef,
|
|
|
+ queueScroll,
|
|
|
+ promptLength,
|
|
|
+ addToHistory,
|
|
|
+ resetHistoryNavigation: () => {
|
|
|
+ setStore("historyIndex", -1)
|
|
|
+ setStore("savedPrompt", null)
|
|
|
+ },
|
|
|
+ setMode: (mode) => setStore("mode", mode),
|
|
|
+ setPopover: (popover) => setStore("popover", popover),
|
|
|
+ newSessionWorktree: props.newSessionWorktree,
|
|
|
+ onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
|
|
+ onSubmit: props.onSubmit,
|
|
|
+ })
|
|
|
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
|
if (event.key === "Backspace") {
|
|
|
@@ -1127,503 +926,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- const handleSubmit = async (event: Event) => {
|
|
|
- event.preventDefault()
|
|
|
-
|
|
|
- const currentPrompt = prompt.current()
|
|
|
- const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
|
|
|
- const images = imageAttachments().slice()
|
|
|
- const mode = store.mode
|
|
|
-
|
|
|
- if (text.trim().length === 0 && images.length === 0 && commentCount() === 0) {
|
|
|
- if (working()) abort()
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- const currentModel = local.model.current()
|
|
|
- const currentAgent = local.agent.current()
|
|
|
- if (!currentModel || !currentAgent) {
|
|
|
- showToast({
|
|
|
- title: language.t("prompt.toast.modelAgentRequired.title"),
|
|
|
- description: language.t("prompt.toast.modelAgentRequired.description"),
|
|
|
- })
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- const errorMessage = (err: unknown) => {
|
|
|
- if (err && typeof err === "object" && "data" in err) {
|
|
|
- const data = (err as { data?: { message?: string } }).data
|
|
|
- if (data?.message) return data.message
|
|
|
- }
|
|
|
- if (err instanceof Error) return err.message
|
|
|
- return language.t("common.requestFailed")
|
|
|
- }
|
|
|
-
|
|
|
- addToHistory(currentPrompt, mode)
|
|
|
- setStore("historyIndex", -1)
|
|
|
- setStore("savedPrompt", null)
|
|
|
-
|
|
|
- const projectDirectory = sdk.directory
|
|
|
- const isNewSession = !params.id
|
|
|
- const worktreeSelection = props.newSessionWorktree ?? "main"
|
|
|
-
|
|
|
- let sessionDirectory = projectDirectory
|
|
|
- let client = sdk.client
|
|
|
-
|
|
|
- if (isNewSession) {
|
|
|
- if (worktreeSelection === "create") {
|
|
|
- const createdWorktree = await client.worktree
|
|
|
- .create({ directory: projectDirectory })
|
|
|
- .then((x) => x.data)
|
|
|
- .catch((err) => {
|
|
|
- showToast({
|
|
|
- title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
|
|
- description: errorMessage(err),
|
|
|
- })
|
|
|
- return undefined
|
|
|
- })
|
|
|
-
|
|
|
- if (!createdWorktree?.directory) {
|
|
|
- showToast({
|
|
|
- title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
|
|
- description: language.t("common.requestFailed"),
|
|
|
- })
|
|
|
- return
|
|
|
- }
|
|
|
- WorktreeState.pending(createdWorktree.directory)
|
|
|
- sessionDirectory = createdWorktree.directory
|
|
|
- }
|
|
|
-
|
|
|
- if (worktreeSelection !== "main" && worktreeSelection !== "create") {
|
|
|
- sessionDirectory = worktreeSelection
|
|
|
- }
|
|
|
-
|
|
|
- if (sessionDirectory !== projectDirectory) {
|
|
|
- client = createOpencodeClient({
|
|
|
- baseUrl: sdk.url,
|
|
|
- fetch: platform.fetch,
|
|
|
- directory: sessionDirectory,
|
|
|
- throwOnError: true,
|
|
|
- })
|
|
|
- globalSync.child(sessionDirectory)
|
|
|
- }
|
|
|
-
|
|
|
- props.onNewSessionWorktreeReset?.()
|
|
|
- }
|
|
|
-
|
|
|
- let session = info()
|
|
|
- if (!session && isNewSession) {
|
|
|
- session = await client.session
|
|
|
- .create()
|
|
|
- .then((x) => x.data ?? undefined)
|
|
|
- .catch((err) => {
|
|
|
- showToast({
|
|
|
- title: language.t("prompt.toast.sessionCreateFailed.title"),
|
|
|
- description: errorMessage(err),
|
|
|
- })
|
|
|
- return undefined
|
|
|
- })
|
|
|
- if (session) {
|
|
|
- layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
|
|
- navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
|
|
- }
|
|
|
- }
|
|
|
- if (!session) return
|
|
|
-
|
|
|
- props.onSubmit?.()
|
|
|
-
|
|
|
- const model = {
|
|
|
- modelID: currentModel.id,
|
|
|
- providerID: currentModel.provider.id,
|
|
|
- }
|
|
|
- const agent = currentAgent.name
|
|
|
- const variant = local.model.variant.current()
|
|
|
-
|
|
|
- const clearInput = () => {
|
|
|
- prompt.reset()
|
|
|
- setStore("mode", "normal")
|
|
|
- setStore("popover", null)
|
|
|
- }
|
|
|
-
|
|
|
- const restoreInput = () => {
|
|
|
- prompt.set(currentPrompt, promptLength(currentPrompt))
|
|
|
- setStore("mode", mode)
|
|
|
- setStore("popover", null)
|
|
|
- requestAnimationFrame(() => {
|
|
|
- editorRef.focus()
|
|
|
- setCursorPosition(editorRef, promptLength(currentPrompt))
|
|
|
- queueScroll()
|
|
|
- })
|
|
|
- }
|
|
|
-
|
|
|
- if (mode === "shell") {
|
|
|
- clearInput()
|
|
|
- client.session
|
|
|
- .shell({
|
|
|
- sessionID: session.id,
|
|
|
- agent,
|
|
|
- model,
|
|
|
- command: text,
|
|
|
- })
|
|
|
- .catch((err) => {
|
|
|
- showToast({
|
|
|
- title: language.t("prompt.toast.shellSendFailed.title"),
|
|
|
- description: errorMessage(err),
|
|
|
- })
|
|
|
- restoreInput()
|
|
|
- })
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- 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) {
|
|
|
- clearInput()
|
|
|
- client.session
|
|
|
- .command({
|
|
|
- sessionID: session.id,
|
|
|
- command: commandName,
|
|
|
- arguments: args.join(" "),
|
|
|
- agent,
|
|
|
- model: `${model.providerID}/${model.modelID}`,
|
|
|
- variant,
|
|
|
- parts: images.map((attachment) => ({
|
|
|
- id: Identifier.ascending("part"),
|
|
|
- type: "file" as const,
|
|
|
- mime: attachment.mime,
|
|
|
- url: attachment.dataUrl,
|
|
|
- filename: attachment.filename,
|
|
|
- })),
|
|
|
- })
|
|
|
- .catch((err) => {
|
|
|
- showToast({
|
|
|
- title: language.t("prompt.toast.commandSendFailed.title"),
|
|
|
- description: errorMessage(err),
|
|
|
- })
|
|
|
- restoreInput()
|
|
|
- })
|
|
|
- return
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- const toAbsolutePath = (path: string) =>
|
|
|
- path.startsWith("/") ? path : (sessionDirectory + "/" + path).replace("//", "/")
|
|
|
-
|
|
|
- const fileAttachments = currentPrompt.filter((part) => part.type === "file") as FileAttachmentPart[]
|
|
|
- const agentAttachments = currentPrompt.filter((part) => part.type === "agent") as AgentPart[]
|
|
|
-
|
|
|
- const fileAttachmentParts = fileAttachments.map((attachment) => {
|
|
|
- const absolute = toAbsolutePath(attachment.path)
|
|
|
- const query = attachment.selection
|
|
|
- ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
|
|
|
- : ""
|
|
|
- return {
|
|
|
- id: Identifier.ascending("part"),
|
|
|
- type: "file" as const,
|
|
|
- mime: "text/plain",
|
|
|
- url: `file://${absolute}${query}`,
|
|
|
- filename: getFilename(attachment.path),
|
|
|
- source: {
|
|
|
- type: "file" as const,
|
|
|
- text: {
|
|
|
- value: attachment.content,
|
|
|
- start: attachment.start,
|
|
|
- end: attachment.end,
|
|
|
- },
|
|
|
- path: absolute,
|
|
|
- },
|
|
|
- }
|
|
|
- })
|
|
|
-
|
|
|
- const agentAttachmentParts = agentAttachments.map((attachment) => ({
|
|
|
- id: Identifier.ascending("part"),
|
|
|
- type: "agent" as const,
|
|
|
- name: attachment.name,
|
|
|
- source: {
|
|
|
- value: attachment.content,
|
|
|
- start: attachment.start,
|
|
|
- end: attachment.end,
|
|
|
- },
|
|
|
- }))
|
|
|
-
|
|
|
- const usedUrls = new Set(fileAttachmentParts.map((part) => part.url))
|
|
|
-
|
|
|
- const context = prompt.context.items().slice()
|
|
|
-
|
|
|
- const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
|
|
-
|
|
|
- const contextParts: Array<
|
|
|
- | {
|
|
|
- id: string
|
|
|
- type: "text"
|
|
|
- text: string
|
|
|
- synthetic?: boolean
|
|
|
- }
|
|
|
- | {
|
|
|
- id: string
|
|
|
- type: "file"
|
|
|
- mime: string
|
|
|
- url: string
|
|
|
- filename?: string
|
|
|
- }
|
|
|
- > = []
|
|
|
-
|
|
|
- const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => {
|
|
|
- const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined
|
|
|
- const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined
|
|
|
- const range =
|
|
|
- start === undefined || end === undefined
|
|
|
- ? "this file"
|
|
|
- : start === end
|
|
|
- ? `line ${start}`
|
|
|
- : `lines ${start} through ${end}`
|
|
|
-
|
|
|
- return `The user made the following comment regarding ${range} of ${path}: ${comment}`
|
|
|
- }
|
|
|
-
|
|
|
- const addContextFile = (input: { path: string; selection?: FileSelection; comment?: string }) => {
|
|
|
- const absolute = toAbsolutePath(input.path)
|
|
|
- const query = input.selection ? `?start=${input.selection.startLine}&end=${input.selection.endLine}` : ""
|
|
|
- const url = `file://${absolute}${query}`
|
|
|
-
|
|
|
- const comment = input.comment?.trim()
|
|
|
- if (!comment && usedUrls.has(url)) return
|
|
|
- usedUrls.add(url)
|
|
|
-
|
|
|
- if (comment) {
|
|
|
- contextParts.push({
|
|
|
- id: Identifier.ascending("part"),
|
|
|
- type: "text",
|
|
|
- text: commentNote(input.path, input.selection, comment),
|
|
|
- synthetic: true,
|
|
|
- })
|
|
|
- }
|
|
|
-
|
|
|
- contextParts.push({
|
|
|
- id: Identifier.ascending("part"),
|
|
|
- type: "file",
|
|
|
- mime: "text/plain",
|
|
|
- url,
|
|
|
- filename: getFilename(input.path),
|
|
|
- })
|
|
|
- }
|
|
|
-
|
|
|
- for (const item of context) {
|
|
|
- if (item.type !== "file") continue
|
|
|
- addContextFile({ path: item.path, selection: item.selection, comment: item.comment })
|
|
|
- }
|
|
|
-
|
|
|
- const imageAttachmentParts = images.map((attachment) => ({
|
|
|
- id: Identifier.ascending("part"),
|
|
|
- type: "file" as const,
|
|
|
- mime: attachment.mime,
|
|
|
- url: attachment.dataUrl,
|
|
|
- filename: attachment.filename,
|
|
|
- }))
|
|
|
-
|
|
|
- const messageID = Identifier.ascending("message")
|
|
|
- const textPart = {
|
|
|
- id: Identifier.ascending("part"),
|
|
|
- type: "text" as const,
|
|
|
- text,
|
|
|
- }
|
|
|
- const requestParts = [
|
|
|
- textPart,
|
|
|
- ...fileAttachmentParts,
|
|
|
- ...contextParts,
|
|
|
- ...agentAttachmentParts,
|
|
|
- ...imageAttachmentParts,
|
|
|
- ]
|
|
|
-
|
|
|
- const optimisticParts = requestParts.map((part) => ({
|
|
|
- ...part,
|
|
|
- sessionID: session.id,
|
|
|
- messageID,
|
|
|
- })) as unknown as Part[]
|
|
|
-
|
|
|
- const optimisticMessage: Message = {
|
|
|
- id: messageID,
|
|
|
- sessionID: session.id,
|
|
|
- role: "user",
|
|
|
- time: { created: Date.now() },
|
|
|
- agent,
|
|
|
- model,
|
|
|
- }
|
|
|
-
|
|
|
- const addOptimisticMessage = () => {
|
|
|
- if (sessionDirectory === projectDirectory) {
|
|
|
- sync.set(
|
|
|
- produce((draft) => {
|
|
|
- const messages = draft.message[session.id]
|
|
|
- if (!messages) {
|
|
|
- draft.message[session.id] = [optimisticMessage]
|
|
|
- } else {
|
|
|
- const result = Binary.search(messages, messageID, (m) => m.id)
|
|
|
- messages.splice(result.index, 0, optimisticMessage)
|
|
|
- }
|
|
|
- draft.part[messageID] = optimisticParts
|
|
|
- .filter((p) => !!p?.id)
|
|
|
- .slice()
|
|
|
- .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
|
- }),
|
|
|
- )
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- globalSync.child(sessionDirectory)[1](
|
|
|
- produce((draft) => {
|
|
|
- const messages = draft.message[session.id]
|
|
|
- if (!messages) {
|
|
|
- draft.message[session.id] = [optimisticMessage]
|
|
|
- } else {
|
|
|
- const result = Binary.search(messages, messageID, (m) => m.id)
|
|
|
- messages.splice(result.index, 0, optimisticMessage)
|
|
|
- }
|
|
|
- draft.part[messageID] = optimisticParts
|
|
|
- .filter((p) => !!p?.id)
|
|
|
- .slice()
|
|
|
- .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
|
- }),
|
|
|
- )
|
|
|
- }
|
|
|
-
|
|
|
- const removeOptimisticMessage = () => {
|
|
|
- if (sessionDirectory === projectDirectory) {
|
|
|
- sync.set(
|
|
|
- produce((draft) => {
|
|
|
- const messages = draft.message[session.id]
|
|
|
- if (messages) {
|
|
|
- const result = Binary.search(messages, messageID, (m) => m.id)
|
|
|
- if (result.found) messages.splice(result.index, 1)
|
|
|
- }
|
|
|
- delete draft.part[messageID]
|
|
|
- }),
|
|
|
- )
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- globalSync.child(sessionDirectory)[1](
|
|
|
- produce((draft) => {
|
|
|
- const messages = draft.message[session.id]
|
|
|
- if (messages) {
|
|
|
- const result = Binary.search(messages, messageID, (m) => m.id)
|
|
|
- if (result.found) messages.splice(result.index, 1)
|
|
|
- }
|
|
|
- delete draft.part[messageID]
|
|
|
- }),
|
|
|
- )
|
|
|
- }
|
|
|
-
|
|
|
- for (const item of commentItems) {
|
|
|
- prompt.context.remove(item.key)
|
|
|
- }
|
|
|
-
|
|
|
- clearInput()
|
|
|
- addOptimisticMessage()
|
|
|
-
|
|
|
- const waitForWorktree = async () => {
|
|
|
- const worktree = WorktreeState.get(sessionDirectory)
|
|
|
- if (!worktree || worktree.status !== "pending") return true
|
|
|
-
|
|
|
- if (sessionDirectory === projectDirectory) {
|
|
|
- sync.set("session_status", session.id, { type: "busy" })
|
|
|
- }
|
|
|
-
|
|
|
- const controller = new AbortController()
|
|
|
-
|
|
|
- const cleanup = () => {
|
|
|
- if (sessionDirectory === projectDirectory) {
|
|
|
- sync.set("session_status", session.id, { type: "idle" })
|
|
|
- }
|
|
|
- removeOptimisticMessage()
|
|
|
- for (const item of commentItems) {
|
|
|
- prompt.context.add({
|
|
|
- type: "file",
|
|
|
- path: item.path,
|
|
|
- selection: item.selection,
|
|
|
- comment: item.comment,
|
|
|
- commentID: item.commentID,
|
|
|
- commentOrigin: item.commentOrigin,
|
|
|
- preview: item.preview,
|
|
|
- })
|
|
|
- }
|
|
|
- restoreInput()
|
|
|
- }
|
|
|
-
|
|
|
- pending.set(session.id, { abort: controller, cleanup })
|
|
|
-
|
|
|
- const abort = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
|
|
- if (controller.signal.aborted) {
|
|
|
- resolve({ status: "failed", message: "aborted" })
|
|
|
- return
|
|
|
- }
|
|
|
- controller.signal.addEventListener(
|
|
|
- "abort",
|
|
|
- () => {
|
|
|
- resolve({ status: "failed", message: "aborted" })
|
|
|
- },
|
|
|
- { once: true },
|
|
|
- )
|
|
|
- })
|
|
|
-
|
|
|
- const timeoutMs = 5 * 60 * 1000
|
|
|
- const timer = { id: undefined as number | undefined }
|
|
|
- const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
|
|
- timer.id = window.setTimeout(() => {
|
|
|
- resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") })
|
|
|
- }, timeoutMs)
|
|
|
- })
|
|
|
-
|
|
|
- const result = await Promise.race([WorktreeState.wait(sessionDirectory), abort, timeout]).finally(() => {
|
|
|
- if (timer.id === undefined) return
|
|
|
- clearTimeout(timer.id)
|
|
|
- })
|
|
|
- pending.delete(session.id)
|
|
|
- if (controller.signal.aborted) return false
|
|
|
- if (result.status === "failed") throw new Error(result.message)
|
|
|
- return true
|
|
|
- }
|
|
|
-
|
|
|
- const send = async () => {
|
|
|
- const ok = await waitForWorktree()
|
|
|
- if (!ok) return
|
|
|
- await client.session.prompt({
|
|
|
- sessionID: session.id,
|
|
|
- agent,
|
|
|
- model,
|
|
|
- messageID,
|
|
|
- parts: requestParts,
|
|
|
- variant,
|
|
|
- })
|
|
|
- }
|
|
|
-
|
|
|
- void send().catch((err) => {
|
|
|
- pending.delete(session.id)
|
|
|
- if (sessionDirectory === projectDirectory) {
|
|
|
- sync.set("session_status", session.id, { type: "idle" })
|
|
|
- }
|
|
|
- showToast({
|
|
|
- title: language.t("prompt.toast.promptSendFailed.title"),
|
|
|
- description: errorMessage(err),
|
|
|
- })
|
|
|
- removeOptimisticMessage()
|
|
|
- for (const item of commentItems) {
|
|
|
- prompt.context.add({
|
|
|
- type: "file",
|
|
|
- path: item.path,
|
|
|
- selection: item.selection,
|
|
|
- comment: item.comment,
|
|
|
- commentID: item.commentID,
|
|
|
- commentOrigin: item.commentOrigin,
|
|
|
- preview: item.preview,
|
|
|
- })
|
|
|
- }
|
|
|
- restoreInput()
|
|
|
- })
|
|
|
- }
|
|
|
-
|
|
|
return (
|
|
|
<div class="relative size-full _max-h-[320px] flex flex-col gap-3">
|
|
|
<Show when={store.popover}>
|
|
|
@@ -2087,109 +1389,3 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
</div>
|
|
|
)
|
|
|
}
|
|
|
-
|
|
|
-function createTextFragment(content: string): DocumentFragment {
|
|
|
- const fragment = document.createDocumentFragment()
|
|
|
- const segments = content.split("\n")
|
|
|
- segments.forEach((segment, index) => {
|
|
|
- if (segment) {
|
|
|
- fragment.appendChild(document.createTextNode(segment))
|
|
|
- } else if (segments.length > 1) {
|
|
|
- fragment.appendChild(document.createTextNode("\u200B"))
|
|
|
- }
|
|
|
- if (index < segments.length - 1) {
|
|
|
- fragment.appendChild(document.createElement("br"))
|
|
|
- }
|
|
|
- })
|
|
|
- return fragment
|
|
|
-}
|
|
|
-
|
|
|
-function getNodeLength(node: Node): number {
|
|
|
- if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
|
|
|
- return (node.textContent ?? "").replace(/\u200B/g, "").length
|
|
|
-}
|
|
|
-
|
|
|
-function getTextLength(node: Node): number {
|
|
|
- if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length
|
|
|
- if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
|
|
|
- let length = 0
|
|
|
- for (const child of Array.from(node.childNodes)) {
|
|
|
- length += getTextLength(child)
|
|
|
- }
|
|
|
- return length
|
|
|
-}
|
|
|
-
|
|
|
-function getCursorPosition(parent: HTMLElement): number {
|
|
|
- const selection = window.getSelection()
|
|
|
- if (!selection || selection.rangeCount === 0) return 0
|
|
|
- const range = selection.getRangeAt(0)
|
|
|
- if (!parent.contains(range.startContainer)) return 0
|
|
|
- const preCaretRange = range.cloneRange()
|
|
|
- preCaretRange.selectNodeContents(parent)
|
|
|
- preCaretRange.setEnd(range.startContainer, range.startOffset)
|
|
|
- return getTextLength(preCaretRange.cloneContents())
|
|
|
-}
|
|
|
-
|
|
|
-function setCursorPosition(parent: HTMLElement, position: number) {
|
|
|
- let remaining = position
|
|
|
- let node = parent.firstChild
|
|
|
- while (node) {
|
|
|
- const length = getNodeLength(node)
|
|
|
- const isText = node.nodeType === Node.TEXT_NODE
|
|
|
- const isPill =
|
|
|
- node.nodeType === Node.ELEMENT_NODE &&
|
|
|
- ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
|
|
|
- const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
|
|
|
-
|
|
|
- if (isText && remaining <= length) {
|
|
|
- const range = document.createRange()
|
|
|
- const selection = window.getSelection()
|
|
|
- range.setStart(node, remaining)
|
|
|
- range.collapse(true)
|
|
|
- selection?.removeAllRanges()
|
|
|
- selection?.addRange(range)
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- if ((isPill || isBreak) && remaining <= length) {
|
|
|
- const range = document.createRange()
|
|
|
- const selection = window.getSelection()
|
|
|
- if (remaining === 0) {
|
|
|
- range.setStartBefore(node)
|
|
|
- }
|
|
|
- if (remaining > 0 && isPill) {
|
|
|
- range.setStartAfter(node)
|
|
|
- }
|
|
|
- if (remaining > 0 && isBreak) {
|
|
|
- const next = node.nextSibling
|
|
|
- if (next && next.nodeType === Node.TEXT_NODE) {
|
|
|
- range.setStart(next, 0)
|
|
|
- }
|
|
|
- if (!next || next.nodeType !== Node.TEXT_NODE) {
|
|
|
- range.setStartAfter(node)
|
|
|
- }
|
|
|
- }
|
|
|
- range.collapse(true)
|
|
|
- selection?.removeAllRanges()
|
|
|
- selection?.addRange(range)
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- remaining -= length
|
|
|
- node = node.nextSibling
|
|
|
- }
|
|
|
-
|
|
|
- const fallbackRange = document.createRange()
|
|
|
- const fallbackSelection = window.getSelection()
|
|
|
- const last = parent.lastChild
|
|
|
- if (last && last.nodeType === Node.TEXT_NODE) {
|
|
|
- const len = last.textContent ? last.textContent.length : 0
|
|
|
- fallbackRange.setStart(last, len)
|
|
|
- }
|
|
|
- if (!last || last.nodeType !== Node.TEXT_NODE) {
|
|
|
- fallbackRange.selectNodeContents(parent)
|
|
|
- }
|
|
|
- fallbackRange.collapse(false)
|
|
|
- fallbackSelection?.removeAllRanges()
|
|
|
- fallbackSelection?.addRange(fallbackRange)
|
|
|
-}
|