Skip to content

Commit cbd3fde

Browse files
authored
Fix numpad input handling (#727)
1 parent 83da0c2 commit cbd3fde

20 files changed

Lines changed: 505 additions & 102 deletions

.github/actions/setup-bun-compile-runtime/action.yml

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ runs:
1414
shell: bash
1515
run: echo "version=$(bun --version)" >> "$GITHUB_OUTPUT"
1616

17-
- name: Cache Bun compile runtime
18-
uses: actions/cache@v5
17+
- name: Restore Bun compile runtime cache
18+
id: compile-runtime-cache
19+
uses: actions/cache/restore@v5
20+
continue-on-error: true
1921
with:
2022
path: ${{ runner.temp }}/bun-compile-runtimes/${{ inputs.target }}-v${{ steps.bun-version.outputs.version }}
21-
key: ${{ runner.os }}-bun-compile-runtime-${{ inputs.target }}-v${{ steps.bun-version.outputs.version }}
23+
key: ${{ runner.os }}-bun-compile-runtime-v2-${{ inputs.target }}-v${{ steps.bun-version.outputs.version }}
2224

2325
- name: Prepare Bun compile runtime
2426
shell: pwsh
@@ -49,3 +51,11 @@ runs:
4951
}
5052
5153
"BUN_COMPILE_EXECUTABLE_PATH=$runtimePath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
54+
55+
- name: Save Bun compile runtime cache
56+
if: steps.compile-runtime-cache.outputs.cache-hit != 'true'
57+
uses: actions/cache/save@v5
58+
continue-on-error: true
59+
with:
60+
path: ${{ runner.temp }}/bun-compile-runtimes/${{ inputs.target }}-v${{ steps.bun-version.outputs.version }}
61+
key: ${{ runner.os }}-bun-compile-runtime-v2-${{ inputs.target }}-v${{ steps.bun-version.outputs.version }}

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/ask-user/index.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import { getOptionLabel, KEYBOARD_HINTS, CUSTOM_OPTION_INDEX } from './constants'
1717
import { useTheme } from '../../hooks/use-theme'
1818
import { useChatStore } from '../../state/chat-store'
19+
import { isPlainEnterKey } from '../../utils/terminal-enter-detection'
1920
import { BORDER_CHARS } from '../../utils/ui-constants'
2021
import { Button } from '../button'
2122

