// The composer: draft, attachments, `/` command autocomplete off the live // `available_commands_update` notices, the session-settings menu, and the // send/cancel pair. Sending is the pane's job — this only hands it a draft and // the files that go with it. import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ChangeEvent, type ClipboardEvent, type DragEvent, type FormEvent, type KeyboardEvent, } from "react"; import type { AcpSessionConfigChoice, AcpSessionConfigOption } from "../../types.js"; import { applyCommand, commandTokenAt, filterCommands, orderConfigOptions, type AvailableCommand, } from "./model.js"; /** Per-message ceilings the daemon enforces on uploads * (docs/STRUCTURED-VIEW.md, "Prompt and cancel"). */ export const MAX_ATTACHMENTS = 10; export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024; /** Pasted text at or above either bound becomes a `.txt` attachment instead of * going inline. */ const PASTE_TO_FILE_CHARS = 4_000; const PASTE_TO_FILE_LINES = 40; export interface ComposerProps { /** Files chosen but not yet sent. Owned by the pane so a failed send can put * them back. */ attachments: File[]; onAttachmentsChange(files: File[]): void; commands: AvailableCommand[]; configOptions: AcpSessionConfigOption[]; /** A turn is in flight — the cancel control replaces nothing, it appears * beside send (a queued message is still allowed). */ turnActive: boolean; /** An upload/POST is in flight: the attachment controls are frozen. */ uploading: boolean; configBusy: boolean; /** Restored into the box when a send fails. */ draft: string; onDraftChange(draft: string): void; onSubmit(text: string, attachments: File[]): void; onCancel(): void; onConfigChange(option: AcpSessionConfigOption, value: string | boolean): void; onError(message: string): void; menuId: string; } function configChoices(option: AcpSessionConfigOption): AcpSessionConfigChoice[] { return (option.options ?? []).flatMap((choice) => ("options" in choice ? choice.options : [choice])); } function currentConfigLabel(option: AcpSessionConfigOption): string { if (typeof option.currentValue === "boolean") return option.currentValue ? "On" : "Off"; return configChoices(option).find((choice) => choice.value === option.currentValue)?.name ?? String(option.currentValue); } export function Composer({ attachments, onAttachmentsChange, commands, configOptions, turnActive, uploading, configBusy, draft, onDraftChange, onSubmit, onCancel, onConfigChange, onError, menuId, }: ComposerProps) { const inputRef = useRef(null); const fileRef = useRef(null); const configRef = useRef(null); const [caret, setCaret] = useState(0); const [activeCommand, setActiveCommand] = useState(0); const [configOpen, setConfigOpen] = useState(false); /** Escape closes the menu until the draft or caret moves again. */ const [menuDismissed, setMenuDismissed] = useState(false); const pasteCount = useRef(0); // Grow with the draft, up to the CSS max-height; past that it scrolls. useLayoutEffect(() => { const input = inputRef.current; if (!input) return; input.style.height = "auto"; input.style.overflowY = "hidden"; input.style.height = `${Math.min(input.scrollHeight, 160)}px`; if (input.scrollHeight > 160) input.style.overflowY = "auto"; }, [draft]); useEffect(() => { if (!configOpen) return; const onPointerDown = (event: PointerEvent) => { if (!configRef.current?.contains(event.target as Node)) setConfigOpen(false); }; document.addEventListener("pointerdown", onPointerDown); return () => document.removeEventListener("pointerdown", onPointerDown); }, [configOpen]); const token = commandTokenAt(draft, caret, caret); const matches = token && commands.length > 0 ? filterCommands(commands, token.query) : []; const menuOpen = matches.length > 0 && !menuDismissed; const activeIndex = menuOpen ? Math.min(Math.max(activeCommand, 0), matches.length - 1) : -1; const addFiles = useCallback( (files: File[]) => { if (uploading) return; const next = [...attachments]; for (const file of files) { if (file.size > MAX_ATTACHMENT_BYTES) { onError(`${file.name} exceeds 20 MiB. Choose a smaller file.`); } else if (next.length >= MAX_ATTACHMENTS) { onError(`Attach up to ${MAX_ATTACHMENTS} files per message.`); } else { next.push(file); } } onAttachmentsChange(next); }, [attachments, onAttachmentsChange, onError, uploading], ); const syncCaret = () => { setMenuDismissed(false); setCaret(inputRef.current?.selectionStart ?? 0); }; const chooseCommand = (command: AvailableCommand) => { const input = inputRef.current; if (!input) return; const current = commandTokenAt(draft, input.selectionStart, input.selectionEnd); if (!current) return; const applied = applyCommand(draft, current, command.name); onDraftChange(applied.value); setActiveCommand(0); // The caret has to move after React has written the new value. requestAnimationFrame(() => { input.focus(); input.setSelectionRange(applied.cursor, applied.cursor); setCaret(applied.cursor); }); }; const submit = () => { if ((!draft.trim() && attachments.length === 0) || uploading) return; onSubmit(draft, attachments); }; const onKeyDown = (event: KeyboardEvent) => { if (menuOpen) { if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); const direction = event.key === "ArrowDown" ? 1 : -1; setActiveCommand((index) => (index + direction + matches.length) % matches.length); return; } if ((event.key === "Enter" && !event.shiftKey) || event.key === "Tab") { event.preventDefault(); chooseCommand(matches[activeIndex]); return; } if (event.key === "Escape") { event.preventDefault(); setMenuDismissed(true); return; } } if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); submit(); } }; const onPaste = (event: ClipboardEvent) => { const clipboard = event.clipboardData; if (!clipboard || uploading) return; if (clipboard.files.length) { event.preventDefault(); addFiles(Array.from(clipboard.files)); return; } const text = clipboard.getData("text/plain"); if (text.length < PASTE_TO_FILE_CHARS && text.split("\n").length < PASTE_TO_FILE_LINES) return; // No slot for it, or too big to upload: leave it inline rather than // dropping it on the floor. if (attachments.length >= MAX_ATTACHMENTS || new Blob([text]).size > MAX_ATTACHMENT_BYTES) return; event.preventDefault(); addFiles([new File([text], `pasted-${++pasteCount.current}.txt`, { type: "text/plain" })]); }; const ordered = orderConfigOptions(configOptions); const summary = ordered.map((option) => `${option.name}: ${currentConfigLabel(option)}`).join("\n"); return (
{ event.preventDefault(); submit(); }} onDragOver={(event: DragEvent) => { if (event.dataTransfer?.types.includes("Files")) event.preventDefault(); }} onDrop={(event: DragEvent) => { if (!event.dataTransfer?.files.length) return; event.preventDefault(); addFiles(Array.from(event.dataTransfer.files)); }} > ) => { addFiles(Array.from(event.target.files ?? [])); event.target.value = ""; }} />