import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg, type KeyBinding } from "@opentui/core" import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, Show, Switch, Match } from "solid-js" import "opentui-spinner/solid" import { useLocal } from "@tui/context/local" import { useTheme } from "@tui/context/theme" import { EmptyBorder } from "@tui/component/border" import { useSDK } from "@tui/context/sdk" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" import { Identifier } from "@/id/id" import { createStore, produce } from "solid-js/store" import { useKeybind } from "@tui/context/keybind" import { Keybind } from "@/util/keybind" import { usePromptHistory, type PromptInfo } from "./history" import { usePromptStash } from "./stash" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { useCommandDialog } from "../dialog-command" import { useRenderer } from "@opentui/solid" import { Editor } from "@tui/util/editor" import { useExit } from "../../context/exit" import { Clipboard } from "../../util/clipboard" import type { FilePart } from "@opencode-ai/sdk/v2" import { TuiEvent } from "../../event" import { iife } from "@/util/iife" import { Locale } from "@/util/locale" 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" export type PromptProps = { sessionID?: string disabled?: boolean onSubmit?: () => void ref?: (ref: PromptRef) => void hint?: JSX.Element showPlaceholder?: boolean } export type PromptRef = { focused: boolean current: PromptInfo set(prompt: PromptInfo): void reset(): void blur(): void focus(): void submit(): void } const PLACEHOLDERS = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"] const TEXTAREA_ACTIONS = [ "submit", "newline", "move-left", "move-right", "move-up", "move-down", "select-left", "select-right", "select-up", "select-down", "line-home", "line-end", "select-line-home", "select-line-end", "visual-line-home", "visual-line-end", "select-visual-line-home", "select-visual-line-end", "buffer-home", "buffer-end", "select-buffer-home", "select-buffer-end", "delete-line", "delete-to-line-end", "delete-to-line-start", "backspace", "delete", "undo", "redo", "word-forward", "word-backward", "select-word-forward", "select-word-backward", "delete-word-forward", "delete-word-backward", ] as const function mapTextareaKeybindings( keybinds: Record, action: (typeof TEXTAREA_ACTIONS)[number], ): KeyBinding[] { const configKey = `input_${action.replace(/-/g, "_")}` const bindings = keybinds[configKey] if (!bindings) return [] return bindings.map((binding) => ({ name: binding.name, ctrl: binding.ctrl || undefined, meta: binding.meta || undefined, shift: binding.shift || undefined, super: binding.super || undefined, action, })) } 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() 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 = createMemo(() => { const keybinds = keybind.all return [ { name: "return", action: "submit" }, { name: "return", meta: true, action: "newline" }, ...TEXTAREA_ACTIONS.flatMap((action) => mapTextareaKeybindings(keybinds, action)), ] satisfies KeyBinding[] }) const fileStyleId = syntax().getStyleId("extmark.file")! const agentStyleId = syntax().getStyleId("extmark.agent")! const pasteStyleId = syntax().getStyleId("extmark.paste")! let promptPartTypeId: number sdk.event.on(TuiEvent.PromptAppend.type, (evt) => { input.insertText(evt.properties.text) setTimeout(() => { 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.role === "user") }) const [store, setStore] = createStore<{ prompt: PromptInfo mode: "normal" | "shell" extmarkToPartIndex: Map interrupt: number placeholder: number }>({ placeholder: Math.floor(Math.random() * PLACEHOLDERS.length), prompt: { input: "", parts: [], }, mode: "normal", extmarkToPartIndex: new Map(), interrupt: 0, }) // 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 if (msg.agent) local.agent.set(msg.agent) if (msg.model) local.model.set(msg.model) if (msg.variant) local.model.variant.set(msg.variant) } }) command.register(() => { return [ { title: "Clear prompt", value: "prompt.clear", category: "Prompt", disabled: true, onSelect: (dialog) => { input.extmarks.clear() input.clear() dialog.clear() }, }, { title: "Submit prompt", value: "prompt.submit", disabled: true, keybind: "input_submit", category: "Prompt", onSelect: (dialog) => { if (!input.focused) return submit() dialog.clear() }, }, { title: "Paste", value: "prompt.paste", disabled: true, keybind: "input_paste", category: "Prompt", onSelect: async () => { const content = await Clipboard.read() if (content?.mime.startsWith("image/")) { await pasteImage({ filename: "clipboard", mime: content.mime, content: content.data, }) } }, }, { title: "Interrupt session", value: "session.interrupt", keybind: "session_interrupt", disabled: status().type === "idle", category: "Session", 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", onSelect: async (dialog, trigger) => { 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 = trigger === "prompt" ? "" : 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) }, }, ] }) createEffect(() => { input.focus() }) onMount(() => { promptPartTypeId = input.extmarks.registerType("prompt-part") }) 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", disabled: !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", disabled: 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", disabled: 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() }} /> )) }, }, ]) props.ref?.({ 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() }, }) 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 } const sessionID = props.sessionID ? props.sessionID : await (async () => { const sessionID = await sdk.client.session.create({}).then((x) => x.data!.id) return sessionID })() const messageID = Identifier.ascending("message") 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 command = inputText.split(" ")[0].slice(1) console.log(command) return sync.data.command.some((x) => x.name === command) }) ) { let [command, ...args] = inputText.split(" ") sdk.client.session.command({ sessionID, command: command.slice(1), arguments: args.join(" "), agent: local.agent.current().name, model: `${selectedModel.providerID}/${selectedModel.modelID}`, messageID, variant, }) } else { sdk.client.session.prompt({ sessionID, ...selectedModel, messageID, agent: local.agent.current().name, model: selectedModel, variant, parts: [ { id: Identifier.ascending("part"), type: "text", text: inputText, }, ...nonTextParts.map((x) => ({ id: Identifier.ascending("part"), ...x, })), ], }) } 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 pasteImage(file: { filename?: string; content: string; mime: string }) { const currentOffset = input.visualCursor.offset const extmarkStart = currentOffset const count = store.prompt.parts.filter((x) => x.type === "file").length const virtualText = `[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.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 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)} 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)}>