diff --git a/packages/app/src/components/prompt-editor.tsx b/packages/app/src/components/prompt-editor.tsx new file mode 100644 index 00000000000..336b8b15ecd --- /dev/null +++ b/packages/app/src/components/prompt-editor.tsx @@ -0,0 +1,1002 @@ +import { createEffect, createMemo, createSignal, For, on, onCleanup, Show, Switch, Match } from "solid-js" +import { Portal } from "solid-js/web" +import { createStore } from "solid-js/store" +import { createFocusSignal } from "@solid-primitives/active-element" +import { useFilteredList } from "@opencode-ai/ui/hooks" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { Icon } from "@opencode-ai/ui/icon" +import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { useLanguage } from "@/context/language" +import { DEFAULT_PROMPT, type AgentPart, type FileAttachmentPart, type Prompt } from "@/context/prompt" + +export type SlashCommand = { + id: string + trigger: string + title: string + description?: string + keybind?: string + type: "builtin" | "custom" + source?: "command" | "mcp" | "skill" +} + +export type TemplateOption = { + value: string + label: string + description?: string +} + +type AtOption = + | { type: "agent"; name: string; display: string } + | { type: "file"; path: string; display: string; recent?: boolean } + +export function promptText(parts: Prompt) { + return parts.map((part) => ("content" in part ? part.content : "")).join("") +} + +export function PromptEditor(props: { + value: Prompt + placeholder: string + class?: string + editorClass?: string + showPlaceholder?: boolean + portal?: boolean + portalMount?: HTMLElement + mode?: "normal" | "shell" + onChange: (value: Prompt, cursor: number) => void + onKeyDown?: (event: KeyboardEvent, state: { popover: "at" | "slash" | "template" | null }) => boolean | void + onPaste?: (event: ClipboardEvent) => boolean | void | Promise + ref?: (el: HTMLDivElement) => void + scrollRef?: (el: HTMLDivElement) => void + slash: { + commands: SlashCommand[] + keybind?: (id: string) => string | undefined + onSelect?: (command: SlashCommand) => boolean | void + } + at: { + agents?: { name: string; hidden?: boolean; mode?: string }[] + recent?: string[] + search?: (query: string) => Promise + } + templates?: { + items: TemplateOption[] + onSelect?: (option: TemplateOption) => boolean | void + } +}) { + const language = useLanguage() + let editorRef!: HTMLDivElement + let scrollRef!: HTMLDivElement + let slashPopoverRef!: HTMLDivElement + const mirror = { input: false } + const mode = () => props.mode ?? "normal" + + const [store, setStore] = createStore<{ popover: "at" | "slash" | "template" | null }>({ popover: null }) + const [rect, setRect] = createStore({ left: 0, top: 0, width: 0 }) + const [composing, setComposing] = createSignal(false) + + const isFocused = createFocusSignal(() => editorRef) + + createEffect(() => { + if (isFocused()) return + setStore("popover", null) + }) + + createEffect(() => { + if (!props.portal) return + if (!store.popover) return + const update = () => { + const bounds = editorRef.getBoundingClientRect() + setRect({ left: bounds.left, top: bounds.top - 12, width: bounds.width }) + } + + update() + window.addEventListener("resize", update) + window.addEventListener("scroll", update, true) + onCleanup(() => { + window.removeEventListener("resize", update) + window.removeEventListener("scroll", update, true) + }) + }) + + // Safety: reset composing state on focus change to prevent stuck state + createEffect(() => { + if (isFocused()) return + setComposing(false) + }) + + const agentList = createMemo(() => + (props.at.agents ?? []) + .filter((agent) => !agent.hidden && agent.mode !== "primary") + .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })), + ) + const recent = createMemo(() => props.at.recent ?? []) + const templateItems = createMemo(() => props.templates?.items ?? []) + const templateMatch = (value: string) => value.match(/\{\{([^\s}]*)$/) + + 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 }) + return + } + addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 }) + } + + const atKey = (x: AtOption | undefined) => { + if (!x) return "" + return x.type === "agent" ? `agent:${x.name}` : `file:${x.path}` + } + + const { + flat: atFlat, + active: atActive, + setActive: setAtActive, + onInput: atOnInput, + onKeyDown: atOnKeyDown, + } = useFilteredList({ + items: async (query) => { + const agents = agentList() + const open = recent() + const seen = new Set(open) + const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) + if (!props.at.search) return [...agents, ...pinned] + const paths = await props.at.search(query) + const fileOptions: AtOption[] = paths + .filter((path) => !seen.has(path)) + .map((path) => ({ type: "file", path, display: path })) + return [...agents, ...pinned, ...fileOptions] + }, + key: atKey, + filterKeys: ["display"], + groupBy: (item) => { + if (item.type === "agent") return "agent" + if (item.recent) return "recent" + return "file" + }, + sortGroupsBy: (a, b) => { + const rank = (category: string) => { + if (category === "agent") return 0 + if (category === "recent") return 1 + return 2 + } + return rank(a.category) - rank(b.category) + }, + onSelect: handleAtSelect, + }) + + const handleSlashSelect = (cmd: SlashCommand | undefined) => { + if (!cmd) return + setStore("popover", null) + if (props.slash.onSelect?.(cmd)) return + + const text = `/${cmd.trigger} ` + editorRef.innerHTML = "" + editorRef.textContent = text + mirror.input = true + props.onChange([{ 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) + }) + } + + const { + flat: slashFlat, + active: slashActive, + setActive: setSlashActive, + onInput: slashOnInput, + onKeyDown: slashOnKeyDown, + refetch: slashRefetch, + } = useFilteredList({ + items: () => props.slash.commands, + key: (x) => x?.id, + filterKeys: ["trigger", "title", "description"], + onSelect: handleSlashSelect, + }) + + createEffect( + on( + () => props.slash.commands, + () => slashRefetch(), + { defer: true }, + ), + ) + + // Auto-scroll active command into view when navigating with keyboard + createEffect(() => { + const activeId = slashActive() + if (!activeId || !slashPopoverRef) return + + requestAnimationFrame(() => { + const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`) + element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) + }) + }) + + const selectPopoverActive = () => { + if (store.popover === "at") { + const items = atFlat() + if (items.length === 0) return + const active = atActive() + const item = items.find((entry) => atKey(entry) === active) ?? items[0] + handleAtSelect(item) + return + } + + if (store.popover === "slash") { + const items = slashFlat() + if (items.length === 0) return + const active = slashActive() + const item = items.find((entry) => entry.id === active) ?? items[0] + handleSlashSelect(item) + } + + if (store.popover === "template") { + const items = templateFlat() + if (items.length === 0) return + const active = templateActive() + const item = items.find((entry) => entry.value === active) ?? items[0] + handleTemplateSelect(item) + } + } + + const isNormalizedEditor = () => + Array.from(editorRef.childNodes).every((node) => { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent ?? "" + if (!text.includes("\u200B")) return true + if (text !== "\u200B") return false + + const prev = node.previousSibling + const next = node.nextSibling + const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR" + const nextIsBr = next?.nodeType === Node.ELEMENT_NODE && (next as HTMLElement).tagName === "BR" + if (!prevIsBr && !nextIsBr) return false + if (nextIsBr && !prevIsBr && prev) return false + return true + } + 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" + }) + + const renderEditor = (parts: Prompt) => { + editorRef.innerHTML = "" + for (const part of parts) { + if (part.type === "text") { + editorRef.appendChild(createTextFragment(part.content)) + continue + } + if (part.type === "file" || part.type === "agent") { + editorRef.appendChild(createPill(part)) + } + } + } + + createEffect( + on( + () => props.value, + (currentParts) => { + if (mirror.input) { + mirror.input = false + if (isNormalizedEditor()) return + + const selection = window.getSelection() + let cursorPosition: number | null = null + if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) { + cursorPosition = getCursorPosition(editorRef) + } + + renderEditor(currentParts) + + if (cursorPosition !== null) { + setCursorPosition(editorRef, cursorPosition) + } + return + } + + const domParts = parseFromDOM() + if (isNormalizedEditor() && isPromptEqual(currentParts, domParts)) return + + const selection = window.getSelection() + let cursorPosition: number | null = null + if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) { + cursorPosition = getCursorPosition(editorRef) + } + + renderEditor(currentParts) + + if (cursorPosition !== null) { + setCursorPosition(editorRef, cursorPosition) + } + }, + ), + ) + + const parseFromDOM = (): Prompt => { + const parts: Prompt = [] + let position = 0 + let buffer = "" + + const flushText = () => { + const content = buffer.replace(/\r\n?/g, "\n").replace(/\u200B/g, "") + buffer = "" + if (!content) return + parts.push({ type: "text", content, start: position, end: position + content.length }) + position += content.length + } + + const pushFile = (file: HTMLElement) => { + const content = file.textContent ?? "" + parts.push({ + type: "file", + path: file.dataset.path!, + content, + start: position, + end: position + content.length, + }) + 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 ?? "" + return + } + if (node.nodeType !== Node.ELEMENT_NODE) return + + const el = node as HTMLElement + if (el.dataset.type === "file") { + flushText() + pushFile(el) + return + } + if (el.dataset.type === "agent") { + flushText() + pushAgent(el) + return + } + if (el.tagName === "BR") { + buffer += "\n" + return + } + + for (const child of Array.from(el.childNodes)) { + visit(child) + } + } + + const children = Array.from(editorRef.childNodes) + children.forEach((child, index) => { + const isBlock = child.nodeType === Node.ELEMENT_NODE && ["DIV", "P"].includes((child as HTMLElement).tagName) + visit(child) + if (isBlock && index < children.length - 1) { + buffer += "\n" + } + }) + + flushText() + + if (parts.length === 0) parts.push(...DEFAULT_PROMPT) + return parts + } + + const handleInput = () => { + 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 + + if (shouldReset) { + setStore("popover", null) + mirror.input = true + props.onChange(DEFAULT_PROMPT, 0) + return + } + + if (mode() === "shell") { + setStore("popover", null) + } + + if (mode() !== "shell") { + const textBeforeCursor = rawText.substring(0, cursorPosition) + const templateResult = templateItems().length > 0 ? templateMatch(textBeforeCursor) : null + const atMatch = textBeforeCursor.match(/@(\S*)$/) + const slashMatch = rawText.match(/^\/(\S*)$/) + + let popover: "template" | "at" | "slash" | null = null + if (templateResult) popover = "template" + if (!templateResult && atMatch) popover = "at" + if (!templateResult && !atMatch && slashMatch) popover = "slash" + + if (popover === "template" && templateResult) templateOnInput(templateResult[1]) + if (popover === "at" && atMatch) atOnInput(atMatch[1]) + if (popover === "slash" && slashMatch) slashOnInput(slashMatch[1]) + + setStore("popover", popover) + } + + mirror.input = true + props.onChange(rawParts, cursorPosition) + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && store.popover) { + setStore("popover", null) + event.preventDefault() + return + } + + const handled = props.onKeyDown?.(event, { popover: store.popover }) + if (handled) return + if (event.defaultPrevented) return + + const isEnter = event.key === "Enter" + const isShiftEnter = isEnter && event.shiftKey + if (isShiftEnter) { + addPart({ type: "text", content: "\n", start: 0, end: 0 }) + event.preventDefault() + return + } + + const isComposing = event.isComposing || composing() || event.keyCode === 229 + if (isEnter && isComposing) { + return + } + + const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey + + if (ctrl && event.code === "KeyG" && store.popover) { + setStore("popover", null) + event.preventDefault() + return + } + + const popover = store.popover + if (!popover) return + + if (event.key === "Tab") { + selectPopoverActive() + event.preventDefault() + return + } + + const nav = event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter" + const ctrlNav = ctrl && (event.key === "n" || event.key === "p") + if (!nav && !ctrlNav) return + + if (popover === "at") { + atOnKeyDown(event) + event.preventDefault() + return + } + if (popover === "slash") { + slashOnKeyDown(event) + } + if (popover === "template") { + templateOnKeyDown(event) + } + event.preventDefault() + } + + const handlePaste = async (event: ClipboardEvent) => { + const handled = await props.onPaste?.(event) + if (handled) return + if (event.defaultPrevented) return + + const clipboardData = event.clipboardData + if (!clipboardData) return + + const plainText = clipboardData.getData("text/plain") ?? "" + if (!plainText) return + + event.preventDefault() + event.stopPropagation() + addPart({ type: "text", content: plainText, start: 0, end: 0 }) + } + + const createPill = (part: FileAttachmentPart | AgentPart) => { + const pill = document.createElement("span") + pill.textContent = part.content + pill.setAttribute("data-type", part.type) + if (part.type === "file") pill.setAttribute("data-path", part.path) + if (part.type === "agent") pill.setAttribute("data-name", part.name) + pill.setAttribute("contenteditable", "false") + pill.style.userSelect = "text" + pill.style.cursor = "default" + return pill + } + + 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 insertText = (content: string, replace?: { start: number; end: number }) => { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0) return + + const range = selection.getRangeAt(0) + if (replace) { + setRangeEdge(range, "start", replace.start) + setRangeEdge(range, "end", replace.end) + range.deleteContents() + } + + const fragment = createTextFragment(content) + const last = fragment.lastChild + range.insertNode(fragment) + if (last) { + if (last.nodeType === Node.TEXT_NODE) { + const text = last.textContent ?? "" + if (text === "\u200B") { + range.setStart(last, 0) + } + if (text !== "\u200B") { + range.setStart(last, text.length) + } + } + if (last.nodeType !== Node.TEXT_NODE) { + range.setStartAfter(last) + } + } + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + } + + const handleTemplateSelect = (option: TemplateOption | undefined) => { + if (!option) return + setStore("popover", null) + if (props.templates?.onSelect?.(option)) return + + const cursorPosition = getCursorPosition(editorRef) + const currentPrompt = parseFromDOM() + const rawText = promptText(currentPrompt) + const textBeforeCursor = rawText.substring(0, cursorPosition) + const match = templateMatch(textBeforeCursor) + + let replace: { start: number; end: number } | undefined + if (match) { + const start = match.index ?? cursorPosition - match[0].length + const end = cursorPosition + replace = { start, end } + } + + insertText(option.value, replace) + handleInput() + } + + const { + flat: templateFlat, + active: templateActive, + setActive: setTemplateActive, + onInput: templateOnInput, + onKeyDown: templateOnKeyDown, + } = useFilteredList({ + items: () => templateItems(), + key: (x) => x?.value, + filterKeys: ["label", "description", "value"], + onSelect: handleTemplateSelect, + }) + + const addPart = (part: Prompt[number]) => { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0) return + + const cursorPosition = getCursorPosition(editorRef) + const currentPrompt = parseFromDOM() + const rawText = promptText(currentPrompt) + const textBeforeCursor = rawText.substring(0, cursorPosition) + const atMatch = textBeforeCursor.match(/@(\S*)$/) + + if (part.type === "file" || part.type === "agent") { + const pill = createPill(part) + const gap = document.createTextNode(" ") + const range = selection.getRangeAt(0) + + if (atMatch) { + const start = atMatch.index ?? cursorPosition - atMatch[0].length + setRangeEdge(range, "start", start) + setRangeEdge(range, "end", cursorPosition) + } + + range.deleteContents() + range.insertNode(gap) + range.insertNode(pill) + range.setStartAfter(gap) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + } + + if (part.type === "text") { + const range = selection.getRangeAt(0) + const fragment = createTextFragment(part.content) + const last = fragment.lastChild + + range.deleteContents() + range.insertNode(fragment) + if (last) { + if (last.nodeType === Node.TEXT_NODE) { + const text = last.textContent ?? "" + if (text === "\u200B") { + range.setStart(last, 0) + } + if (text !== "\u200B") { + range.setStart(last, text.length) + } + } + if (last.nodeType !== Node.TEXT_NODE) { + range.setStartAfter(last) + } + } + + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + } + + handleInput() + } + + const popover = () => ( +
{ + if (store.popover === "slash") slashPopoverRef = el + }} + classList={{ + "origin-bottom-left max-h-80 min-h-10 overflow-auto no-scrollbar flex flex-col p-2 rounded-md z-50": true, + "border border-border-base bg-surface-raised-stronger-non-alpha shadow-md": true, + "absolute inset-x-0 -top-3 -translate-y-full": !props.portal, + fixed: !!props.portal, + }} + style={ + props.portal + ? { + left: `${rect.left}px`, + top: `${rect.top}px`, + width: `${rect.width}px`, + transform: "translateY(-100%)", + } + : undefined + } + onMouseDown={(e) => e.preventDefault()} + > + + + 0} + fallback={
{language.t("prompt.popover.emptyResults")}
} + > + + {(item) => ( + + )} + +
+
+ + 0} + fallback={
{language.t("prompt.popover.emptyCommands")}
} + > + + {(cmd) => ( + + )} + +
+
+ + 0} + fallback={
{language.t("prompt.popover.emptyResults")}
} + > + + {(item) => ( + + )} + +
+
+
+
+ ) + + return ( +
+ + + + {popover()} + + {popover()} + + +
{ + scrollRef = el + props.scrollRef?.(el) + }} + > +
{ + editorRef = el + props.ref?.(el) + }} + role="textbox" + aria-multiline="true" + aria-label={props.placeholder} + contenteditable="true" + onInput={handleInput} + onPaste={handlePaste} + onCompositionStart={() => setComposing(true)} + onCompositionEnd={() => setComposing(false)} + onKeyDown={handleKeyDown} + classList={{ + "select-text": true, + "w-full p-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, + "[&_[data-type=file]]:text-syntax-property": true, + "[&_[data-type=agent]]:text-syntax-type": true, + "font-mono!": mode() === "shell", + [props.editorClass ?? ""]: !!props.editorClass, + }} + /> + +
+ {props.placeholder} +
+
+
+
+ ) +} + +function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { + if (promptA.length !== promptB.length) return false + for (let i = 0; i < promptA.length; i++) { + const partA = promptA[i] + const partB = promptB[i] + if (partA.type !== partB.type) return false + if (partA.type === "text" && partA.content !== (partB as { content: string }).content) return false + if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) return false + if (partA.type === "agent" && partA.name !== (partB as AgentPart).name) return false + } + return true +} + +export 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)) + } + if (!segment && 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 +} + +export 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()) +} + +export 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) +} diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 544c6abdd21..a1b9f7dd969 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -2,6 +2,7 @@ import { splitProps, type ComponentProps } from "solid-js" const icons = { "align-right": ``, + automate: ``, "arrow-up": ``, "arrow-left": ``, "arrow-right": ``, @@ -31,6 +32,7 @@ const icons = { "magnifying-glass": ``, "plus-small": ``, plus: ``, + play: ``, "pencil-line": ``, mcp: ``, glasses: ``, @@ -59,6 +61,7 @@ const icons = { check: ``, photo: ``, share: ``, + sparkles: ``, download: ``, menu: ``, server: ``, diff --git a/packages/web/src/content/docs/index.mdx b/packages/web/src/content/docs/index.mdx index bb3b8cb5d00..1798bea4e0e 100644 --- a/packages/web/src/content/docs/index.mdx +++ b/packages/web/src/content/docs/index.mdx @@ -196,6 +196,8 @@ used. You are now ready to use OpenCode to work on your project. Feel free to ask it anything! +If you want to learn how OpenCode structures prompts, see the [Prompt Editor](/docs/prompt-editor) guide. + If you are new to using an AI coding agent, here are some examples that might help. diff --git a/packages/web/src/content/docs/prompt-editor.mdx b/packages/web/src/content/docs/prompt-editor.mdx new file mode 100644 index 00000000000..b0776bd3db4 --- /dev/null +++ b/packages/web/src/content/docs/prompt-editor.mdx @@ -0,0 +1,78 @@ +--- +title: Prompt Editor +description: Use mentions, slash commands, and templates in prompts. +--- + +The prompt editor powers rich prompt composition across OpenCode. It lets you insert file and agent references, pick slash commands, and drop in templates without leaving the keyboard. + +--- + +## Tokens + +### File references + +Type `@` to open file suggestions and insert a file reference. + +```text +Review @packages/opencode/src/server/routes/automation.ts and summarize the API. +``` + +The editor inserts a file pill that stays linked to the file path and renders as part of the prompt. + +--- + +### Agent references + +Type `@` and select an agent to insert an agent reference. + +```text +@plan Draft a rollout plan for automations. +``` + +Agents are shown alongside file results, and selecting one inserts an agent pill. + +--- + +### Slash commands + +Type `/` at the start of an empty prompt to open slash command suggestions. + +```text +/help +``` + +Selecting a command inserts `/ ` so you can continue typing arguments. + +--- + +### Templates + +Type `{{` to open template suggestions and insert a template token. + +```text +Create a summary for {{project.name}} on {{date}}. +``` + +Templates are inserted at the cursor and replace the `{{` fragment you were typing. + +--- + +## Keyboard interaction + +- Use `ArrowUp` and `ArrowDown` or `ctrl+n` / `ctrl+p` to navigate popovers. +- Press `Tab` or `Enter` to accept the highlighted suggestion. +- Press `Esc` (or `ctrl+g`) to close an open popover. +- Press `Shift+Enter` to insert a newline in the editor. + +--- + +## Paste behavior + +The editor pastes plain text by default (formatting is stripped). If a parent handler overrides `onPaste`, it can replace or augment this behavior. + +--- + +## Rendering and modes + +- The editor uses a structured `Prompt` model and preserves cursor position on updates. +- When `mode="shell"` is enabled, the editor switches to a monospace appearance and disables popover suggestions.