import { createEffect, createSignal, onMount, onCleanup } from 'solid-js'; import { defineWebComponent } from './define'; import { DefaultPromptInput } from './default-input'; import type { AttachmentData } from '../components/attachments'; import type { SlashCommandItem } from '../components/slash-command'; import type { CustomAction } from './chat-types'; /** Parse a single light-DOM `` element into a SlashCommandItem. * Attribute mapping: * - `command` → SlashCommandItem.id (required; empty string fallback) * - textContent → SlashCommandItem.label (primary); `label` attr as fallback * - `description` → SlashCommandItem.description * - `category` → SlashCommandItem.category */ export function parseKcSlashCommandElement(n: Element): SlashCommandItem { return { id: n.getAttribute('command') ?? '', label: n.textContent?.trim() || n.getAttribute('label') || n.getAttribute('command') || '', description: n.getAttribute('description') ?? undefined, category: n.getAttribute('category') ?? undefined, }; } interface Props extends Record { /** Controlled value of the input. When set, the host owns the text and must * update it on `kc-value-change`; leave unset for uncontrolled behavior. */ value?: string; /** Placeholder text shown in the empty input. */ placeholder?: string; /** Disable the input and submit button entirely (non-interactive). */ disabled?: boolean; /** Show the loading/streaming state and block submit (use while awaiting a * reply). */ loading?: boolean; /** Starter prompts shown above the input. Clicking one follows * `suggestionMode`. Set as a JS property. */ suggestions?: string[]; /** What clicking a suggestion does: `'submit'` (default) sends it immediately * as if typed and submitted; `'fill'` just places it in the input. */ suggestionMode?: 'submit' | 'fill'; /** Slash commands — when set, typing `/` opens the command palette. Set as a * JS property. */ slashCommands?: SlashCommandItem[]; /** Command ids to highlight as active. */ slashActiveIds?: string[]; /** Single-line palette rows. */ slashCompact?: boolean; /** Show a Search (Globe) button in the left toolbar; clicking it fires a * `search` event. */ search?: boolean; /** Show a Voice (Mic) button in the left toolbar; clicking it fires a `voice` * event. */ voice?: boolean; /** When set and `loading` is true, the send button is replaced by a Stop * button (square icon, "Stop" aria-label). Clicking it fires `kc-stop`. */ stoppable?: boolean; /** Attachments to seed the input with (so a consumer can pre-populate staged * files without an upload). Set as a JS property; the element then manages its * own attachment state from there (add via the paperclip, remove per chip). */ attachments?: AttachmentData[]; } /** Events fired by ``. */ interface Events { /** The user submitted the prompt (Enter or send button) with its attachments. */ 'kc-submit': { value: string; attachments: AttachmentData[] }; /** The input text changed (fires on every keystroke). */ 'kc-value-change': { value: string }; /** A suggestion was clicked while `suggestion-mode="fill"`. */ 'kc-suggestion-click': { value: string }; /** A slash command was chosen from the palette. */ 'kc-slash-select': { command: SlashCommandItem }; /** The Search (Globe) toolbar button was clicked. */ 'kc-search': Record; /** The Voice (Mic) toolbar button was clicked. */ 'kc-voice': Record; /** The Stop button was clicked while `stoppable` and `loading` are both true. */ 'kc-stop': Record; /** A custom `` toolbar button was clicked. `action` is the `id` of * the `` element that was clicked. */ 'kc-toolbar-action': { action: string }; } defineWebComponent('kc-prompt-input', { value: undefined, placeholder: 'Send a message...', disabled: false, loading: false, suggestions: undefined, suggestionMode: 'submit', slashCommands: undefined, slashActiveIds: undefined, slashCompact: false, search: false, voice: false, stoppable: false, attachments: undefined, }, (props, { dispatch, flag, element }) => { const [internal, setInternal] = createSignal(props.value ?? ''); // Seed staged attachments from the `attachments` property; the element manages // its own state from there (paperclip adds, per-chip remove deletes). const [attachments, setAttachments] = createSignal(props.attachments ?? []); // Re-seed when the `attachments` property is (re)assigned by the consumer // (e.g. set via a `ref` after mount). Subsequent in-element edits stay local. createEffect(() => { if (props.attachments) setAttachments(props.attachments); }); // Read declarative and children from light DOM — // same pattern as kc-message. Shadow DOM with no suppresses them visually; // they are invisible data carriers. One MutationObserver covers both element types. const [toolbarActions, setToolbarActions] = createSignal([]); const [slottedSlashCommands, setSlottedSlashCommands] = createSignal([]); onMount(() => { const readActions = () => { const nodes = [...element.querySelectorAll('kc-action')]; setToolbarActions(nodes.map(n => ({ id: n.id || n.getAttribute('action') || '', label: n.textContent?.trim() || n.getAttribute('label') || n.id || '', icon: n.getAttribute('icon') ?? undefined, tooltip: n.getAttribute('tooltip') ?? undefined, }))); }; const readSlashCommands = () => { const nodes = [...element.querySelectorAll('kc-slash-command')]; setSlottedSlashCommands(nodes.map(parseKcSlashCommandElement)); }; const readAll = () => { readActions(); readSlashCommands(); }; readAll(); const observer = new MutationObserver(readAll); observer.observe(element, { childList: true, attributes: true, subtree: true }); onCleanup(() => observer.disconnect()); }); const current = () => props.value ?? internal(); const handleChange = (v: string) => { setInternal(v); dispatch('kc-value-change', { value: v }); }; const handleSubmit = () => { dispatch('kc-submit', { value: current(), attachments: attachments() }); setAttachments([]); }; const handleSuggestionClick = (v: string) => { if ((props.suggestionMode ?? 'submit') === 'fill') { handleChange(v); dispatch('kc-suggestion-click', { value: v }); } else { // Default: behave as if the user typed the suggestion and pressed submit. dispatch('kc-submit', { value: v, attachments: attachments() }); setAttachments([]); } }; // Prop slash commands take precedence; slotted children are appended after. const allSlashCommands = () => [ ...(props.slashCommands ?? []), ...slottedSlashCommands(), ]; return ( dispatch('kc-search')} onVoice={() => dispatch('kc-voice')} onStop={() => dispatch('kc-stop')} onSlashSelect={(command) => dispatch('kc-slash-select', { command })} onAction={(id) => dispatch('kc-toolbar-action', { action: id })} /> ); });