diff --git a/src/lib/util/prompt.test.ts b/src/lib/util/prompt.test.ts index 910867b9..2a73dbcb 100644 --- a/src/lib/util/prompt.test.ts +++ b/src/lib/util/prompt.test.ts @@ -1,6 +1,14 @@ +import { EventEmitter } from 'node:events' +import type { Key } from 'node:readline' + import { expect, test } from 'vitest' -import { type SearchableChoice, searchChoices } from './prompt.js' +import { + arrowKeyFor, + emitArrowKeyAliases, + type SearchableChoice, + searchChoices, +} from './prompt.js' const workspaces = [ { label: 'Sandbox', hint: 'ws_1' }, @@ -33,3 +41,54 @@ test('searchChoices: offers every choice until something is typed', () => { expect(search('', workspaces)).toEqual(workspaces) expect(search(' ', workspaces)).toEqual(workspaces) }) + +const ctrl = (name: string): Key => ({ + name, + ctrl: true, + meta: false, + shift: false, + sequence: String.fromCharCode(name.charCodeAt(0) - 96), +}) + +test('arrowKeyFor: maps ctrl-p and ctrl-n to the arrow keys', () => { + expect(arrowKeyFor(ctrl('p'))?.name).toBe('up') + expect(arrowKeyFor(ctrl('n'))?.name).toBe('down') +}) + +test('arrowKeyFor: leaves every other key alone', () => { + expect(arrowKeyFor(undefined)).toBeUndefined() + expect(arrowKeyFor(ctrl('c'))).toBeUndefined() + expect(arrowKeyFor({ name: 'p', sequence: 'p' })).toBeUndefined() + expect(arrowKeyFor({ name: 'n', sequence: 'n' })).toBeUndefined() + expect(arrowKeyFor({ ...ctrl('p'), meta: true })).toBeUndefined() + expect(arrowKeyFor({ ...ctrl('n'), shift: true })).toBeUndefined() + expect(arrowKeyFor({ name: 'up', sequence: '\x1B[A' })).toBeUndefined() +}) + +test('emitArrowKeyAliases: re-emits control keypresses as arrow keys', () => { + const input = new EventEmitter() + emitArrowKeyAliases(input) + + const keypresses: Array<[string | undefined, Key | undefined]> = [] + input.on('keypress', (char, key) => keypresses.push([char, key])) + + input.emit('keypress', '\x10', ctrl('p')) + input.emit('keypress', 'a', { name: 'a', sequence: 'a' }) + + // The synthetic arrow key arrives first: the re-emit is synchronous, + // and the alias listener runs before any listener attached after it. + expect(keypresses).toEqual([ + [ + undefined, + { + name: 'up', + ctrl: false, + meta: false, + shift: false, + sequence: '\x1B[A', + }, + ], + ['\x10', ctrl('p')], + ['a', { name: 'a', sequence: 'a' }], + ]) +}) diff --git a/src/lib/util/prompt.ts b/src/lib/util/prompt.ts index 491ddd26..5eae9953 100644 --- a/src/lib/util/prompt.ts +++ b/src/lib/util/prompt.ts @@ -1,3 +1,6 @@ +import type { EventEmitter } from 'node:events' +import type { Key } from 'node:readline' + import { autocomplete, autocompleteMultiselect, @@ -40,6 +43,46 @@ const ensureInteractive = (): void => { 'Cannot prompt without a terminal: pass the missing arguments, or pipe them in as JSON', ) } + installArrowKeyAliases() +} + +/** + * The arrow keypress an Emacs-style control keypress stands for, or + * undefined for any other key: ctrl-p is up and ctrl-n is down. + */ +export const arrowKeyFor = (key: Key | undefined): Key | undefined => { + if (key?.ctrl !== true || key.meta === true || key.shift === true) { + return undefined + } + const base = { ctrl: false, meta: false, shift: false } + if (key.name === 'p') return { ...base, name: 'up', sequence: '\x1B[A' } + if (key.name === 'n') return { ...base, name: 'down', sequence: '\x1B[B' } + return undefined +} + +/** + * Re-emit Emacs-style control keypresses as the arrow keys they stand for. + * + * Clack navigates on the readline key name, so a synthetic arrow keypress + * moves the cursor in every prompt kind. Its own alias table cannot express + * this: aliases match bare key names, unaware of ctrl, and are ignored by + * prompts that track typed input, such as autocomplete. + */ +export const emitArrowKeyAliases = (input: EventEmitter): void => { + input.on('keypress', (_char, key: Key | undefined) => { + const arrowKey = arrowKeyFor(key) + if (arrowKey !== undefined) input.emit('keypress', undefined, arrowKey) + }) +} + +let arrowKeyAliasesInstalled = false + +// Keypress events only flow while a prompt has stdin in raw mode, so the +// listener is inert the rest of the time and never holds the process open. +const installArrowKeyAliases = (): void => { + if (arrowKeyAliasesInstalled) return + arrowKeyAliasesInstalled = true + emitArrowKeyAliases(process.stdin) } const unwrap = (value: Value | symbol): Value => {