feat(cli): group model selector by provider with search

Replace the flat numbered model list with provider-grouped browsing
(combos first, then providers by alias order), full-text search across
all models, and manual custom model ID entry. A single available
category opens directly into its model list.

Also bump root and cli packages to 0.5.75 and ignore packed
`9router-*` tarballs.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
decolua
2026-09-10 21:56:30 +07:00
parent 537b3befd2
commit 4a390685b3
4 changed files with 186 additions and 48 deletions

3
.gitignore vendored
View File

@@ -88,4 +88,5 @@ graphify-out/*
.next-analyze/* .next-analyze/*
# Kiro local workspace state # Kiro local workspace state
.kiro/ .kiro/
9router-*

View File

@@ -1,6 +1,6 @@
{ {
"name": "9router", "name": "9router",
"version": "0.5.69", "version": "0.5.75",
"description": "9Router CLI - Start and manage 9Router server", "description": "9Router CLI - Start and manage 9Router server",
"bin": { "bin": {
"9router": "./cli.js" "9router": "./cli.js"

View File

@@ -55,7 +55,7 @@ async function getAvailableModelsGrouped() {
} }
/** /**
* Display model list and prompt for selection * Display model list and prompt for selection with provider grouping & search
* @param {string} title - Title to display * @param {string} title - Title to display
* @param {string} currentValue - Current selected value (optional) * @param {string} currentValue - Current selected value (optional)
* @param {Object} options - { excludeCombos?: boolean } * @param {Object} options - { excludeCombos?: boolean }
@@ -70,62 +70,199 @@ async function selectModelFromList(title, currentValue = "", options = {}) {
if (totalModels === 0) { if (totalModels === 0) {
return null; return null;
} }
// Build flat list for selection // All models for flat search
const allModels = []; const allModelsList = [
...combos,
// Display ...Object.values(groups).flat()
clearScreen(); ];
console.log(`\n🎯 ${title}`);
console.log("=".repeat(50)); // Build category list
if (currentValue) { const categories = [];
console.log(`Current: ${currentValue}\n`);
} else {
console.log();
}
let idx = 1;
// Combos first (skipped when excludeCombos is true)
if (combos.length > 0) { if (combos.length > 0) {
console.log("[Combos]"); categories.push({
combos.forEach(combo => { id: "combos",
console.log(` ${idx}. ${combo}`); name: "[Combos]",
allModels.push(combo); models: combos
idx++;
}); });
console.log();
} }
// Provider groups in order (by alias)
const sortedProviders = Object.keys(groups).sort((a, b) => { const sortedProviders = Object.keys(groups).sort((a, b) => {
const idxA = PROVIDER_ALIAS_ORDER.indexOf(a); const idxA = PROVIDER_ALIAS_ORDER.indexOf(a);
const idxB = PROVIDER_ALIAS_ORDER.indexOf(b); const idxB = PROVIDER_ALIAS_ORDER.indexOf(b);
return (idxA === -1 ? 999 : idxA) - (idxB === -1 ? 999 : idxB); return (idxA === -1 ? 999 : idxA) - (idxB === -1 ? 999 : idxB);
}); });
sortedProviders.forEach(provider => { sortedProviders.forEach((provider) => {
const providerName = PROVIDER_ALIAS_NAMES[provider] || provider; const providerName = PROVIDER_ALIAS_NAMES[provider] || provider;
console.log(`[${providerName}]`); categories.push({
groups[provider].forEach(model => { id: provider,
console.log(` ${idx}. ${model}`); name: providerName,
allModels.push(model); models: groups[provider]
idx++;
}); });
console.log();
}); });
console.log(" 0. Cancel\n"); let filterQuery = null;
// Prompt for number input while (true) {
const input = await prompt("Enter number: "); clearScreen();
const num = parseInt(input, 10); console.log(`\n🎯 ${title}`);
console.log("=".repeat(50));
if (isNaN(num) || num === 0 || num < 0 || num > allModels.length) { if (currentValue) {
return null; console.log(`Current: ${currentValue}\n`);
} else {
console.log();
}
// Active search view
if (filterQuery !== null) {
const q = filterQuery.toLowerCase().trim();
const matched = allModelsList.filter((m) => m.toLowerCase().includes(q));
console.log(`🔍 Search results for "${filterQuery}": (${matched.length} found)\n`);
if (matched.length === 0) {
console.log(" No matching models found.\n");
console.log(" 0. ← Back to providers");
console.log(" s. Search again\n");
const act = await prompt("Select option: ");
if (act.toLowerCase() === "s") {
const newQ = await prompt("Enter search keyword: ");
filterQuery = newQ.trim() || null;
} else {
filterQuery = null;
}
continue;
}
matched.forEach((m, i) => {
console.log(` ${i + 1}. ${m}`);
});
console.log("\n 0. ← Back to providers");
console.log(" s. Search again\n");
const input = await prompt("Enter number to select (or 0/s): ");
if (input.toLowerCase() === "s") {
const newQ = await prompt("Enter search keyword: ");
filterQuery = newQ.trim() || null;
continue;
}
const num = parseInt(input, 10);
if (isNaN(num) || num === 0) {
filterQuery = null;
continue;
}
if (num > 0 && num <= matched.length) {
return matched[num - 1];
}
continue;
}
// If only 1 category exists, jump straight into its model list
if (categories.length === 1) {
const singleCategory = categories[0];
console.log(`[${singleCategory.name}]`);
singleCategory.models.forEach((m, i) => {
console.log(` ${i + 1}. ${m}`);
});
console.log();
console.log(" s. 🔍 Search models");
console.log(" m. ✍️ Enter custom model ID");
console.log(" 0. Cancel\n");
const input = await prompt("Enter choice (number / s / m / 0): ");
const trimmed = input.trim();
if (!trimmed || trimmed === "0") return null;
const lower = trimmed.toLowerCase();
if (lower === "s") {
const q = await prompt("Enter search keyword: ");
if (q.trim()) filterQuery = q.trim();
continue;
}
if (lower === "m") {
const customModel = await prompt("Enter custom model ID: ");
if (customModel.trim()) return customModel.trim();
continue;
}
const num = parseInt(trimmed, 10);
if (!isNaN(num) && num > 0 && num <= singleCategory.models.length) {
return singleCategory.models[num - 1];
}
filterQuery = trimmed;
continue;
}
// Multiple categories view
console.log("[Providers & Groups]");
categories.forEach((cat, i) => {
console.log(` ${i + 1}. ${cat.name} (${cat.models.length} models)`);
});
console.log();
console.log(" s. 🔍 Search models");
console.log(" m. ✍️ Enter custom model ID");
console.log(" 0. Cancel\n");
const input = await prompt("Enter choice (number / keyword / s / m): ");
const trimmed = input.trim();
if (!trimmed || trimmed === "0") {
return null;
}
const lower = trimmed.toLowerCase();
if (lower === "s") {
const q = await prompt("Enter search keyword: ");
if (q.trim()) {
filterQuery = q.trim();
}
continue;
}
if (lower === "m") {
const customModel = await prompt("Enter custom model ID: ");
if (customModel.trim()) {
return customModel.trim();
}
continue;
}
const num = parseInt(trimmed, 10);
// Selected a category
if (!isNaN(num) && num > 0 && num <= categories.length) {
const selectedCategory = categories[num - 1];
while (true) {
clearScreen();
console.log(`\n🎯 ${title} > ${selectedCategory.name}`);
console.log("=".repeat(50));
if (currentValue) {
console.log(`Current: ${currentValue}\n`);
} else {
console.log();
}
selectedCategory.models.forEach((m, i) => {
console.log(` ${i + 1}. ${m}`);
});
console.log("\n 0. ← Back\n");
const modelChoice = await prompt("Enter number to select (0 to back): ");
const modelNum = parseInt(modelChoice, 10);
if (isNaN(modelNum) || modelNum === 0) {
break;
}
if (modelNum > 0 && modelNum <= selectedCategory.models.length) {
return selectedCategory.models[modelNum - 1];
}
}
continue;
}
// User typed text directly -> treat as search query
filterQuery = trimmed;
} }
return allModels[num - 1];
} }
module.exports = { module.exports = {

View File

@@ -1,6 +1,6 @@
{ {
"name": "9router-app", "name": "9router-app",
"version": "0.5.69", "version": "0.5.75",
"description": "9Router web dashboard", "description": "9Router web dashboard",
"private": true, "private": true,
"scripts": { "scripts": {