Skip to content

Commit df68ed9

Browse files
razor-xclaude
andauthored
feat: Navigate prompts with ctrl-p and ctrl-n (#605)
* feat: Navigate prompts with ctrl-p and ctrl-n Re-emit the Emacs-style control keypresses as arrow keys so they move the cursor in every clack prompt kind, including autocomplete, which ignores clack's own key alias table. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyVFq6gYChW9wtoDq8CHsD * feat: Add ctrl-j/k prompt navigation and arrow key back and forth Replace the keypress alias listener with an input stream that rewrites keys before readline decodes them, which the alias approach could not do: ctrl-j arrives as a line feed that readline submits, wiping the typed autocomplete filter. The translated stream also makes the right arrow submit and the left arrow return to the previous prompt, both only while nothing is typed, so the caret still works while editing a filter or value. Going back is opt in per prompt, so a stray left arrow cannot abandon a command, and the command menu now goes up one level rather than back to the root. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyVFq6gYChW9wtoDq8CHsD * Revert "feat: Add ctrl-j/k prompt navigation and arrow key back and forth" This reverts commit f4e10c3. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 68f068c commit df68ed9

2 files changed

Lines changed: 103 additions & 1 deletion

File tree

src/lib/util/prompt.test.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
1+
import { EventEmitter } from 'node:events'
2+
import type { Key } from 'node:readline'
3+
14
import { expect, test } from 'vitest'
25

3-
import { type SearchableChoice, searchChoices } from './prompt.js'
6+
import {
7+
arrowKeyFor,
8+
emitArrowKeyAliases,
9+
type SearchableChoice,
10+
searchChoices,
11+
} from './prompt.js'
412

513
const workspaces = [
614
{ label: 'Sandbox', hint: 'ws_1' },
@@ -33,3 +41,54 @@ test('searchChoices: offers every choice until something is typed', () => {
3341
expect(search('', workspaces)).toEqual(workspaces)
3442
expect(search(' ', workspaces)).toEqual(workspaces)
3543
})
44+
45+
const ctrl = (name: string): Key => ({
46+
name,
47+
ctrl: true,
48+
meta: false,
49+
shift: false,
50+
sequence: String.fromCharCode(name.charCodeAt(0) - 96),
51+
})
52+
53+
test('arrowKeyFor: maps ctrl-p and ctrl-n to the arrow keys', () => {
54+
expect(arrowKeyFor(ctrl('p'))?.name).toBe('up')
55+
expect(arrowKeyFor(ctrl('n'))?.name).toBe('down')
56+
})
57+
58+
test('arrowKeyFor: leaves every other key alone', () => {
59+
expect(arrowKeyFor(undefined)).toBeUndefined()
60+
expect(arrowKeyFor(ctrl('c'))).toBeUndefined()
61+
expect(arrowKeyFor({ name: 'p', sequence: 'p' })).toBeUndefined()
62+
expect(arrowKeyFor({ name: 'n', sequence: 'n' })).toBeUndefined()
63+
expect(arrowKeyFor({ ...ctrl('p'), meta: true })).toBeUndefined()
64+
expect(arrowKeyFor({ ...ctrl('n'), shift: true })).toBeUndefined()
65+
expect(arrowKeyFor({ name: 'up', sequence: '\x1B[A' })).toBeUndefined()
66+
})
67+
68+
test('emitArrowKeyAliases: re-emits control keypresses as arrow keys', () => {
69+
const input = new EventEmitter()
70+
emitArrowKeyAliases(input)
71+
72+
const keypresses: Array<[string | undefined, Key | undefined]> = []
73+
input.on('keypress', (char, key) => keypresses.push([char, key]))
74+
75+
input.emit('keypress', '\x10', ctrl('p'))
76+
input.emit('keypress', 'a', { name: 'a', sequence: 'a' })
77+
78+
// The synthetic arrow key arrives first: the re-emit is synchronous,
79+
// and the alias listener runs before any listener attached after it.
80+
expect(keypresses).toEqual([
81+
[
82+
undefined,
83+
{
84+
name: 'up',
85+
ctrl: false,
86+
meta: false,
87+
shift: false,
88+
sequence: '\x1B[A',
89+
},
90+
],
91+
['\x10', ctrl('p')],
92+
['a', { name: 'a', sequence: 'a' }],
93+
])
94+
})

src/lib/util/prompt.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import type { EventEmitter } from 'node:events'
2+
import type { Key } from 'node:readline'
3+
14
import {
25
autocomplete,
36
autocompleteMultiselect,
@@ -40,6 +43,46 @@ const ensureInteractive = (): void => {
4043
'Cannot prompt without a terminal: pass the missing arguments, or pipe them in as JSON',
4144
)
4245
}
46+
installArrowKeyAliases()
47+
}
48+
49+
/**
50+
* The arrow keypress an Emacs-style control keypress stands for, or
51+
* undefined for any other key: ctrl-p is up and ctrl-n is down.
52+
*/
53+
export const arrowKeyFor = (key: Key | undefined): Key | undefined => {
54+
if (key?.ctrl !== true || key.meta === true || key.shift === true) {
55+
return undefined
56+
}
57+
const base = { ctrl: false, meta: false, shift: false }
58+
if (key.name === 'p') return { ...base, name: 'up', sequence: '\x1B[A' }
59+
if (key.name === 'n') return { ...base, name: 'down', sequence: '\x1B[B' }
60+
return undefined
61+
}
62+
63+
/**
64+
* Re-emit Emacs-style control keypresses as the arrow keys they stand for.
65+
*
66+
* Clack navigates on the readline key name, so a synthetic arrow keypress
67+
* moves the cursor in every prompt kind. Its own alias table cannot express
68+
* this: aliases match bare key names, unaware of ctrl, and are ignored by
69+
* prompts that track typed input, such as autocomplete.
70+
*/
71+
export const emitArrowKeyAliases = (input: EventEmitter): void => {
72+
input.on('keypress', (_char, key: Key | undefined) => {
73+
const arrowKey = arrowKeyFor(key)
74+
if (arrowKey !== undefined) input.emit('keypress', undefined, arrowKey)
75+
})
76+
}
77+
78+
let arrowKeyAliasesInstalled = false
79+
80+
// Keypress events only flow while a prompt has stdin in raw mode, so the
81+
// listener is inert the rest of the time and never holds the process open.
82+
const installArrowKeyAliases = (): void => {
83+
if (arrowKeyAliasesInstalled) return
84+
arrowKeyAliasesInstalled = true
85+
emitArrowKeyAliases(process.stdin)
4386
}
4487

4588
const unwrap = <Value>(value: Value | symbol): Value => {

0 commit comments

Comments
 (0)