import { Suspense, createEffect, createSignal, lazy, on, onCleanup, Show } from "solid-js" import { ArrowBigUp, ArrowBigDown, Loader2, Mic, X } from "lucide-solid" import ExpandButton from "./expand-button" import { clearAttachments, removeAttachment } from "../stores/attachments" import { resolvePastedPlaceholders } from "../lib/prompt-placeholders" import { createPastedPlaceholderRegex, pastedDisplayCounterRegex } from "./prompt-input/attachmentPlaceholders" import Kbd from "./kbd" import { getActiveInstance } from "../stores/instances" import { agents, executeCustomCommand } from "../stores/sessions" import { getCommands } from "../stores/commands" import { showAlertDialog } from "../stores/alerts" import { useI18n } from "../lib/i18n" import { getLogger } from "../lib/logger" import { preferences } from "../stores/preferences" import type { ExpandState, PromptInputApi, PromptInputProps, PromptInsertMode, PromptMode } from "./prompt-input/types" import type { Attachment } from "../types/attachment" import { usePromptState } from "./prompt-input/usePromptState" import { usePromptAttachments } from "./prompt-input/usePromptAttachments" import { usePromptPicker } from "./prompt-input/usePromptPicker" import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown" import { usePromptVoiceInput } from "./prompt-input/usePromptVoiceInput" const log = getLogger("actions") const LazyUnifiedPicker = lazy(() => import("./unified-picker")) function getConsumedPastedTextAttachmentIds(text: string, attachments: Attachment[]): string[] { if (!text || attachments.length === 0) return [] const usedCounters = new Set() for (const match of text.matchAll(createPastedPlaceholderRegex())) { const counter = match?.[1] if (counter) usedCounters.add(counter) } if (usedCounters.size === 0) return [] const consumed = new Set() for (const attachment of attachments) { if (!attachment?.id) continue if (attachment?.source?.type !== "text") continue const display = attachment.display if (typeof display !== "string") continue const match = display.match(pastedDisplayCounterRegex) if (!match?.[1]) continue if (usedCounters.has(match[1])) { consumed.add(attachment.id) } } return Array.from(consumed) } export default function PromptInput(props: PromptInputProps) { const { t } = useI18n() const [, setIsFocused] = createSignal(false) const [mode, setMode] = createSignal("normal") const [expandState, setExpandState] = createSignal("normal") const SELECTION_INSERT_MAX_LENGTH = 2000 let textareaRef: HTMLTextAreaElement | undefined const getPlaceholder = () => { if (mode() === "shell") { return t("promptInput.placeholder.shell") } return t("promptInput.placeholder.default") } const promptState = usePromptState({ instanceId: () => props.instanceId, sessionId: () => props.sessionId, instanceFolder: () => props.instanceFolder, }) const { prompt, setPrompt, clearPrompt, draftLoadedNonce, history, historyIndex, recordHistoryEntry, clearHistoryDraft, resetHistoryNavigation, selectPreviousHistory, selectNextHistory, } = promptState const { attachments, isDragging, handlePaste, handleDragOver, handleDragLeave, handleDrop, syncAttachmentCounters, handleExpandTextAttachment, handleRemoveAttachment, } = usePromptAttachments({ instanceId: () => props.instanceId, sessionId: () => props.sessionId, instanceFolder: () => props.instanceFolder, prompt, setPrompt, getTextarea: () => textareaRef ?? null, }) createEffect(() => { if (!props.registerPromptInputApi) return const api: PromptInputApi = { insertSelection: (text: string, mode: PromptInsertMode) => { if (mode === "code") { insertCodeSelection(text) } else { insertQuotedSelection(text) } }, expandTextAttachment: (attachmentId: string) => { const attachment = attachments().find((a) => a.id === attachmentId) if (!attachment) return handleExpandTextAttachment(attachment) }, removeAttachment: (attachmentId: string) => { handleRemoveAttachment(attachmentId) }, setPromptText: (text: string, opts?: { focus?: boolean }) => { const textarea = textareaRef if (textarea) { textarea.value = text textarea.dispatchEvent(new Event("input", { bubbles: true })) if (opts?.focus) { try { textarea.focus({ preventScroll: true } as any) } catch { textarea.focus() } } return } setPrompt(text) if (opts?.focus) { setTimeout(() => { api.focus() }, 0) } }, focus: () => { const textarea = textareaRef if (!textarea || textarea.disabled) return try { textarea.focus({ preventScroll: true } as any) } catch { textarea.focus() } }, } const cleanup = props.registerPromptInputApi(api) onCleanup(() => { if (typeof cleanup === "function") { cleanup() } }) }) const instanceAgents = () => agents().get(props.instanceId) || [] const promptPicker = usePromptPicker({ instanceId: () => props.instanceId, sessionId: () => props.sessionId, instanceFolder: () => props.instanceFolder, prompt, setPrompt, getTextarea: () => textareaRef ?? null, instanceAgents, commands: () => getCommands(props.instanceId), }) const { showPicker, pickerMode, searchQuery, ignoredAtPositions, setShowPicker, setPickerMode, setSearchQuery, setAtPosition, setIgnoredAtPositions, handleInput, handlePickerSelect, handlePickerClose, } = promptPicker createEffect( on( draftLoadedNonce, () => { // Session switch resets (picker/counters/ignored positions) stay in the component. setIgnoredAtPositions(new Set()) setShowPicker(false) setPickerMode("mention") setAtPosition(null) setSearchQuery("") syncAttachmentCounters(prompt()) }, { defer: true }, ), ) const isCoarsePointer = () => { if (typeof window === "undefined") return false return Boolean(window.matchMedia?.("(pointer: coarse)")?.matches) } createEffect(() => { // Scope global "type-to-focus" behavior to the active, visible prompt only. if (typeof document === "undefined") return if (isCoarsePointer()) return if (props.isActive === false) return if (props.disabled) return const handleGlobalKeyDown = (e: KeyboardEvent) => { const activeElement = document.activeElement as HTMLElement | null const isInputElement = activeElement?.tagName === "INPUT" || activeElement?.tagName === "TEXTAREA" || activeElement?.tagName === "SELECT" || Boolean(activeElement?.isContentEditable) if (isInputElement) return const isModifierKey = e.ctrlKey || e.metaKey || e.altKey if (isModifierKey) return const isSpecialKey = e.key === "Tab" || e.key === "Enter" || e.key.startsWith("Arrow") || e.key === "Backspace" || e.key === "Delete" if (isSpecialKey) return const textarea = textareaRef if (!textarea || textarea.disabled) return // In session cache mode inactive panes are display:none; avoid stealing focus. if (textarea.offsetParent === null) return if (e.key.length === 1) { textarea.focus() } } document.addEventListener("keydown", handleGlobalKeyDown) onCleanup(() => { document.removeEventListener("keydown", handleGlobalKeyDown) }) }) async function handleSend() { const text = prompt().trim() const currentAttachments = attachments() if (props.disabled || (!text && currentAttachments.length === 0)) return const isShellMode = mode() === "shell" // Slash command routing (match OpenCode TUI): only run if the command exists. const isSlashCandidate = !isShellMode && text.startsWith("/") const firstSpace = isSlashCandidate ? text.indexOf(" ") : -1 const commandToken = isSlashCandidate ? (firstSpace === -1 ? text : text.slice(0, firstSpace)) : "" const commandName = isSlashCandidate ? commandToken.slice(1) : "" const commandArgs = isSlashCandidate ? (firstSpace === -1 ? "" : text.slice(firstSpace + 1).trimStart()) : "" const isKnownSlashCommand = isSlashCandidate && commandName.length > 0 && getCommands(props.instanceId).some((cmd) => cmd.name === commandName) const resolvedCommandArgs = isKnownSlashCommand ? resolvePastedPlaceholders(commandArgs, currentAttachments) : "" const resolvedPrompt = isKnownSlashCommand ? resolvedCommandArgs ? `${commandToken} ${resolvedCommandArgs}` : commandToken : resolvePastedPlaceholders(text, currentAttachments) const historyEntry = resolvedPrompt const refreshHistory = () => recordHistoryEntry(historyEntry) setExpandState("normal") clearPrompt() clearHistoryDraft() setMode("normal") // Ignore attachments for slash commands, but keep them for next prompt. if (!isKnownSlashCommand) { clearAttachments(props.instanceId, props.sessionId) syncAttachmentCounters("") setIgnoredAtPositions(new Set()) } else { const consumedIds = getConsumedPastedTextAttachmentIds(commandArgs, currentAttachments) for (const attachmentId of consumedIds) { removeAttachment(props.instanceId, props.sessionId, attachmentId) } syncAttachmentCounters("") setIgnoredAtPositions(new Set()) } clearHistoryDraft() if (isKnownSlashCommand) { // Record attempted slash commands even if execution fails. void refreshHistory() } try { if (isShellMode) { if (props.onRunShell) { await props.onRunShell(resolvedPrompt) } else { await props.onSend(resolvedPrompt, []) } } else if (isKnownSlashCommand) { await executeCustomCommand(props.instanceId, props.sessionId, commandName, resolvedCommandArgs) } else { await props.onSend(resolvedPrompt, currentAttachments) } if (!isKnownSlashCommand) { void refreshHistory() } } catch (error) { log.error("Failed to send message:", error) showAlertDialog(t("promptInput.send.errorFallback"), { title: t("promptInput.send.errorTitle"), detail: error instanceof Error ? error.message : String(error), variant: "error", }) } finally { textareaRef?.focus() } } function handleAbort() { if (!props.onAbortSession || !props.isSessionBusy) return void props.onAbortSession() } function handleExpandToggle(nextState: "normal" | "expanded") { setExpandState(nextState) // Keep focus on textarea textareaRef?.focus() } function handleClearPrompt() { clearPrompt() clearHistoryDraft() resetHistoryNavigation() setShowPicker(false) setPickerMode("mention") setAtPosition(null) setSearchQuery("") setIgnoredAtPositions(new Set()) syncAttachmentCounters("") textareaRef?.focus() } function insertBlockContent(block: string) { const textarea = textareaRef const current = prompt() const start = textarea ? textarea.selectionStart : current.length const end = textarea ? textarea.selectionEnd : current.length const before = current.substring(0, start) const after = current.substring(end) const needsLeading = before.length > 0 && !before.endsWith("\n") ? "\n" : "" const insertion = `${needsLeading}${block}` const nextValue = before + insertion + after setPrompt(nextValue) setShowPicker(false) setAtPosition(null) if (textarea) { setTimeout(() => { const cursor = before.length + insertion.length textarea.focus() textarea.setSelectionRange(cursor, cursor) }, 0) } } function insertQuotedSelection(rawText: string) { const normalized = (rawText ?? "").replace(/\r/g, "").trim() if (!normalized) return const limited = normalized.length > SELECTION_INSERT_MAX_LENGTH ? normalized.slice(0, SELECTION_INSERT_MAX_LENGTH).trimEnd() : normalized const lines = limited .split(/\n/) .map((line) => line.trim()) .filter((line) => line.length > 0) if (lines.length === 0) return const blockquote = lines.map((line) => `> ${line}`).join("\n") if (!blockquote) return // End the blockquote with a blank line so the user's next line // doesn't get parsed as a lazy continuation of the quote. insertBlockContent(`${blockquote}\n\n`) } function insertCodeSelection(rawText: string) { const normalized = (rawText ?? "").replace(/\r/g, "") const limited = normalized.length > SELECTION_INSERT_MAX_LENGTH ? normalized.slice(0, SELECTION_INSERT_MAX_LENGTH) : normalized const trimmed = limited.replace(/^\n+/, "").replace(/\n+$/, "") if (!trimmed) return const block = "```\n" + trimmed + "\n```\n\n" insertBlockContent(block) } const canStop = () => Boolean(props.isSessionBusy && props.onAbortSession) const hasHistory = () => history().length > 0 const canHistoryGoPrevious = () => hasHistory() && (historyIndex() === -1 || historyIndex() < history().length - 1) const canHistoryGoNext = () => historyIndex() >= 0 const canSend = () => { if (props.disabled) return false const hasText = prompt().trim().length > 0 if (mode() === "shell") return hasText return hasText || attachments().length > 0 } const canClearPrompt = () => prompt().length > 0 const shellHint = () => mode() === "shell" ? { key: "Esc", text: t("promptInput.hints.shell.exit") } : { key: "!", text: t("promptInput.hints.shell.enable") } const commandHint = () => ({ key: "/", text: t("promptInput.hints.commands") }) const submitOnEnter = () => preferences().promptSubmitOnEnter const handleKeyDown = usePromptKeyDown({ getTextarea: () => textareaRef ?? null, prompt, setPrompt, mode, setMode, isPickerOpen: showPicker, closePicker: handlePickerClose, ignoredAtPositions, setIgnoredAtPositions, getAttachments: attachments, removeAttachment: (attachmentId) => removeAttachment(props.instanceId, props.sessionId, attachmentId), submitOnEnter, onSend: () => void handleSend(), selectPreviousHistory: (force) => selectPreviousHistory({ force, isPickerOpen: showPicker(), getTextarea: () => textareaRef ?? null }), selectNextHistory: (force) => selectNextHistory({ force, isPickerOpen: showPicker(), getTextarea: () => textareaRef ?? null }), }) const shouldShowOverlay = () => prompt().length === 0 const voiceInput = usePromptVoiceInput({ prompt, setPrompt, getTextarea: () => textareaRef ?? null, enabled: () => preferences().showPromptVoiceInput, disabled: () => Boolean(props.disabled), }) const showVoiceInput = () => preferences().showPromptVoiceInput && (voiceInput.canUseVoiceInput() || voiceInput.isRecording() || voiceInput.isTranscribing()) const instance = () => getActiveInstance() let voiceButtonPressed = false const beginVoicePress = (event?: PointerEvent | KeyboardEvent) => { if (voiceButtonPressed || props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput()) return voiceButtonPressed = true if (event instanceof PointerEvent) { const target = event.currentTarget if (target instanceof HTMLElement) { try { target.setPointerCapture(event.pointerId) } catch { // no-op } } } void voiceInput.startRecording() } const endVoicePress = () => { if (!voiceButtonPressed) return voiceButtonPressed = false voiceInput.stopRecording() } return (