UNPKG

@botpress/adk-cli

Version:

Command-line interface for the Botpress Agent Development Kit (ADK)

270 lines (267 loc) 9.26 kB
// @bun import { formatKeyHints } from "./chunk-bexyahs9.js"; import { bind, bold, box, fg, getActiveTheme, interval, onKey, signal, t, text } from "./chunk-m2h26j5f.js"; // src/components/user-input.ts var BLINK_MS = 530; function userInput(renderer, scope, props) { const { prompt, allowTextInput = true, placeholder = "Type your response...", initialValue = "", validate, onSubmit, onCancel, dropdownConfigs = [], maxDropdownItems = 10, type = "text", maskChar = "*" } = props; const theme = getActiveTheme(); const width = renderer.width || 80; const value = signal(initialValue); const cursorVisible = signal(true); const error = signal(null); const dropdownVisible = signal(false); const filtered = signal([]); const selectedIdx = signal(0); const scrollOffset = signal(0); let currentConfig = null; let currentTrigger = ""; const hideDropdown = () => { dropdownVisible.set(false); filtered.set([]); selectedIdx.set(0); scrollOffset.set(0); currentConfig = null; currentTrigger = ""; }; const updateDropdown = (input) => { for (const config of dropdownConfigs) { if (config.trigger === "") { const filterText = input.toLowerCase(); const matches = config.options.filter((o) => o.label.toLowerCase().includes(filterText) || o.value.toLowerCase().includes(filterText)); filtered.set(matches); selectedIdx.set(0); scrollOffset.set(0); currentTrigger = config.trigger; currentConfig = config; dropdownVisible.set(true); return; } const triggerIndex = input.lastIndexOf(config.trigger); if (triggerIndex !== -1) { const afterTrigger = input.slice(triggerIndex + config.trigger.length); if (afterTrigger.includes(" ") || afterTrigger.includes(` `)) continue; if (config.options.some((o) => o.value === config.trigger + afterTrigger)) continue; const filterText = afterTrigger.toLowerCase(); const matches = config.options.filter((o) => o.label.toLowerCase().includes(filterText) || o.value.toLowerCase().includes(filterText)); if (matches.length > 0) { filtered.set(matches); selectedIdx.set(0); scrollOffset.set(0); currentTrigger = config.trigger; currentConfig = config; dropdownVisible.set(true); return; } } } dropdownVisible.set(false); }; const updateScroll = (idx, total) => { if (total <= maxDropdownItems) { scrollOffset.set(0); return; } if (idx < scrollOffset()) scrollOffset.set(idx); else if (idx >= scrollOffset() + maxDropdownItems) scrollOffset.set(idx - maxDropdownItems + 1); }; const submit = (raw) => { const finalText = raw.trim(); if (validate) { const err = validate(finalText); if (err) { error.set(err); return; } } error.set(null); onSubmit(finalText, false); }; const selectDropdownOption = (option) => { if (!currentConfig) return; const newText = currentTrigger === "" ? option.value : value().slice(0, value().lastIndexOf(currentTrigger)) + option.value; if ((currentConfig.behavior || "input") === "submit") { hideDropdown(); submit(newText); } else { value.set(newText); hideDropdown(); error.set(null); } }; const promptLine = text(renderer, t`${fg(theme.accent.cyan)(bold(prompt))}`); const inputLine = text(renderer, ""); scope.add(bind(() => { const masked = type === "password" ? maskChar.repeat(value().length) : value(); const cursor = cursorVisible() ? "\u2588" : " "; inputLine.content = masked.length === 0 ? t`${fg(theme.status.success)("> ")}${fg(theme.text.primary)(cursor)} ${fg(theme.text.dimmer)(placeholder)}` : t`${fg(theme.status.success)("> ")}${fg(theme.text.primary)(masked)}${fg(theme.text.primary)(cursor)}`; }, [value, cursorVisible])); const inputBox = box(renderer, { border: true, borderStyle: "rounded", borderColor: theme.ui.border, width, paddingX: 1, marginBottom: 1 }, [inputLine]); const dropdownInner = box(renderer, { flexDirection: "column" }); const dropdownBox = box(renderer, { border: true, borderStyle: "rounded", borderColor: theme.ui.borderFocus, width, paddingX: 1, marginBottom: 1 }, [dropdownInner]); const rebuildDropdown = () => { for (const child of dropdownInner.getChildren()) dropdownInner.remove(child.id); const opts = filtered(); const off = scrollOffset(); const visible = opts.slice(off, off + maxDropdownItems); if (off > 0) { dropdownInner.add(text(renderer, t`${fg(theme.text.dim)(`\u2191 ${off} more items above`)}`)); } const maxLabel = visible.reduce((m, o) => Math.max(m, o.label.length), 0); const tabStop = Math.ceil((maxLabel + 4) / 4) * 4; visible.forEach((opt, vi) => { const actual = off + vi; const sel = actual === selectedIdx(); const marker = fg(sel ? theme.accent.cyan : theme.text.dim)(sel ? `${theme.symbols.pointer} ` : " "); const label = sel ? fg(theme.accent.cyan)(bold(opt.label)) : fg(theme.text.primary)(opt.label); if (opt.description) { const pad = " ".repeat(Math.max(0, tabStop - opt.label.length)); dropdownInner.add(text(renderer, t`${marker}${label}${fg(theme.text.dim)(`${pad}${opt.description}`)}`)); } else { dropdownInner.add(text(renderer, t`${marker}${label}`)); } }); if (off + maxDropdownItems < opts.length) { dropdownInner.add(text(renderer, t`${fg(theme.text.dim)(`\u2193 ${opts.length - (off + maxDropdownItems)} more items below`)}`)); } }; scope.add(bind(() => { const show = dropdownVisible() && filtered().length > 0; dropdownBox.visible = show; if (show) rebuildDropdown(); }, [dropdownVisible, filtered, selectedIdx, scrollOffset])); const errorLine = text(renderer, ""); scope.add(bind(() => { const err = error(); errorLine.visible = err !== null; if (err) errorLine.content = t`${fg(theme.status.error)(bold(`${theme.symbols.cross} ${err}`))}`; }, [error])); const errorBox = box(renderer, { marginBottom: 1 }, [errorLine]); const helpLine = text(renderer, ""); scope.add(bind(() => { const hints = dropdownVisible() ? [ { key: "\u2191\u2193", label: "navigate" }, { key: "Enter", label: currentConfig?.behavior === "submit" ? "submit" : "select" }, { label: "continue typing to filter" } ] : [ { key: "Enter", label: "submit" }, { key: "Ctrl-W", label: "delete word" } ]; if (onCancel) hints.push({ key: "Esc", label: "cancel" }); else if (dropdownVisible()) hints.push({ key: "Esc", label: "hide" }); helpLine.content = t`${fg(theme.text.dim)(formatKeyHints(hints))}`; }, [dropdownVisible])); interval(scope, BLINK_MS, () => cursorVisible.set(!cursorVisible())); if (type !== "password") updateDropdown(value()); scope.add(onKey(renderer, (key) => { const printable = key.sequence.length === 1 && !key.ctrl && !key.meta && key.sequence >= " " && key.sequence !== "\x7F"; if (key.name === "escape") { if (onCancel) { onCancel(); return; } if (dropdownVisible()) { hideDropdown(); return; } } if (key.ctrl && key.name === "w" && allowTextInput) { const before = value(); let pos = before.length - 1; while (pos >= 0 && /\s/.test(before[pos])) pos--; while (pos >= 0 && !/\s/.test(before[pos])) pos--; const next = before.slice(0, pos + 1); value.set(next); error.set(null); if (type !== "password") updateDropdown(next); return; } if (dropdownVisible() && filtered().length > 0) { if (key.name === "up") { const next = selectedIdx() > 0 ? selectedIdx() - 1 : filtered().length - 1; selectedIdx.set(next); updateScroll(next, filtered().length); return; } if (key.name === "down") { const next = selectedIdx() < filtered().length - 1 ? selectedIdx() + 1 : 0; selectedIdx.set(next); updateScroll(next, filtered().length); return; } if (key.name === "return") { selectDropdownOption(filtered()[selectedIdx()]); return; } } if (key.name === "return") { submit(value()); return; } if (key.name === "backspace" || key.name === "delete") { if (value().length > 0) { const next = value().slice(0, -1); value.set(next); error.set(null); if (type !== "password") updateDropdown(next); } return; } if (printable && allowTextInput) { const next = value() + key.sequence; value.set(next); error.set(null); if (type !== "password") updateDropdown(next); } })); return box(renderer, { flexDirection: "column" }, [ box(renderer, { marginBottom: 1 }, [promptLine]), inputBox, dropdownBox, errorBox, box(renderer, {}, [helpLine]) ]); } export { userInput };