Skip to content

Commit f36ea5e

Browse files
committed
Fix numpad input handling
1 parent f9f63c8 commit f36ea5e

5 files changed

Lines changed: 220 additions & 35 deletions

File tree

cli/src/components/__tests__/multiline-input.test.tsx

Lines changed: 98 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { describe, test, expect } from 'bun:test'
22

3+
import {
4+
getKeypadPrintableSequence,
5+
isKeypadEnter,
6+
} from '../../utils/keypad-keys'
7+
38
/**
49
* Tests for tab character cursor rendering in MultilineInput component.
510
*
@@ -13,23 +18,23 @@ import { describe, test, expect } from 'bun:test'
1318
/**
1419
* Check if a key event represents printable character input (not a special key).
1520
* This mirrors the function in multiline-input.tsx for testing.
16-
*
21+
*
1722
* Uses a positive heuristic based on key.name length rather than a brittle deny-list.
1823
* Special keys have descriptive multi-character names (like 'backspace', 'up', 'f1')
1924
* while regular printable characters either have no name or a single-character name.
2025
*/
2126
function isPrintableCharacterKey(key: { name?: string }): boolean {
2227
const name = key.name
23-
28+
2429
// No name = likely multi-byte input (Chinese, Japanese, Korean, etc.)
2530
if (!name) return true
26-
31+
2732
// Single character name = regular ASCII printable (a, b, 1, $, etc.)
2833
if (name.length === 1) return true
29-
34+
3035
// Special case: space key has name 'space' but is printable
3136
if (name === 'space') return true
32-
37+
3338
// Multi-char name = special key (up, f1, backspace, etc.)
3439
return false
3540
}
@@ -256,27 +261,42 @@ describe('MultilineInput - Chinese/IME character input', () => {
256261
meta?: boolean
257262
option?: boolean
258263
}): boolean {
264+
return getPrintableKeySequence(key) !== null
265+
}
266+
267+
function getPrintableKeySequence(key: {
268+
sequence?: string
269+
name?: string
270+
ctrl?: boolean
271+
meta?: boolean
272+
option?: boolean
273+
}): string | null {
259274
// Must have a sequence with at least one character
260275
if (!key.sequence || key.sequence.length < 1) {
261-
return false
276+
return null
262277
}
263278

264279
// No modifier keys allowed
265280
if (key.ctrl || key.meta || key.option) {
266-
return false
281+
return null
282+
}
283+
284+
const keypadValue = getKeypadPrintableSequence(key)
285+
if (keypadValue !== null) {
286+
return keypadValue
267287
}
268288

269289
// Must not be a control character
270290
if (CONTROL_CHAR_REGEX.test(key.sequence)) {
271-
return false
291+
return null
272292
}
273293

274294
// Must be a printable character key (not a special key like arrows, function keys, etc.)
275295
if (!isPrintableCharacterKey(key)) {
276-
return false
296+
return null
277297
}
278298

279-
return true
299+
return key.sequence
280300
}
281301

