import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from 'react'; import { Check, Plus, Send } from 'lucide-react'; import { toast } from 'sonner'; import { Textarea } from '@/components/ui/textarea'; import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { flattenToMessage, inferLanguage, looksBinary, MAX_FILE_SIZE_BYTES, PASTE_INLINE_MAX_LINES, type Attachment, } from '@/lib/attachments'; import { AttachmentChip } from '@/components/AttachmentChip'; import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal'; import { FileMentionPopover } from '@/components/FileMentionPopover'; import { DropOverlay } from '@/components/DropOverlay'; import { AgentPicker } from '@/components/AgentPicker'; import { AgentCommandsHint } from '@/components/AgentCommandsHint'; import { ContextBar } from '@/components/ContextBar'; import { SlashCommandPicker, type SlashCommandGroup } from '@/components/SlashCommandPicker'; import { isSlashCommandToken, parseSlashInput, slashQuery } from '@/lib/slash-command'; import { api } from '@/api/client'; import type { Message } from '@/api/types'; import { sessionEvents } from '@/hooks/sessionEvents'; import { chatInputsRegistry, sendToChat } from '@/lib/events'; import { useSkills } from '@/hooks/useSkills'; import { useViewport } from '@/hooks/useViewport'; const MAX_ATTACHMENTS = 10; interface Props { disabled?: boolean; projectId: string; // Batch 9: optional so callers that pre-date the agent picker still compile. // When omitted, the toolbar row is hidden entirely. agentId?: string | null; onAgentChange?: (agentId: string | null) => void | Promise; // v1.9: when sessionId + webSearchEnabled are both provided, the + menu // renders next to the AgentPicker with a single "Web search" toggle item. // The check reflects the *stored* session value (not the effective one): // null counts as unchecked. Clicking PATCHes session.web_search_enabled // with the inverted boolean (null → true, true → false, false → true). sessionId?: string; webSearchEnabled?: boolean | null; onSend: (content: string) => void | Promise; onForceSend?: (content: string) => void | Promise; // Batch 9.6: slash-command dispatch. When the input parses to a known skill, // ChatInput calls this with the skill name + the post-name args (possibly // empty). Callers wire this to api.chats.skillInvoke. Omitting the prop // disables slash-command dispatch (input is sent as literal text). onSlashCommand?: (skillName: string, userMessage: string) => void | Promise; // v2.5.9: segmented slash-command DISPLAY source for the picker + hint. When // provided (e.g. CoderPane passing [agent commands, skills]), these labeled // groups are shown instead of the BooChat skills. Invocation routing still // uses the skills lookup — names not in skills (opencode's /help etc.) fall // through and are sent to the agent as literal text. Omitted → BooChat skills // (flat, unchanged — parity). slashGroups?: SlashCommandGroup[]; // v1.10.4: send-to-chat reverse path. When chatId is provided, this input // registers in chatInputsRegistry so the terminal floating menu can list // it, and subscribes to sendToChat events scoped to this chatId. Receiving // an event appends the text to the current draft (with a newline separator // when non-empty) and focuses — no auto-send. chatId?: string; chatLabel?: string; // v1.11.5: context-bar inputs. messages drives the latest-pair walk; // modelContextLimit is the zero-state fallback (and powers the // auto-compaction-threshold tooltip when no assistant message has run // yet). Both are optional so older call sites still compile. messages?: Message[]; modelContextLimit?: number | null; } export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand, slashGroups, chatId, chatLabel, messages, modelContextLimit }: Props) { const { isMobile } = useViewport(); const [value, setValue] = useState(''); const [busy, setBusy] = useState(false); const [attachments, setAttachments] = useState([]); const [previewAttachment, setPreviewAttachment] = useState(null); const [isDraggingOver, setIsDraggingOver] = useState(false); const dropRootRef = useRef(null); const pasteCounterRef = useRef(0); const [mentionState, setMentionState] = useState<{ open: boolean; query: string; atIdx: number; anchorRect: { top: number; left: number }; } | null>(null); // Batch 9.6: slash-command dropdown. Opens when `/` is the first char of // the input and stays open while the input is `/` with no whitespace. // Disabled entirely when the caller doesn't pass onSlashCommand. // SlashCommandPicker reads the live textarea rect via inputRef (textareaRef below) // so it can recompute on visualViewport changes (iOS keyboard open/close). const [slashState, setSlashState] = useState<{ query: string; } | null>(null); const { skills } = useSkills(); const skillsLookup = useMemo(() => { const m = new Map(); for (const s of skills) m.set(s.name, true); return m; }, [skills]); // Flat display source for the hint (and the picker's no-groups fallback): // caller-provided groups flattened, else the BooChat skills. const slashItems = useMemo( () => slashGroups ? slashGroups.flatMap((g) => g.items) : skills.map((s) => ({ name: s.name, description: s.description })), [slashGroups, skills], ); const [fileIndex, setFileIndex] = useState(null); const textareaRef = useRef(null); function addAttachment(a: Attachment) { setAttachments(prev => { if (prev.length >= MAX_ATTACHMENTS) { toast.error(`Max ${MAX_ATTACHMENTS} attachments per message`); return prev; } return [...prev, a]; }); } const addAttachmentRef = useRef(addAttachment); addAttachmentRef.current = addAttachment; useEffect(() => { return sessionEvents.subscribe((event) => { if (event.type !== 'attach_chat_file') return; addAttachmentRef.current({ id: crypto.randomUUID(), ...event.attachment, }); }); }, []); // v1.10.4: register this input in the chat-input registry so the terminal // pane's "Send to chat" menu can list it. Re-registers when chatLabel // changes (e.g. rename) so the menu reflects the current name. useEffect(() => { if (!chatId) return; return chatInputsRegistry.register(chatId, chatLabel ?? 'Chat', () => { textareaRef.current?.focus(); }); }, [chatId, chatLabel]); // v1.10.4: subscribe to send_to_chat events scoped by chatId. Appends the // payload text to the current draft (with a newline separator if the // draft is non-empty) and focuses the textarea. Does NOT auto-submit. useEffect(() => { if (!chatId) return; return sendToChat.subscribe(({ chat_id, text }) => { if (chat_id !== chatId) return; setValue((prev) => (prev.length === 0 ? text : `${prev}\n${text}`)); requestAnimationFrame(() => { const ta = textareaRef.current; if (!ta) return; ta.focus(); // Put caret at end so the user can keep typing immediately. const end = ta.value.length; ta.selectionStart = ta.selectionEnd = end; }); }); }, [chatId]); function removeAttachment(id: string) { setAttachments(prev => prev.filter(a => a.id !== id)); } async function submit() { const text = value.trim(); if (!text && attachments.length === 0) return; if (disabled || busy) return; // Batch 9.6: slash-command dispatch. Only when no attachments and the // input parses to a known skill. Falls through to onSend for unknown // slash names (literal text) or when slash dispatch isn't wired. if (onSlashCommand && attachments.length === 0 && text.startsWith('/')) { const parsed = parseSlashInput(text); if (parsed && skillsLookup.has(parsed.cmdName)) { setBusy(true); try { await onSlashCommand(parsed.cmdName, parsed.args); setValue(''); setAttachments([]); setSlashState(null); } catch (err) { toast.error(err instanceof Error ? err.message : 'skill invocation failed'); } finally { setBusy(false); } return; } // Unknown skill name — fall through and send as literal text. } setBusy(true); try { const body = flattenToMessage(attachments, text); await onSend(body); setValue(''); setAttachments([]); } catch (err) { toast.error(err instanceof Error ? err.message : 'failed to send'); } finally { setBusy(false); } } function handleSlashSelect(skillName: string) { const next = `/${skillName} `; setValue(next); setSlashState(null); requestAnimationFrame(() => { const ta = textareaRef.current; if (ta) { ta.selectionStart = ta.selectionEnd = next.length; ta.focus(); } }); } function getCaretCoords(textarea: HTMLTextAreaElement): { top: number; left: number } { const mirror = document.createElement('div'); const style = window.getComputedStyle(textarea); const properties = [ 'fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'letterSpacing', 'lineHeight', 'textTransform', 'wordSpacing', 'textIndent', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', 'boxSizing', 'whiteSpace', 'overflowWrap', ] as const; mirror.style.position = 'absolute'; mirror.style.visibility = 'hidden'; mirror.style.overflow = 'hidden'; mirror.style.width = style.width; for (const prop of properties) { mirror.style[prop] = style[prop]; } mirror.style.whiteSpace = 'pre-wrap'; mirror.style.overflowWrap = 'break-word'; const textBefore = textarea.value.slice(0, textarea.selectionStart); mirror.textContent = textBefore; const span = document.createElement('span'); span.textContent = '​'; // zero-width space mirror.appendChild(span); document.body.appendChild(mirror); const taRect = textarea.getBoundingClientRect(); const spanRect = span.getBoundingClientRect(); const mirrorRect = mirror.getBoundingClientRect(); const top = taRect.top + (spanRect.top - mirrorRect.top) - textarea.scrollTop + span.offsetHeight; const left = taRect.left + (spanRect.left - mirrorRect.left); document.body.removeChild(mirror); return { top, left }; } function handleChange(e: React.ChangeEvent) { const newValue = e.target.value; setValue(newValue); const ta = e.target; const pos = ta.selectionStart; // Batch 9.6: slash-command trigger. Active while the input is a single // slash-prefixed token with no whitespace (i.e. user is still typing the // skill name). Hand off to args mode the moment a space appears or the // slash leaves position 0. if (onSlashCommand && isSlashCommandToken(newValue)) { const query = slashQuery(newValue); if (!slashState) { setSlashState({ query }); } else if (slashState.query !== query) { setSlashState({ query }); } if (mentionState?.open) setMentionState(null); return; } if (slashState) setSlashState(null); // Check for @ trigger if (pos > 0 && newValue[pos - 1] === '@') { const charBefore = pos >= 2 ? newValue[pos - 2] : null; if (charBefore === null || charBefore === ' ' || charBefore === '\n') { const coords = getCaretCoords(ta); setMentionState({ open: true, query: '', atIdx: pos - 1, anchorRect: coords }); if (!fileIndex) { api.projects.files(projectId).then(r => setFileIndex(r.files)).catch(() => {}); } return; } } // Update query if popover is open — use stored atIdx if (mentionState?.open) { const { atIdx } = mentionState; if (atIdx < pos && newValue[atIdx] === '@') { const query = newValue.slice(atIdx + 1, pos); setMentionState(prev => prev ? { ...prev, query } : null); } else { setMentionState(null); } } } async function handleMentionSelect(path: string) { const atIdx = mentionState?.atIdx ?? -1; const ta = textareaRef.current; const caretPos = ta?.selectionStart ?? value.length; setMentionState(null); try { const result = await api.projects.viewFile(projectId, path); if (atIdx >= 0) { const cleaned = value.slice(0, atIdx) + value.slice(caretPos); setValue(cleaned); if (ta) { requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = atIdx; ta.focus(); }); } } addAttachment({ id: crypto.randomUUID(), kind: 'file', filename: path, language: inferLanguage(path), content: result.content, source: '@', }); } catch { toast.error('Failed to load file'); } } const closeMention = useCallback(() => setMentionState(null), []); // ---- Drag & drop (F1 + F3 + F4) ---------------------------------------- // The drop zone is the outer ChatInput container (ref'd as dropRootRef). // onDragLeave only clears the highlight when the cursor leaves the // container, not when it crosses into a child element. async function processDroppedFile(file: File) { // Size gate if (file.size > MAX_FILE_SIZE_BYTES) { const mb = (file.size / (1024 * 1024)).toFixed(1); toast.error(`File ${file.name} is too large (${mb} MB). Limit is 5 MB.`); return; } // Read once as ArrayBuffer so we can do byte-level binary detection // before deciding whether to decode as text. let buf: ArrayBuffer; try { buf = await file.arrayBuffer(); } catch (err) { toast.error(`Failed to read ${file.name}: ${err instanceof Error ? err.message : String(err)}`); return; } if (looksBinary(buf)) { toast.error(`${file.name} appears to be binary.`); return; } const text = new TextDecoder('utf-8', { fatal: false }).decode(buf); addAttachment({ id: crypto.randomUUID(), kind: 'file', filename: file.name, language: inferLanguage(file.name), content: text, source: 'drop', }); } function isFolderItem(item: DataTransferItem | undefined): boolean { if (!item) return false; // webkitGetAsEntry is non-standard but supported in Chromium + Safari. // If unavailable, we conservatively treat the entry as a file. const entry = typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null; if (entry && entry.isDirectory) return true; // Heuristic fallback: folders dragged from Finder have type === '' and // a 0-byte File. The empty-type alone isn't reliable for files (some // plaintext drops also lack a type), so we only flag when the entry // explicitly says directory. return false; } async function handleDroppedItems(dt: DataTransfer) { // Snapshot items first because reading files inside the loop can // detach the DataTransfer between awaits. const itemsArray: { file: File | null; isFolder: boolean }[] = []; if (dt.items && dt.items.length > 0) { for (let i = 0; i < dt.items.length; i++) { const it = dt.items[i]; if (!it || it.kind !== 'file') continue; const folder = isFolderItem(it); const file = folder ? null : it.getAsFile(); itemsArray.push({ file, isFolder: folder }); } } else { for (let i = 0; i < dt.files.length; i++) { const f = dt.files[i]; if (f) itemsArray.push({ file: f, isFolder: false }); } } let remainingSlots = MAX_ATTACHMENTS - attachments.length; let folderRejected = false; for (const { file, isFolder } of itemsArray) { if (isFolder) { if (!folderRejected) { toast.error('Folders are not supported'); folderRejected = true; } continue; } if (!file) continue; if (remainingSlots <= 0) { toast.error(`Attachment limit reached (${MAX_ATTACHMENTS}).`); return; } await processDroppedFile(file); remainingSlots -= 1; } } function onDragEnter(e: DragEvent) { if (disabled || busy) return; e.preventDefault(); setIsDraggingOver(true); } function onDragOver(e: DragEvent) { if (disabled || busy) return; e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; } function onDragLeave(e: DragEvent) { // Only clear when the cursor actually leaves the root container. // relatedTarget is the element being entered; if it's inside the root, // ignore — we're just crossing into a child. const root = dropRootRef.current; if (!root) return; const related = e.relatedTarget as Node | null; if (related && root.contains(related)) return; setIsDraggingOver(false); } function onDrop(e: DragEvent) { e.preventDefault(); setIsDraggingOver(false); if (disabled || busy) return; void handleDroppedItems(e.dataTransfer); } // ---- end Drag & drop ----------------------------------------------------- // ---- Paste-as-attachment (F2) ------------------------------------------- // Pasting >PASTE_INLINE_MAX_LINES lines of text becomes a chip rather than // inline content. Image pastes are rejected with a toast. If both text and // image are present (e.g. screenshot tool that sets both), prefer text. function onPaste(e: React.ClipboardEvent) { const cd = e.clipboardData; if (!cd) return; const text = cd.getData('text/plain'); const hasImage = Array.from(cd.items ?? []).some((it) => it.type.startsWith('image/'), ); if (text) { const lineCount = text.split('\n').length; if (lineCount > PASTE_INLINE_MAX_LINES) { e.preventDefault(); pasteCounterRef.current += 1; addAttachment({ id: crypto.randomUUID(), kind: 'paste', filename: `pasted-${pasteCounterRef.current}.txt`, language: 'plaintext', content: text, source: 'paste', }); } // <= threshold: let default paste insert inline. return; } if (hasImage) { e.preventDefault(); toast.error('Image paste is not supported. Drop a file or paste text.'); } } // ---- end Paste-as-attachment -------------------------------------------- function onKeyDown(e: KeyboardEvent) { if (mentionState?.open) return; // SlashCommandPicker owns Arrow/Enter/Tab/Esc via a document listener; let // it consume them so the textarea doesn't also submit on Enter. if (slashState) return; // IME safety: never act on Enter while an IME composition is in flight // (CJK input methods commit composition via Enter). Without this, the // first Enter of a Japanese/Chinese/Korean composition would submit // instead of finalizing the candidate. if (e.nativeEvent.isComposing) return; if (e.key === 'Enter' && e.shiftKey && (e.metaKey || e.ctrlKey) && onForceSend) { e.preventDefault(); void forceSubmit(); return; } if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); void submit(); return; } // Bare Enter: sends on desktop, inserts a newline on mobile (per spec — // send is via the dedicated button on touch devices). if (e.key === 'Enter' && !e.shiftKey && !isMobile) { e.preventDefault(); void submit(); } } async function forceSubmit() { const text = value.trim(); if (!text || !onForceSend) return; if (busy) return; setBusy(true); try { const body = flattenToMessage(attachments, text); await onForceSend(body); setValue(''); setAttachments([]); } catch (err) { toast.error(err instanceof Error ? err.message : 'force send failed'); } finally { setBusy(false); } } return (
{attachments.length > 0 && (
{attachments.map(a => ( ))}
)} {slashItems.length > 0 && ( )} {/* Batch 9 toolbar — agent picker + quick-toggle menu. v1.11.5.1 inlines ContextBar in the same row so the bar lives next to the picker rather than as a separate header above it. The row renders when ANY of {picker, quick-toggle, ContextBar} is wanted. */} {(onAgentChange || sessionId || messages !== undefined) && (
{onAgentChange && ( )} {sessionId && ( { // v1.9: tri-state collapses to two on the wire when toggled // here. null (inherit) treated as off; click flips to true. // To restore "inherit" the user opens SettingsPane. const next = webSearchEnabled === true ? false : true; try { await api.sessions.update(sessionId, { web_search_enabled: next }); } catch (err) { toast.error(err instanceof Error ? err.message : 'failed to toggle web search'); } }} className="text-xs" > Enable web search and fetch )} {/* v1.11.5.1: ContextBar fills the remaining horizontal space. `flex-1 min-w-0` is set inside the component. Mounts only when the caller passes `messages` so older call sites (without the prop) keep their original layout. */} {messages !== undefined && ( )}
)}