@botpress/adk-cli
Version:
Command-line interface for the Botpress Agent Development Kit (ADK)
270 lines (267 loc) • 9.26 kB
JavaScript
// @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 };