feat(pi): filter the project picker instead of scrolling hundreds of rows
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m10s
CI/CD / test (pull_request) Successful in 1m22s
CI/CD / lint (pull_request) Successful in 2m45s
CI/CD / smoke (pull_request) Failing after 3m35s
CI/CD / build (pull_request) Successful in 2m7s
CI/CD / publish (pull_request) Has been skipped

pi's selector is a plain arrow-key list: `ExtensionUIDialogOptions` has no
search field and `ExtensionSelectorComponent` ignores typed characters, so
filtering has to happen before the list is handed over. With 356 projects —
most of them `smoke-proj-none-*` leftovers — arrowing to the one you want is
hopeless.

Above 20 projects the picker now asks for a filter first. Terms are
space-separated and all must match as case-insensitive substrings, so
`home auto` finds `homeautomation`. Blank shows everything, Esc cancels.

The active project sorts first (most likely pick), then alphabetical. A
result set over 50 is capped, and the title says what was dropped — a
silently truncated list reads as "that's all of them".

The ordering and matching are extracted into an exported `filterProjects` so
they are unit-tested rather than eyeballed through a TUI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
This commit is contained in:
Michal
2026-08-08 17:51:36 +01:00
parent 739e679d1a
commit 24a3b8cc0a
3 changed files with 140 additions and 2 deletions

View File

@@ -177,6 +177,30 @@ function convertProp(raw: unknown): TSchema {
}
}
/**
* Order and filter the project list for the picker.
*
* Ordering: the active project first (most likely pick), then alphabetical.
* Filtering: space-separated terms, ALL of which must appear as
* case-insensitive substrings — so `home auto` finds `homeautomation`. A blank
* query keeps everything.
*
* Exported so the behaviour is unit-tested rather than eyeballed through a TUI.
*/
export function filterProjects(projects: string[], query: string, active: string | null): string[] {
const ordered = [...projects].sort((a, b) => {
if (a === active) return -1;
if (b === active) return 1;
return a.localeCompare(b);
});
const terms = query.toLowerCase().split(/\s+/).filter((t) => t.length > 0);
if (terms.length === 0) return ordered;
return ordered.filter((p) => {
const name = p.toLowerCase();
return terms.every((t) => name.includes(t));
});
}
/** Sanitize a name for use as a pi tool name segment ([a-z0-9_]). */
function safeSegment(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || "x";
@@ -402,6 +426,47 @@ export default function (pi: ExtensionAPI) {
},
});
/** Above this many projects, arrowing through the list stops being usable. */
const FILTER_THRESHOLD = 20;
/** Never render more than this in one selector, even after filtering. */
const MAX_SHOWN = 50;
/**
* Choose a project, asking for a filter first when the list is long.
*
* pi's selector is a plain arrow-key list — `ExtensionUIDialogOptions` has no
* search and `ExtensionSelectorComponent` ignores typed characters — so the
* filtering has to happen before the list is handed over. Real installs run
* to hundreds of projects (smoke-test leftovers included), where scrolling is
* hopeless.
*
* Terms are space-separated and ALL must match, case-insensitively, as
* substrings: `home auto` finds `homeautomation`. Blank shows everything.
*/
async function pickProject(ctx: ExtensionContext, projects: string[]): Promise<string | undefined> {
let candidates = filterProjects(projects, "", activeProject);
if (candidates.length > FILTER_THRESHOLD) {
const query = await ctx.ui.input(
`Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`,
"e.g. home auto",
);
if (query === undefined) return undefined; // cancelled
candidates = filterProjects(projects, query, activeProject);
if (candidates.length === 0) {
ctx.ui.notify(`No project matches '${query}'`, "warning");
return undefined;
}
}
const truncated = candidates.length > MAX_SHOWN;
const shown = truncated ? candidates.slice(0, MAX_SHOWN) : candidates;
const title = truncated
// Say what was dropped: a silently capped list reads as "that's all of them".
? `Switch to project (${String(MAX_SHOWN)} of ${String(candidates.length)} matches — narrow the filter)`
: `Switch to project (${String(shown.length)})`;
return ctx.ui.select(title, shown);
}
async function switchProject(ctx: ExtensionContext): Promise<void> {
let projects: string[] = [];
try {
@@ -414,7 +479,7 @@ export default function (pi: ExtensionAPI) {
ctx.ui.notify("No projects returned by mcpd", "warning");
return;
}
const picked = await ctx.ui.select("Switch to project", projects);
const picked = await pickProject(ctx, projects);
if (!picked) return;
// Tear down old project's session + active tools.