From 7de24044ef4c635f634b78fa6bc4b5905967bfd8 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Wed, 16 Sep 2026 15:47:10 +0000 Subject: [PATCH] fix(init): make the universal tool target findable in the picker (#1778) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(init): make the universal tool target findable in the picker Closes #653 `openspec init`'s tool picker is a searchable list of product names. The vendor-neutral target every unlisted assistant is meant to use was named "Shared .agents skills" — after the directory it writes, which is not a word anyone in that position searches for. Typing "universal", "other" or "generic" returned "No matches", so the escape hatch was unreachable and the reporter had to open an issue to find it. Rename the entry to "Other / Universal (shared .agents skills)" and give choices optional `searchAliases` the filter also matches. The picker also dropped every non-alphanumeric keystroke: readline reports punctuation only in `key.sequence`, leaving `key.name` undefined, so ".agents" and "amazon-q" could not be typed at all. Co-Authored-By: Claude Opus 5 * fix(init): point at the universal target when a tool search matches nothing "No matches" is where someone whose assistant is not on the list gives up — the picker knows the answer and does not say it. Add an optional `emptyHint` to the searchable multi-select, and have init name the vendor-neutral entry there. Co-Authored-By: Claude Opus 5 * fix(init): close every dead end that hides the universal tool target Hardening pass over the same defect. Reviewing the first fix turned up four more places the answer was withheld: - `openspec update`'s tool picker builds its own choices and never passed searchAliases through, so the same search failed there. - `--tools ` printed a bare list of ids. It now names the fallback, the scripted counterpart of the picker's empty hint. The hint I first put on validateTools sat on an unreachable branch; the path users actually hit is the "Invalid tool(s)" parse error, and a test now pins it. - The search box dropped pasted text as well as punctuation. Any sequence whose characters are all printable is now accepted, which also lets a space reach the box so "claude code" filters. Escape sequences carry control characters and are still rejected, and the `name` fallback stays single-character so readline names like 'tab' are never typed. - docs-lab still taught the old label in two places. Co-Authored-By: Claude Opus 5 * docs: keep the FAQ a router and finish the alias list - help/faq.md is a one-liner surface (README's "FAQ is one-liners" rule), so the answer points at the support matrix's Other / Universal section instead of restating the picker's search terms. Drops the em dash that writing.md forbids. - reference/supported-tools.md keeps the search terms, now all nine the picker actually matches: `vendor-neutral` and `agents.md` were added to searchAliases after the first draft of this page. Same correction in the legacy docs/supported-tools.md paragraph. Co-Authored-By: Claude Opus 5 * chore(init): keep the tool-not-listed hint ASCII The non-interactive fallback hint is new terminal output and carried an em dash, an ambiguous-width glyph in the class #983 covered. A colon reads the same and cannot misalign a terminal. Co-Authored-By: Claude Opus 5 * docs: drop the legacy docs/ tool-matrix edit docs-lab/reference/supported-tools.md already carries the label and search terms. Co-Authored-By: Claude Opus 5 * test(init): prove picker punctuation through real readline key events The prompt tests fed a multi-character sequence that readline never emits: it splits a paste into one key per character, so a pasted space still toggles. Drive the handler with real emitKeypressEvents output (fails on main with 'amazonq'), pin the space limit, and stop claiming multi-word paste in the changeset and code comment. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- .changeset/universal-tool-picker-entry.md | 5 + docs-lab/help/faq.md | 5 +- docs-lab/reference/supported-tools.md | 9 +- src/core/config.ts | 31 ++- src/core/init.ts | 18 +- src/core/update.ts | 1 + src/prompts/searchable-multi-select.ts | 50 +++- test/core/init.test.ts | 54 ++++- test/core/tool-search-aliases.test.ts | 58 +++++ test/core/update.test.ts | 4 +- test/prompts/searchable-multi-select.test.ts | 226 ++++++++++++++++++- 11 files changed, 444 insertions(+), 17 deletions(-) create mode 100644 .changeset/universal-tool-picker-entry.md create mode 100644 test/core/tool-search-aliases.test.ts diff --git a/.changeset/universal-tool-picker-entry.md b/.changeset/universal-tool-picker-entry.md new file mode 100644 index 00000000..c382719b --- /dev/null +++ b/.changeset/universal-tool-picker-entry.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +Make the vendor-neutral tool target findable when your assistant is not on the list. `openspec init` now shows it as "Other / Universal (shared .agents skills)"; the picker's search box matches it on `universal`, `other`, `generic`, `custom`, `proprietary`, `unlisted`, `unsupported`, `vendor-neutral` and `agents.md`; a search that matches nothing points at it instead of ending at "No matches"; and `--tools ` names it in the error. The search box also accepts punctuation, so `.agents` and `amazon-q` filter instead of silently dropping their `.` and `-`. diff --git a/docs-lab/help/faq.md b/docs-lab/help/faq.md index 1325c2eb..064e018c 100644 --- a/docs-lab/help/faq.md +++ b/docs-lab/help/faq.md @@ -17,7 +17,8 @@ once the prose lands. --> If it has a row in the [support matrix](../reference/supported-tools.md), yes. Pick its id at init. If it isn't listed but reads the shared `.agents/skills/` -folder, pick **Shared `.agents` skills** (`--tools agents`). If neither, request -it in the [OpenSpec repo](https://github.com/Fission-AI/OpenSpec/issues). +folder, pick **Other / Universal** (`--tools agents`), covered by the support +matrix's Other / Universal section. If neither, request it in the +[OpenSpec repo](https://github.com/Fission-AI/OpenSpec/issues). ## Where did the old /openspec:* commands go? diff --git a/docs-lab/reference/supported-tools.md b/docs-lab/reference/supported-tools.md index 7f2998c1..bdaf8936 100644 --- a/docs-lab/reference/supported-tools.md +++ b/docs-lab/reference/supported-tools.md @@ -49,7 +49,7 @@ The id goes to `openspec init --tools ` to skip the picker ([CLI](cli.md)). | Trae | `trae` | `.trae/skills/` | `/openspec-apply-change` | `.trae/commands/` | `/opsx-apply` | | ZCode | `zcode` | `.zcode/skills/` | `/openspec-apply-change` | `.zcode/commands/opsx/` | `/opsx:apply` | | Zoo Code | `roocode` | `.roo/skills/` | `/openspec-apply-change` | `.roo/commands/` | `/opsx-apply` | -| Shared `.agents` skills | `agents` | `.agents/skills/` | `/openspec-apply-change` | none | none | +| Other / Universal | `agents` | `.agents/skills/` | `/openspec-apply-change` | none | none | - **Skill invocation**: whether a tool registers skills as typed entries is the tool's own behavior. The column shows the spelling OpenSpec uses in generated files and in @@ -118,10 +118,13 @@ init prints this reminder after install. - **Safe across projects**: a commands-only delivery leaves the global skills in place, so one project's setting cannot remove skills another project uses. -### Shared `.agents` skills +### Other / Universal (shared `.agents` skills) - **When it fits**: any tool that reads the shared `.agents/skills/` folder, - including tools with no row in the matrix. + including tools with no row in the matrix. It is the entry to pick when your + assistant is not listed. The init picker's search box finds it by `universal`, + `other`, `generic`, `custom`, `proprietary`, `unlisted`, `unsupported`, + `vendor-neutral`, or `agents.md`. - **Alongside other targets**: Antigravity, Codex, Zed Agent, and this target share one physical skill tree. OpenSpec records one writer in `.openspec-target` and writes the tree once per run. Each tool's separate command files are still diff --git a/src/core/config.ts b/src/core/config.ts index 761931c6..3b8287ba 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -33,6 +33,7 @@ export interface AIToolOption { legacySkillsDirs?: string[]; // Former roots read for detection and migrated after replacement globalSkillsDir?: string; // e.g., '.minimax' - /skills suffix, resolved from the user's home directory detectionPaths?: string[]; // Override skillsDir for auto-detection; any path existing triggers detection + searchAliases?: string[]; // Extra single-word terms the init tool picker matches; never displayed setupNote?: string; // Manual setup required before the tool picks up generated files; shown after init/update requiresIdeRestart?: boolean; // True when slash commands are loaded by an IDE/editor process (a CLI picks them up immediately, so no restart hint — see #1067) } @@ -88,9 +89,37 @@ export const AI_TOOLS: AIToolOption[] = [ // A project that does keep skills there is a project this target fits, the same // way `.claude/` selects Claude Code — the signal is the user's setup, not // OpenSpec's own files. - { name: 'Shared .agents skills', value: 'agents', available: true, successLabel: 'shared .agents skills', skillsDir: '.agents', detectionPaths: ['.agents/skills'] } + // The picker is searchable, so this entry also answers to the words someone + // whose assistant is not on the list actually types (#653) — it is named for + // a directory, which none of them would guess. Aliases are single words: the + // space bar toggles a selection rather than typing into the search box. + { name: 'Other / Universal (shared .agents skills)', value: 'agents', available: true, successLabel: 'shared .agents skills', skillsDir: '.agents', detectionPaths: ['.agents/skills'], searchAliases: ['universal', 'other', 'generic', 'custom', 'proprietary', 'unlisted', 'unsupported', 'vendor-neutral', 'agents.md'] } ]; +/** + * The vendor-neutral target every assistant that is not listed above can use. + * Named wherever a tool lookup comes up empty, so "my tool isn't here" is never + * a dead end (#653). + */ +export const UNIVERSAL_TOOL_ID = 'agents'; + +/** The universal target's entry, or undefined if it was removed from AI_TOOLS. */ +export function getUniversalTool(): AIToolOption | undefined { + return AI_TOOLS.find((tool) => tool.value === UNIVERSAL_TOOL_ID); +} + +/** + * One-line pointer at the universal target for non-interactive errors, the + * scripted counterpart of the picker's empty-search hint. Undefined when the + * target is not among the tools on offer, so the hint never names a choice the + * caller cannot make. + */ +export function universalToolFallbackHint(offeredToolIds: string[]): string | undefined { + const universal = getUniversalTool(); + if (!universal || !offeredToolIds.includes(universal.value)) return undefined; + return `Tool not listed? Use --tools ${universal.value}: the vendor-neutral target that writes ${universal.skillsDir}/skills/ for any assistant.`; +} + /** * Retired tool ids that still resolve, so a rebrand does not break scripted * `--tools` invocations. Windsurf was rebranded to Devin Desktop on diff --git a/src/core/init.ts b/src/core/init.ts index f8fa0773..c61652be 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -22,6 +22,8 @@ import { ANCHORED_OPENSPEC_DIRS, ensureDirectoryAnchor } from './openspec-root.j import { getSkillReferenceTransformer, getTransformerForTool, usesNaturalLanguageSkillReferences } from '../utils/command-references.js'; import { AI_TOOLS, + getUniversalTool, + universalToolFallbackHint, OPENSPEC_DIR_NAME, AIToolOption, resolveToolIdAlias, @@ -632,8 +634,9 @@ export class InitCommand { if (detectedToolIds.size > 0) { return [...detectedToolIds]; } + const fallbackHint = universalToolFallbackHint(validTools); throw new Error( - `No tools detected and no --tools flag provided. Valid tools:\n ${validTools.join('\n ')}\n\nUse --tools all, --tools none, or --tools claude,cursor,...` + `No tools detected and no --tools flag provided. Valid tools:\n ${validTools.join('\n ')}\n\nUse --tools all, --tools none, or --tools claude,cursor,...${fallbackHint ? `\n${fallbackHint}` : ''}` ); } @@ -657,6 +660,7 @@ export class InitCommand { return { name: tool?.name || toolId, value: toolId, + searchAliases: tool?.searchAliases, configured, detected: detected && !configured, preSelected: configured || (shouldPreselectDetected && detected && !configured), @@ -690,10 +694,19 @@ export class InitCommand { console.log(`Detected tool directories: ${detectedOnlyNames.join(', ')} (${detectionLabel})`); } + // A search that matches nothing is where someone whose assistant is not on + // the list gives up (#653), so name the vendor-neutral entry right there. + const universalTool = getUniversalTool(); + const universalHint = + universalTool && validTools.includes(universalTool.value) + ? `Tool not listed? Clear the search and pick "${universalTool.name}".` + : undefined; + const selectedTools = await searchableMultiSelect({ message: `Select tools to set up (${validTools.length} available)`, pageSize: 15, choices: sortedChoices, + emptyHint: universalHint, validate: (selected: string[]) => selected.length > 0 || 'Select at least one tool', }); @@ -753,8 +766,9 @@ export class InitCommand { ); if (invalidTokens.length > 0) { + const fallbackHint = universalToolFallbackHint([...availableSet]); throw new Error( - `Invalid tool(s): ${invalidTokens.join(', ')}. Available values: ${availableList}` + `Invalid tool(s): ${invalidTokens.join(', ')}. Available values: ${availableList}${fallbackHint ? `\n${fallbackHint}` : ''}` ); } diff --git a/src/core/update.ts b/src/core/update.ts index 5e44b571..7c2e6f89 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -1172,6 +1172,7 @@ export class UpdateCommand { return { name: tool?.name || toolId, value: toolId, + searchAliases: tool?.searchAliases, configured: false, preSelected: true, // Pre-select all detected legacy tools }; diff --git a/src/prompts/searchable-multi-select.ts b/src/prompts/searchable-multi-select.ts index 84f338d9..e6cdb1f3 100644 --- a/src/prompts/searchable-multi-select.ts +++ b/src/prompts/searchable-multi-select.ts @@ -3,6 +3,11 @@ import chalk from 'chalk'; interface Choice { name: string; value: string; + /** + * Extra terms the search box matches, for choices users look for by a word + * the name does not spell (see #653). Never rendered. + */ + searchAliases?: string[]; description?: string; configured?: boolean; detected?: boolean; @@ -15,6 +20,16 @@ interface Config { choices: Choice[]; pageSize?: number; validate?: (selected: string[]) => boolean | string; + /** Shown when a search matches nothing, so the list is not a dead end. */ + emptyHint?: string; +} + +/** + * True when every character is printable, so the text can go in the search box. + * `\u007f` is DEL, which sorts above the printable range. + */ +function isPrintable(text: string): boolean { + return text.length > 0 && [...text].every((char) => char >= ' ' && char !== '\u007f'); } /** @@ -37,7 +52,7 @@ async function createSearchableMultiSelect(): Promise< } = await import('@inquirer/core'); return createPrompt((config: Config, done: (value: string[]) => void): string => { - const { message, choices, pageSize = 15, validate } = config; + const { message, choices, pageSize = 15, validate, emptyHint } = config; const [searchText, setSearchText] = useState(''); const [selectedValues, setSelectedValues] = useState( @@ -52,11 +67,14 @@ async function createSearchableMultiSelect(): Promise< // Filter choices by search const filteredChoices = useMemo(() => { if (!searchText.trim()) return choices; - const term = searchText.toLowerCase(); + const term = searchText.trim().toLowerCase(); return choices.filter( (c) => c.name.toLowerCase().includes(term) || - c.value.toLowerCase().includes(term) + c.value.toLowerCase().includes(term) || + (c.searchAliases ?? []).some((alias) => + alias.toLowerCase().includes(term) + ) ); }, [searchText, choices]); @@ -117,9 +135,28 @@ async function createSearchableMultiSelect(): Promise< return; } - // Character input - handle printable characters - if (key.name && key.name.length === 1 && !key.ctrl) { - setSearchText(searchText + key.name); + // Character input - handle printable characters. + // readline reports punctuation (`.`, `-`, `/`) only in `sequence`, leaving + // `name` undefined, so keying off `name` alone silently dropped every + // non-alphanumeric character the user typed. + // `@inquirer/core` types only `name` and `ctrl`; readline emits more. + const event = key as typeof key & { sequence?: string; meta?: boolean }; + if (event.ctrl || event.meta) return; + // Requiring every character to be printable drops escape sequences + // (arrows, function keys), tab, escape and delete. readline splits a + // paste into one key per character, so a pasted space still toggles. + const typed = + typeof event.sequence === 'string' && isPrintable(event.sequence) + ? event.sequence + : // Only the sequence may be multi-character: readline `name`s such as + // 'tab' and 'escape' are printable strings but not typed input. + typeof event.name === 'string' && + event.name.length === 1 && + isPrintable(event.name) + ? event.name + : undefined; + if (typed) { + setSearchText(searchText + typed); setCursor(0); } }); @@ -158,6 +195,7 @@ async function createSearchableMultiSelect(): Promise< // List if (filteredChoices.length === 0) { lines.push(chalk.yellow(' No matches')); + if (emptyHint) lines.push(chalk.dim(` ${emptyHint}`)); } else { // Calculate pagination const startIndex = Math.max( diff --git a/test/core/init.test.ts b/test/core/init.test.ts index c6a00c84..edc87698 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -1014,7 +1014,7 @@ describe('InitCommand', () => { .map(String); expect(logCalls.some((entry) => entry.includes('Created: Codex'))).toBe(true); expect(logCalls.some((entry) => entry.includes('Zed Agent'))).toBe(true); - expect(logCalls.some((entry) => entry.includes('Shared .agents skills'))).toBe(true); + expect(logCalls.some((entry) => entry.includes('Other / Universal (shared .agents skills)'))).toBe(true); expect( logCalls.some((entry) => entry.includes('writing one tree for codex')) ).toBe(true); @@ -1487,6 +1487,22 @@ describe('InitCommand', () => { await expect(initCommand.execute(testDir)).rejects.toThrow(/No tools detected and no --tools flag/); }); + + it('should name the universal target when no tools are detected non-interactively', async () => { + // The scripted counterpart of the picker's empty-search hint (#653): + // a bare list of ids does not tell someone whose tool is absent what to do. + const initCommand = new InitCommand({ interactive: false }); + + await expect(initCommand.execute(testDir)).rejects.toThrow(/--tools agents/); + }); + + it('should name the universal target when --tools names something unknown', async () => { + const initCommand = new InitCommand({ tools: 'turing-corp-plugin', force: true }); + + await expect(initCommand.execute(testDir)).rejects.toThrow( + /Invalid tool\(s\): turing-corp-plugin[\s\S]*--tools agents/ + ); + }); }); describe('tool-specific adapters', () => { @@ -1888,6 +1904,42 @@ describe('InitCommand - profile and detection features', () => { expect(githubCopilot?.preSelected).toBe(true); }); + it('should offer the universal target with the search terms an unlisted tool suggests', async () => { + // #653: the picker filters on name and id, and this entry is named for a + // directory. Without aliases the escape hatch cannot be searched for. + searchableMultiSelectMock.mockResolvedValue(['claude']); + + const initCommand = new InitCommand({ force: true }); + vi.spyOn(initCommand as any, 'canPromptInteractively').mockReturnValue(true); + + await initCommand.execute(testDir); + + const [config] = searchableMultiSelectMock.mock.calls[0] as [ + { choices: Array<{ value: string; name: string; searchAliases?: string[] }>; emptyHint?: string } + ]; + const universal = config.choices.find((choice) => choice.value === 'agents'); + + expect(universal).toBeDefined(); + expect(universal?.name).toContain('Other / Universal'); + for (const term of ['universal', 'other', 'generic', 'unlisted']) { + expect(universal?.searchAliases).toContain(term); + } + }); + + it('should hand the picker a fallback hint naming the universal target', async () => { + searchableMultiSelectMock.mockResolvedValue(['claude']); + + const initCommand = new InitCommand({ force: true }); + vi.spyOn(initCommand as any, 'canPromptInteractively').mockReturnValue(true); + + await initCommand.execute(testDir); + + const [config] = searchableMultiSelectMock.mock.calls[0] as [{ emptyHint?: string }]; + + expect(config.emptyHint).toContain('Tool not listed?'); + expect(config.emptyHint).toContain('Other / Universal (shared .agents skills)'); + }); + it('interactive init: confirming the cloud prompt writes files and persists the opt-in', async () => { searchableMultiSelectMock.mockResolvedValue(['github-copilot']); confirmMock.mockImplementation(({ message }: { message: string }) => diff --git a/test/core/tool-search-aliases.test.ts b/test/core/tool-search-aliases.test.ts new file mode 100644 index 00000000..3e73c7d8 --- /dev/null +++ b/test/core/tool-search-aliases.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { + AI_TOOLS, + getUniversalTool, + universalToolFallbackHint, +} from '../../src/core/config.js'; + +/** + * The `openspec init` tool picker filters on a tool's name and id. A user whose + * assistant is not on the list searches for the category, not the directory + * OpenSpec writes to, and used to find nothing (#653). Search aliases close + * that gap, so the vendor-neutral entry has to keep carrying them. + */ +describe('tool search aliases', () => { + const universal = AI_TOOLS.find((tool) => tool.value === 'agents'); + + it('answers the words someone with an unlisted tool searches for', () => { + expect(universal).toBeDefined(); + const aliases = universal?.searchAliases ?? []; + for (const term of ['universal', 'other', 'generic', 'unlisted']) { + expect(aliases).toContain(term); + } + }); + + it('names the entry so it reads as the escape hatch in the picker', () => { + expect(universal?.name).toMatch(/Other \/ Universal/); + }); + + it('keeps every alias to a single word', () => { + // Space toggles the highlighted choice instead of typing into the search + // box, so a multi-word alias can never be entered. + for (const tool of AI_TOOLS) { + for (const alias of tool.searchAliases ?? []) { + expect(alias).not.toMatch(/\s/); + expect(alias).toBe(alias.toLowerCase()); + } + } + }); + + it('keeps the universal target resolvable by id', () => { + expect(getUniversalTool()?.value).toBe('agents'); + }); +}); + +describe('universal tool fallback hint', () => { + it('names the flag that reaches the universal target', () => { + const hint = universalToolFallbackHint(['claude', 'agents']); + + expect(hint).toContain('--tools agents'); + expect(hint).toContain('.agents/skills/'); + }); + + it('stays silent when the universal target is not on offer', () => { + // Never point at a choice the caller cannot make. + expect(universalToolFallbackHint(['claude', 'cursor'])).toBeUndefined(); + expect(universalToolFallbackHint([])).toBeUndefined(); + }); +}); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 86fa6bba..7710ec0c 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -712,7 +712,9 @@ metadata: expect(configured).not.toContain('codex'); // The skip names the established owner so the user understands why. expect(streamOutput).toMatch(/Skipped Codex/); - expect(streamOutput).toMatch(/managed by another tool \(Shared \.agents skills\)/); + expect(streamOutput).toMatch( + /managed by another tool \(Other \/ Universal \(shared \.agents skills\)\)/ + ); // The legacy signal must survive: because Codex was skipped, no // replacement skill exists, so the deferred global-prompt cleanup must // preserve `~/.codex/prompts` untouched (byte-for-byte) rather than diff --git a/test/prompts/searchable-multi-select.test.ts b/test/prompts/searchable-multi-select.test.ts index 3e212f0f..a77e0c57 100644 --- a/test/prompts/searchable-multi-select.test.ts +++ b/test/prompts/searchable-multi-select.test.ts @@ -80,6 +80,46 @@ function pressKey(name: string) { keypressHandler({ name, ctrl: false }); } +/** + * Types one printable character. Node's readline leaves `name` undefined for + * punctuation such as `.` or `-` and only reports it in `sequence`, so the + * two arrive at the handler differently. + */ +function typeChar(sequence: string, name?: string) { + if (!keypressHandler) throw new Error('No keypress handler registered'); + keypressHandler({ name, sequence, ctrl: false }); +} + +function typeSearch(text: string) { + for (const char of text) { + typeChar(char, /^[a-z0-9]$/.test(char) ? char : undefined); + } +} + +/** Key events exactly as Node's readline emits them for raw terminal input. */ +async function readlineKeys(input: string): Promise[]> { + const readline = await import('node:readline'); + const { PassThrough } = await import('node:stream'); + const stream = new PassThrough(); + readline.emitKeypressEvents(stream); + const keys: Record[] = []; + stream.on('keypress', (_char: string, key: Record) => keys.push(key)); + stream.write(input); + await new Promise((resolve) => setImmediate(resolve)); + return keys; +} + +function getSearchText(): string { + return (state[0] as string) ?? ''; +} + +function visibleNames(): string[] { + return renderOutput + .split('\n') + .filter((line) => line.includes('[ ]') || line.includes('[x]')) + .map((line) => line.replace(/.*\[[ x]\]\s*/, '').trim()); +} + function getSelectedValues(): string[] { return (state[1] as string[]) ?? []; } @@ -98,7 +138,21 @@ const testChoices = [ { name: 'Tool C', value: 'tool-c' }, ]; -async function setup(choices = testChoices, validate?: (selected: string[]) => boolean | string) { +const searchChoices = [ + { name: 'Claude Code', value: 'claude' }, + { name: 'Amazon Q Developer', value: 'amazon-q' }, + { + name: 'Other / Universal', + value: 'agents', + searchAliases: ['unlisted', 'generic', '.agents'], + }, +]; + +async function setup( + choices = testChoices, + validate?: (selected: string[]) => boolean | string, + emptyHint?: string +) { resetState(); const mod = await import('../../src/prompts/searchable-multi-select.js'); @@ -109,6 +163,7 @@ async function setup(choices = testChoices, validate?: (selected: string[]) => b message: 'Select tools', choices, validate, + emptyHint, }); // The async chain in searchableMultiSelect involves: @@ -233,6 +288,175 @@ describe('searchable-multi-select keybindings', () => { }); }); + describe('search filtering', () => { + it('should match a choice by an alias its name does not spell', async () => { + await setup(searchChoices); + typeSearch('unlisted'); + expect(getSearchText()).toBe('unlisted'); + expect(visibleNames()).toEqual(['Other / Universal']); + }); + + it('should match a second alias for the same choice', async () => { + await setup(searchChoices); + typeSearch('generic'); + expect(visibleNames()).toEqual(['Other / Universal']); + }); + + it('should match an alias on a prefix, as it does for names', async () => { + await setup(searchChoices); + typeSearch('unlis'); + expect(visibleNames()).toEqual(['Other / Universal']); + }); + + it('should match an alias regardless of case', async () => { + await setup(searchChoices); + if (!keypressHandler) throw new Error('No keypress handler registered'); + keypressHandler({ sequence: 'UNLISTED' }); + expect(visibleNames()).toEqual(['Other / Universal']); + }); + + it('should never render an alias as part of a choice', async () => { + await setup(searchChoices); + typeSearch('unlisted'); + expect(renderOutput).not.toContain('generic'); + expect(renderOutput).not.toContain('.agents'); + }); + + it('should leave choices without aliases matching exactly as before', async () => { + await setup(searchChoices); + typeSearch('amazon'); + expect(visibleNames()).toEqual(['Amazon Q Developer']); + }); + + it('should still match on name and value', async () => { + await setup(searchChoices); + typeSearch('claude'); + expect(visibleNames()).toEqual(['Claude Code']); + }); + + it('should still show no matches for a term nothing carries', async () => { + await setup(searchChoices); + typeSearch('nonesuch'); + expect(visibleNames()).toEqual([]); + expect(renderOutput).toContain('No matches'); + }); + + it('should point at the fallback choice when a search matches nothing', async () => { + await setup(searchChoices, undefined, 'Tool not listed? Pick "Other / Universal".'); + typeSearch('nonesuch'); + expect(renderOutput).toContain('Tool not listed?'); + }); + + it('should not show the fallback hint while matches remain', async () => { + await setup(searchChoices, undefined, 'Tool not listed? Pick "Other / Universal".'); + typeSearch('claude'); + expect(renderOutput).not.toContain('Tool not listed?'); + }); + }); + + describe('search input', () => { + it('should accept punctuation, which readline reports only in sequence', async () => { + await setup(searchChoices); + typeSearch('amazon-q'); + expect(getSearchText()).toBe('amazon-q'); + expect(visibleNames()).toEqual(['Amazon Q Developer']); + }); + + it('should accept a leading dot so directory-shaped terms filter', async () => { + await setup(searchChoices); + typeSearch('.agents'); + expect(getSearchText()).toBe('.agents'); + expect(visibleNames()).toEqual(['Other / Universal']); + }); + + it('should ignore control chords rather than typing them', async () => { + await setup(searchChoices); + if (!keypressHandler) throw new Error('No keypress handler registered'); + keypressHandler({ name: 'c', sequence: '\u0003', ctrl: true }); + expect(getSearchText()).toBe(''); + }); + + it('should ignore meta chords rather than typing them', async () => { + await setup(searchChoices); + if (!keypressHandler) throw new Error('No keypress handler registered'); + keypressHandler({ name: 'a', sequence: '\u001ba', meta: true }); + expect(getSearchText()).toBe(''); + }); + + it('should not type a named control key into the search box', async () => { + // readline names these, and the names are printable strings; only a + // single-character `name` is real input. + await setup(searchChoices); + if (!keypressHandler) throw new Error('No keypress handler registered'); + keypressHandler({ name: 'tab', sequence: '\t' }); + keypressHandler({ name: 'escape', sequence: '\u001b' }); + keypressHandler({ name: 'delete', sequence: '\u007f' }); + keypressHandler({ name: 'f1', sequence: '\u001bOP' }); + expect(getSearchText()).toBe(''); + }); + + it('should not type an arrow key escape sequence into the search box', async () => { + await setup(searchChoices); + if (!keypressHandler) throw new Error('No keypress handler registered'); + keypressHandler({ name: 'right', sequence: '\u001b[C' }); + expect(getSearchText()).toBe(''); + }); + + it('should accept a pasted multi-character sequence', async () => { + await setup(searchChoices); + if (!keypressHandler) throw new Error('No keypress handler registered'); + keypressHandler({ sequence: 'amazon-q' }); + expect(getSearchText()).toBe('amazon-q'); + expect(visibleNames()).toEqual(['Amazon Q Developer']); + }); + + it('should reject a paste carrying a newline rather than mangling it', async () => { + await setup(searchChoices); + if (!keypressHandler) throw new Error('No keypress handler registered'); + keypressHandler({ sequence: 'claude\ncode' }); + expect(getSearchText()).toBe(''); + }); + + it('should keep uppercase input case-insensitive for matching', async () => { + await setup(searchChoices); + if (!keypressHandler) throw new Error('No keypress handler registered'); + keypressHandler({ name: 'c', sequence: 'C' }); + keypressHandler({ name: 'l', sequence: 'L' }); + expect(getSearchText()).toBe('CL'); + expect(visibleNames()).toEqual(['Claude Code']); + }); + + it('should ignore padding around a pasted term when matching', async () => { + await setup(searchChoices); + if (!keypressHandler) throw new Error('No keypress handler registered'); + keypressHandler({ sequence: ' claude ' }); + expect(visibleNames()).toEqual(['Claude Code']); + }); + + it('should accept punctuation delivered by a real readline keypress stream', async () => { + await setup(searchChoices); + for (const key of await readlineKeys('amazon-q')) keypressHandler!(key); + expect(getSearchText()).toBe('amazon-q'); + for (const key of await readlineKeys('\u007f'.repeat(8) + '.agents')) keypressHandler!(key); + expect(getSearchText()).toBe('.agents'); + }); + + it('should still toggle, not type, on a space inside text readline delivers', async () => { + // readline splits a paste into one keypress per character, so a pasted + // space arrives as the space key. Multi-word search is not reachable. + await setup(searchChoices); + for (const key of await readlineKeys('claude code')) keypressHandler!(key); + expect(getSearchText()).toBe('claudecode'); + }); + + it('should match a multi-word name if a single sequence carries the space', async () => { + await setup(searchChoices); + if (!keypressHandler) throw new Error('No keypress handler registered'); + keypressHandler({ sequence: 'claude code' }); + expect(visibleNames()).toEqual(['Claude Code']); + }); + }); + describe('hint text', () => { it('should include Space toggle and Enter confirm in rendered output', async () => { await setup();