Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion src/lib/util/prompt.test.ts
Original file line number Diff line number Diff line change
@@ -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' },
Expand Down Expand Up @@ -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' }],
])
})
43 changes: 43 additions & 0 deletions src/lib/util/prompt.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import type { EventEmitter } from 'node:events'
import type { Key } from 'node:readline'

import {
autocomplete,
autocompleteMultiselect,
Expand Down Expand Up @@ -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: Value | symbol): Value => {
Expand Down
Loading