@@ -338,7 +339,7 @@ export const MultipleChoiceForm: React.FC<MultipleChoiceFormProps> = ({
338339
}
339340
return
340341
}
341-
if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
342+
if (isPlainEnterKey(key) || key.name === 'space') {
342343
preventDefault()
343344
handleSubmit()
344345
return
@@ -442,7 +443,7 @@ export const MultipleChoiceForm: React.FC<MultipleChoiceFormProps> = ({
442443
return
443444
}
444445

445-
if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
446+
if (isPlainEnterKey(key) || key.name === 'space') {
446447
preventDefault()
447448

448449
if (expandedIndex === null) {

cli/src/components/chat-history-screen.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
formatRelativeTime,
1313
getAllChats,
1414
} from '../utils/chat-history'
15+
import { isPlainEnterKey } from '../utils/terminal-enter-detection'
1516

1617
import type { SelectableListItem } from './selectable-list'
1718

@@ -170,7 +171,14 @@ export const ChatHistoryScreen: React.FC<ChatHistoryScreenProps> = ({
170171

171172
// Handle keyboard input
172173
const handleKeyIntercept = useCallback(
173-
(key: { name?: string; shift?: boolean; ctrl?: boolean }) => {
174+
(key: {
175+
name?: string
176+
sequence?: string
177+
shift?: boolean
178+
ctrl?: boolean
179+
meta?: boolean
180+
option?: boolean
181+
}) => {
174182
if (key.name === 'escape') {
175183
if (searchQuery.length > 0) {
176184
setSearchQuery('')
@@ -189,7 +197,7 @@ export const ChatHistoryScreen: React.FC<ChatHistoryScreenProps> = ({
189197
setFocusedIndex((prev) => Math.min(maxIndex, prev + 1))
190198
return true
191199
}
192-
if (key.name === 'return' || key.name === 'enter') {
200+
if (isPlainEnterKey(key)) {
193201
const focused = filteredItems[focusedIndex]
194202
if (focused) {
195203
onSelectChat(focused.id)

cli/src/components/chat-input-bar.tsx

Lines changed: 8 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import { SuggestionMenu, type SuggestionItem } from './suggestion-menu'
1111
import { useAskUserBridge } from '../hooks/use-ask-user-bridge'
1212
import { useEvent } from '../hooks/use-event'
1313
import { useChatStore } from '../state/chat-store'
14+
import { shouldInterceptChatInputKey } from '../utils/chat-input-key-intercept'
1415
import { getInputModeConfig } from '../utils/input-modes'
15-
import { isLinefeedActingAsEnter } from '../utils/terminal-enter-detection'
1616
import { BORDER_CHARS } from '../utils/ui-constants'
1717

1818
import type { useTheme } from '../hooks/use-theme'
@@ -133,38 +133,13 @@ export const ChatInputBar = ({
133133
meta?: boolean
134134
option?: boolean
135135
}) => {
136-
const isPlainEnter =
137-
(key.name === 'return' || key.name === 'enter' ||
138-
(key.name === 'linefeed' && isLinefeedActingAsEnter())) &&
139-
!key.shift &&
140-
!key.ctrl &&
141-
!key.meta &&
142-
!key.option
143-
const isTab = key.name === 'tab' && !key.ctrl && !key.meta && !key.option
144-
const isUp = key.name === 'up' && !key.ctrl && !key.meta && !key.option
145-
const isDown = key.name === 'down' && !key.ctrl && !key.meta && !key.option
146-
const isUpDown = isUp || isDown
147-
148-
const hasSuggestions = hasSlashSuggestions || hasMentionSuggestions
149-
if (hasSuggestions) {
150-
if (isUpDown && lastEditDueToNav) {
151-
return true
152-
}
153-
if (isPlainEnter || isTab || isUpDown) {
154-
return true
155-
}
156-
}
157-
158-
const historyUpEnabled = lastEditDueToNav || cursorPosition === 0
159-
const historyDownEnabled = lastEditDueToNav || cursorPosition === inputValue.length
160-
if (isUp && historyUpEnabled) {
161-
return true
162-
}
163-
if (isDown && historyDownEnabled) {
164-
return true
165-
}
166-
167-
return false
136+
return shouldInterceptChatInputKey(key, {
137+
hasSlashSuggestions,
138+
hasMentionSuggestions,
139+
lastEditDueToNav,
140+
cursorPosition,
141+
inputLength: inputValue.length,
142+
})
168143
},
169144
)
170145

cli/src/components/feedback-input-mode.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useTheme } from '../hooks/use-theme'
88
import { useChatStore } from '../state/chat-store'
99
import { IS_FREEBUFF } from '../utils/constants'
1010
import { createTextPasteHandler } from '../utils/strings'
11+
import { isPlainEnterKey } from '../utils/terminal-enter-detection'
1112
import { BORDER_CHARS } from '../utils/ui-constants'
1213

1314
import type { FeedbackCategory } from '@codebuff/common/constants/feedback'
@@ -120,8 +121,7 @@ const FeedbackTextSection: React.FC<FeedbackTextSectionProps> = ({
120121
}}
121122
onSubmit={onSubmit}
122123
onKeyIntercept={(key) => {
123-
const isEnter = key.name === 'return' || key.name === 'enter'
124-
if (!isEnter) return false
124+
if (!isPlainEnterKey(key)) return false
125125
// Just add newline on Enter
126126
const newText = value.slice(0, cursor) + '\n' + value.slice(cursor)
127127
onChange(newText)

0 commit comments

Comments
 (0)