import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes, t, dim, fg } from "@opentui/core" import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js" import "opentui-spinner/solid" import path from "path" import { fileURLToPath } from "url" import { Filesystem } from "@/util/filesystem" import { useLocal } from "@tui/context/local" import { useTheme } from "@tui/context/theme" import { EmptyBorder, SplitBorder } from "@tui/component/border" import { useSDK } from "@tui/context/sdk" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" import { MessageID, PartID } from "@/session/schema" import { createStore, produce } from "solid-js/store" import { useKeybind } from "@tui/context/keybind" import { usePromptHistory, type PromptInfo } from "./history" import { assign } from "./part" import { usePromptStash } from "./stash" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { useCommandDialog } from "../dialog-command" import { useRenderer, type JSX } from "@opentui/solid" import { Editor } from "@tui/util/editor" import { useExit } from "../../context/exit" import { Clipboard } from "../../util/clipboard" import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2" import { TuiEvent } from "../../event" import { iife } from "@/util/iife" import { Locale } from "@/util/locale" import { formatDuration } from "@/util/format" import { createColors, createFrames } from "../../ui/spinner.ts" import { useDialog } from "@tui/ui/dialog" import { DialogProvider as DialogProviderConnect } from "../dialog-provider" import { DialogAlert } from "../../ui/dialog-alert" import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" import { useTextareaKeybindings } from "../textarea-keybindings" import { DialogSkill } from "../dialog-skill" export type PromptProps = { sessionID?: string workspaceID?: string visible?: boolean disabled?: boolean onSubmit?: () => void ref?: (ref: PromptRef | undefined) => void hint?: JSX.Element right?: JSX.Element showPlaceholder?: boolean placeholders?: { normal?: string[] shell?: string[] } } export type PromptRef = { focused: boolean current: PromptInfo set(prompt: PromptInfo): void reset(): void blur(): void focus(): void submit(): void } const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", }) function randomIndex(count: number) { if (count <= 0) return 0 return Math.floor(Math.random() * count) } export function Prompt(props: PromptProps) { let input: TextareaRenderable let anchor: BoxRenderable let autocomplete: AutocompleteRef const keybind = useKeybind() const local = useLocal() const sdk = useSDK() const route = useRoute() const sync = useSync() const dialog = useDialog() const toast = useToast() const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" }) const history = usePromptHistory() const stash = usePromptStash() const command = useCommandDialog() const renderer = useRenderer() const { theme, syntax } = useTheme() const kv = useKV() const list = createMemo(() => props.placeholders?.normal ?? []) const shell = createMemo(() => props.placeholders?.shell ?? []) const [auto, setAuto] = createSignal() const currentProviderLabel = createMemo(() => local.model.parsed().provider) const hasRightContent = createMemo(() => Boolean(props.right)) function promptModelWarning() { toast.show({ variant: "warning", message: "Connect a provider to send prompts", duration: 3000, }) if (sync.data.provider.length === 0) { dialog.replace(() => ) } } const textareaKeybindings = useTextareaKeybindings() const fileStyleId = syntax().getStyleId("extmark.file")! const agentStyleId = syntax().getStyleId("extmark.agent")! const pasteStyleId = syntax().getStyleId("extmark.paste")! let promptPartTypeId = 0 sdk.event.on(TuiEvent.PromptAppend.type, (evt) => { if (!input || input.isDestroyed) return input.insertText(evt.properties.text) setTimeout(() => { // setTimeout is a workaround and needs to be addressed properly if (!input || input.isDestroyed) return input.getLayoutNode().markDirty() input.gotoBufferEnd() renderer.requestRender() }, 0) }) createEffect(() => { if (props.disabled) input.cursorColor = theme.backgroundElement if (!props.disabled) input.cursorColor = theme.text }) const lastUserMessage = createMemo(() => { if (!props.sessionID) return undefined const messages = sync.data.message[props.sessionID] if (!messages) return undefined return messages.findLast((m): m is UserMessage => m.role === "user") }) const usage = createMemo(() => { if (!props.sessionID) return const msg = sync.data.message[props.sessionID] ?? [] const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0) if (!last) return const tokens = last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write if (tokens <= 0) return const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID] const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined const cost = msg.reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0) return { context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens), cost: cost > 0 ? money.format(cost) : undefined, } }) const [store, setStore] = createStore<{ prompt: PromptInfo mode: "normal" | "shell" extmarkToPartIndex: Map interrupt: number placeholder: number }>({ placeholder: randomIndex(list().length), prompt: { input: "", parts: [], }, mode: "normal", extmarkToPartIndex: new Map(), interrupt: 0, }) createEffect( on( () => props.sessionID, () => { setStore("placeholder", randomIndex(list().length)) }, { defer: true }, ), ) // Initialize agent/model/variant from last user message when session changes let syncedSessionID: string | undefined createEffect(() => { const sessionID = props.sessionID const msg = lastUserMessage() if (sessionID !== syncedSessionID) { if (!sessionID || !msg) return syncedSessionID = sessionID // Only set agent if it's a primary agent (not a subagent) const isPrimaryAgent = local.agent.list().some((x) => x.name === msg.agent) if (msg.agent && isPrimaryAgent) { local.agent.set(msg.agent) if (msg.model) { local.model.set(msg.model) local.model.variant.set(msg.model.variant) } } } }) command.register(() => { return [ { title: "Clear prompt", value: "prompt.clear", category: "Prompt", hidden: true, onSelect: (dialog) => { input.extmarks.clear() input.clear() dialog.clear() }, }, { title: "Submit prompt", value: "prompt.submit", keybind: "input_submit", category: "Prompt", hidden: true, onSelect: (dialog) => { if (!input.focused) return submit() dialog.clear() }, }, { title: "Paste", value: "prompt.paste", keybind: "input_paste", category: "Prompt", hidden: true, onSelect: async () => { const content = await Clipboard.read() if (content?.mime.startsWith("image/")) { await pasteAttachment({ filename: "clipboard", mime: content.mime, content: content.data, }) } }, }, { title: "Interrupt session", value: "session.interrupt", keybind: "session_interrupt", category: "Session", hidden: true, enabled: status().type !== "idle", onSelect: (dialog) => { if (autocomplete.visible) return if (!input.focused) return // TODO: this should be its own command if (store.mode === "shell") { setStore("mode", "normal") return } if (!props.sessionID) return setStore("interrupt", store.interrupt + 1) setTimeout(() => { setStore("interrupt", 0) }, 5000) if (store.interrupt >= 2) { sdk.client.session.abort({ sessionID: props.sessionID, }) setStore("interrupt", 0) } dialog.clear() }, }, { title: "Open editor", category: "Session", keybind: "editor_open", value: "prompt.editor", slash: { name: "editor", }, onSelect: async (dialog) => { dialog.clear() // replace summarized text parts with the actual text const text = store.prompt.parts .filter((p) => p.type === "text") .reduce((acc, p) => { if (!p.source) return acc return acc.replace(p.source.text.value, p.text) }, store.prompt.input) const nonTextParts = store.prompt.parts.filter((p) => p.type !== "text") const value = text const content = await Editor.open({ value, renderer }) if (!content) return input.setText(content) // Update positions for nonTextParts based on their location in new content // Filter out parts whose virtual text was deleted // this handles a case where the user edits the text in the editor // such that the virtual text moves around or is deleted const updatedNonTextParts = nonTextParts .map((part) => { let virtualText = "" if (part.type === "file" && part.source?.text) { virtualText = part.source.text.value } else if (part.type === "agent" && part.source) { virtualText = part.source.value } if (!virtualText) return part const newStart = content.indexOf(virtualText) // if the virtual text is deleted, remove the part if (newStart === -1) return null const newEnd = newStart + virtualText.length if (part.type === "file" && part.source?.text) { return { ...part, source: { ...part.source, text: { ...part.source.text, start: newStart, end: newEnd, }, }, } } if (part.type === "agent" && part.source) { return { ...part, source: { ...part.source, start: newStart, end: newEnd, }, } } return part }) .filter((part) => part !== null) setStore("prompt", { input: content, // keep only the non-text parts because the text parts were // already expanded inline parts: updatedNonTextParts, }) restoreExtmarksFromParts(updatedNonTextParts) input.cursorOffset = Bun.stringWidth(content) }, }, { title: "Skills", value: "prompt.skills", category: "Prompt", slash: { name: "skills", }, onSelect: () => { dialog.replace(() => ( { input.setText(`/${skill} `) setStore("prompt", { input: `/${skill} `, parts: [], }) input.gotoBufferEnd() }} /> )) }, }, ] }) const ref: PromptRef = { get focused() { return input.focused }, get current() { return store.prompt }, focus() { input.focus() }, blur() { input.blur() }, set(prompt) { input.setText(prompt.input) setStore("prompt", prompt) restoreExtmarksFromParts(prompt.parts) input.gotoBufferEnd() }, reset() { input.clear() input.extmarks.clear() setStore("prompt", { input: "", parts: [], }) setStore("extmarkToPartIndex", new Map()) }, submit() { submit() }, } onCleanup(() => { props.ref?.(undefined) }) createEffect(() => { if (!input || input.isDestroyed) return if (props.visible === false || dialog.stack.length > 0) { input.blur() return } // Slot/plugin updates can remount the background prompt while a dialog is open. // Keep focus with the dialog and let the prompt reclaim it after the dialog closes. input.focus() }) createEffect(() => { if (!input || input.isDestroyed) return input.traits = { capture: auto()?.visible ? ["escape", "navigate", "submit", "tab"] : undefined, suspend: !!props.disabled || store.mode === "shell", status: store.mode === "shell" ? "SHELL" : undefined, } }) function restoreExtmarksFromParts(parts: PromptInfo["parts"]) { input.extmarks.clear() setStore("extmarkToPartIndex", new Map()) parts.forEach((part, partIndex) => { let start = 0 let end = 0 let virtualText = "" let styleId: number | undefined if (part.type === "file" && part.source?.text) { start = part.source.text.start end = part.source.text.end virtualText = part.source.text.value styleId = fileStyleId } else if (part.type === "agent" && part.source) { start = part.source.start end = part.source.end virtualText = part.source.value styleId = agentStyleId } else if (part.type === "text" && part.source?.text) { start = part.source.text.start end = part.source.text.end virtualText = part.source.text.value styleId = pasteStyleId } if (virtualText) { const extmarkId = input.extmarks.create({ start, end, virtual: true, styleId, typeId: promptPartTypeId, }) setStore("extmarkToPartIndex", (map: Map) => { const newMap = new Map(map) newMap.set(extmarkId, partIndex) return newMap }) } }) } function syncExtmarksWithPromptParts() { const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId) setStore( produce((draft) => { const newMap = new Map() const newParts: typeof draft.prompt.parts = [] for (const extmark of allExtmarks) { const partIndex = draft.extmarkToPartIndex.get(extmark.id) if (partIndex !== undefined) { const part = draft.prompt.parts[partIndex] if (part) { if (part.type === "agent" && part.source) { part.source.start = extmark.start part.source.end = extmark.end } else if (part.type === "file" && part.source?.text) { part.source.text.start = extmark.start part.source.text.end = extmark.end } else if (part.type === "text" && part.source?.text) { part.source.text.start = extmark.start part.source.text.end = extmark.end } newMap.set(extmark.id, newParts.length) newParts.push(part) } } } draft.extmarkToPartIndex = newMap draft.prompt.parts = newParts }), ) } command.register(() => [ { title: "Stash prompt", value: "prompt.stash", category: "Prompt", enabled: !!store.prompt.input, onSelect: (dialog) => { if (!store.prompt.input) return stash.push({ input: store.prompt.input, parts: store.prompt.parts, }) input.extmarks.clear() input.clear() setStore("prompt", { input: "", parts: [] }) setStore("extmarkToPartIndex", new Map()) dialog.clear() }, }, { title: "Stash pop", value: "prompt.stash.pop", category: "Prompt", enabled: stash.list().length > 0, onSelect: (dialog) => { const entry = stash.pop() if (entry) { input.setText(entry.input) setStore("prompt", { input: entry.input, parts: entry.parts }) restoreExtmarksFromParts(entry.parts) input.gotoBufferEnd() } dialog.clear() }, }, { title: "Stash list", value: "prompt.stash.list", category: "Prompt", enabled: stash.list().length > 0, onSelect: (dialog) => { dialog.replace(() => ( { input.setText(entry.input) setStore("prompt", { input: entry.input, parts: entry.parts }) restoreExtmarksFromParts(entry.parts) input.gotoBufferEnd() }} /> )) }, }, ]) async function submit() { if (props.disabled) return if (autocomplete?.visible) return if (!store.prompt.input) return const trimmed = store.prompt.input.trim() if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") { exit() return } const selectedModel = local.model.current() if (!selectedModel) { promptModelWarning() return } let sessionID = props.sessionID if (sessionID == null) { const res = await sdk.client.session.create({ workspaceID: props.workspaceID, }) if (res.error) { console.log("Creating a session failed:", res.error) toast.show({ message: "Creating a session failed. Open console for more details.", variant: "error", }) return } sessionID = res.data.id } const messageID = MessageID.ascending() let inputText = store.prompt.input // Expand pasted text inline before submitting const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId) const sortedExtmarks = allExtmarks.sort((a: { start: number }, b: { start: number }) => b.start - a.start) for (const extmark of sortedExtmarks) { const partIndex = store.extmarkToPartIndex.get(extmark.id) if (partIndex !== undefined) { const part = store.prompt.parts[partIndex] if (part?.type === "text" && part.text) { const before = inputText.slice(0, extmark.start) const after = inputText.slice(extmark.end) inputText = before + part.text + after } } } // Filter out text parts (pasted content) since they're now expanded inline const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text") // Capture mode before it gets reset const currentMode = store.mode const variant = local.model.variant.current() if (store.mode === "shell") { sdk.client.session.shell({ sessionID, agent: local.agent.current().name, model: { providerID: selectedModel.providerID, modelID: selectedModel.modelID, }, command: inputText, }) setStore("mode", "normal") } else if ( inputText.startsWith("/") && iife(() => { const firstLine = inputText.split("\n")[0] const command = firstLine.split(" ")[0].slice(1) return sync.data.command.some((x) => x.name === command) }) ) { // Parse command from first line, preserve multi-line content in arguments const firstLineEnd = inputText.indexOf("\n") const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd) const [command, ...firstLineArgs] = firstLine.split(" ") const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1) const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "") sdk.client.session.command({ sessionID, command: command.slice(1), arguments: args, agent: local.agent.current().name, model: `${selectedModel.providerID}/${selectedModel.modelID}`, messageID, variant, parts: nonTextParts .filter((x) => x.type === "file") .map((x) => ({ id: PartID.ascending(), ...x, })), }) } else { sdk.client.session .prompt({ sessionID, ...selectedModel, messageID, agent: local.agent.current().name, model: selectedModel, variant, parts: [ { id: PartID.ascending(), type: "text", text: inputText, }, ...nonTextParts.map(assign), ], }) .catch(() => {}) } history.append({ ...store.prompt, mode: currentMode, }) input.extmarks.clear() setStore("prompt", { input: "", parts: [], }) setStore("extmarkToPartIndex", new Map()) props.onSubmit?.() // temporary hack to make sure the message is sent if (!props.sessionID) setTimeout(() => { route.navigate({ type: "session", sessionID, }) }, 50) input.clear() } const exit = useExit() function pasteText(text: string, virtualText: string) { const currentOffset = input.visualCursor.offset const extmarkStart = currentOffset const extmarkEnd = extmarkStart + virtualText.length input.insertText(virtualText + " ") const extmarkId = input.extmarks.create({ start: extmarkStart, end: extmarkEnd, virtual: true, styleId: pasteStyleId, typeId: promptPartTypeId, }) setStore( produce((draft) => { const partIndex = draft.prompt.parts.length draft.prompt.parts.push({ type: "text" as const, text, source: { text: { start: extmarkStart, end: extmarkEnd, value: virtualText, }, }, }) draft.extmarkToPartIndex.set(extmarkId, partIndex) }), ) } async function pasteAttachment(file: { filename?: string; filepath?: string; content: string; mime: string }) { const currentOffset = input.visualCursor.offset const extmarkStart = currentOffset const pdf = file.mime === "application/pdf" const count = store.prompt.parts.filter((x) => { if (x.type !== "file") return false if (pdf) return x.mime === "application/pdf" return x.mime.startsWith("image/") }).length const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]` const extmarkEnd = extmarkStart + virtualText.length const textToInsert = virtualText + " " input.insertText(textToInsert) const extmarkId = input.extmarks.create({ start: extmarkStart, end: extmarkEnd, virtual: true, styleId: pasteStyleId, typeId: promptPartTypeId, }) const part: Omit = { type: "file" as const, mime: file.mime, filename: file.filename, url: `data:${file.mime};base64,${file.content}`, source: { type: "file", path: file.filepath ?? file.filename ?? "", text: { start: extmarkStart, end: extmarkEnd, value: virtualText, }, }, } setStore( produce((draft) => { const partIndex = draft.prompt.parts.length draft.prompt.parts.push(part) draft.extmarkToPartIndex.set(extmarkId, partIndex) }), ) return } const highlight = createMemo(() => { if (keybind.leader) return theme.border if (store.mode === "shell") return theme.primary return local.agent.color(local.agent.current().name) }) const showVariant = createMemo(() => { const variants = local.model.variant.list() if (variants.length === 0) return false const current = local.model.variant.current() return !!current }) const placeholderText = createMemo(() => { if (props.showPlaceholder === false) return undefined if (store.mode === "shell") { if (!shell().length) return undefined const example = shell()[store.placeholder % shell().length] return `Run a command... "${example}"` } if (!list().length) return undefined return `Ask anything... "${list()[store.placeholder % list().length]}"` }) const spinnerDef = createMemo(() => { const color = local.agent.color(local.agent.current().name) return { frames: createFrames({ color, style: "blocks", inactiveFactor: 0.6, // enableFading: false, minAlpha: 0.3, }), color: createColors({ color, style: "blocks", inactiveFactor: 0.6, // enableFading: false, minAlpha: 0.3, }), } }) return ( <> { autocomplete = r setAuto(() => r) }} anchor={() => anchor} input={() => input} setPrompt={(cb) => { setStore("prompt", produce(cb)) }} setExtmark={(partIndex, extmarkId) => { setStore("extmarkToPartIndex", (map: Map) => { const newMap = new Map(map) newMap.set(extmarkId, partIndex) return newMap }) }} value={store.prompt.input} fileStyleId={fileStyleId} agentStyleId={agentStyleId} promptPartTypeId={() => promptPartTypeId} /> (anchor = r)} visible={props.visible !== false}>