From f80a86796ad9112f718d64fa2f34bcb08bf8ecd2 Mon Sep 17 00:00:00 2001 From: owenkephart Date: Tue, 4 Aug 2026 19:46:22 +0000 Subject: [PATCH 1/5] feat(eve): add skills.sh to /add Signed-off-by: owenkephart --- .changeset/green-skills-sh.md | 5 + docs/install-integrations.mdx | 12 +- docs/reference/cli.md | 2 +- packages/eve/package.json | 2 +- .../eve/src/cli/commands/registry-project.ts | 2 +- .../eve/src/cli/commands/registry.test.ts | 233 ++++++++++++++---- packages/eve/src/cli/commands/registry.ts | 208 ++++++++++++---- pnpm-lock.yaml | 11 +- 8 files changed, 374 insertions(+), 101 deletions(-) create mode 100644 .changeset/green-skills-sh.md diff --git a/.changeset/green-skills-sh.md b/.changeset/green-skills-sh.md new file mode 100644 index 0000000000..c8ec449092 --- /dev/null +++ b/.changeset/green-skills-sh.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Add skills.sh as a built-in registry, so `eve registry search --registry @skills ` and `eve add @skills/` work without project configuration. diff --git a/docs/install-integrations.mdx b/docs/install-integrations.mdx index 26dca286d1..666c91b837 100644 --- a/docs/install-integrations.mdx +++ b/docs/install-integrations.mdx @@ -42,7 +42,17 @@ Inspect an integration before you install it: eve registry view extension/agent-browser ``` -`list` and `search` include the official eve catalog and every source you add to the project. +`list` includes the official eve catalog and every source you add to the project. `search` also includes [skills.sh](https://skills.sh), available as the built-in `@skills` source. + +## Add a skill + +Add a known [skills.sh](https://skills.sh) item directly: + +```bash +eve add @skills/vercel-labs/agent-skills/vercel-react-best-practices +``` + +Skills from skills.sh are community-authored project files. Review their source and the resulting diff before you run your agent. ## Add a third-party source diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 4be60b18e6..e4d4af791a 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -99,7 +99,7 @@ eve add @acme/my-extension `eve add` asks before running setup declared by an official item and prints the matching `eve add --skip-install` command when setup is skipped or cancelled. `--skip-install` reruns setup without reinstalling the item. -`eve registry add` records configured sources in `package.json#registries`. `eve registry list` and `search` aggregate the official catalog and all configured sources by default, or browse one supplied URL or namespace. Search returns up to 10 matches by default; pass `--limit ` to request between 1 and 100. Official and other universal items with explicit file targets do not require shadcn project configuration. +`eve registry add` records configured sources in `package.json#registries`. `eve registry list` aggregates the official catalog and all configured sources by default. `eve registry search` also includes [skills.sh](https://skills.sh), available without configuration at `@skills`, and groups results by source with each source's available result count. Search returns up to 10 matches per source by default; pass `--limit ` to request between 1 and 100. Either command can browse one supplied URL or namespace. Official and other universal items with explicit file targets do not require shadcn project configuration. ## `eve info` diff --git a/packages/eve/package.json b/packages/eve/package.json index 443538c7f7..b0c3eb3da7 100644 --- a/packages/eve/package.json +++ b/packages/eve/package.json @@ -368,7 +368,7 @@ "react": "catalog:", "react-test-renderer": "19.2.6", "semver": "7.8.4", - "shadcn": "4.16.0", + "shadcn": "4.16.1", "svelte": "^5.0.0", "turndown": "7.2.4", "vite": "^8.1.5", diff --git a/packages/eve/src/cli/commands/registry-project.ts b/packages/eve/src/cli/commands/registry-project.ts index e02fdcc074..efffaa4c32 100644 --- a/packages/eve/src/cli/commands/registry-project.ts +++ b/packages/eve/src/cli/commands/registry-project.ts @@ -97,7 +97,7 @@ export async function addRegistryMappings( }; for (const mapping of mappings) { - if (mapping.namespace === "@shadcn") { + if (mapping.namespace === "@shadcn" || mapping.namespace === "@skills") { result.skippedBuiltIn.push(mapping.namespace); } else if (configured[mapping.namespace] !== undefined) { result.skippedExisting.push(mapping.namespace); diff --git a/packages/eve/src/cli/commands/registry.test.ts b/packages/eve/src/cli/commands/registry.test.ts index c84725b716..b589de7403 100644 --- a/packages/eve/src/cli/commands/registry.test.ts +++ b/packages/eve/src/cli/commands/registry.test.ts @@ -63,6 +63,7 @@ describe("registry commands", () => { vi.fn(async () => new Response(JSON.stringify({ items: [] }))), ); isEveProject.mockResolvedValue(true); + getRegistryItems.mockResolvedValue([]); readFile.mockResolvedValue( JSON.stringify({ name: "project", @@ -101,12 +102,18 @@ describe("registry commands", () => { expect(getRegistryItems).toHaveBeenCalledWith(["https://eve.dev/r/extension/browser.json"], { config: { - registries: { "@acme": "https://example.com/r/{name}.json" }, + registries: { + "@skills": "https://www.skills.sh/r/{name}?agent=eve", + "@acme": "https://example.com/r/{name}.json", + }, }, }); expect(addRegistryItems).toHaveBeenCalledWith(["https://eve.dev/r/extension/browser.json"], { config: { - registries: { "@acme": "https://example.com/r/{name}.json" }, + registries: { + "@skills": "https://www.skills.sh/r/{name}?agent=eve", + "@acme": "https://example.com/r/{name}.json", + }, }, cwd: "/project", overwrite: true, @@ -467,7 +474,11 @@ describe("registry commands", () => { ], }); expect(searchRegistries).toHaveBeenCalledWith( - ["https://eve.dev/r/registry.json", "@acme"], + ["https://eve.dev/r/registry.json"], + expect.objectContaining({ limit: 100, query: "sdk" }), + ); + expect(searchRegistries).toHaveBeenCalledWith( + ["@acme"], expect.objectContaining({ limit: 100, query: "sdk" }), ); }); @@ -483,8 +494,7 @@ describe("registry commands", () => { await runRegistryListCommand(logger, "/project"); expect(searchRegistries).toHaveBeenCalledWith(["https://eve.dev/r/registry.json"], { - config: { registries: {} }, - continueOnError: false, + config: { registries: { "@skills": "https://www.skills.sh/r/{name}?agent=eve" } }, limit: 100, query: undefined, }); @@ -501,11 +511,21 @@ describe("registry commands", () => { await runRegistryListCommand(logger, "/project", undefined, { json: true }); - expect(logger.logs).toEqual([JSON.stringify(result, null, 2)]); + expect(logger.logs).toEqual([ + JSON.stringify( + { + ...result, + pagination: { hasMore: false, limit: 100, offset: 0, total: 1 }, + }, + null, + 2, + ), + ]); }); - it("preserves explicit registry URLs in list output", async () => { + it("uses sanitized manifest titles and preserves explicit registry URLs in list output", async () => { const logger = createLogger(); + getRegistryItems.mockResolvedValue([{ title: "External\u001B]0;spoofed\u0007 Search" }]); searchRegistries.mockResolvedValue({ items: [ { @@ -522,53 +542,90 @@ describe("registry commands", () => { expect(logger.logs).toEqual([ [ - "Found 1 item in 1 registry", - "", - "https://example.com/r/search.json", - " External search tools", + "https://example.com/r/registry.json (1 result)", + " External Search", + " https://example.com/r/search.json", + " External search tools", ].join("\n"), ]); }); - it("searches the official catalog and configured registries", async () => { + it("segments search results by source and shows each source's available results", async () => { const logger = createLogger(); - searchRegistries.mockResolvedValue({ - items: [ - { - registry: "https://eve.dev/r/registry.json", - name: "extension/agent-browser", - addCommandArgument: "https://eve.dev/r/extension/agent-browser.json", - description: "Browser automation", - }, - { - registry: "@acme", - name: "browser", - addCommandArgument: "@acme/browser", - description: "Browser tools", - }, - ], - pagination: { total: 2, offset: 0, limit: 2, hasMore: false }, + searchRegistries.mockImplementation(async ([source]: string[]) => { + if (source === "https://eve.dev/r/registry.json") { + return { + items: [ + { + registry: source, + name: "extension/agent-browser", + addCommandArgument: "https://eve.dev/r/extension/agent-browser.json", + description: "Browser automation", + }, + ], + pagination: { total: 1, offset: 0, limit: 10, hasMore: false }, + }; + } + if (source === "@skills") { + return { + items: [ + { + registry: source, + name: "browser", + addCommandArgument: "@skills/example/browser", + description: "Browser skills", + }, + ], + pagination: { total: 200, offset: 0, limit: 10, hasMore: true }, + }; + } + return { + items: [ + { + registry: source!, + name: "browser", + addCommandArgument: "@acme/browser", + description: "Browser tools", + }, + ], + pagination: { total: 1, offset: 0, limit: 10, hasMore: false }, + }; }); await runRegistrySearchCommand(logger, "/project", "browser"); - expect(searchRegistries).toHaveBeenCalledWith(["https://eve.dev/r/registry.json", "@acme"], { + expect(searchRegistries).toHaveBeenCalledWith(["https://eve.dev/r/registry.json"], { config: { - registries: { "@acme": "https://example.com/r/{name}.json" }, + registries: { + "@skills": "https://www.skills.sh/r/{name}?agent=eve", + "@acme": "https://example.com/r/{name}.json", + }, }, - continueOnError: true, limit: 10, query: "browser", }); + expect(searchRegistries).toHaveBeenCalledWith( + ["@skills"], + expect.objectContaining({ limit: 10 }), + ); + expect(searchRegistries).toHaveBeenCalledWith( + ["@acme"], + expect.objectContaining({ limit: 10 }), + ); expect(logger.logs).toEqual([ [ - 'Found 2 items matching "browser" in 2 registries', - "", - "extension/agent-browser", - " Browser automation", - "", - "@acme/browser", - " Browser tools", + "eve (1 result)", + " agent-browser", + " extension/agent-browser", + " Browser automation", + "skills.sh (showing 1 of 200 results)", + " browser", + " @skills/example/browser", + " Browser skills", + "@acme (1 result)", + " browser", + " @acme/browser", + " Browser tools", ].join("\n"), ]); }); @@ -587,10 +644,48 @@ describe("registry commands", () => { await runRegistrySearchCommand(logger, "/project", "web", undefined, { limit: 5 }); expect(searchRegistries).toHaveBeenCalledWith( - ["https://eve.dev/r/registry.json", "@acme"], + ["https://eve.dev/r/registry.json"], expect.objectContaining({ limit: 5, query: "web" }), ); - expect(logger.logs[0]).toMatch(/^Showing 5 of 21 items matching "web" in 2 registries/); + expect(logger.logs[0]).toMatch(/^@acme \(showing 5 of 21 results\)/); + }); + + it("puts descriptions below long addresses instead of creating a narrow second column", async () => { + const logger = createLogger(); + const columnsDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "columns"); + Object.defineProperty(process.stdout, "columns", { configurable: true, value: 120 }); + searchRegistries.mockResolvedValue({ + items: [ + { + registry: "@skills", + name: "vercel-react-best-practices", + addCommandArgument: + "@skills/vercel-labs/agent-skills/vercel-react-best-practices-with-a-long-name", + description: + "React and Next.js performance optimization guidelines from Vercel Engineering.", + }, + ], + pagination: { total: 200, offset: 0, limit: 10, hasMore: true }, + }); + + try { + await runRegistrySearchCommand(logger, "/project", "react", "@skills"); + } finally { + if (columnsDescriptor === undefined) { + Reflect.deleteProperty(process.stdout, "columns"); + } else { + Object.defineProperty(process.stdout, "columns", columnsDescriptor); + } + } + + expect(logger.logs).toEqual([ + [ + "skills.sh (showing 1 of 200 results)", + " vercel-react-best-practices", + " @skills/vercel-labs/agent-skills/vercel-react-best-practices-with-a-long-name", + " React and Next.js performance optimization guidelines from Vercel Engineering.", + ].join("\n"), + ]); }); it("sanitizes and wraps registry descriptions beneath their addresses", async () => { @@ -622,11 +717,11 @@ describe("registry commands", () => { expect(logger.logs).toEqual([ [ - 'Found 1 item matching "browser" in 2 registries', - "", - "@acme/browser", - " A long registry description that wraps", - " cleanly beneath its address.", + "@acme (1 result)", + " browser", + " @acme/browser", + " A long registry description that", + " wraps cleanly beneath its address.", ].join("\n"), ]); expect(logger.logs[0]).not.toContain("spoofed"); @@ -652,14 +747,26 @@ describe("registry commands", () => { expect(logger.logs).toEqual([ [ - 'Found 1 item matching "resources" in 2 registries', - "", - "@acme/resources", - ' Use when asked to "list resources".', + "@acme (1 result)", + " resources", + " @acme/resources", + ' Use when asked to "list resources".', ].join("\n"), ]); }); + it("names the active filter in an empty search result", async () => { + const logger = createLogger(); + searchRegistries.mockResolvedValue({ + items: [], + pagination: { total: 0, offset: 0, limit: 10, hasMore: false }, + }); + + await runRegistrySearchCommand(logger, "/project", "missing"); + + expect(logger.logs).toEqual(['No registry items match "missing".']); + }); + it("emits search results as JSON", async () => { const logger = createLogger(); const result = { @@ -670,7 +777,16 @@ describe("registry commands", () => { await runRegistrySearchCommand(logger, "/project", "browser", undefined, { json: true }); - expect(logger.logs).toEqual([JSON.stringify(result, null, 2)]); + expect(logger.logs).toEqual([ + JSON.stringify( + { + ...result, + pagination: { hasMore: false, limit: 10, offset: 0, total: 1 }, + }, + null, + 2, + ), + ]); }); it("emits JSON when every registry search fails", async () => { @@ -684,7 +800,17 @@ describe("registry commands", () => { await runRegistryListCommand(logger, "/project", undefined, { json: true }); - expect(logger.logs).toEqual([JSON.stringify(result, null, 2)]); + expect(logger.logs).toEqual([ + JSON.stringify( + { + items: [], + pagination: { hasMore: false, limit: 100, offset: 0, total: 0 }, + errors: result.errors, + }, + null, + 2, + ), + ]); expect(logger.errors).toEqual(["https://eve.dev/r/registry.json: eve unavailable"]); expect(process.exitCode).toBe(1); }); @@ -718,7 +844,10 @@ describe("registry commands", () => { expect(getRegistryItems).toHaveBeenCalledWith(["https://eve.dev/r/extension/browser.json"], { config: { - registries: { "@acme": "https://example.com/r/{name}.json" }, + registries: { + "@skills": "https://www.skills.sh/r/{name}?agent=eve", + "@acme": "https://example.com/r/{name}.json", + }, }, }); expect(logger.logs).toEqual([ diff --git a/packages/eve/src/cli/commands/registry.ts b/packages/eve/src/cli/commands/registry.ts index a12f0a4715..a3b35eaede 100644 --- a/packages/eve/src/cli/commands/registry.ts +++ b/packages/eve/src/cli/commands/registry.ts @@ -117,6 +117,8 @@ export function resolveOfficialRegistryUrl( const OFFICIAL_REGISTRY = resolveOfficialRegistryUrl(); const OFFICIAL_CATALOG = `${OFFICIAL_REGISTRY}/registry.json`; +const SKILLS_REGISTRY = "@skills"; +const SKILLS_REGISTRY_URL = "https://www.skills.sh/r/{name}?agent=eve"; const CATALOG_PAGE_SIZE = 100; const DEFAULT_SEARCH_LIMIT = 10; @@ -134,7 +136,7 @@ export async function installOfficialRegistryItem( item: string, options: AddCommandOptions = {}, ): Promise { - const config = await readRegistryConfig(appRoot); + const config = await readEveRegistryConfig(appRoot); await addRegistryItems([itemAddress(item)], { config, cwd: appRoot, @@ -214,8 +216,19 @@ async function runRegistryAction( } } +function withBuiltInRegistries(config: RegistryConfig): RegistryConfig { + return { + ...config, + registries: { [SKILLS_REGISTRY]: SKILLS_REGISTRY_URL, ...config.registries }, + }; +} + +async function readEveRegistryConfig(appRoot: string): Promise { + return withBuiltInRegistries(await readRegistryConfig(appRoot)); +} + function configuredRegistrySources(config: RegistryConfig): string[] { - return Object.keys(config.registries ?? {}); + return Object.keys(config.registries ?? {}).filter((source) => source !== SKILLS_REGISTRY); } function validateRegistrySource(source: string | undefined): void { @@ -237,56 +250,93 @@ function registryDescriptionSummary(description: string): string { return normalized.match(/^.*?[.!?](?=\s|$)/u)?.[0] ?? normalized; } +function searchItemAddress(item: RegistrySearchItem): string { + const address = item.registry === OFFICIAL_CATALOG ? item.name : item.addCommandArgument; + return normalizeRegistryText(address); +} + +function searchItemFallbackTitle(item: RegistrySearchItem): string { + const name = normalizeRegistryText(item.name); + return name.split("/").at(-1) ?? name; +} + function renderSearchItem( item: RegistrySearchItem, + title: string | undefined, width: number, theme: ReturnType, ): string { - const rawAddress = item.registry === OFFICIAL_CATALOG ? item.name : item.addCommandArgument; - const address = theme.accent(normalizeRegistryText(rawAddress)); - if (!item.description) return address; + const valueWidth = Math.max(1, width - 4); + const addressLines = wrapVisibleLine(searchItemAddress(item), valueWidth); + const lines = [ + ` ${theme.label(title ?? searchItemFallbackTitle(item))}`, + ...addressLines.map((line) => ` ${line}`), + ]; + if (!item.description) return lines.join("\n"); const description = registryDescriptionSummary(item.description); - if (description.length === 0) return address; + if (description.length === 0) return lines.join("\n"); - const descriptionWidth = Math.max(1, width - 2); - const wrapped = wrapVisibleLine(description, descriptionWidth); - const lines = + const wrapped = wrapVisibleLine(description, valueWidth); + const descriptionLines = wrapped.length <= 2 ? wrapped - : [wrapped[0]!, `${clipVisible(wrapped[1]!, Math.max(1, descriptionWidth - 1)).trimEnd()}…`]; - return [address, ...lines.map((line) => theme.muted(` ${line}`))].join("\n"); + : [wrapped[0]!, `${clipVisible(wrapped[1]!, Math.max(1, valueWidth - 1)).trimEnd()}…`]; + lines.push(...descriptionLines.map((line) => theme.muted(` ${line}`))); + return lines.join("\n"); +} + +type RegistrySearchResult = Awaited>; + +function registrySourceLabel(source: string): string { + if (source === OFFICIAL_CATALOG) return "eve"; + if (source === SKILLS_REGISTRY) return "skills.sh"; + return source; } function printSearchResults( logger: RegistryCommandLogger, - result: Awaited>, - options: { query: string | undefined; sources: string[]; json?: boolean }, + result: RegistrySearchResult, + options: { + json?: boolean; + query: string | undefined; + resultsBySource: ReadonlyMap; + sources: string[]; + titles: ReadonlyMap; + }, ): void { if (options.json) { logger.log(JSON.stringify(result, null, 2)); return; } - if (result.items.length === 0) { - logger.log("No registry items found."); + const query = options.query && normalizeRegistryText(options.query); + logger.log(query ? `No registry items match "${query}".` : "No registry items found."); return; } - const total = result.pagination.total; - const count = `${total} item${total === 1 ? "" : "s"}`; - const query = - options.query === undefined ? "" : ` matching "${normalizeRegistryText(options.query)}"`; - const registries = `${options.sources.length} registr${options.sources.length === 1 ? "y" : "ies"}`; - const resultCount = result.items.length; - const summary = - resultCount < total - ? `Showing ${resultCount} of ${count}${query} in ${registries}` - : `Found ${count}${query} in ${registries}`; const theme = createCliTheme(); const width = Math.max(20, process.stdout.columns ?? 80); - const items = result.items.map((item) => renderSearchItem(item, width, theme)); - logger.log([summary, ...items].join("\n\n")); + const sections = options.sources.flatMap((source) => { + const sourceResult = options.resultsBySource.get(source); + if (sourceResult === undefined || sourceResult.items.length === 0) return []; + const { pagination } = sourceResult; + const count = `${pagination.total} result${pagination.total === 1 ? "" : "s"}`; + const detail = + sourceResult.items.length < pagination.total + ? `showing ${sourceResult.items.length} of ${count}` + : count; + const heading = `${theme.label(registrySourceLabel(source))} ${theme.muted(`(${detail})`)}`; + return [ + [ + heading, + ...sourceResult.items.map((item) => + renderSearchItem(item, options.titles.get(item.addCommandArgument), width, theme), + ), + ].join("\n"), + ]; + }); + logger.log(sections.join("\n")); } async function searchRegistryCatalog( @@ -294,17 +344,68 @@ async function searchRegistryCatalog( options: { limit?: number; query?: string; source?: string }, ) { validateRegistrySource(options.source); - const config = await readRegistryConfig(appRoot); + const config = await readEveRegistryConfig(appRoot); const sources = options.source ? [options.source] - : [OFFICIAL_CATALOG, ...configuredRegistrySources(config)]; - const result = await searchRegistries(sources, { - config, - continueOnError: sources.length > 1, - limit: options.limit ?? CATALOG_PAGE_SIZE, - query: options.query, - }); - return { config, result, sources }; + : [ + OFFICIAL_CATALOG, + ...configuredRegistrySources(config).filter( + (source) => options.query !== undefined || source !== SKILLS_REGISTRY, + ), + ]; + const responses = await Promise.all( + sources.map(async (source) => { + try { + return { + result: await searchRegistries([source], { + config, + limit: options.limit ?? CATALOG_PAGE_SIZE, + query: options.query, + }), + source, + }; + } catch (error) { + return { error, source }; + } + }), + ); + const errors: NonNullable = []; + const resultsBySource = new Map(); + for (const response of responses) { + if ("error" in response) { + errors.push({ message: errorMessage(response.error), registry: response.source }); + } else { + const sourceErrors = response.result.errors ?? []; + errors.push(...sourceErrors); + if (sourceErrors.length === 0) { + const items = response.result.items.filter((item) => item.registry === response.source); + resultsBySource.set(response.source, { + ...response.result, + items, + pagination: { + ...response.result.pagination, + total: + response.result.items.length === items.length ? response.result.pagination.total : 0, + }, + }); + } + } + } + const uniqueErrors = new Map( + errors.map((error) => [`${error.registry}\0${error.message}`, error]), + ); + const results = [...resultsBySource.values()]; + const result: RegistrySearchResult = { + items: results.flatMap((entry) => entry.items), + pagination: { + hasMore: results.some((entry) => entry.pagination.hasMore), + limit: options.limit ?? CATALOG_PAGE_SIZE, + offset: 0, + total: results.reduce((total, entry) => total + entry.pagination.total, 0), + }, + ...(uniqueErrors.size > 0 ? { errors: [...uniqueErrors.values()] } : {}), + }; + return { config, result, resultsBySource, sources }; } function registryManifestTitle(manifest: unknown): string | undefined { @@ -344,6 +445,26 @@ export async function browseRegistryCatalog( }; } +async function loadRegistrySearchTitles( + items: readonly RegistrySearchItem[], + config: RegistryConfig, +): Promise> { + const entries = await Promise.all( + items.map(async (item) => { + try { + const [manifest] = await getRegistryItems([item.addCommandArgument], { config }); + const title = registryManifestTitle(manifest); + return [item.addCommandArgument, title && normalizeRegistryText(title)] as const; + } catch { + return [item.addCommandArgument, undefined] as const; + } + }), + ); + return new Map( + entries.filter((entry): entry is readonly [string, string] => entry[1] !== undefined), + ); +} + async function browseRegistryItems( logger: RegistryCommandLogger, appRoot: string, @@ -351,14 +472,17 @@ async function browseRegistryItems( source: string | undefined, options: RegistrySearchCommandOptions = {}, ): Promise { - const { result, sources } = await searchRegistryCatalog(appRoot, { + const { config, result, resultsBySource, sources } = await searchRegistryCatalog(appRoot, { limit: options.limit, query, source, }); const errors = result.errors ?? []; - if (options.json || errors.length < sources.length) { - printSearchResults(logger, result, { ...options, query, sources }); + if (options.json || resultsBySource.size > 0) { + const titles = options.json + ? new Map() + : await loadRegistrySearchTitles(result.items, config); + printSearchResults(logger, result, { ...options, query, resultsBySource, sources, titles }); } for (const error of errors) { logger.error(`${error.registry}: ${error.message}`); @@ -368,7 +492,7 @@ async function browseRegistryItems( /** Resolves one official, configured, or URL-addressed item manifest. */ export async function getRegistryItemManifest(appRoot: string, item: string): Promise { - const config = await readRegistryConfig(appRoot); + const config = await readEveRegistryConfig(appRoot); const items = await getRegistryItems([itemAddress(item)], { config }); return items.length === 1 ? items[0] : items; } @@ -432,7 +556,7 @@ export async function runAddCommand( dependencies: AddCommandDependencies = defaultAddCommandDependencies, ): Promise { await runRegistryAction(logger, appRoot, async () => { - const config = await readRegistryConfig(appRoot); + const config = await readEveRegistryConfig(appRoot); const address = itemAddress(item); if (options.skipInstall === true) { if (options.overwrite === true) { @@ -558,7 +682,7 @@ export async function runRegistryViewCommand( item: string, ): Promise { await runRegistryAction(logger, appRoot, async () => { - const config = await readRegistryConfig(appRoot); + const config = await readEveRegistryConfig(appRoot); const items = await getRegistryItems([itemAddress(item)], { config }); logger.log(JSON.stringify(items.length === 1 ? items[0] : items, null, 2)); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3b989a8c3..4e95497d19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1394,8 +1394,8 @@ importers: specifier: 7.8.4 version: 7.8.4 shadcn: - specifier: 4.16.0 - version: 4.16.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(typescript@7.0.2) + specifier: 4.16.1 + version: 4.16.1(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(typescript@7.0.2) svelte: specifier: ^5.0.0 version: 5.56.1(@typescript-eslint/types@8.59.4) @@ -13875,6 +13875,11 @@ packages: engines: {node: '>=20.18.1'} hasBin: true + shadcn@4.16.1: + resolution: {integrity: sha512-XLFzfNNIUPlUlyheFEzj0H4Vnhi9nI0nl3Nfgg8HYXW1FkUVhVT1X+mgmOUW8aWL5SeG0A+yJIV5fm3Hr9MVkQ==} + engines: {node: '>=20.18.1'} + hasBin: true + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -30496,7 +30501,7 @@ snapshots: - supports-color - typescript - shadcn@4.16.0(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(typescript@7.0.2): + shadcn@4.16.1(@cfworker/json-schema@4.1.1)(supports-color@10.2.2)(typescript@7.0.2): dependencies: '@babel/core': 7.29.0(supports-color@10.2.2) '@babel/parser': 7.29.7 From ffb5a1440f545fd0346842cf8833c6043dc32b52 Mon Sep 17 00:00:00 2001 From: owenkephart Date: Tue, 4 Aug 2026 16:52:59 -0500 Subject: [PATCH 2/5] fix(eve): include skills in registry search Signed-off-by: owenkephart --- packages/eve/src/cli/commands/registry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eve/src/cli/commands/registry.ts b/packages/eve/src/cli/commands/registry.ts index a3b35eaede..6764c9e3e1 100644 --- a/packages/eve/src/cli/commands/registry.ts +++ b/packages/eve/src/cli/commands/registry.ts @@ -228,7 +228,7 @@ async function readEveRegistryConfig(appRoot: string): Promise { } function configuredRegistrySources(config: RegistryConfig): string[] { - return Object.keys(config.registries ?? {}).filter((source) => source !== SKILLS_REGISTRY); + return Object.keys(config.registries ?? {}); } function validateRegistrySource(source: string | undefined): void { From 752e971ea08816ca89c52b6e9303436442ccc4f7 Mon Sep 17 00:00:00 2001 From: owenkephart Date: Tue, 4 Aug 2026 17:00:53 -0500 Subject: [PATCH 3/5] perf(eve): avoid registry title lookups Signed-off-by: owenkephart --- .../eve/src/cli/commands/registry.test.ts | 5 +-- packages/eve/src/cli/commands/registry.ts | 40 +++---------------- 2 files changed, 8 insertions(+), 37 deletions(-) diff --git a/packages/eve/src/cli/commands/registry.test.ts b/packages/eve/src/cli/commands/registry.test.ts index b589de7403..780ad9a809 100644 --- a/packages/eve/src/cli/commands/registry.test.ts +++ b/packages/eve/src/cli/commands/registry.test.ts @@ -523,9 +523,8 @@ describe("registry commands", () => { ]); }); - it("uses sanitized manifest titles and preserves explicit registry URLs in list output", async () => { + it("preserves explicit registry URLs in list output", async () => { const logger = createLogger(); - getRegistryItems.mockResolvedValue([{ title: "External\u001B]0;spoofed\u0007 Search" }]); searchRegistries.mockResolvedValue({ items: [ { @@ -543,7 +542,7 @@ describe("registry commands", () => { expect(logger.logs).toEqual([ [ "https://example.com/r/registry.json (1 result)", - " External Search", + " search", " https://example.com/r/search.json", " External search tools", ].join("\n"), diff --git a/packages/eve/src/cli/commands/registry.ts b/packages/eve/src/cli/commands/registry.ts index 6764c9e3e1..26608a79e5 100644 --- a/packages/eve/src/cli/commands/registry.ts +++ b/packages/eve/src/cli/commands/registry.ts @@ -262,14 +262,13 @@ function searchItemFallbackTitle(item: RegistrySearchItem): string { function renderSearchItem( item: RegistrySearchItem, - title: string | undefined, width: number, theme: ReturnType, ): string { const valueWidth = Math.max(1, width - 4); const addressLines = wrapVisibleLine(searchItemAddress(item), valueWidth); const lines = [ - ` ${theme.label(title ?? searchItemFallbackTitle(item))}`, + ` ${theme.label(searchItemFallbackTitle(item))}`, ...addressLines.map((line) => ` ${line}`), ]; if (!item.description) return lines.join("\n"); @@ -302,7 +301,6 @@ function printSearchResults( query: string | undefined; resultsBySource: ReadonlyMap; sources: string[]; - titles: ReadonlyMap; }, ): void { if (options.json) { @@ -328,12 +326,9 @@ function printSearchResults( : count; const heading = `${theme.label(registrySourceLabel(source))} ${theme.muted(`(${detail})`)}`; return [ - [ - heading, - ...sourceResult.items.map((item) => - renderSearchItem(item, options.titles.get(item.addCommandArgument), width, theme), - ), - ].join("\n"), + [heading, ...sourceResult.items.map((item) => renderSearchItem(item, width, theme))].join( + "\n", + ), ]; }); logger.log(sections.join("\n")); @@ -445,26 +440,6 @@ export async function browseRegistryCatalog( }; } -async function loadRegistrySearchTitles( - items: readonly RegistrySearchItem[], - config: RegistryConfig, -): Promise> { - const entries = await Promise.all( - items.map(async (item) => { - try { - const [manifest] = await getRegistryItems([item.addCommandArgument], { config }); - const title = registryManifestTitle(manifest); - return [item.addCommandArgument, title && normalizeRegistryText(title)] as const; - } catch { - return [item.addCommandArgument, undefined] as const; - } - }), - ); - return new Map( - entries.filter((entry): entry is readonly [string, string] => entry[1] !== undefined), - ); -} - async function browseRegistryItems( logger: RegistryCommandLogger, appRoot: string, @@ -472,17 +447,14 @@ async function browseRegistryItems( source: string | undefined, options: RegistrySearchCommandOptions = {}, ): Promise { - const { config, result, resultsBySource, sources } = await searchRegistryCatalog(appRoot, { + const { result, resultsBySource, sources } = await searchRegistryCatalog(appRoot, { limit: options.limit, query, source, }); const errors = result.errors ?? []; if (options.json || resultsBySource.size > 0) { - const titles = options.json - ? new Map() - : await loadRegistrySearchTitles(result.items, config); - printSearchResults(logger, result, { ...options, query, resultsBySource, sources, titles }); + printSearchResults(logger, result, { ...options, query, resultsBySource, sources }); } for (const error of errors) { logger.error(`${error.registry}: ${error.message}`); From e2ae7e43e2c07f22f9f655bcaaa5969759364bc6 Mon Sep 17 00:00:00 2001 From: owenkephart Date: Tue, 4 Aug 2026 15:03:23 -0500 Subject: [PATCH 4/5] feat(eve): search skills in the dev TUI Signed-off-by: owenkephart --- packages/eve/src/cli/commands/registry.ts | 32 +++++++++ packages/eve/src/setup/flows/registry.test.ts | 49 +++++++++++++ packages/eve/src/setup/flows/registry.ts | 69 ++++++++++++++----- 3 files changed, 133 insertions(+), 17 deletions(-) diff --git a/packages/eve/src/cli/commands/registry.ts b/packages/eve/src/cli/commands/registry.ts index 26608a79e5..f97ce36be0 100644 --- a/packages/eve/src/cli/commands/registry.ts +++ b/packages/eve/src/cli/commands/registry.ts @@ -121,6 +121,7 @@ const SKILLS_REGISTRY = "@skills"; const SKILLS_REGISTRY_URL = "https://www.skills.sh/r/{name}?agent=eve"; const CATALOG_PAGE_SIZE = 100; const DEFAULT_SEARCH_LIMIT = 10; +const SKILLS_PAGE_SIZE = 20; function isRegistryAddress(value: string): boolean { return value.startsWith("@") || /^https?:\/\//.test(value); @@ -410,6 +411,11 @@ function registryManifestTitle(manifest: unknown): string | undefined { return typeof title === "string" ? title : undefined; } +function registrySearchItemTitle(item: RegistrySearchItem): string | undefined { + const title = (item as RegistrySearchItem & { title?: unknown }).title; + return typeof title === "string" ? title : undefined; +} + /** Browses all configured catalogs, or one namespace or URL source. */ export async function browseRegistryCatalog( appRoot: string, @@ -440,6 +446,32 @@ export async function browseRegistryCatalog( }; } +/** Searches skills.sh for skills targeted to this eve project. */ +export async function browseSkillsCatalog(query: string): Promise { + const result = await searchRegistries([SKILLS_REGISTRY], { + config: withBuiltInRegistries({}), + limit: SKILLS_PAGE_SIZE, + query, + }); + return { + items: result.items.map((item) => { + const catalogItem: RegistryCatalogItem = { + address: item.addCommandArgument, + name: item.name, + source: "skills.sh", + }; + const title = registrySearchItemTitle(item); + if (title !== undefined) catalogItem.title = title; + if (item.type !== undefined) catalogItem.type = item.type; + if (item.description !== undefined) catalogItem.description = item.description; + return catalogItem; + }), + total: result.pagination.total, + errors: result.errors ?? [], + }; +} + + async function browseRegistryItems( logger: RegistryCommandLogger, appRoot: string, diff --git a/packages/eve/src/setup/flows/registry.test.ts b/packages/eve/src/setup/flows/registry.test.ts index 800012168e..7bd6625c0d 100644 --- a/packages/eve/src/setup/flows/registry.test.ts +++ b/packages/eve/src/setup/flows/registry.test.ts @@ -21,6 +21,19 @@ function deps(overrides: Partial = {}): RegistryFlowDeps { total: 1, errors: [], })), + browseSkillsCatalog: vi.fn(async () => ({ + items: [ + { + address: "https://www.skills.sh/r/example/react?agent=eve", + name: "example/react", + title: "React", + description: "React guidance", + source: "skills.sh", + }, + ], + total: 1, + errors: [], + })), getRegistryItemManifest: vi.fn(async () => ({ name: "extension/agent-browser", title: "agent-browser", @@ -108,6 +121,42 @@ describe("runRegistryFlow", () => { }); }); + it("dynamically searches skills.sh in the picker", async () => { + const answers = ["category:skills", "item:0", "add"]; + const prompts: unknown[] = []; + const fake = createFakePrompter({ + single: async (options) => { + prompts.push(options); + if (answers[0] === "item:0") { + await (options.searchAction as { load(query: string): Promise }).load("react"); + } + return answers.shift()!; + }, + }); + const flowDeps = deps(); + + await expect( + runRegistryFlow({ appRoot: APP_ROOT, prompter: fake.prompter, deps: flowDeps }), + ).resolves.toEqual({ + kind: "done", + addedItems: ["https://www.skills.sh/r/example/react?agent=eve"], + }); + + const searchAction = ( + prompts[1] as { + searchAction: { label(query: string): string; load(query: string): Promise }; + } + ).searchAction; + expect(searchAction.label("react")).toBe("Search skills.sh for “react”"); + expect(flowDeps.browseSkillsCatalog).toHaveBeenCalledWith("react"); + expect(flowDeps.browseRegistryCatalog).not.toHaveBeenCalled(); + expect(flowDeps.installRegistryItem).toHaveBeenCalledWith( + APP_ROOT, + "https://www.skills.sh/r/example/react?agent=eve", + expect.objectContaining({ silent: true, prompter: fake.prompter }), + ); + }); + it("uses the registry title when labeling an item", async () => { const answers = ["category:channel", "action:back", "action:done"]; const prompts: unknown[] = []; diff --git a/packages/eve/src/setup/flows/registry.ts b/packages/eve/src/setup/flows/registry.ts index 9948f1fea9..b0707aa268 100644 --- a/packages/eve/src/setup/flows/registry.ts +++ b/packages/eve/src/setup/flows/registry.ts @@ -13,6 +13,7 @@ const ADDRESS_PREFIX = "address:"; const BACK = "action:back"; const DONE = "action:done"; const ALL = "category:all"; +const SKILLS = "category:skills"; type RegistryRow = string; type RegistryCategory = "channel" | "connection" | "extension" | "instrumentation"; @@ -56,6 +57,7 @@ const REGISTRY_CATEGORIES: ReadonlyArray<{ export interface RegistryFlowDeps { browseRegistryCatalog: (typeof import("#cli/commands/registry.js"))["browseRegistryCatalog"]; + browseSkillsCatalog: (typeof import("#cli/commands/registry.js"))["browseSkillsCatalog"]; getRegistryItemManifest: (typeof import("#cli/commands/registry.js"))["getRegistryItemManifest"]; installRegistryItem: (typeof import("#cli/commands/registry.js"))["installRegistryItem"]; } @@ -88,6 +90,11 @@ function categoryRows(): SelectOption[] { label: category.label, hint: category.hint, })), + { + value: SKILLS, + label: "Skills", + hint: "Search skills.sh", + }, { value: ALL, label: "Browse all", @@ -247,6 +254,7 @@ export async function runRegistryFlow(input: { let loaded: typeof import("#cli/commands/registry.js") | undefined; if ( input.deps?.browseRegistryCatalog === undefined || + input.deps.browseSkillsCatalog === undefined || input.deps.getRegistryItemManifest === undefined || input.deps.installRegistryItem === undefined ) { @@ -254,6 +262,7 @@ export async function runRegistryFlow(input: { } const deps: RegistryFlowDeps = { browseRegistryCatalog: input.deps?.browseRegistryCatalog ?? loaded!.browseRegistryCatalog, + browseSkillsCatalog: input.deps?.browseSkillsCatalog ?? loaded!.browseSkillsCatalog, getRegistryItemManifest: input.deps?.getRegistryItemManifest ?? loaded!.getRegistryItemManifest, installRegistryItem: input.deps?.installRegistryItem ?? loaded!.installRegistryItem, }; @@ -262,16 +271,6 @@ export async function runRegistryFlow(input: { try { while (true) { input.signal?.throwIfAborted(); - const catalog = await withSpinner(input.prompter, "Loading registry…", () => - deps.browseRegistryCatalog(input.appRoot), - ); - notices = [ - ...notices, - ...catalog.errors.map((error) => ({ - tone: "warning" as const, - text: `${error.registry}: ${error.message}`, - })), - ]; const selectedCategory = await input.prompter.select({ message: "Add an integration", options: categoryRows(), @@ -281,19 +280,55 @@ export async function runRegistryFlow(input: { notices = []; if (selectedCategory === DONE) return { kind: "done", addedItems: [] }; - const categoryItems = itemsForCategory(catalog.items, selectedCategory); + const catalog = + selectedCategory === SKILLS + ? undefined + : await withSpinner(input.prompter, "Loading registry…", () => + deps.browseRegistryCatalog(input.appRoot), + ); + let categoryItems: readonly RegistryCatalogItem[] = + selectedCategory === SKILLS ? [] : itemsForCategory(catalog!.items, selectedCategory); const rows = itemRows(categoryItems); rows.push({ value: BACK, label: "Back", trailingAction: true }); const selected = await input.prompter.select({ - message: categoryFor(selectedCategory)?.browseLabel ?? "Browse integrations", + message: + selectedCategory === SKILLS + ? "Skills from skills.sh" + : (categoryFor(selectedCategory)?.browseLabel ?? "Browse integrations"), options: rows, search: true, - placeholder: "Search integrations or enter an item address", - searchAction: { - label: (query) => `Add “${query}”`, - value: (query) => `${ADDRESS_PREFIX}${query.trim()}`, - }, + ...(selectedCategory === SKILLS + ? { + placeholder: "What skill do you need?", + searchAction: { + label: (query: string) => `Search skills.sh for “${query}”`, + value: (query: string) => `${ADDRESS_PREFIX}${query.trim()}`, + load: async (query: string) => { + categoryItems = ( + await withSpinner(input.prompter, "Searching skills.sh…", () => + deps.browseSkillsCatalog(query), + ) + ).items; + return [ + ...itemRows(categoryItems), + { value: BACK, label: "Back", trailingAction: true }, + ]; + }, + }, + } + : { + placeholder: "Search integrations or enter an item address", + searchAction: { + label: (query: string) => `Add “${query}”`, + value: (query: string) => `${ADDRESS_PREFIX}${query.trim()}`, + }, + }), hintLayout: "inline", + notices: + catalog?.errors.map((error) => ({ + tone: "warning" as const, + text: `${error.registry}: ${error.message}`, + })) ?? [], }); if (selected === BACK) continue; const resolved = selected.startsWith(ADDRESS_PREFIX) From 732df83f0bbc5af41d35dc9eeb02953ffa45c387 Mon Sep 17 00:00:00 2001 From: owenkephart Date: Tue, 4 Aug 2026 15:11:38 -0500 Subject: [PATCH 5/5] feat(eve): refresh skill results while typing Signed-off-by: owenkephart --- packages/eve/src/cli/dev/tui/setup-flow.ts | 1 + .../src/cli/dev/tui/terminal-renderer.test.ts | 24 +++++++++++++++++++ .../eve/src/cli/dev/tui/terminal-renderer.ts | 23 ++++++++++++++---- packages/eve/src/cli/dev/tui/tui-prompter.ts | 1 + packages/eve/src/setup/flows/registry.ts | 1 + packages/eve/src/setup/prompter.ts | 2 ++ 6 files changed, 48 insertions(+), 4 deletions(-) diff --git a/packages/eve/src/cli/dev/tui/setup-flow.ts b/packages/eve/src/cli/dev/tui/setup-flow.ts index 1221a791bc..cd49a49317 100644 --- a/packages/eve/src/cli/dev/tui/setup-flow.ts +++ b/packages/eve/src/cli/dev/tui/setup-flow.ts @@ -31,6 +31,7 @@ interface SetupSingleSelectRequest extends SetupSelectRequestBase { interface SetupSearchAction extends SearchActionOption { load?(query: string): Promise; + loadOnChange?: boolean; } interface SetupSearchSelectRequest extends SetupSelectRequestBase { diff --git a/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts b/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts index 4175d7f9af..b9aef4dd43 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts @@ -4272,6 +4272,30 @@ describe("TerminalRenderer setup select typing", () => { renderer.shutdown(); }); + it("loads dynamic search results for the empty filter and each filter change", async () => { + const { input, renderer } = makeRenderer(); + const search = vi.fn(async (query: string) => [ + { value: query || "popular", label: query || "Popular" }, + ]); + + renderer.setupFlow.begin("/add"); + const answer = renderer.setupFlow.readSelect({ + kind: "search", + message: "Skills from skills.sh", + options: [{ value: "back", label: "Back", trailingAction: true }], + searchAction: { label: (query) => `Search for '${query}'`, load: search, loadOnChange: true }, + }); + + await vi.waitFor(() => expect(search).toHaveBeenCalledWith("")); + input.type("react"); + await vi.waitFor(() => expect(search).toHaveBeenLastCalledWith("react")); + input.enter(); + await expect(answer).resolves.toEqual(["react"]); + + renderer.setupFlow.end(); + renderer.shutdown(); + }); + it("keeps the searchable panel open while a search action loads results", async () => { const { screen, input, renderer } = makeRenderer(); let resolveSearch!: (options: readonly { value: string; label: string }[]) => void; diff --git a/packages/eve/src/cli/dev/tui/terminal-renderer.ts b/packages/eve/src/cli/dev/tui/terminal-renderer.ts index 9f26316cb9..bf392df348 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.ts @@ -1796,6 +1796,10 @@ export class TerminalRenderer implements AgentTUIRenderer { } }; + if (searchAction?.loadOnChange === true && searchAction.load !== undefined) { + void loadSearch("", searchAction.load); + } + let notices = opts.notices; if (opts.kind === "task-list" || (opts.kind === "search" && opts.layout === "task-list")) { const start = flow.taskListLineStart ?? flow.lines.length; @@ -1826,10 +1830,18 @@ export class TerminalRenderer implements AgentTUIRenderer { settle(value); }; if (loading) { - if (key.type === "ctrl-c") close(undefined); - else if (key.type === "escape") clearSearch(); - else if (key.type === "ctrl-r") this.#paint(); - return; + if (key.type === "ctrl-c") { + close(undefined); + return; + } + if (key.type === "ctrl-r") { + this.#paint(); + return; + } + if (searchAction?.loadOnChange !== true) { + if (key.type === "escape") clearSearch(); + return; + } } const base = { key, options: selectOptions, searchAction, select }; @@ -1847,6 +1859,9 @@ export class TerminalRenderer implements AgentTUIRenderer { select = result.select; error = undefined; this.#paint(); + if (searchAction?.loadOnChange === true && searchAction.load !== undefined) { + void loadSearch(select.filter, searchAction.load); + } return; case "submit": { const query = searchActionQuery(result.values[0] ?? ""); diff --git a/packages/eve/src/cli/dev/tui/tui-prompter.ts b/packages/eve/src/cli/dev/tui/tui-prompter.ts index aa4e691521..a9f0ab02b4 100644 --- a/packages/eve/src/cli/dev/tui/tui-prompter.ts +++ b/packages/eve/src/cli/dev/tui/tui-prompter.ts @@ -82,6 +82,7 @@ function setupSelectRequest( if (load !== undefined) { request.searchAction.load = async (query) => encodeOptions(await load(query)); } + if (opts.searchAction.loadOnChange === true) request.searchAction.loadOnChange = true; } } else { // The public "inline" hint layout is the panel's "task-list" presentation. diff --git a/packages/eve/src/setup/flows/registry.ts b/packages/eve/src/setup/flows/registry.ts index b0707aa268..46de44512c 100644 --- a/packages/eve/src/setup/flows/registry.ts +++ b/packages/eve/src/setup/flows/registry.ts @@ -303,6 +303,7 @@ export async function runRegistryFlow(input: { searchAction: { label: (query: string) => `Search skills.sh for “${query}”`, value: (query: string) => `${ADDRESS_PREFIX}${query.trim()}`, + loadOnChange: true, load: async (query: string) => { categoryItems = ( await withSpinner(input.prompter, "Searching skills.sh…", () => diff --git a/packages/eve/src/setup/prompter.ts b/packages/eve/src/setup/prompter.ts index 3b49d4aaa4..1dd3cb799c 100644 --- a/packages/eve/src/setup/prompter.ts +++ b/packages/eve/src/setup/prompter.ts @@ -140,6 +140,8 @@ export interface SearchAction { * and shows the wait beside it. */ load?(query: string): Promise[]>; + /** Refresh rows when the filter changes, including once for the empty filter. TUI only. */ + loadOnChange?: boolean; } /** Single-select form: navigate, then enter picks the highlighted option. */