import { BoxRenderable, RGBA, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes, type KeyEvent, type Renderable, } from "@opentui/core" import type { CommandContext } from "@opentui/keymap" 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 { useLocal } from "../../context/local" import { tint, useTheme } from "../../context/theme" import { EmptyBorder, SplitBorder } from "../../ui/border" import { useTuiEnvironment } from "../../runtime" import { useTuiPlatform } from "../../platform" import { Spinner } from "../spinner" import { useSDK } from "../../context/sdk" import { useRoute } from "../../context/route" import { useProject } from "../../context/project" import { useSync } from "../../context/sync" import { useEvent } from "../../context/event" import { editorSelectionKey, useEditorContext, type EditorSelection } from "../../context/editor" import { promptOffsetWidth } from "../../prompt/display" import { createStore, produce, unwrap } from "solid-js/store" import { usePromptHistory, type PromptInfo } from "../../prompt/history" import { computePromptTraits } from "../../prompt/traits" import { expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part" import { usePromptStash } from "../../prompt/stash" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useExit } from "../../context/exit" import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2" import { Locale } from "../../util/locale" import { errorMessage } from "../../util/error" import { formatDuration } from "../../util/format" import { createColors, createFrames } from "../../ui/spinner" import { useDialog } from "../../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 { createFadeIn } from "../../util/signal" import { DialogSkill } from "../dialog-skill" import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable" import { useArgs } from "../../context/args" import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap" import { useTuiConfig } from "../../config" import { usePromptWorkspace } from "./workspace" import { usePromptMove } from "./move" import { readLocalAttachment } from "./local-attachment" export type PromptProps = { sessionID?: 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[] } } function pastedFilepath(value: string, platform: string) { const raw = value.replace(/^['"]+|['"]+$/g, "") if (raw.startsWith("file://")) { try { return fileURLToPath(raw) } catch {} } if (platform === "win32") return raw return raw.replace(/\\(.)/g, "$1") } 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", }) const DRAFT_RETENTION_MIN_CHARS = 20 function randomIndex(count: number) { if (count <= 0) return 0 return Math.floor(Math.random() * count) } function fadeColor(color: RGBA, alpha: number) { return RGBA.fromValues(color.r, color.g, color.b, color.a * alpha) } function hasEditorRangeSelection(selection: EditorSelection["ranges"][number]) { return ( selection.selection.start.line !== selection.selection.end.line || selection.selection.start.character !== selection.selection.end.character ) } function getEditorRangeLabel(selection: EditorSelection["ranges"][number]) { if (!hasEditorRangeSelection(selection)) return if (selection.selection.start.line === selection.selection.end.line) return `#${selection.selection.start.line}` return `#${selection.selection.start.line}-${selection.selection.end.line}` } function formatEditorContext(selection: EditorSelection) { const selected = selection.ranges.filter(hasEditorRangeSelection) if (selected.length === 0) return `Note: The user opened the file "${selection.filePath}". This may or may not be relevant to the current task.\n` const ranges = selected.map((range, index) => { const prefix = selected.length > 1 ? `Selection ${index + 1}: ` : "" return `Note: The user selected ${prefix}${getEditorRangeLabel(range)} from "${selection.filePath}". \`\`\`${range.text}\`\`\`\n\n` }) return `${ranges.join("\n")} This may or may not be relevant to the current task.\n` } let stashed: { prompt: PromptInfo; cursor: number } | undefined export function Prompt(props: PromptProps) { let input: TextareaRenderable let anchor: BoxRenderable const [inputTarget, setInputTarget] = createSignal() const leader = useLeaderActive() const local = useLocal() const args = useArgs() const environment = useTuiEnvironment() const platform = useTuiPlatform() const sdk = useSDK() const editor = useEditorContext() const route = useRoute() const project = useProject() const sync = useSync() const tuiConfig = useTuiConfig() const dialog = useDialog() const toast = useToast() const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" }) const history = usePromptHistory() const stash = usePromptStash() const keymap = useOpencodeKeymap() const agentShortcut = useCommandShortcut("agent.cycle") const paletteShortcut = useCommandShortcut("command.palette.show") const renderer = useRenderer() const dimensions = useTerminalDimensions() const { theme, syntax } = useTheme() const kv = useKV() const animationsEnabled = createMemo(() => kv.get("animations_enabled", true)) const list = createMemo(() => props.placeholders?.normal ?? []) const shell = createMemo(() => props.placeholders?.shell ?? []) const fileContextEnabled = createMemo(() => kv.get("file_context_enabled", true)) const [dismissedEditorSelectionKey, setDismissedEditorSelectionKey] = createSignal() const editorContext = createMemo(() => { const selection = fileContextEnabled() ? editor.selection() : undefined if (!selection) return return editorSelectionKey(selection) === dismissedEditorSelectionKey() ? undefined : selection }) const editorPath = createMemo(() => editorContext()?.filePath) const editorSelectionLabel = createMemo(() => { const ranges = editorContext()?.ranges if (!ranges) return const first = ranges.find(hasEditorRangeSelection) ?? ranges[0] if (!first) return return [getEditorRangeLabel(first), ranges.length > 1 ? `+${ranges.length - 1}` : undefined] .filter(Boolean) .join(" ") }) const editorFileLabel = createMemo(() => { const value = editorPath() if (!value) return const filename = path.basename(value) const file = /^index\.[^./]+$/.test(filename) ? [path.basename(path.dirname(value)), filename].filter(Boolean).join("/") : filename return `${file.split(path.sep).join("/")}${editorSelectionLabel() ?? ""}` }) const editorFileLabelDisplay = createMemo(() => { const file = editorFileLabel() if (!file) return return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3)))) }) const editorContextLabelState = createMemo(() => editor.labelState()) const [auto, setAuto] = createSignal() const workspace = usePromptWorkspace(props.sessionID) const move = usePromptMove({ projectID: project.project, sessionID: () => props.sessionID }) const [cursorVersion, setCursorVersion] = createSignal(0) 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(() => ) } } function dismissEditorContext() { setDismissedEditorSelectionKey(editorSelectionKey(editorContext())) editor.clearSelection() } const fileStyleId = syntax().getStyleId("extmark.file")! const agentStyleId = syntax().getStyleId("extmark.agent")! const pasteStyleId = syntax().getStyleId("extmark.paste")! let promptPartTypeId = 0 const event = useEvent() event.on("tui.prompt.append", (evt, { workspace }) => { if (workspace !== project.workspace.current()) return 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 (!input || input.isDestroyed) return 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 session = sync.session.get(props.sessionID) 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 = session?.cost ?? 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) { // Keep command line --agent if specified. if (!args.agent) local.agent.set(msg.agent) if (msg.model) { local.model.set(msg.model) local.model.variant.set(msg.model.variant) } } } }) const promptCommands = createMemo(() => [ { title: "Clear prompt", name: "prompt.clear", category: "Prompt", hidden: true, run: () => { clearPrompt() dialog.clear() }, }, { title: "Submit prompt", name: "prompt.submit", category: "Prompt", hidden: true, run: async () => { if (!input.focused) return const handled = await submit() if (!handled) return dialog.clear() }, }, { title: "Remove editor context", name: "prompt.editor_context.clear", category: "Prompt", enabled: Boolean(editorContext()), run: () => { dismissEditorContext() dialog.clear() }, }, { title: "Paste", name: "prompt.paste", category: "Prompt", hidden: true, run: async (ctx: CommandContext) => { ctx.event.preventDefault() ctx.event.stopPropagation() const content = await platform.clipboard?.read?.() if (content?.mime.startsWith("image/")) { await pasteAttachment({ filename: "clipboard", mime: content.mime, content: content.data, }) return } if (content?.mime === "text/plain") { await pasteInputText(content.data) } }, }, { title: "Interrupt session", name: "session.interrupt", category: "Session", hidden: true, enabled: status().type !== "idle", run: () => { if (auto()?.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) { void sdk.client.session.abort({ sessionID: props.sessionID, }) setStore("interrupt", 0) } dialog.clear() }, }, { title: "Open editor", category: "Session", name: "prompt.editor", slashName: "editor", run: async () => { 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 platform.editor?.open({ value, cwd: (project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) || project.instance.directory() || environment.cwd, }) 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", name: "prompt.skills", category: "Prompt", slashName: "skills", run: () => { dialog.replace(() => ( { input.setText(`/${skill} `) setStore("prompt", { input: `/${skill} `, parts: [], }) input.gotoBufferEnd() }} /> )) }, }, { title: "Warp", desc: "Change the workspace for the session", name: "workspace.set", category: "Session", enabled: environment.capabilities.workspaces, slashName: "warp", run: () => { workspace.open() }, }, { title: "Move session", desc: "Move the session to another project directory", name: "session.move", category: "Session", slashName: "move", run: () => { move.open() }, }, ].map((entry) => ({ namespace: "palette", ...entry, })), ) useBindings(() => ({ commands: promptCommands(), })) useBindings(() => ({ mode: OPENCODE_BASE_MODE, bindings: tuiConfig.keybinds.gather("prompt.palette", [ "prompt.submit", "prompt.editor", "prompt.editor_context.clear", "prompt.stash", "prompt.stash.pop", "prompt.stash.list", "session.interrupt", "workspace.set", "session.move", ]), })) 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() { void submit() }, } onMount(() => { const saved = stashed stashed = undefined if (store.prompt.input) return if (saved && saved.prompt.input) { input.setText(saved.prompt.input) setStore("prompt", saved.prompt) restoreExtmarksFromParts(saved.prompt.parts) input.cursorOffset = saved.cursor } }) onCleanup(() => { if (store.prompt.input) { stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset } } setInputTarget(undefined) props.ref?.(undefined) }) createEffect(() => { if (!input || input.isDestroyed) return if (props.visible === false || dialog.stack.length > 0) { if (input.focused) 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. if (!input.focused) input.focus() }) createEffect(() => { if (!input || input.isDestroyed) return input.traits = { ...input.traits, ...computePromptTraits({ mode: store.mode, autocompleteVisible: !!auto()?.visible, }), } }) 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 }), ) } const stashCommands = createMemo(() => [ { title: "Stash prompt", name: "prompt.stash", category: "Prompt", enabled: !!store.prompt.input, run: () => { 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", name: "prompt.stash.pop", category: "Prompt", enabled: stash.list().length > 0, run: () => { 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", name: "prompt.stash.list", category: "Prompt", enabled: stash.list().length > 0, run: () => { dialog.replace(() => ( { input.setText(entry.input) setStore("prompt", { input: entry.input, parts: entry.parts }) restoreExtmarksFromParts(entry.parts) input.gotoBufferEnd() }} /> )) }, }, ].map((entry) => ({ namespace: "palette", ...entry, })), ) useBindings(() => ({ commands: stashCommands(), })) useBindings(() => { return { target: inputTarget, enabled: inputTarget() !== undefined && !props.disabled, bindings: tuiConfig.keybinds.get("prompt.paste"), } }) useBindings(() => { return { target: inputTarget, enabled: inputTarget() !== undefined && !props.disabled && store.prompt.input !== "", bindings: tuiConfig.keybinds.get("prompt.clear"), } }) useBindings(() => { return { target: inputTarget, enabled: (() => { cursorVersion() return ( inputTarget() !== undefined && !props.disabled && store.mode === "normal" && !auto()?.visible && input?.visualCursor.offset === 0 ) })(), bindings: [ { key: "!", desc: "Shell mode", group: "Prompt", cmd: () => { setStore("placeholder", randomIndex(shell().length)) setStore("mode", "shell") }, }, ], } }) useBindings(() => { return { target: inputTarget, enabled: inputTarget() !== undefined && store.mode === "shell", bindings: [{ key: "escape", desc: "Exit shell mode", group: "Prompt", cmd: () => setStore("mode", "normal") }], } }) useBindings(() => { return { target: inputTarget, enabled: (() => { cursorVersion() return inputTarget() !== undefined && store.mode === "shell" && input?.visualCursor.offset === 0 })(), bindings: [{ key: "backspace", desc: "Exit shell mode", group: "Prompt", cmd: () => setStore("mode", "normal") }], } }) useBindings(() => { return { target: inputTarget, enabled: (() => { cursorVersion() return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined })(), commands: [ { name: "prompt.history.previous", title: "Previous prompt history", category: "Prompt", run() { if (input.cursorOffset !== 0) { if (input.scrollY + input.visualCursor.visualRow === 0) input.cursorOffset = 0 return false } const item = history.move(-1, input.plainText) if (!item) return false input.setText(item.input) setStore("prompt", item) setStore("mode", item.mode ?? "normal") restoreExtmarksFromParts(item.parts) input.cursorOffset = 0 }, }, ], bindings: tuiConfig.keybinds.get("prompt.history.previous"), } }) useBindings(() => { return { target: inputTarget, enabled: (() => { cursorVersion() return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined })(), commands: [ { name: "prompt.history.next", title: "Next prompt history", category: "Prompt", run() { if (input.cursorOffset !== input.plainText.length) { if ( input.scrollY + input.visualCursor.visualRow === Math.max(0, input.editorView.getTotalVirtualLineCount() - 1) ) input.cursorOffset = input.plainText.length return false } const item = history.move(1, input.plainText) if (!item) return false input.setText(item.input) setStore("prompt", item) setStore("mode", item.mode ?? "normal") restoreExtmarksFromParts(item.parts) input.cursorOffset = input.plainText.length }, }, ], bindings: tuiConfig.keybinds.get("prompt.history.next"), } }) let submitting = false async function submit() { // Prevent overlapping invocations (e.g. a double-pressed Enter, or the // input's native onSubmit racing another dispatch). Without this guard, // a second call slips past the empty-input check before the first call // clears `store.prompt.input`, then awaits its own `session.create` and // ultimately reads the now-empty store — sending a phantom empty prompt // to a freshly created session. if (submitting) return false submitting = true try { return await submitInner() } finally { submitting = false } } async function submitInner() { workspace.clearNotice() // IME: double-defer may fire before onContentChange flushes the last // composed character (e.g. Korean hangul) to the store, so read // plainText directly and sync before any downstream reads. if (input && !input.isDestroyed && input.plainText !== store.prompt.input) { setStore("prompt", "input", input.plainText) syncExtmarksWithPromptParts() } if (props.disabled) return false if (workspace.creating() || move.creating()) return false if (auto()?.visible) return false if (!store.prompt.input) return false const agent = local.agent.current() if (!agent) return false const trimmed = store.prompt.input.trim() if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") { void exit() return true } const selectedModel = local.model.current() if (!selectedModel) { void promptModelWarning() return false } const workspaceSession = props.sessionID ? sync.session.get(props.sessionID) : undefined const workspaceID = workspaceSession?.workspaceID const workspaceStatus = workspaceID ? (project.workspace.status(workspaceID) ?? "error") : undefined if (props.sessionID && workspaceID && workspaceStatus !== "connected") { dialog.replace(() => ( { workspace.open() return false }} /> )) return false } const variant = local.model.variant.current() let sessionID = props.sessionID let finishMoveProgress = false if (sessionID == null) { const selectedWorkspace = workspace.selection() const workspaceID = selectedWorkspace?.type === "existing" ? selectedWorkspace.workspaceID : undefined const directory = await move.getDirectory(store.prompt.input) if (move.pending() && !directory) return false finishMoveProgress = Boolean(move.progress()) const res = await sdk.client.session.create({ directory, workspace: workspaceID, agent: agent.name, model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant, }, }) if (res.error) { if (finishMoveProgress) move.finishSubmit() console.log("Creating a session failed:", res.error) toast.show({ message: "Creating a session failed. Open console for more details.", variant: "error", }) return true } sessionID = res.data.id } const inputText = expandTrackedPastedText( store.prompt.input, input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => { const partIndex = store.extmarkToPartIndex.get(extmark.id) const part = partIndex === undefined ? undefined : store.prompt.parts[partIndex] if (part?.type !== "text") return [] return [{ start: extmark.start, end: extmark.end, text: part.text }] }), ) // 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 editorSelection = editorContext() const editorParts = editorSelection && editor.labelState() === "pending" ? [ { type: "text" as const, text: formatEditorContext(editorSelection), synthetic: true, metadata: { kind: "editor_context", source: editorSelection.source ?? "editor", filePath: editorSelection.filePath, ranges: editorSelection.ranges, }, }, ] : [] if (store.mode === "shell") { move.startSubmit() void sdk.client.session.shell({ sessionID, agent: agent.name, model: { providerID: selectedModel.providerID, modelID: selectedModel.modelID, }, command: inputText, }) setStore("mode", "normal") } else if ( inputText.startsWith("/") && sync.data.command.some((x) => x.name === inputText.split("\n")[0].split(" ")[0].slice(1)) ) { move.startSubmit() // 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 : "") void sdk.client.session.command({ sessionID, command: command.slice(1), arguments: args, agent: agent.name, model: `${selectedModel.providerID}/${selectedModel.modelID}`, variant, parts: nonTextParts.filter((x) => x.type === "file"), }) } else { move.startSubmit() sdk.client.session .prompt({ sessionID, ...selectedModel, agent: agent.name, model: selectedModel, variant, parts: [ ...editorParts, { type: "text", text: inputText, }, ...nonTextParts, ], }) .catch(() => {}) if (editorParts.length > 0) editor.markSelectionSent() } 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) { if (editorParts.length > 0) editor.preserveSelectionFromNewSession() setTimeout(() => { route.navigate({ type: "session", sessionID, }) }, 50) } input.clear() if (finishMoveProgress) move.finishSubmit() return true } const exit = useExit() function pasteText(text: string, virtualText: string) { const currentOffset = input.cursorOffset const extmarkStart = currentOffset const extmarkEnd = extmarkStart + promptOffsetWidth(virtualText) 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 pasteInputText(text: string) { const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") const pastedContent = normalizedText.trim() const filepath = pastedFilepath(pastedContent, environment.platform) const isUrl = /^(https?):\/\//.test(filepath) if (!isUrl) { const attachment = await readLocalAttachment(platform.files, filepath) const filename = path.basename(filepath) if (attachment?.type === "text") { pasteText(attachment.content, `[SVG: ${filename ?? "image"}]`) return } if (attachment?.type === "binary") { await pasteAttachment({ filename, filepath, mime: attachment.mime, content: Buffer.from(attachment.content).toString("base64"), }) return } } const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 if ( (lineCount >= 3 || pastedContent.length > 150) && kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary) ) { pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) return } input.insertText(normalizedText) setTimeout(() => { if (!input || input.isDestroyed) return input.getLayoutNode().markDirty() renderer.requestRender() }, 0) } async function pasteAttachment(file: { filename?: string; filepath?: string; content: string; mime: string }) { const currentOffset = input.cursorOffset 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 } function clearPrompt() { if (store.prompt.input.trim().length >= DRAFT_RETENTION_MIN_CHARS || store.prompt.parts.length > 0) { history.append({ ...store.prompt, mode: store.mode, }) } input.clear() input.extmarks.clear() setStore("prompt", { input: "", parts: [], }) setStore("extmarkToPartIndex", new Map()) } const highlight = createMemo(() => { if (leader()) return theme.border if (store.mode === "shell") return theme.primary const agent = local.agent.current() if (!agent) return theme.border return local.agent.color(agent.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 agentMetaAlpha = createFadeIn(() => !!local.agent.current(), animationsEnabled) const modelMetaAlpha = createFadeIn(() => !!local.agent.current() && store.mode === "normal", animationsEnabled) const variantMetaAlpha = createFadeIn( () => !!local.agent.current() && store.mode === "normal" && showVariant(), animationsEnabled, ) const borderHighlight = createMemo(() => tint(theme.border, highlight(), agentMetaAlpha())) 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 agent = status().type !== "idle" ? (local.agent.list().find((a) => a.name === lastUserMessage()?.agent) ?? local.agent.current()) : local.agent.current() const color = agent ? local.agent.color(agent.name) : theme.border 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, }), } }) const maxHeight = createMemo(() => tuiConfig.prompt?.max_height ?? Math.max(6, Math.floor(dimensions().height / 3))) const moveLabelWidth = createMemo(() => Math.max(12, Math.min(44, dimensions().width - 48))) return ( <> (anchor = r)} visible={props.visible !== false} width="100%">