|
|
@@ -3,7 +3,15 @@ import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Mat
|
|
|
import { createStore, produce } from "solid-js/store"
|
|
|
import { createFocusSignal } from "@solid-primitives/active-element"
|
|
|
import { useLocal } from "@/context/local"
|
|
|
-import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt, ImageAttachmentPart } from "@/context/prompt"
|
|
|
+import {
|
|
|
+ ContentPart,
|
|
|
+ DEFAULT_PROMPT,
|
|
|
+ isPromptEqual,
|
|
|
+ Prompt,
|
|
|
+ usePrompt,
|
|
|
+ ImageAttachmentPart,
|
|
|
+ AgentPart,
|
|
|
+} from "@/context/prompt"
|
|
|
import { useLayout } from "@/context/layout"
|
|
|
import { useSDK } from "@/context/sdk"
|
|
|
import { useNavigate, useParams } from "@solidjs/router"
|
|
|
@@ -11,18 +19,19 @@ import { useSync } from "@/context/sync"
|
|
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
|
|
import { Button } from "@opencode-ai/ui/button"
|
|
|
import { Icon } from "@opencode-ai/ui/icon"
|
|
|
-import { Tooltip } from "@opencode-ai/ui/tooltip"
|
|
|
+import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
|
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
|
|
import { Select } from "@opencode-ai/ui/select"
|
|
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
|
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
|
-import { DialogSelectModel } from "@/components/dialog-select-model"
|
|
|
+import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
|
|
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
|
|
import { useProviders } from "@/hooks/use-providers"
|
|
|
import { useCommand } from "@/context/command"
|
|
|
import { persisted } from "@/utils/persist"
|
|
|
import { Identifier } from "@/utils/id"
|
|
|
import { SessionContextUsage } from "@/components/session-context-usage"
|
|
|
+import { usePermission } from "@/context/permission"
|
|
|
|
|
|
const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
|
|
const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
|
|
|
@@ -80,6 +89,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
const dialog = useDialog()
|
|
|
const providers = useProviders()
|
|
|
const command = useCommand()
|
|
|
+ const permission = usePermission()
|
|
|
let editorRef!: HTMLDivElement
|
|
|
let fileInputRef!: HTMLInputElement
|
|
|
let scrollRef!: HTMLDivElement
|
|
|
@@ -126,7 +136,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
const working = createMemo(() => status()?.type !== "idle")
|
|
|
|
|
|
const [store, setStore] = createStore<{
|
|
|
- popover: "file" | "slash" | null
|
|
|
+ popover: "at" | "slash" | null
|
|
|
historyIndex: number
|
|
|
savedPrompt: Prompt | null
|
|
|
placeholder: number
|
|
|
@@ -134,6 +144,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
imageAttachments: ImageAttachmentPart[]
|
|
|
mode: "normal" | "shell"
|
|
|
applyingHistory: boolean
|
|
|
+ killBuffer: string
|
|
|
}>({
|
|
|
popover: null,
|
|
|
historyIndex: -1,
|
|
|
@@ -143,6 +154,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
imageAttachments: [],
|
|
|
mode: "normal",
|
|
|
applyingHistory: false,
|
|
|
+ killBuffer: "",
|
|
|
})
|
|
|
|
|
|
const MAX_HISTORY = 100
|
|
|
@@ -167,6 +179,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
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,
|
|
|
@@ -317,15 +330,43 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
if (!isFocused()) setStore("popover", null)
|
|
|
})
|
|
|
|
|
|
- const handleFileSelect = (path: string | undefined) => {
|
|
|
- if (!path) return
|
|
|
- addPart({ type: "file", path, content: "@" + path, start: 0, end: 0 })
|
|
|
+ type AtOption = { type: "agent"; name: string; display: string } | { type: "file"; path: string; display: string }
|
|
|
+
|
|
|
+ const agentList = createMemo(() =>
|
|
|
+ sync.data.agent
|
|
|
+ .filter((agent) => !agent.hidden && agent.mode !== "primary")
|
|
|
+ .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
|
|
|
+ )
|
|
|
+
|
|
|
+ const handleAtSelect = (option: AtOption | undefined) => {
|
|
|
+ if (!option) return
|
|
|
+ if (option.type === "agent") {
|
|
|
+ addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 })
|
|
|
+ } else {
|
|
|
+ addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 })
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- const { flat, active, onInput, onKeyDown } = useFilteredList<string>({
|
|
|
- items: local.file.searchFilesAndDirectories,
|
|
|
- key: (x) => x,
|
|
|
- onSelect: handleFileSelect,
|
|
|
+ const atKey = (x: AtOption | undefined) => {
|
|
|
+ if (!x) return ""
|
|
|
+ return x.type === "agent" ? `agent:${x.name}` : `file:${x.path}`
|
|
|
+ }
|
|
|
+
|
|
|
+ const {
|
|
|
+ flat: atFlat,
|
|
|
+ active: atActive,
|
|
|
+ onInput: atOnInput,
|
|
|
+ onKeyDown: atOnKeyDown,
|
|
|
+ } = useFilteredList<AtOption>({
|
|
|
+ items: async (query) => {
|
|
|
+ const agents = agentList()
|
|
|
+ const files = await local.file.searchFilesAndDirectories(query)
|
|
|
+ const fileOptions: AtOption[] = files.map((path) => ({ type: "file", path, display: path }))
|
|
|
+ return [...agents, ...fileOptions]
|
|
|
+ },
|
|
|
+ key: atKey,
|
|
|
+ filterKeys: ["display"],
|
|
|
+ onSelect: handleAtSelect,
|
|
|
})
|
|
|
|
|
|
const slashCommands = createMemo<SlashCommand[]>(() => {
|
|
|
@@ -411,6 +452,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
if (node.nodeType !== Node.ELEMENT_NODE) return false
|
|
|
const el = node as HTMLElement
|
|
|
if (el.dataset.type === "file") return true
|
|
|
+ if (el.dataset.type === "agent") return true
|
|
|
return el.tagName === "BR"
|
|
|
})
|
|
|
if (normalized && isPromptEqual(currentParts, domParts)) return
|
|
|
@@ -434,6 +476,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
pill.style.userSelect = "text"
|
|
|
pill.style.cursor = "default"
|
|
|
editorRef.appendChild(pill)
|
|
|
+ } else if (part.type === "agent") {
|
|
|
+ const pill = document.createElement("span")
|
|
|
+ pill.textContent = part.content
|
|
|
+ pill.setAttribute("data-type", "agent")
|
|
|
+ pill.setAttribute("data-name", part.name)
|
|
|
+ pill.setAttribute("contenteditable", "false")
|
|
|
+ pill.style.userSelect = "text"
|
|
|
+ pill.style.cursor = "default"
|
|
|
+ editorRef.appendChild(pill)
|
|
|
}
|
|
|
})
|
|
|
|
|
|
@@ -469,6 +520,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
position += content.length
|
|
|
}
|
|
|
|
|
|
+ const pushAgent = (agent: HTMLElement) => {
|
|
|
+ const content = agent.textContent ?? ""
|
|
|
+ parts.push({
|
|
|
+ type: "agent",
|
|
|
+ name: agent.dataset.name!,
|
|
|
+ content,
|
|
|
+ start: position,
|
|
|
+ end: position + content.length,
|
|
|
+ })
|
|
|
+ position += content.length
|
|
|
+ }
|
|
|
+
|
|
|
const visit = (node: Node) => {
|
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
|
buffer += node.textContent ?? ""
|
|
|
@@ -482,6 +545,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
pushFile(el)
|
|
|
return
|
|
|
}
|
|
|
+ if (el.dataset.type === "agent") {
|
|
|
+ flushText()
|
|
|
+ pushAgent(el)
|
|
|
+ return
|
|
|
+ }
|
|
|
if (el.tagName === "BR") {
|
|
|
buffer += "\n"
|
|
|
return
|
|
|
@@ -535,8 +603,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
const slashMatch = rawText.match(/^\/(\S*)$/)
|
|
|
|
|
|
if (atMatch) {
|
|
|
- onInput(atMatch[1])
|
|
|
- setStore("popover", "file")
|
|
|
+ atOnInput(atMatch[1])
|
|
|
+ setStore("popover", "at")
|
|
|
} else if (slashMatch) {
|
|
|
slashOnInput(slashMatch[1])
|
|
|
setStore("popover", "slash")
|
|
|
@@ -556,6 +624,36 @@ 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
|
|
|
@@ -578,38 +676,35 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
const gap = document.createTextNode(" ")
|
|
|
const range = selection.getRangeAt(0)
|
|
|
|
|
|
- const setEdge = (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 isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
|
|
|
- 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 (atMatch) {
|
|
|
+ const start = atMatch.index ?? cursorPosition - atMatch[0].length
|
|
|
+ setRangeEdge(range, "start", start)
|
|
|
+ setRangeEdge(range, "end", cursorPosition)
|
|
|
+ }
|
|
|
|
|
|
- if ((isFile || 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
|
|
|
- }
|
|
|
+ range.deleteContents()
|
|
|
+ range.insertNode(gap)
|
|
|
+ range.insertNode(pill)
|
|
|
+ range.setStartAfter(gap)
|
|
|
+ range.collapse(true)
|
|
|
+ selection.removeAllRanges()
|
|
|
+ selection.addRange(range)
|
|
|
+ } else if (part.type === "agent") {
|
|
|
+ const pill = document.createElement("span")
|
|
|
+ pill.textContent = part.content
|
|
|
+ pill.setAttribute("data-type", "agent")
|
|
|
+ pill.setAttribute("data-name", part.name)
|
|
|
+ pill.setAttribute("contenteditable", "false")
|
|
|
+ pill.style.userSelect = "text"
|
|
|
+ pill.style.cursor = "default"
|
|
|
|
|
|
- remaining -= length
|
|
|
- }
|
|
|
- }
|
|
|
+ const gap = document.createTextNode(" ")
|
|
|
+ const range = selection.getRangeAt(0)
|
|
|
|
|
|
if (atMatch) {
|
|
|
const start = atMatch.index ?? cursorPosition - atMatch[0].length
|
|
|
- setEdge("start", start)
|
|
|
- setEdge("end", cursorPosition)
|
|
|
+ setRangeEdge(range, "start", start)
|
|
|
+ setRangeEdge(range, "end", cursorPosition)
|
|
|
}
|
|
|
|
|
|
range.deleteContents()
|
|
|
@@ -648,6 +743,77 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
setStore("popover", null)
|
|
|
}
|
|
|
|
|
|
+ const setSelectionOffsets = (start: number, end: number) => {
|
|
|
+ const selection = window.getSelection()
|
|
|
+ if (!selection) return false
|
|
|
+
|
|
|
+ const length = promptLength(prompt.current())
|
|
|
+ const a = Math.max(0, Math.min(start, length))
|
|
|
+ const b = Math.max(0, Math.min(end, length))
|
|
|
+ const rangeStart = Math.min(a, b)
|
|
|
+ const rangeEnd = Math.max(a, b)
|
|
|
+
|
|
|
+ const range = document.createRange()
|
|
|
+ range.selectNodeContents(editorRef)
|
|
|
+
|
|
|
+ const setEdge = (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 isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
|
|
|
+ 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 ((isFile || 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 last = editorRef.lastChild
|
|
|
+ if (!last) {
|
|
|
+ if (edge === "start") range.setStart(editorRef, 0)
|
|
|
+ if (edge === "end") range.setEnd(editorRef, 0)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (edge === "start") range.setStartAfter(last)
|
|
|
+ if (edge === "end") range.setEndAfter(last)
|
|
|
+ }
|
|
|
+
|
|
|
+ setEdge("start", rangeStart)
|
|
|
+ setEdge("end", rangeEnd)
|
|
|
+ selection.removeAllRanges()
|
|
|
+ selection.addRange(range)
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ const replaceOffsets = (start: number, end: number, content: string) => {
|
|
|
+ if (!setSelectionOffsets(start, end)) return false
|
|
|
+ addPart({ type: "text", content, start: 0, end: 0 })
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ const killText = (start: number, end: number) => {
|
|
|
+ if (start === end) return
|
|
|
+ const current = prompt.current()
|
|
|
+ if (!current.every((part) => part.type === "text")) return
|
|
|
+ const text = current.map((part) => part.content).join("")
|
|
|
+ setStore("killBuffer", text.slice(start, end))
|
|
|
+ }
|
|
|
+
|
|
|
const abort = () =>
|
|
|
sdk.client.session
|
|
|
.abort({
|
|
|
@@ -759,8 +925,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
}
|
|
|
|
|
|
if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
|
|
|
- if (store.popover === "file") {
|
|
|
- onKeyDown(event)
|
|
|
+ if (store.popover === "at") {
|
|
|
+ atOnKeyDown(event)
|
|
|
} else {
|
|
|
slashOnKeyDown(event)
|
|
|
}
|
|
|
@@ -768,6 +934,164 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
return
|
|
|
}
|
|
|
|
|
|
+ const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey
|
|
|
+ const alt = event.altKey && !event.metaKey && !event.ctrlKey && !event.shiftKey
|
|
|
+
|
|
|
+ if (ctrl && event.code === "KeyG") {
|
|
|
+ if (store.popover) {
|
|
|
+ setStore("popover", null)
|
|
|
+ event.preventDefault()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (working()) {
|
|
|
+ abort()
|
|
|
+ event.preventDefault()
|
|
|
+ }
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (ctrl || alt) {
|
|
|
+ const { collapsed, cursorPosition, textLength } = getCaretState()
|
|
|
+ if (collapsed) {
|
|
|
+ const current = prompt.current()
|
|
|
+ const text = current.map((part) => ("content" in part ? part.content : "")).join("")
|
|
|
+
|
|
|
+ if (ctrl) {
|
|
|
+ if (event.code === "KeyA") {
|
|
|
+ const pos = text.lastIndexOf("\n", cursorPosition - 1) + 1
|
|
|
+ setCursorPosition(editorRef, pos)
|
|
|
+ event.preventDefault()
|
|
|
+ queueScroll()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (event.code === "KeyE") {
|
|
|
+ const next = text.indexOf("\n", cursorPosition)
|
|
|
+ const pos = next === -1 ? textLength : next
|
|
|
+ setCursorPosition(editorRef, pos)
|
|
|
+ event.preventDefault()
|
|
|
+ queueScroll()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (event.code === "KeyB") {
|
|
|
+ const pos = Math.max(0, cursorPosition - 1)
|
|
|
+ setCursorPosition(editorRef, pos)
|
|
|
+ event.preventDefault()
|
|
|
+ queueScroll()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (event.code === "KeyF") {
|
|
|
+ const pos = Math.min(textLength, cursorPosition + 1)
|
|
|
+ setCursorPosition(editorRef, pos)
|
|
|
+ event.preventDefault()
|
|
|
+ queueScroll()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (event.code === "KeyD") {
|
|
|
+ if (store.mode === "shell" && cursorPosition === 0 && textLength === 0) {
|
|
|
+ setStore("mode", "normal")
|
|
|
+ event.preventDefault()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (cursorPosition >= textLength) return
|
|
|
+ replaceOffsets(cursorPosition, cursorPosition + 1, "")
|
|
|
+ event.preventDefault()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (event.code === "KeyK") {
|
|
|
+ const next = text.indexOf("\n", cursorPosition)
|
|
|
+ const lineEnd = next === -1 ? textLength : next
|
|
|
+ const end = lineEnd === cursorPosition && lineEnd < textLength ? lineEnd + 1 : lineEnd
|
|
|
+ if (end === cursorPosition) return
|
|
|
+ killText(cursorPosition, end)
|
|
|
+ replaceOffsets(cursorPosition, end, "")
|
|
|
+ event.preventDefault()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (event.code === "KeyU") {
|
|
|
+ const start = text.lastIndexOf("\n", cursorPosition - 1) + 1
|
|
|
+ if (start === cursorPosition) return
|
|
|
+ killText(start, cursorPosition)
|
|
|
+ replaceOffsets(start, cursorPosition, "")
|
|
|
+ event.preventDefault()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (event.code === "KeyW") {
|
|
|
+ let start = cursorPosition
|
|
|
+ while (start > 0 && /\s/.test(text[start - 1])) start -= 1
|
|
|
+ while (start > 0 && !/\s/.test(text[start - 1])) start -= 1
|
|
|
+ if (start === cursorPosition) return
|
|
|
+ killText(start, cursorPosition)
|
|
|
+ replaceOffsets(start, cursorPosition, "")
|
|
|
+ event.preventDefault()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (event.code === "KeyY") {
|
|
|
+ if (!store.killBuffer) return
|
|
|
+ addPart({ type: "text", content: store.killBuffer, start: 0, end: 0 })
|
|
|
+ event.preventDefault()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (event.code === "KeyT") {
|
|
|
+ if (!current.every((part) => part.type === "text")) return
|
|
|
+ if (textLength < 2) return
|
|
|
+ if (cursorPosition === 0) return
|
|
|
+
|
|
|
+ const atEnd = cursorPosition === textLength
|
|
|
+ const first = atEnd ? cursorPosition - 2 : cursorPosition - 1
|
|
|
+ const second = atEnd ? cursorPosition - 1 : cursorPosition
|
|
|
+
|
|
|
+ if (text[first] === "\n" || text[second] === "\n") return
|
|
|
+
|
|
|
+ replaceOffsets(first, second + 1, `${text[second]}${text[first]}`)
|
|
|
+ event.preventDefault()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (alt) {
|
|
|
+ if (event.code === "KeyB") {
|
|
|
+ let pos = cursorPosition
|
|
|
+ while (pos > 0 && /\s/.test(text[pos - 1])) pos -= 1
|
|
|
+ while (pos > 0 && !/\s/.test(text[pos - 1])) pos -= 1
|
|
|
+ setCursorPosition(editorRef, pos)
|
|
|
+ event.preventDefault()
|
|
|
+ queueScroll()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (event.code === "KeyF") {
|
|
|
+ let pos = cursorPosition
|
|
|
+ while (pos < textLength && /\s/.test(text[pos])) pos += 1
|
|
|
+ while (pos < textLength && !/\s/.test(text[pos])) pos += 1
|
|
|
+ setCursorPosition(editorRef, pos)
|
|
|
+ event.preventDefault()
|
|
|
+ queueScroll()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (event.code === "KeyD") {
|
|
|
+ let end = cursorPosition
|
|
|
+ while (end < textLength && /\s/.test(text[end])) end += 1
|
|
|
+ while (end < textLength && !/\s/.test(text[end])) end += 1
|
|
|
+ if (end === cursorPosition) return
|
|
|
+ killText(cursorPosition, end)
|
|
|
+ replaceOffsets(cursorPosition, end, "")
|
|
|
+ event.preventDefault()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
|
|
if (event.altKey || event.ctrlKey || event.metaKey) return
|
|
|
const { collapsed } = getCaretState()
|
|
|
@@ -842,11 +1166,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
if (!existing) return
|
|
|
|
|
|
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
|
|
|
- const attachments = currentPrompt.filter(
|
|
|
+ const fileAttachments = currentPrompt.filter(
|
|
|
(part) => part.type === "file",
|
|
|
) as import("@/context/prompt").FileAttachmentPart[]
|
|
|
+ const agentAttachments = currentPrompt.filter((part) => part.type === "agent") as AgentPart[]
|
|
|
|
|
|
- const fileAttachmentParts = attachments.map((attachment) => {
|
|
|
+ const fileAttachmentParts = fileAttachments.map((attachment) => {
|
|
|
const absolute = toAbsolutePath(attachment.path)
|
|
|
const query = attachment.selection
|
|
|
? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
|
|
|
@@ -869,6 +1194,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
}
|
|
|
})
|
|
|
|
|
|
+ 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 imageAttachmentParts = store.imageAttachments.map((attachment) => ({
|
|
|
id: Identifier.ascending("part"),
|
|
|
type: "file" as const,
|
|
|
@@ -884,11 +1220,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
setStore("imageAttachments", [])
|
|
|
setStore("mode", "normal")
|
|
|
|
|
|
+ const currentModel = local.model.current()
|
|
|
+ const currentAgent = local.agent.current()
|
|
|
+ if (!currentModel || !currentAgent) {
|
|
|
+ console.warn("No agent or model available for prompt submission")
|
|
|
+ return
|
|
|
+ }
|
|
|
const model = {
|
|
|
- modelID: local.model.current()!.id,
|
|
|
- providerID: local.model.current()!.provider.id,
|
|
|
+ modelID: currentModel.id,
|
|
|
+ providerID: currentModel.provider.id,
|
|
|
}
|
|
|
- const agent = local.agent.current()!.name
|
|
|
+ const agent = currentAgent.name
|
|
|
+ const variant = local.model.variant.current()
|
|
|
|
|
|
if (isShellMode) {
|
|
|
sdk.client.session
|
|
|
@@ -916,6 +1259,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
arguments: args.join(" "),
|
|
|
agent,
|
|
|
model: `${model.providerID}/${model.modelID}`,
|
|
|
+ variant,
|
|
|
})
|
|
|
.catch((e) => {
|
|
|
console.error("Failed to send command", e)
|
|
|
@@ -930,7 +1274,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
type: "text" as const,
|
|
|
text,
|
|
|
}
|
|
|
- const requestParts = [textPart, ...fileAttachmentParts, ...imageAttachmentParts]
|
|
|
+ const requestParts = [textPart, ...fileAttachmentParts, ...agentAttachmentParts, ...imageAttachmentParts]
|
|
|
const optimisticParts = requestParts.map((part) => ({
|
|
|
...part,
|
|
|
sessionID: existing.id,
|
|
|
@@ -952,6 +1296,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
model,
|
|
|
messageID,
|
|
|
parts: requestParts,
|
|
|
+ variant,
|
|
|
})
|
|
|
.catch((e) => {
|
|
|
console.error("Failed to send prompt", e)
|
|
|
@@ -967,24 +1312,46 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
|
|
|
>
|
|
|
<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) => (
|
|
|
+ <Match when={store.popover === "at"}>
|
|
|
+ <Show
|
|
|
+ when={atFlat().length > 0}
|
|
|
+ fallback={<div class="text-text-weak px-2 py-1">No matching results</div>}
|
|
|
+ >
|
|
|
+ <For each={atFlat().slice(0, 10)}>
|
|
|
+ {(item) => (
|
|
|
<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,
|
|
|
+ "bg-surface-raised-base-hover": atActive() === atKey(item),
|
|
|
}}
|
|
|
- onClick={() => handleFileSelect(i)}
|
|
|
+ onClick={() => handleAtSelect(item)}
|
|
|
>
|
|
|
- <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>
|
|
|
+ <Show
|
|
|
+ when={item.type === "agent"}
|
|
|
+ fallback={
|
|
|
+ <>
|
|
|
+ <FileIcon
|
|
|
+ node={{ path: (item as { type: "file"; path: string }).path, 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((item as { type: "file"; path: string }).path)}
|
|
|
+ </span>
|
|
|
+ <Show when={!(item as { type: "file"; path: string }).path.endsWith("/")}>
|
|
|
+ <span class="text-text-strong whitespace-nowrap">
|
|
|
+ {getFilename((item as { type: "file"; path: string }).path)}
|
|
|
+ </span>
|
|
|
+ </Show>
|
|
|
+ </div>
|
|
|
+ </>
|
|
|
+ }
|
|
|
+ >
|
|
|
+ <Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
|
|
|
+ <span class="text-14-regular text-text-strong whitespace-nowrap">
|
|
|
+ @{(item as { type: "agent"; name: string }).name}
|
|
|
+ </span>
|
|
|
+ </Show>
|
|
|
</button>
|
|
|
)}
|
|
|
</For>
|
|
|
@@ -1031,6 +1398,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
<form
|
|
|
onSubmit={handleSubmit}
|
|
|
classList={{
|
|
|
+ "group/prompt-input": 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,
|
|
|
@@ -1090,8 +1458,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
onInput={handleInput}
|
|
|
onKeyDown={handleKeyDown}
|
|
|
classList={{
|
|
|
+ "select-text": true,
|
|
|
"w-full px-5 py-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
|
|
- "[&_[data-type=file]]:text-icon-info-active": true,
|
|
|
+ "[&_[data-type=file]]:text-syntax-property": true,
|
|
|
+ "[&_[data-type=agent]]:text-syntax-type": true,
|
|
|
"font-mono!": store.mode === "shell",
|
|
|
}}
|
|
|
/>
|
|
|
@@ -1102,12 +1472,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
: `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
|
|
|
</div>
|
|
|
</Show>
|
|
|
- <div class="absolute top-4.5 right-4">
|
|
|
- <SessionContextUsage />
|
|
|
- </div>
|
|
|
</div>
|
|
|
<div class="relative p-3 flex items-center justify-between">
|
|
|
- <div class="flex items-center justify-start gap-1">
|
|
|
+ <div class="flex items-center justify-start gap-0.5">
|
|
|
<Switch>
|
|
|
<Match when={store.mode === "shell"}>
|
|
|
<div class="flex items-center gap-2 px-2 h-6">
|
|
|
@@ -1117,52 +1484,77 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
</div>
|
|
|
</Match>
|
|
|
<Match when={store.mode === "normal"}>
|
|
|
- <Tooltip
|
|
|
- placement="top"
|
|
|
- value={
|
|
|
- <div class="flex items-center gap-2">
|
|
|
- <span>Cycle agent</span>
|
|
|
- <span class="text-icon-base text-12-medium">{command.keybind("agent.cycle")}</span>
|
|
|
- </div>
|
|
|
- }
|
|
|
- >
|
|
|
+ <TooltipKeybind placement="top" title="Cycle agent" keybind={command.keybind("agent.cycle")}>
|
|
|
<Select
|
|
|
options={local.agent.list().map((agent) => agent.name)}
|
|
|
- current={local.agent.current().name}
|
|
|
+ current={local.agent.current()?.name ?? ""}
|
|
|
onSelect={local.agent.set}
|
|
|
class="capitalize"
|
|
|
variant="ghost"
|
|
|
/>
|
|
|
- </Tooltip>
|
|
|
- <Tooltip
|
|
|
- placement="top"
|
|
|
- value={
|
|
|
- <div class="flex items-center gap-2">
|
|
|
- <span>Choose model</span>
|
|
|
- <span class="text-icon-base text-12-medium">{command.keybind("model.choose")}</span>
|
|
|
- </div>
|
|
|
+ </TooltipKeybind>
|
|
|
+ <Show
|
|
|
+ when={providers.paid().length > 0}
|
|
|
+ fallback={
|
|
|
+ <TooltipKeybind placement="top" title="Choose model" keybind={command.keybind("model.choose")}>
|
|
|
+ <Button as="div" variant="ghost" onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}>
|
|
|
+ {local.model.current()?.name ?? "Select model"}
|
|
|
+ <Icon name="chevron-down" size="small" />
|
|
|
+ </Button>
|
|
|
+ </TooltipKeybind>
|
|
|
}
|
|
|
>
|
|
|
- <Button
|
|
|
- as="div"
|
|
|
- variant="ghost"
|
|
|
- onClick={() =>
|
|
|
- dialog.show(() =>
|
|
|
- providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />,
|
|
|
- )
|
|
|
- }
|
|
|
+ <ModelSelectorPopover>
|
|
|
+ <TooltipKeybind placement="top" title="Choose model" keybind={command.keybind("model.choose")}>
|
|
|
+ <Button as="div" variant="ghost">
|
|
|
+ {local.model.current()?.name ?? "Select model"}
|
|
|
+ <Icon name="chevron-down" size="small" />
|
|
|
+ </Button>
|
|
|
+ </TooltipKeybind>
|
|
|
+ </ModelSelectorPopover>
|
|
|
+ </Show>
|
|
|
+ <Show when={local.model.variant.list().length > 0}>
|
|
|
+ <TooltipKeybind
|
|
|
+ placement="top"
|
|
|
+ title="Thinking effort"
|
|
|
+ keybind={command.keybind("model.variant.cycle")}
|
|
|
>
|
|
|
- {local.model.current()?.name ?? "Select model"}
|
|
|
- <span class="hidden md:block ml-0.5 text-text-weak text-12-regular">
|
|
|
- {local.model.current()?.provider.name}
|
|
|
- </span>
|
|
|
- <Icon name="chevron-down" size="small" />
|
|
|
- </Button>
|
|
|
- </Tooltip>
|
|
|
+ <Button
|
|
|
+ variant="ghost"
|
|
|
+ class="text-text-base _hidden group-hover/prompt-input:inline-block"
|
|
|
+ onClick={() => local.model.variant.cycle()}
|
|
|
+ >
|
|
|
+ <span class="capitalize text-12-regular">{local.model.variant.current() ?? "Default"}</span>
|
|
|
+ </Button>
|
|
|
+ </TooltipKeybind>
|
|
|
+ </Show>
|
|
|
+ <Show when={permission.permissionsEnabled() && params.id}>
|
|
|
+ <TooltipKeybind
|
|
|
+ placement="top"
|
|
|
+ title="Auto-accept edits"
|
|
|
+ keybind={command.keybind("permissions.autoaccept")}
|
|
|
+ >
|
|
|
+ <Button
|
|
|
+ variant="ghost"
|
|
|
+ onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
|
|
|
+ classList={{
|
|
|
+ "_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
|
|
|
+ "text-text-base": !permission.isAutoAccepting(params.id!),
|
|
|
+ "hover:bg-surface-success-base": permission.isAutoAccepting(params.id!),
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <Icon
|
|
|
+ name="chevron-double-right"
|
|
|
+ size="small"
|
|
|
+ classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!) }}
|
|
|
+ />
|
|
|
+ </Button>
|
|
|
+ </TooltipKeybind>
|
|
|
+ </Show>
|
|
|
</Match>
|
|
|
</Switch>
|
|
|
</div>
|
|
|
- <div class="flex items-center gap-1 absolute right-2 bottom-2">
|
|
|
+ <div class="flex items-center gap-3 absolute right-2 bottom-2">
|
|
|
<input
|
|
|
ref={fileInputRef}
|
|
|
type="file"
|
|
|
@@ -1174,17 +1566,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
e.currentTarget.value = ""
|
|
|
}}
|
|
|
/>
|
|
|
- <Show when={store.mode === "normal"}>
|
|
|
- <Tooltip placement="top" value="Attach image">
|
|
|
- <IconButton
|
|
|
- type="button"
|
|
|
- icon="photo"
|
|
|
- variant="ghost"
|
|
|
- class="h-10 w-8"
|
|
|
- onClick={() => fileInputRef.click()}
|
|
|
- />
|
|
|
- </Tooltip>
|
|
|
- </Show>
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <SessionContextUsage />
|
|
|
+ <Show when={store.mode === "normal"}>
|
|
|
+ <Tooltip placement="top" value="Attach image">
|
|
|
+ <Button type="button" variant="ghost" class="size-6" onClick={() => fileInputRef.click()}>
|
|
|
+ <Icon name="photo" class="size-4.5" />
|
|
|
+ </Button>
|
|
|
+ </Tooltip>
|
|
|
+ </Show>
|
|
|
+ </div>
|
|
|
<Tooltip
|
|
|
placement="top"
|
|
|
inactive={!prompt.dirty() && !working()}
|
|
|
@@ -1210,7 +1601,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|
|
disabled={!prompt.dirty() && store.imageAttachments.length === 0 && !working()}
|
|
|
icon={working() ? "stop" : "arrow-up"}
|
|
|
variant="primary"
|
|
|
- class="h-10 w-8"
|
|
|
+ class="h-6 w-4.5"
|
|
|
/>
|
|
|
</Tooltip>
|
|
|
</div>
|
|
|
@@ -1268,7 +1659,9 @@ function setCursorPosition(parent: HTMLElement, position: number) {
|
|
|
while (node) {
|
|
|
const length = getNodeLength(node)
|
|
|
const isText = node.nodeType === Node.TEXT_NODE
|
|
|
- const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
|
|
|
+ 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) {
|
|
|
@@ -1281,13 +1674,13 @@ function setCursorPosition(parent: HTMLElement, position: number) {
|
|
|
return
|
|
|
}
|
|
|
|
|
|
- if ((isFile || isBreak) && remaining <= length) {
|
|
|
+ if ((isPill || isBreak) && remaining <= length) {
|
|
|
const range = document.createRange()
|
|
|
const selection = window.getSelection()
|
|
|
if (remaining === 0) {
|
|
|
range.setStartBefore(node)
|
|
|
}
|
|
|
- if (remaining > 0 && isFile) {
|
|
|
+ if (remaining > 0 && isPill) {
|
|
|
range.setStartAfter(node)
|
|
|
}
|
|
|
if (remaining > 0 && isBreak) {
|