CLI: stabilize interactive selector ordering per query

This commit is contained in:
Benjamin Jesuiter
2026-02-16 22:04:48 +01:00
parent 92e1e87034
commit 3b2e145587
2 changed files with 67 additions and 3 deletions

View File

@@ -52,6 +52,28 @@ describe("command-selector", () => {
expect(ranked.some((candidate) => candidate.label === "status")).toBe(false);
});
it("prioritizes deep commands when querying a shared subcommand name", () => {
const program = new Command();
const models = program.command("models").description("Model commands");
const aliases = models.command("aliases").description("Alias commands");
aliases.command("add").description("Add alias");
const fallbacks = models.command("fallbacks").description("Fallback commands");
fallbacks.command("add").description("Add fallback");
const candidates = collectCommandSelectorCandidates(program);
const ranked = rankCommandSelectorCandidates(candidates, "add");
const topLabels = ranked.slice(0, 2).map((candidate) => candidate.label);
expect(topLabels).toEqual(["models aliases add", "models fallbacks add"]);
const aliasesParentIndex = ranked.findIndex(
(candidate) => candidate.label === "models aliases",
);
const aliasesAddIndex = ranked.findIndex(
(candidate) => candidate.label === "models aliases add",
);
expect(aliasesParentIndex).toBeGreaterThan(aliasesAddIndex);
});
it("resolves commands by path", () => {
const program = new Command();
const models = program.command("models");

View File

@@ -10,6 +10,7 @@ const SHOW_HELP_VALUE = "__show_help__";
const BACK_TO_MAIN_VALUE = "__back_to_main__";
const RUN_CURRENT_VALUE = "__run_current__";
const PATH_SEPARATOR = "\u0000";
const SELECTION_VALUE_SEPARATOR = "\u0001";
const MAX_RESULTS = 200;
type CommandSelectorCandidate = {
@@ -142,6 +143,36 @@ export function collectDirectSubcommandSelectorCandidates(
return prepareSortedCandidates(raw);
}
function prioritizeDeepCommandsForSubcommandQuery(params: {
ranked: PreparedCommandSelectorCandidate[];
queryLower: string;
}): PreparedCommandSelectorCandidate[] {
const tokens = params.queryLower.split(/\s+/).filter((token) => token.length > 0);
if (tokens.length !== 1) {
return params.ranked;
}
const [token] = tokens;
if (!token) {
return params.ranked;
}
const deepExact: PreparedCommandSelectorCandidate[] = [];
const remaining: PreparedCommandSelectorCandidate[] = [];
for (const candidate of params.ranked) {
const last = candidate.path[candidate.path.length - 1]?.toLowerCase();
if (candidate.path.length >= 2 && last === token) {
deepExact.push(candidate);
continue;
}
remaining.push(candidate);
}
if (deepExact.length === 0) {
return params.ranked;
}
return [...deepExact, ...remaining];
}
export function rankCommandSelectorCandidates(
candidates: PreparedCommandSelectorCandidate[],
query: string,
@@ -150,7 +181,8 @@ export function rankCommandSelectorCandidates(
if (!queryLower) {
return candidates;
}
return fuzzyFilterLower(candidates, queryLower);
const ranked = fuzzyFilterLower(candidates, queryLower);
return prioritizeDeepCommandsForSubcommandQuery({ ranked, queryLower });
}
async function hydrateProgramCommandsForSelector(program: Command): Promise<void> {
@@ -185,6 +217,16 @@ function deserializePath(value: string): string[] {
.filter(Boolean);
}
function serializeSelectionValue(params: { path: string[]; query: string }): string {
return `${params.query}${SELECTION_VALUE_SEPARATOR}${serializePath(params.path)}`;
}
function deserializeSelectionPath(value: string): string[] {
const separatorIndex = value.indexOf(SELECTION_VALUE_SEPARATOR);
const pathValue = separatorIndex >= 0 ? value.slice(separatorIndex + 1) : value;
return deserializePath(pathValue);
}
async function promptForCommandSelection(params: {
message: string;
placeholder: string;
@@ -205,7 +247,7 @@ async function promptForCommandSelection(params: {
const ranked = rankCommandSelectorCandidates(params.candidates, query).slice(0, MAX_RESULTS);
return [
...ranked.map((candidate) => ({
value: serializePath(candidate.path),
value: serializeSelectionValue({ path: candidate.path, query }),
label: candidate.label,
hint: stylePromptHint(candidate.description),
})),
@@ -247,7 +289,7 @@ async function promptForCommandSelection(params: {
if (selection === RUN_CURRENT_VALUE) {
return "run_current";
}
return deserializePath(selection);
return deserializeSelectionPath(selection);
}
export async function runInteractiveCommandSelector(program: Command): Promise<string[] | null> {