282302
test('accepts single Chinese character (你)', () => {
@@ -387,6 +407,42 @@ describe('MultilineInput - Chinese/IME character input', () => {
387407
expect(shouldAcceptCharacterInput(key)).toBe(true)
388408
})
389409

410+
test('accepts Kitty keyboard numpad digit names', () => {
411+
const key = {
412+
sequence: '\x1b[57400u',
413+
name: 'kp1',
414+
ctrl: false,
415+
meta: false,
416+
option: false,
417+
}
418+
419+
expect(getPrintableKeySequence(key)).toBe('1')
420+
})
421+
422+
test('accepts raw application keypad digit sequences', () => {
423+
const key = {
424+
sequence: '\x1bOq',
425+
name: '',
426+
ctrl: false,
427+
meta: false,
428+
option: false,
429+
}
430+
431+
expect(getPrintableKeySequence(key)).toBe('1')
432+
})
433+
434+
test('accepts raw application keypad operator sequences', () => {
435+
const key = {
436+
sequence: '\x1bOk',
437+
name: '',
438+
ctrl: false,
439+
meta: false,
440+
option: false,
441+
}
442+
443+
expect(getPrintableKeySequence(key)).toBe('+')
444+
})
445+
390446
test('rejects arrow key (up)', () => {
391447
const key = {
392448
sequence: '\x1b[A',
@@ -625,7 +681,9 @@ describe('MultilineInput - newline keyboard shortcuts', () => {
625681
hasBackslashBeforeCursor: boolean = false,
626682
): 'newline' | 'submit' | 'ignore' {
627683
const lowerKeyName = (key.name ?? '').toLowerCase()
628-
const isEnterKey = key.name === 'return' || key.name === 'enter'
684+
const keypadEnter = isKeypadEnter(key)
685+
const isEnterKey =
686+
key.name === 'return' || key.name === 'enter' || keypadEnter
629687
// Ctrl+J is translated by the terminal to a linefeed character (0x0a)
630688
// So we detect it by checking for name === 'linefeed' rather than ctrl + j
631689
const isCtrlJ =
@@ -651,13 +709,13 @@ describe('MultilineInput - newline keyboard shortcuts', () => {
651709
!key.meta &&
652710
!key.option &&
653711
!isAltLikeModifier &&
654-
!hasEscapePrefix &&
655-
key.sequence === '\r' &&
712+
(!hasEscapePrefix || keypadEnter) &&
713+
(key.sequence === '\r' || keypadEnter) &&
656714
!hasBackslashBeforeCursor
657715
const isShiftEnter =
658716
isEnterKey && (Boolean(key.shift) || key.sequence === '\n')
659717
const isOptionEnter =
660-
isEnterKey && (isAltLikeModifier || hasEscapePrefix)
718+
isEnterKey && !keypadEnter && (isAltLikeModifier || hasEscapePrefix)
661719
const isBackslashEnter = isEnterKey && hasBackslashBeforeCursor
662720

663721
const shouldInsertNewline =
@@ -900,6 +958,32 @@ describe('MultilineInput - newline keyboard shortcuts', () => {
900958
expect(getEnterKeyAction(key, false)).toBe('submit')
901959
})
902960

961+
test('keypad Enter submits with Kitty keyboard key name', () => {
962+
const key = {
963+
name: 'kpenter',
964+
sequence: '\x1b[57414u',
965+
ctrl: false,
966+
meta: false,
967+
shift: false,
968+
option: false,
969+
}
970+
971+
expect(getEnterKeyAction(key, false)).toBe('submit')
972+
})
973+
974+
test('keypad Enter submits with raw application keypad sequence', () => {
975+
const key = {
976+
name: '',
977+
sequence: '\x1bOM',
978+
ctrl: false,
979+
meta: false,
980+
shift: false,
981+
option: false,
982+
}
983+
984+
expect(getEnterKeyAction(key, false)).toBe('submit')
985+
})
986+
903987
// --- Non-Enter key tests ---
904988

905989
test('Regular J key (no ctrl) is ignored', () => {

cli/src/components/multiline-input.tsx

Lines changed: 32 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ import {
1616
import { InputCursor } from './input-cursor'
1717
import { useTheme } from '../hooks/use-theme'
1818
import { useChatStore } from '../state/chat-store'
19+
import {
20+
getKeypadPrintableSequence,
21+
isKeypadEnter,
22+
} from '../utils/keypad-keys'
1923
import { clamp } from '../utils/math'
2024
import { isLinefeedActingAsEnter, markReturnKeySeen } from '../utils/terminal-enter-detection'
2125
import { supportsTruecolor } from '../utils/theme-system'
@@ -91,27 +95,41 @@ const TAB_WIDTH = 4
9195
/**
9296
* Check if a key event represents printable character input (not a special key).
9397
* Uses a positive heuristic based on key.name length rather than a brittle deny-list.
94-
*
98+
*
9599
* The key insight is that OpenTUI's parser assigns descriptive multi-character names
96100
* to special keys (like 'backspace', 'up', 'f1') while regular printable characters
97101
* either have no name (multi-byte input like Chinese) or a single-character name.
98102
*/
99103
function isPrintableCharacterKey(key: KeyEvent): boolean {
100104
const name = key.name
101-
105+
102106
// No name = likely multi-byte input (Chinese, Japanese, Korean, etc.) - treat as printable
103107
if (!name) return true
104-
108+
105109
// Single character name = regular ASCII printable (a, b, 1, $, etc.)
106110
if (name.length === 1) return true
107-
111+
108112
// Special case: space key has name 'space' but is printable
109113
if (name === 'space') return true
110-
114+
111115
// Multi-char name = special key (up, f1, backspace, etc.)
112116
return false
113117
}
114118

119+
function getPrintableKeySequence(key: KeyEvent): string | null {
120+
if (!key.sequence || key.sequence.length < 1) return null
121+
if (key.ctrl || key.meta || key.option) return null
122+
123+
const keypadValue = getKeypadPrintableSequence(key)
124+
if (keypadValue !== null) return keypadValue
125+
126+
if (!CONTROL_CHAR_REGEX.test(key.sequence) && isPrintableCharacterKey(key)) {
127+
return key.sequence
128+
}
129+
130+
return null
131+
}
132+
115133
// Helper to convert render position (in tab-expanded string) to original text position
116134
function renderPositionToOriginal(text: string, renderPos: number): number {
117135
let originalPos = 0
@@ -532,7 +550,9 @@ export const MultilineInput = forwardRef<
532550
const handleEnterKeys = useCallback(
533551
(key: KeyEvent): boolean => {
534552
const lowerKeyName = (key.name ?? '').toLowerCase()
535-
const isReturnOrEnter = key.name === 'return' || key.name === 'enter'
553+
const keypadEnter = isKeypadEnter(key)
554+
const isReturnOrEnter =
555+
key.name === 'return' || key.name === 'enter' || keypadEnter
536556

537557
if (isReturnOrEnter) {
538558
markReturnKeySeen()
@@ -567,12 +587,12 @@ export const MultilineInput = forwardRef<
567587
!key.meta &&
568588
!key.option &&
569589
!isAltLikeModifier &&
570-
!hasEscapePrefix &&
571-
(key.sequence === '\r' || key.sequence === '\n') &&
590+
(!hasEscapePrefix || keypadEnter) &&
591+
(key.sequence === '\r' || key.sequence === '\n' || keypadEnter) &&
572592
!hasBackslashBeforeCursor
573593
const isShiftEnter = isEnterKey && Boolean(key.shift)
574594
const isOptionEnter =
575-
isEnterKey && (isAltLikeModifier || hasEscapePrefix)
595+
isEnterKey && !keypadEnter && (isAltLikeModifier || hasEscapePrefix)
576596
const isBackslashEnter = isEnterKey && hasBackslashBeforeCursor
577597

578598
const shouldInsertNewline =
@@ -1003,18 +1023,10 @@ export const MultilineInput = forwardRef<
10031023
}
10041024

10051025
// Character input (including multi-byte characters from IME like Chinese, Japanese, Korean)
1006-
// Check for printable input: has a sequence, no modifier keys, and not a control character
1007-
if (
1008-
key.sequence &&
1009-
key.sequence.length >= 1 &&
1010-
!key.ctrl &&
1011-
!key.meta &&
1012-
!key.option &&
1013-
!CONTROL_CHAR_REGEX.test(key.sequence) &&
1014-
isPrintableCharacterKey(key)
1015-
) {
1026+
const textToInsert = getPrintableKeySequence(key)
1027+
if (textToInsert !== null) {
10161028
preventKeyDefault(key)
1017-
insertTextAtCursor(key.sequence)
1029+
insertTextAtCursor(textToInsert)
10181030
return true
10191031
}
10201032

cli/src/utils/__tests__/keyboard-actions.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ const downKey = createKey({ name: 'down' })
2626
const tabKey = createKey({ name: 'tab' })
2727
const shiftTabKey = createKey({ name: 'tab', shift: true })
2828
const enterKey = createKey({ name: 'return' })
29+
const keypadEnterKey = createKey({ name: 'kpenter', sequence: '\x1b[57414u' })
30+
const rawApplicationKeypadEnterKey = createKey({ sequence: '\x1bOM' })
2931
const backspaceKey = createKey({ name: 'backspace' })
3032

3133
const defaultState = createDefaultChatKeyboardState()
@@ -533,6 +535,44 @@ describe('resolveChatKeyboardAction', () => {
533535
})
534536
})
535537

538+
test('keypad enter without active menu does nothing', () => {
539+
expect(resolveChatKeyboardAction(keypadEnterKey, defaultState)).toEqual({
540+
type: 'none',
541+
})
542+
})
543+
544+
test('raw application keypad enter without active menu does nothing', () => {
545+
expect(
546+
resolveChatKeyboardAction(rawApplicationKeypadEnterKey, defaultState),
547+
).toEqual({
548+
type: 'none',
549+
})
550+
})
551+
552+
test('keypad enter selects an active slash menu item', () => {
553+
const state: ChatKeyboardState = {
554+
...defaultState,
555+
slashMenuActive: true,
556+
slashMatchesLength: 3,
557+
}
558+
expect(resolveChatKeyboardAction(keypadEnterKey, state)).toEqual({
559+
type: 'slash-menu-select',
560+
})
561+
})
562+
563+
test('raw application keypad enter selects an active slash menu item', () => {
564+
const state: ChatKeyboardState = {
565+
...defaultState,
566+
slashMenuActive: true,
567+
slashMatchesLength: 3,
568+
}
569+
expect(
570+
resolveChatKeyboardAction(rawApplicationKeypadEnterKey, state),
571+
).toEqual({
572+
type: 'slash-menu-select',
573+
})
574+
})
575+
536576
test('shift+enter does nothing even in menu', () => {
537577
const shiftEnter = createKey({ name: 'return', shift: true })
538578
const state: ChatKeyboardState = {

cli/src/utils/keyboard-actions.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { getInputModeConfig, type InputMode } from './input-modes'
2+
import { isKeypadEnter } from './keypad-keys'
23
import { isLinefeedActingAsEnter } from './terminal-enter-detection'
34
import type { KeyEvent } from '@opentui/core'
45

@@ -131,8 +132,9 @@ export function resolveChatKeyboardAction(
131132
const isTab = key.name === 'tab' && !hasModifier(key)
132133
const isShiftTab =
133134
key.name === 'tab' && key.shift && !key.ctrl && !key.meta && !key.option
135+
const keypadEnter = isKeypadEnter(key)
134136
const isEnter =
135-
(key.name === 'return' || key.name === 'enter' ||
137+
(key.name === 'return' || key.name === 'enter' || keypadEnter ||
136138
(key.name === 'linefeed' && isLinefeedActingAsEnter())) &&
137139
!key.shift &&
138140
!hasModifier(key)

0 commit comments

Comments
 (0)