import { createSignal, Show, onMount, onCleanup, createEffect, on } from "solid-js" import { ArrowBigUp, ArrowBigDown } from "lucide-solid" import UnifiedPicker from "./unified-picker" import ExpandButton from "./expand-button" import { clearAttachments, removeAttachment } from "../stores/attachments" import { resolvePastedPlaceholders } from "../lib/prompt-placeholders" 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 { usePromptState } from "./prompt-input/usePromptState" import { usePromptAttachments } from "./prompt-input/usePromptAttachments" import { usePromptPicker } from "./prompt-input/usePromptPicker" import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown" const log = getLogger("actions") 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 resolvedPrompt = isKnownSlashCommand ? text : 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 { 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, commandArgs) } 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 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 insertBlockContent(`${blockquote}\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 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 instance = () => getActiveInstance() return (