From 6d94c7faa07398de43265a6e8f1d193eb20e591a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 19:31:47 +0000 Subject: [PATCH] feat: Make selecting a workspace or a server searchable Both were the only lists that could not be searched, while selecting a device or a command already could. Searching matches any part of a name rather than only what it starts with, which is all a list of servers sharing a scheme could be found by. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NgxWoYAQLVsGsNGLBdDYHi --- src/lib/interact-for-server-selection.ts | 3 +- src/lib/interact-for-workspace-id.ts | 4 ++- src/lib/util/prompt.test.ts | 34 +++++++++++++++++++++ src/lib/util/prompt.ts | 38 +++++++++++++++++++++++- 4 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 src/lib/util/prompt.test.ts diff --git a/src/lib/interact-for-server-selection.ts b/src/lib/interact-for-server-selection.ts index 2958fc34..5b6d9a46 100644 --- a/src/lib/interact-for-server-selection.ts +++ b/src/lib/interact-for-server-selection.ts @@ -14,7 +14,8 @@ export async function interactForServerSelection() { const { server } = await prompt([ { - type: 'select', + // Searchable, as selecting a device or a command is. + type: 'autocomplete', name: 'server', message: 'Select a server:', choices: servers.map((server) => ({ title: server, value: server })), diff --git a/src/lib/interact-for-workspace-id.ts b/src/lib/interact-for-workspace-id.ts index 04f58c02..24e39d8b 100644 --- a/src/lib/interact-for-workspace-id.ts +++ b/src/lib/interact-for-workspace-id.ts @@ -19,7 +19,9 @@ export const interactForWorkspaceId = async (personalAccessToken?: string) => { ) const { workspaceId } = await prompt({ name: 'workspaceId', - type: 'select', + // Searchable, as selecting a device or a command is: an account may have + // more workspaces than fit on a screen. + type: 'autocomplete', message: 'Select a workspace:', choices: workspaces.map((workspace: any) => ({ title: workspace.name, diff --git a/src/lib/util/prompt.test.ts b/src/lib/util/prompt.test.ts new file mode 100644 index 00000000..28265d73 --- /dev/null +++ b/src/lib/util/prompt.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from 'vitest' + +import { searchChoices } from './prompt.js' + +const workspaces = [ + { title: 'Sandbox', description: 'ws_1' }, + { title: 'Production Europe', description: 'ws_2' }, + { title: 'Production US', description: 'ws_3' }, +] + +test('searchChoices: matches every term against the title and description', async () => { + await expect(searchChoices('prod us', workspaces)).resolves.toEqual([ + workspaces[2], + ]) + await expect(searchChoices('WS_1', workspaces)).resolves.toEqual([ + workspaces[0], + ]) + await expect(searchChoices('nope', workspaces)).resolves.toEqual([]) +}) + +test('searchChoices: matches any part of a name, not only its start', async () => { + const servers = [ + { title: 'http://localhost:3020' }, + { title: 'https://connect.getseam.com' }, + { title: 'https://fakeseamconnect.seam.vc' }, + ] + + await expect(searchChoices('fake', servers)).resolves.toEqual([servers[2]]) +}) + +test('searchChoices: offers every choice until something is typed', async () => { + await expect(searchChoices('', workspaces)).resolves.toEqual(workspaces) + await expect(searchChoices(' ', workspaces)).resolves.toEqual(workspaces) +}) diff --git a/src/lib/util/prompt.ts b/src/lib/util/prompt.ts index b4e1ae4b..adfab8a8 100644 --- a/src/lib/util/prompt.ts +++ b/src/lib/util/prompt.ts @@ -32,7 +32,43 @@ export const prompt = async ( const questionList = Array.isArray(questions) ? questions : [questions] return await prompts( - questionList.map((question) => ({ ...question, stdout: process.stderr })), + questionList.map((question) => ({ + ...question, + // Search a list by any part of a name, rather than only by what it + // starts with, which is all prompts does for itself. + ...(question.type === 'autocomplete' && question.suggest == null + ? { suggest: searchChoices } + : {}), + stdout: process.stderr, + })), options, ) } + +export interface SearchableChoice { + title?: string | undefined + description?: string | undefined +} + +/** + * Filter choices by every whitespace separated term of the input, matched + * case insensitively against the title and the description. + */ +export const searchChoices = async ( + input: string, + choices: Choice[], +): Promise => { + const terms = input + .toLowerCase() + .split(/\s+/) + .filter((term) => term.length > 0) + + if (terms.length === 0) return choices + + return choices.filter((choice) => { + const searchable = `${choice.title ?? ''} ${choice.description ?? ''}` + .toLowerCase() + .trim() + return terms.every((term) => searchable.includes(term)) + }) +}