|
|
@@ -21,6 +21,7 @@ import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid
|
|
|
import { useProviders } from "@/hooks/use-providers"
|
|
|
import { useCommand, formatKeybind } from "@/context/command"
|
|
|
import { persisted } from "@/utils/persist"
|
|
|
+import { Identifier } from "@opencode-ai/util/identifier"
|
|
|
|
|
|
const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
|
|
const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
|
|
|
@@ -100,6 +101,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
dragging: boolean
|
|
|
imageAttachments: ImageAttachmentPart[]
|
|
|
mode: "normal" | "shell"
|
|
|
+ applyingHistory: boolean
|
|
|
}>({
|
|
|
popover: null,
|
|
|
historyIndex: -1,
|
|
|
@@ -108,6 +110,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
dragging: false,
|
|
|
imageAttachments: [],
|
|
|
mode: "normal",
|
|
|
+ applyingHistory: false,
|
|
|
})
|
|
|
|
|
|
const MAX_HISTORY = 100
|
|
|
@@ -135,10 +138,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
|
|
|
const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
|
|
|
const length = position === "start" ? 0 : promptLength(p)
|
|
|
+ setStore("applyingHistory", true)
|
|
|
prompt.set(p, length)
|
|
|
requestAnimationFrame(() => {
|
|
|
editorRef.focus()
|
|
|
setCursorPosition(editorRef, length)
|
|
|
+ setStore("applyingHistory", false)
|
|
|
})
|
|
|
}
|
|
|
|
|
|
@@ -429,21 +434,42 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
const rawParts = parseFromDOM()
|
|
|
const cursorPosition = getCursorPosition(editorRef)
|
|
|
const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("")
|
|
|
+ const trimmed = rawText.replace(/\u200B/g, "").trim()
|
|
|
+ const hasNonText = rawParts.some((part) => part.type !== "text")
|
|
|
+ const shouldReset = trimmed.length === 0 && !hasNonText
|
|
|
|
|
|
- const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/)
|
|
|
- const slashMatch = rawText.match(/^\/(\S*)$/)
|
|
|
+ if (shouldReset) {
|
|
|
+ setStore("popover", null)
|
|
|
+ if (store.historyIndex >= 0 && !store.applyingHistory) {
|
|
|
+ setStore("historyIndex", -1)
|
|
|
+ setStore("savedPrompt", null)
|
|
|
+ }
|
|
|
+ if (prompt.dirty()) {
|
|
|
+ prompt.set(DEFAULT_PROMPT, 0)
|
|
|
+ }
|
|
|
+ return
|
|
|
+ }
|
|
|
|
|
|
- if (atMatch) {
|
|
|
- onInput(atMatch[1])
|
|
|
- setStore("popover", "file")
|
|
|
- } else if (slashMatch) {
|
|
|
- slashOnInput(slashMatch[1])
|
|
|
- setStore("popover", "slash")
|
|
|
+ const shellMode = store.mode === "shell"
|
|
|
+
|
|
|
+ if (!shellMode) {
|
|
|
+ const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/)
|
|
|
+ const slashMatch = rawText.match(/^\/(\S*)$/)
|
|
|
+
|
|
|
+ if (atMatch) {
|
|
|
+ onInput(atMatch[1])
|
|
|
+ setStore("popover", "file")
|
|
|
+ } else if (slashMatch) {
|
|
|
+ slashOnInput(slashMatch[1])
|
|
|
+ setStore("popover", "slash")
|
|
|
+ } else {
|
|
|
+ setStore("popover", null)
|
|
|
+ }
|
|
|
} else {
|
|
|
setStore("popover", null)
|
|
|
}
|
|
|
|
|
|
- if (store.historyIndex >= 0) {
|
|
|
+ if (store.historyIndex >= 0 && !store.applyingHistory) {
|
|
|
setStore("historyIndex", -1)
|
|
|
setStore("savedPrompt", null)
|
|
|
}
|
|
|
@@ -591,8 +617,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
}
|
|
|
}
|
|
|
if (store.mode === "shell") {
|
|
|
- const cursorPosition = getCursorPosition(editorRef)
|
|
|
- if ((event.key === "Backspace" && cursorPosition === 0) || event.key === "Escape") {
|
|
|
+ const { collapsed, cursorPosition, textLength } = getCaretState()
|
|
|
+ if (event.key === "Escape") {
|
|
|
+ setStore("mode", "normal")
|
|
|
+ event.preventDefault()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (event.key === "Backspace" && collapsed && cursorPosition === 0 && textLength === 0) {
|
|
|
setStore("mode", "normal")
|
|
|
event.preventDefault()
|
|
|
return
|
|
|
@@ -685,6 +716,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
|
|
|
: ""
|
|
|
return {
|
|
|
+ id: Identifier.ascending("part"),
|
|
|
type: "file" as const,
|
|
|
mime: "text/plain",
|
|
|
url: `file://${absolute}${query}`,
|
|
|
@@ -702,6 +734,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
})
|
|
|
|
|
|
const imageAttachmentParts = store.imageAttachments.map((attachment) => ({
|
|
|
+ id: Identifier.ascending("part"),
|
|
|
type: "file" as const,
|
|
|
mime: attachment.mime,
|
|
|
url: attachment.dataUrl,
|
|
|
@@ -747,14 +780,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ const messageID = Identifier.ascending("message")
|
|
|
+ const textPart = {
|
|
|
+ id: Identifier.ascending("part"),
|
|
|
+ type: "text" as const,
|
|
|
+ text,
|
|
|
+ }
|
|
|
+ const requestParts = [textPart, ...fileAttachmentParts, ...imageAttachmentParts]
|
|
|
+ const optimisticParts = requestParts.map((part) => ({
|
|
|
+ ...part,
|
|
|
+ sessionID: existing.id,
|
|
|
+ messageID,
|
|
|
+ }))
|
|
|
+
|
|
|
sync.session.addOptimisticMessage({
|
|
|
sessionID: existing.id,
|
|
|
- text,
|
|
|
- parts: [
|
|
|
- { type: "text", text } as import("@opencode-ai/sdk/v2/client").Part,
|
|
|
- ...(fileAttachmentParts as import("@opencode-ai/sdk/v2/client").Part[]),
|
|
|
- ...(imageAttachmentParts as import("@opencode-ai/sdk/v2/client").Part[]),
|
|
|
- ],
|
|
|
+ messageID,
|
|
|
+ parts: optimisticParts,
|
|
|
agent,
|
|
|
model,
|
|
|
})
|
|
|
@@ -763,14 +805,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
sessionID: existing.id,
|
|
|
agent,
|
|
|
model,
|
|
|
- parts: [
|
|
|
- {
|
|
|
- type: "text",
|
|
|
- text,
|
|
|
- },
|
|
|
- ...fileAttachmentParts,
|
|
|
- ...imageAttachmentParts,
|
|
|
- ],
|
|
|
+ messageID,
|
|
|
+ parts: requestParts,
|
|
|
})
|
|
|
}
|
|
|
|
|
|
@@ -911,6 +947,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
classList={{
|
|
|
"w-full px-5 py-3 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
|
|
"[&>[data-type=file]]:text-icon-info-active": true,
|
|
|
+ "font-mono!": store.mode === "shell",
|
|
|
}}
|
|
|
/>
|
|
|
<Show when={!prompt.dirty() && store.imageAttachments.length === 0}>
|