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();