mcp-config
Version:
CLI client to edit MCP server configurations
234 lines (201 loc) • 7.04 kB
text/typescript
// based on https://github.com/zenithlight/inquirer-action-select
// kudos to @zenithlight
import {
createPrompt,
useState,
useKeypress,
usePrefix,
usePagination,
useRef,
useMemo,
isBackspaceKey,
isEnterKey,
isUpKey,
isDownKey,
isNumberKey,
Separator,
ValidationError,
makeTheme,
type Theme,
} from '@inquirer/core';
import type { PartialDeep } from '@inquirer/type';
import pc from 'picocolors';
import figures from 'figures';
import ansiEscapes from 'ansi-escapes';
type SelectTheme = {
icon: { cursor: string };
style: { disabled: (text: string) => string };
};
const selectTheme: SelectTheme = {
icon: { cursor: figures.pointer },
style: { disabled: (text: string) => pc.dim(`- ${text}`) },
};
type Action<ActionValue> = {
value: ActionValue;
name: string;
key: string;
};
type Choice<Value> = {
value: Value;
name?: string;
description?: string;
disabled?: boolean | string;
type?: never;
};
type ActionSelectConfig<ActionValue, Value> = {
message: string;
actions: ReadonlyArray<Action<ActionValue>>;
choices: ReadonlyArray<Choice<Value> | Separator>;
pageSize?: number;
loop?: boolean;
default?: unknown;
theme?: PartialDeep<Theme<SelectTheme>>;
// TODO: Allow assigning a default action for enter rather than returning an undefined action
};
type ActionSelectResult<ActionValue, Value> = {
action?: ActionValue;
answer: Value;
};
type Item<Value> = Separator | Choice<Value>;
function isSelectable<Value>(item: Item<Value>): item is Choice<Value> {
return !Separator.isSeparator(item) && !item.disabled;
}
export default createPrompt(
<ActionValue, Value>(
config: ActionSelectConfig<ActionValue, Value>,
done: (result: ActionSelectResult<ActionValue, Value>) => void
): string => {
const { choices: items, loop = true, pageSize = 7 } = config;
const theme = makeTheme<SelectTheme>(selectTheme, config.theme);
const prefix = usePrefix({ theme });
const [status, setStatus] = useState('pending');
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const bounds = useMemo(() => {
const first = items.findIndex(isSelectable);
// Manually find last index since findLastIndex might not be available
let last = -1;
for (let i = items.length - 1; i >= 0; i--) {
if (isSelectable(items[i])) {
last = i;
break;
}
}
if (first < 0) {
throw new ValidationError(
'[select prompt] No selectable choices. All choices are disabled.'
);
}
return { first, last };
}, [items]);
const defaultItemIndex = useMemo(() => {
if (!('default' in config)) return -1;
return items.findIndex((item) => isSelectable(item) && item.value === config.default);
}, [config.default, items]);
const [active, setActive] = useState(defaultItemIndex === -1 ? bounds.first : defaultItemIndex);
const [selectedAction, setSelectedAction] = useState<Action<ActionValue> | undefined>(
undefined
);
// Safe to assume the cursor position always point to a Choice.
const selectedChoice = items[active] as Choice<Value>;
useKeypress((key, rl) => {
clearTimeout(searchTimeoutRef.current);
const action = config.actions.find((action) => action.key === key.name);
if (action !== undefined) {
setStatus('done');
setSelectedAction(action);
done({
action: action.value,
answer: selectedChoice.value,
});
} else if (isEnterKey(key)) {
setStatus('done');
done({
action: undefined,
answer: selectedChoice.value,
});
} else if (isUpKey(key) || isDownKey(key)) {
rl.clearLine(0);
if (
loop ||
(isUpKey(key) && active !== bounds.first) ||
(isDownKey(key) && active !== bounds.last)
) {
const offset = isUpKey(key) ? -1 : 1;
let next = active;
do {
next = (next + offset + items.length) % items.length;
} while (!isSelectable(items[next]!));
setActive(next);
}
} else if (isNumberKey(key)) {
rl.clearLine(0);
const position = Number(key.name) - 1;
const item = items[position];
if (item != null && isSelectable(item)) {
setActive(position);
}
} else if (isBackspaceKey(key)) {
rl.clearLine(0);
} else {
// FIXME: you probably won't be able to search some items because their names gets captured by an action
// use a modifier key to enter search?
// Default to search
const searchTerm = rl.line.toLowerCase();
const matchIndex = items.findIndex((item) => {
if (Separator.isSeparator(item) || !isSelectable(item)) return false;
return String(item.name || item.value)
.toLowerCase()
.startsWith(searchTerm);
});
if (matchIndex >= 0) {
setActive(matchIndex);
}
searchTimeoutRef.current = setTimeout(() => {
rl.clearLine(0);
}, 700);
}
});
const message = theme.style.message(config.message, status);
const helpTip = config.actions
.map(
(action) => `${theme.style.help(action.name)} ${theme.style.key(action.key.toUpperCase())}`
)
.join(' ');
const page = usePagination<Item<Value>>({
items,
active,
renderItem({ item, isActive }: { item: Item<Value>; isActive: boolean }) {
if (Separator.isSeparator(item)) {
return ` ${item.separator}`;
}
const line = item.name || item.value;
if (item.disabled) {
const disabledLabel = typeof item.disabled === 'string' ? item.disabled : '(disabled)';
return theme.style.disabled(`${line} ${disabledLabel}`);
}
const color = isActive ? theme.style.highlight : (x: string) => x;
const cursor = isActive ? theme.icon.cursor : ` `;
return color(`${cursor} ${line}`);
},
pageSize,
loop,
theme,
});
if (status === 'done') {
const answer =
selectedChoice.name ||
// TODO: Could we enforce that at the type level? Name should be defined for non-string values.
String(selectedChoice.value);
if (selectedAction !== undefined) {
const action = selectedAction ? selectedAction.name || String(selectedAction.value) : '';
// TODO: separate theme style for action
return `${prefix} ${message} ${theme.style.help(action)} ${theme.style.answer(answer)}`;
} else {
return `${prefix} ${message} ${theme.style.answer(answer)}`;
}
}
const choiceDescription = selectedChoice.description ? `\n${selectedChoice.description}` : ``;
return `${[prefix, message, helpTip].filter(Boolean).join(' ')}\n${page}${choiceDescription}${ansiEscapes.cursorHide}`;
}
);
export { Separator };