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
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:
File diff suppressed because one or more lines are too long
73
src/cli/tests/config/pi-project-filter.test.ts
Normal file
73
src/cli/tests/config/pi-project-filter.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { filterProjects } from '../../../pi-ext/mcpctl-pi.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pi's selector is a plain arrow-key list — no search — so the project picker
|
||||||
|
* filters before handing the list over. Real installs run to hundreds of
|
||||||
|
* projects, where scrolling is hopeless.
|
||||||
|
*/
|
||||||
|
const PROJECTS = [
|
||||||
|
'smoke-proj-none-mohimh46',
|
||||||
|
'homeautomation',
|
||||||
|
'docmost',
|
||||||
|
'copy-homeautomation',
|
||||||
|
'labctl',
|
||||||
|
'mcpctl-development',
|
||||||
|
'sre',
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('filterProjects', () => {
|
||||||
|
it('puts the active project first, then sorts alphabetically', () => {
|
||||||
|
expect(filterProjects(PROJECTS, '', 'labctl')).toEqual([
|
||||||
|
'labctl',
|
||||||
|
'copy-homeautomation',
|
||||||
|
'docmost',
|
||||||
|
'homeautomation',
|
||||||
|
'mcpctl-development',
|
||||||
|
'smoke-proj-none-mohimh46',
|
||||||
|
'sre',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts alphabetically when nothing is active', () => {
|
||||||
|
expect(filterProjects(PROJECTS, '', null)[0]).toBe('copy-homeautomation');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps everything for a blank or whitespace query', () => {
|
||||||
|
expect(filterProjects(PROJECTS, '', null)).toHaveLength(PROJECTS.length);
|
||||||
|
expect(filterProjects(PROJECTS, ' ', null)).toHaveLength(PROJECTS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches case-insensitive substrings', () => {
|
||||||
|
expect(filterProjects(PROJECTS, 'HOMEAUTO', null)).toEqual([
|
||||||
|
'copy-homeautomation',
|
||||||
|
'homeautomation',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires every space-separated term to match', () => {
|
||||||
|
// "home auto" finds homeautomation even though the terms aren't adjacent...
|
||||||
|
expect(filterProjects(PROJECTS, 'home auto', null)).toEqual([
|
||||||
|
'copy-homeautomation',
|
||||||
|
'homeautomation',
|
||||||
|
]);
|
||||||
|
// ...and a term that matches nothing eliminates the row.
|
||||||
|
expect(filterProjects(PROJECTS, 'home zzz', null)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('narrows the smoke-test noise that motivated this', () => {
|
||||||
|
const many = [...PROJECTS, ...Array.from({ length: 300 }, (_, i) => `smoke-proj-none-x${String(i)}`)];
|
||||||
|
expect(filterProjects(many, 'smoke', null)).toHaveLength(301);
|
||||||
|
expect(filterProjects(many, 'ctl', null)).toEqual(['labctl', 'mcpctl-development']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the caller with an empty list rather than a bad match', () => {
|
||||||
|
expect(filterProjects(PROJECTS, 'nosuchproject', null)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mutate the input', () => {
|
||||||
|
const input = [...PROJECTS];
|
||||||
|
filterProjects(input, 'home', 'sre');
|
||||||
|
expect(input).toEqual(PROJECTS);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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_]). */
|
/** Sanitize a name for use as a pi tool name segment ([a-z0-9_]). */
|
||||||
function safeSegment(name: string): string {
|
function safeSegment(name: string): string {
|
||||||
return name.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || "x";
|
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> {
|
async function switchProject(ctx: ExtensionContext): Promise<void> {
|
||||||
let projects: string[] = [];
|
let projects: string[] = [];
|
||||||
try {
|
try {
|
||||||
@@ -414,7 +479,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
ctx.ui.notify("No projects returned by mcpd", "warning");
|
ctx.ui.notify("No projects returned by mcpd", "warning");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const picked = await ctx.ui.select("Switch to project", projects);
|
const picked = await pickProject(ctx, projects);
|
||||||
if (!picked) return;
|
if (!picked) return;
|
||||||
|
|
||||||
// Tear down old project's session + active tools.
|
// Tear down old project's session + active tools.
|
||||||
|
|||||||
Reference in New Issue
Block a user