diff --git a/.github/actions/setup-bun-compile-runtime/action.yml b/.github/actions/setup-bun-compile-runtime/action.yml index f1fa88dc68..74d1cf1bd0 100644 --- a/.github/actions/setup-bun-compile-runtime/action.yml +++ b/.github/actions/setup-bun-compile-runtime/action.yml @@ -14,11 +14,13 @@ runs: shell: bash run: echo "version=$(bun --version)" >> "$GITHUB_OUTPUT" - - name: Cache Bun compile runtime - uses: actions/cache@v5 + - name: Restore Bun compile runtime cache + id: compile-runtime-cache + uses: actions/cache/restore@v5 + continue-on-error: true with: path: ${{ runner.temp }}/bun-compile-runtimes/${{ inputs.target }}-v${{ steps.bun-version.outputs.version }} - key: ${{ runner.os }}-bun-compile-runtime-${{ inputs.target }}-v${{ steps.bun-version.outputs.version }} + key: ${{ runner.os }}-bun-compile-runtime-v2-${{ inputs.target }}-v${{ steps.bun-version.outputs.version }} - name: Prepare Bun compile runtime shell: pwsh @@ -49,3 +51,11 @@ runs: } "BUN_COMPILE_EXECUTABLE_PATH=$runtimePath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Save Bun compile runtime cache + if: steps.compile-runtime-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + continue-on-error: true + with: + path: ${{ runner.temp }}/bun-compile-runtimes/${{ inputs.target }}-v${{ steps.bun-version.outputs.version }} + key: ${{ runner.os }}-bun-compile-runtime-v2-${{ inputs.target }}-v${{ steps.bun-version.outputs.version }} diff --git a/cli/src/components/__tests__/multiline-input.test.tsx b/cli/src/components/__tests__/multiline-input.test.tsx index 97f2d3f78a..a024ad9d3e 100644 --- a/cli/src/components/__tests__/multiline-input.test.tsx +++ b/cli/src/components/__tests__/multiline-input.test.tsx @@ -1,5 +1,10 @@ import { describe, test, expect } from 'bun:test' +import { + getKeypadPrintableSequence, + isKeypadEnter, +} from '../../utils/keypad-keys' + /** * Tests for tab character cursor rendering in MultilineInput component. * @@ -13,23 +18,23 @@ import { describe, test, expect } from 'bun:test' /** * Check if a key event represents printable character input (not a special key). * This mirrors the function in multiline-input.tsx for testing. - * + * * Uses a positive heuristic based on key.name length rather than a brittle deny-list. * Special keys have descriptive multi-character names (like 'backspace', 'up', 'f1') * while regular printable characters either have no name or a single-character name. */ function isPrintableCharacterKey(key: { name?: string }): boolean { const name = key.name - + // No name = likely multi-byte input (Chinese, Japanese, Korean, etc.) if (!name) return true - + // Single character name = regular ASCII printable (a, b, 1, $, etc.) if (name.length === 1) return true - + // Special case: space key has name 'space' but is printable if (name === 'space') return true - + // Multi-char name = special key (up, f1, backspace, etc.) return false } @@ -256,27 +261,42 @@ describe('MultilineInput - Chinese/IME character input', () => { meta?: boolean option?: boolean }): boolean { + return getPrintableKeySequence(key) !== null + } + + function getPrintableKeySequence(key: { + sequence?: string + name?: string + ctrl?: boolean + meta?: boolean + option?: boolean + }): string | null { // Must have a sequence with at least one character if (!key.sequence || key.sequence.length < 1) { - return false + return null } // No modifier keys allowed if (key.ctrl || key.meta || key.option) { - return false + return null + } + + const keypadValue = getKeypadPrintableSequence(key) + if (keypadValue !== null) { + return keypadValue } // Must not be a control character if (CONTROL_CHAR_REGEX.test(key.sequence)) { - return false + return null } // Must be a printable character key (not a special key like arrows, function keys, etc.) if (!isPrintableCharacterKey(key)) { - return false + return null } - return true + return key.sequence } test('accepts single Chinese character (你)', () => { @@ -387,6 +407,42 @@ describe('MultilineInput - Chinese/IME character input', () => { expect(shouldAcceptCharacterInput(key)).toBe(true) }) + test('accepts Kitty keyboard numpad digit names', () => { + const key = { + sequence: '\x1b[57400u', + name: 'kp1', + ctrl: false, + meta: false, + option: false, + } + + expect(getPrintableKeySequence(key)).toBe('1') + }) + + test('accepts raw application keypad digit sequences', () => { + const key = { + sequence: '\x1bOq', + name: '', + ctrl: false, + meta: false, + option: false, + } + + expect(getPrintableKeySequence(key)).toBe('1') + }) + + test('accepts raw application keypad operator sequences', () => { + const key = { + sequence: '\x1bOk', + name: '', + ctrl: false, + meta: false, + option: false, + } + + expect(getPrintableKeySequence(key)).toBe('+') + }) + test('rejects arrow key (up)', () => { const key = { sequence: '\x1b[A', @@ -625,7 +681,9 @@ describe('MultilineInput - newline keyboard shortcuts', () => { hasBackslashBeforeCursor: boolean = false, ): 'newline' | 'submit' | 'ignore' { const lowerKeyName = (key.name ?? '').toLowerCase() - const isEnterKey = key.name === 'return' || key.name === 'enter' + const keypadEnter = isKeypadEnter(key) + const isEnterKey = + key.name === 'return' || key.name === 'enter' || keypadEnter // Ctrl+J is translated by the terminal to a linefeed character (0x0a) // So we detect it by checking for name === 'linefeed' rather than ctrl + j const isCtrlJ = @@ -651,13 +709,13 @@ describe('MultilineInput - newline keyboard shortcuts', () => { !key.meta && !key.option && !isAltLikeModifier && - !hasEscapePrefix && - key.sequence === '\r' && + (!hasEscapePrefix || keypadEnter) && + (key.sequence === '\r' || keypadEnter) && !hasBackslashBeforeCursor const isShiftEnter = isEnterKey && (Boolean(key.shift) || key.sequence === '\n') const isOptionEnter = - isEnterKey && (isAltLikeModifier || hasEscapePrefix) + isEnterKey && !keypadEnter && (isAltLikeModifier || hasEscapePrefix) const isBackslashEnter = isEnterKey && hasBackslashBeforeCursor const shouldInsertNewline = @@ -900,6 +958,32 @@ describe('MultilineInput - newline keyboard shortcuts', () => { expect(getEnterKeyAction(key, false)).toBe('submit') }) + test('keypad Enter submits with Kitty keyboard key name', () => { + const key = { + name: 'kpenter', + sequence: '\x1b[57414u', + ctrl: false, + meta: false, + shift: false, + option: false, + } + + expect(getEnterKeyAction(key, false)).toBe('submit') + }) + + test('keypad Enter submits with raw application keypad sequence', () => { + const key = { + name: '', + sequence: '\x1bOM', + ctrl: false, + meta: false, + shift: false, + option: false, + } + + expect(getEnterKeyAction(key, false)).toBe('submit') + }) + // --- Non-Enter key tests --- test('Regular J key (no ctrl) is ignored', () => { diff --git a/cli/src/components/ask-user/index.tsx b/cli/src/components/ask-user/index.tsx index b56b5cccd2..3743a55533 100644 --- a/cli/src/components/ask-user/index.tsx +++ b/cli/src/components/ask-user/index.tsx @@ -16,6 +16,7 @@ import { import { getOptionLabel, KEYBOARD_HINTS, CUSTOM_OPTION_INDEX } from './constants' import { useTheme } from '../../hooks/use-theme' import { useChatStore } from '../../state/chat-store' +import { isPlainEnterKey } from '../../utils/terminal-enter-detection' import { BORDER_CHARS } from '../../utils/ui-constants' import { Button } from '../button' @@ -338,7 +339,7 @@ export const MultipleChoiceForm: React.FC = ({ } return } - if (key.name === 'return' || key.name === 'enter' || key.name === 'space') { + if (isPlainEnterKey(key) || key.name === 'space') { preventDefault() handleSubmit() return @@ -442,7 +443,7 @@ export const MultipleChoiceForm: React.FC = ({ return } - if (key.name === 'return' || key.name === 'enter' || key.name === 'space') { + if (isPlainEnterKey(key) || key.name === 'space') { preventDefault() if (expandedIndex === null) { diff --git a/cli/src/components/chat-history-screen.tsx b/cli/src/components/chat-history-screen.tsx index 01f3e03322..bf9c72ee51 100644 --- a/cli/src/components/chat-history-screen.tsx +++ b/cli/src/components/chat-history-screen.tsx @@ -12,6 +12,7 @@ import { formatRelativeTime, getAllChats, } from '../utils/chat-history' +import { isPlainEnterKey } from '../utils/terminal-enter-detection' import type { SelectableListItem } from './selectable-list' @@ -170,7 +171,14 @@ export const ChatHistoryScreen: React.FC = ({ // Handle keyboard input const handleKeyIntercept = useCallback( - (key: { name?: string; shift?: boolean; ctrl?: boolean }) => { + (key: { + name?: string + sequence?: string + shift?: boolean + ctrl?: boolean + meta?: boolean + option?: boolean + }) => { if (key.name === 'escape') { if (searchQuery.length > 0) { setSearchQuery('') @@ -189,7 +197,7 @@ export const ChatHistoryScreen: React.FC = ({ setFocusedIndex((prev) => Math.min(maxIndex, prev + 1)) return true } - if (key.name === 'return' || key.name === 'enter') { + if (isPlainEnterKey(key)) { const focused = filteredItems[focusedIndex] if (focused) { onSelectChat(focused.id) diff --git a/cli/src/components/chat-input-bar.tsx b/cli/src/components/chat-input-bar.tsx index cee0a296eb..a95b8cbfb4 100644 --- a/cli/src/components/chat-input-bar.tsx +++ b/cli/src/components/chat-input-bar.tsx @@ -11,8 +11,8 @@ import { SuggestionMenu, type SuggestionItem } from './suggestion-menu' import { useAskUserBridge } from '../hooks/use-ask-user-bridge' import { useEvent } from '../hooks/use-event' import { useChatStore } from '../state/chat-store' +import { shouldInterceptChatInputKey } from '../utils/chat-input-key-intercept' import { getInputModeConfig } from '../utils/input-modes' -import { isLinefeedActingAsEnter } from '../utils/terminal-enter-detection' import { BORDER_CHARS } from '../utils/ui-constants' import type { useTheme } from '../hooks/use-theme' @@ -133,38 +133,13 @@ export const ChatInputBar = ({ meta?: boolean option?: boolean }) => { - const isPlainEnter = - (key.name === 'return' || key.name === 'enter' || - (key.name === 'linefeed' && isLinefeedActingAsEnter())) && - !key.shift && - !key.ctrl && - !key.meta && - !key.option - const isTab = key.name === 'tab' && !key.ctrl && !key.meta && !key.option - const isUp = key.name === 'up' && !key.ctrl && !key.meta && !key.option - const isDown = key.name === 'down' && !key.ctrl && !key.meta && !key.option - const isUpDown = isUp || isDown - - const hasSuggestions = hasSlashSuggestions || hasMentionSuggestions - if (hasSuggestions) { - if (isUpDown && lastEditDueToNav) { - return true - } - if (isPlainEnter || isTab || isUpDown) { - return true - } - } - - const historyUpEnabled = lastEditDueToNav || cursorPosition === 0 - const historyDownEnabled = lastEditDueToNav || cursorPosition === inputValue.length - if (isUp && historyUpEnabled) { - return true - } - if (isDown && historyDownEnabled) { - return true - } - - return false + return shouldInterceptChatInputKey(key, { + hasSlashSuggestions, + hasMentionSuggestions, + lastEditDueToNav, + cursorPosition, + inputLength: inputValue.length, + }) }, ) diff --git a/cli/src/components/feedback-input-mode.tsx b/cli/src/components/feedback-input-mode.tsx index 48b709589f..0d47bdd6dc 100644 --- a/cli/src/components/feedback-input-mode.tsx +++ b/cli/src/components/feedback-input-mode.tsx @@ -8,6 +8,7 @@ import { useTheme } from '../hooks/use-theme' import { useChatStore } from '../state/chat-store' import { IS_FREEBUFF } from '../utils/constants' import { createTextPasteHandler } from '../utils/strings' +import { isPlainEnterKey } from '../utils/terminal-enter-detection' import { BORDER_CHARS } from '../utils/ui-constants' import type { FeedbackCategory } from '@codebuff/common/constants/feedback' @@ -120,8 +121,7 @@ const FeedbackTextSection: React.FC = ({ }} onSubmit={onSubmit} onKeyIntercept={(key) => { - const isEnter = key.name === 'return' || key.name === 'enter' - if (!isEnter) return false + if (!isPlainEnterKey(key)) return false // Just add newline on Enter const newText = value.slice(0, cursor) + '\n' + value.slice(cursor) onChange(newText) diff --git a/cli/src/components/multiline-input.tsx b/cli/src/components/multiline-input.tsx index f6f40b31db..a58fc8c5cb 100644 --- a/cli/src/components/multiline-input.tsx +++ b/cli/src/components/multiline-input.tsx @@ -16,8 +16,15 @@ import { import { InputCursor } from './input-cursor' import { useTheme } from '../hooks/use-theme' import { useChatStore } from '../state/chat-store' +import { + getKeypadPrintableSequence, + isKeypadEnter, +} from '../utils/keypad-keys' import { clamp } from '../utils/math' -import { isLinefeedActingAsEnter, markReturnKeySeen } from '../utils/terminal-enter-detection' +import { + isLinefeedActingAsEnter, + markReturnKeySeenForKey, +} from '../utils/terminal-enter-detection' import { supportsTruecolor } from '../utils/theme-system' import { calculateNewCursorPosition } from '../utils/word-wrap-utils' @@ -91,27 +98,41 @@ const TAB_WIDTH = 4 /** * Check if a key event represents printable character input (not a special key). * Uses a positive heuristic based on key.name length rather than a brittle deny-list. - * + * * The key insight is that OpenTUI's parser assigns descriptive multi-character names * to special keys (like 'backspace', 'up', 'f1') while regular printable characters * either have no name (multi-byte input like Chinese) or a single-character name. */ function isPrintableCharacterKey(key: KeyEvent): boolean { const name = key.name - + // No name = likely multi-byte input (Chinese, Japanese, Korean, etc.) - treat as printable if (!name) return true - + // Single character name = regular ASCII printable (a, b, 1, $, etc.) if (name.length === 1) return true - + // Special case: space key has name 'space' but is printable if (name === 'space') return true - + // Multi-char name = special key (up, f1, backspace, etc.) return false } +function getPrintableKeySequence(key: KeyEvent): string | null { + if (!key.sequence || key.sequence.length < 1) return null + if (key.ctrl || key.meta || key.option) return null + + const keypadValue = getKeypadPrintableSequence(key) + if (keypadValue !== null) return keypadValue + + if (!CONTROL_CHAR_REGEX.test(key.sequence) && isPrintableCharacterKey(key)) { + return key.sequence + } + + return null +} + // Helper to convert render position (in tab-expanded string) to original text position function renderPositionToOriginal(text: string, renderPos: number): number { let originalPos = 0 @@ -532,11 +553,11 @@ export const MultilineInput = forwardRef< const handleEnterKeys = useCallback( (key: KeyEvent): boolean => { const lowerKeyName = (key.name ?? '').toLowerCase() - const isReturnOrEnter = key.name === 'return' || key.name === 'enter' + const keypadEnter = isKeypadEnter(key) + const isReturnOrEnter = + key.name === 'return' || key.name === 'enter' || keypadEnter - if (isReturnOrEnter) { - markReturnKeySeen() - } + markReturnKeySeenForKey(key) const linefeedIsEnter = lowerKeyName === 'linefeed' && isLinefeedActingAsEnter() const isEnterKey = isReturnOrEnter || linefeedIsEnter @@ -567,12 +588,12 @@ export const MultilineInput = forwardRef< !key.meta && !key.option && !isAltLikeModifier && - !hasEscapePrefix && - (key.sequence === '\r' || key.sequence === '\n') && + (!hasEscapePrefix || keypadEnter) && + (key.sequence === '\r' || key.sequence === '\n' || keypadEnter) && !hasBackslashBeforeCursor const isShiftEnter = isEnterKey && Boolean(key.shift) const isOptionEnter = - isEnterKey && (isAltLikeModifier || hasEscapePrefix) + isEnterKey && !keypadEnter && (isAltLikeModifier || hasEscapePrefix) const isBackslashEnter = isEnterKey && hasBackslashBeforeCursor const shouldInsertNewline = @@ -1003,18 +1024,10 @@ export const MultilineInput = forwardRef< } // Character input (including multi-byte characters from IME like Chinese, Japanese, Korean) - // Check for printable input: has a sequence, no modifier keys, and not a control character - if ( - key.sequence && - key.sequence.length >= 1 && - !key.ctrl && - !key.meta && - !key.option && - !CONTROL_CHAR_REGEX.test(key.sequence) && - isPrintableCharacterKey(key) - ) { + const textToInsert = getPrintableKeySequence(key) + if (textToInsert !== null) { preventKeyDefault(key) - insertTextAtCursor(key.sequence) + insertTextAtCursor(textToInsert) return true } diff --git a/cli/src/components/project-picker-screen.tsx b/cli/src/components/project-picker-screen.tsx index 71fdb1cc1b..10db83a0ab 100644 --- a/cli/src/components/project-picker-screen.tsx +++ b/cli/src/components/project-picker-screen.tsx @@ -15,6 +15,7 @@ import { useTerminalLayout } from '../hooks/use-terminal-layout' import { useTheme } from '../hooks/use-theme' import { formatCwd } from '../utils/path-helpers' import { loadRecentProjects } from '../utils/recent-projects' +import { isPlainEnterKey } from '../utils/terminal-enter-detection' import { getLogoBlockColor, getLogoAccentColor } from '../utils/theme-system' import type { SelectableListItem } from './selectable-list' @@ -226,7 +227,14 @@ export const ProjectPickerScreen: React.FC = ({ // Handle search input keyboard intercept const handleSearchKeyIntercept = useCallback( - (key: { name?: string; shift?: boolean; ctrl?: boolean }) => { + (key: { + name?: string + sequence?: string + shift?: boolean + ctrl?: boolean + meta?: boolean + option?: boolean + }) => { if (key.name === 'escape') { if (searchQuery.length > 0) { setSearchQuery('') @@ -246,7 +254,7 @@ export const ProjectPickerScreen: React.FC = ({ ) return true } - if (key.name === 'return' || key.name === 'enter') { + if (isPlainEnterKey(key)) { // If search looks like a path, try to navigate there directly if (searchQuery.startsWith('/') || searchQuery.startsWith('~')) { if (tryNavigateToPath(searchQuery)) { diff --git a/cli/src/components/publish-container.tsx b/cli/src/components/publish-container.tsx index 729b5b14e7..73c2af5290 100644 --- a/cli/src/components/publish-container.tsx +++ b/cli/src/components/publish-container.tsx @@ -15,6 +15,7 @@ import { useTheme } from '../hooks/use-theme' import { useChatStore } from '../state/chat-store' import { usePublishStore } from '../state/publish-store' import { loadLocalAgents, loadAgentDefinitions } from '../utils/local-agent-registry' +import { isPlainEnterKey } from '../utils/terminal-enter-detection' import { BORDER_CHARS } from '../utils/ui-constants' @@ -110,7 +111,14 @@ export const PublishContainer: React.FC = ({ // Handle keyboard navigation in checklist const handleSearchKeyIntercept = useCallback( - (key: { name?: string; shift?: boolean }) => { + (key: { + name?: string + sequence?: string + shift?: boolean + ctrl?: boolean + meta?: boolean + option?: boolean + }) => { if (key.name === 'escape') { // Escape: clear input if there is any, otherwise exit publish mode if (searchQuery.length > 0) { @@ -129,7 +137,7 @@ export const PublishContainer: React.FC = ({ setFocusedIndex(Math.min(filteredAgents.length - 1, focusedIndex + 1)) return true } - if (key.name === 'return' || key.name === 'enter') { + if (isPlainEnterKey(key)) { // Enter: toggle selection const agent = filteredAgents[focusedIndex] if (agent) { diff --git a/cli/src/components/review-screen.tsx b/cli/src/components/review-screen.tsx index 98d8f7d160..d3a557871a 100644 --- a/cli/src/components/review-screen.tsx +++ b/cli/src/components/review-screen.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useState } from 'react' import { buildReviewPrompt, REVIEW_BASE_PROMPT } from '../commands/prompt-builders' import { useTheme } from '../hooks/use-theme' +import { isPlainEnterKey } from '../utils/terminal-enter-detection' import { BORDER_CHARS } from '../utils/ui-constants' import type { KeyEvent } from '@opentui/core' @@ -61,7 +62,7 @@ export const ReviewScreen: React.FC = ({ setSelectedIndex((prev) => Math.min(REVIEW_OPTIONS.length - 1, prev + 1)) return } - if (key.name === 'return' || key.name === 'enter') { + if (isPlainEnterKey(key)) { const option = REVIEW_OPTIONS[selectedIndex] if (option) { handleSelect(option) diff --git a/cli/src/components/session-ended-banner.tsx b/cli/src/components/session-ended-banner.tsx index b99ac28536..78dd623f79 100644 --- a/cli/src/components/session-ended-banner.tsx +++ b/cli/src/components/session-ended-banner.tsx @@ -11,6 +11,7 @@ import { import { useTheme } from '../hooks/use-theme' import { useFreebuffSessionStore } from '../state/freebuff-session-store' import { formatSessionUnits } from '../utils/format-session-units' +import { isPlainEnterKey } from '../utils/terminal-enter-detection' import { BORDER_CHARS } from '../utils/ui-constants' import type { KeyEvent } from '@opentui/core' @@ -89,7 +90,7 @@ export const SessionEndedBanner: React.FC = ({ useCallback( (key: KeyEvent) => { if (!canRestart) return - if (key.name === 'return' || key.name === 'enter') { + if (isPlainEnterKey(key)) { key.preventDefault?.() startSameChatSession() return diff --git a/cli/src/hooks/use-chat-keyboard.ts b/cli/src/hooks/use-chat-keyboard.ts index a2cc87daf9..1ad09d1772 100644 --- a/cli/src/hooks/use-chat-keyboard.ts +++ b/cli/src/hooks/use-chat-keyboard.ts @@ -12,7 +12,7 @@ import { type ChatKeyboardState, type ChatKeyboardAction, } from '../utils/keyboard-actions' -import { markReturnKeySeen } from '../utils/terminal-enter-detection' +import { markReturnKeySeenForKey } from '../utils/terminal-enter-detection' import type { KeyEvent } from '@opentui/core' @@ -305,9 +305,7 @@ export function useChatKeyboard({ reportActivity() } - if (key.name === 'return' || key.name === 'enter') { - markReturnKeySeen() - } + markReturnKeySeenForKey(key) const action = resolveChatKeyboardAction(key, state) const handled = dispatchAction(action, handlers) diff --git a/cli/src/hooks/use-login-keyboard-handlers.ts b/cli/src/hooks/use-login-keyboard-handlers.ts index 5d7d9cded9..16e74d73a2 100644 --- a/cli/src/hooks/use-login-keyboard-handlers.ts +++ b/cli/src/hooks/use-login-keyboard-handlers.ts @@ -1,6 +1,8 @@ import { useKeyboard } from '@opentui/react' import { useCallback } from 'react' +import { isPlainEnterKey } from '../utils/terminal-enter-detection' + import type { KeyEvent } from '@opentui/core' interface UseLoginKeyboardHandlersParams { @@ -27,11 +29,7 @@ export function useLoginKeyboardHandlers({ useKeyboard( useCallback( (key: KeyEvent) => { - const isEnter = - (key.name === 'return' || key.name === 'enter') && - !key.ctrl && - !key.meta && - !key.shift + const isEnter = isPlainEnterKey(key) const isCKey = key.name === 'c' && !key.ctrl && !key.meta && !key.shift const isCtrlC = key.ctrl && key.name === 'c' diff --git a/cli/src/utils/__tests__/chat-input-key-intercept.test.ts b/cli/src/utils/__tests__/chat-input-key-intercept.test.ts new file mode 100644 index 0000000000..acced0445a --- /dev/null +++ b/cli/src/utils/__tests__/chat-input-key-intercept.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'bun:test' + +import { shouldInterceptChatInputKey } from '../chat-input-key-intercept' + +const baseState = { + hasSlashSuggestions: false, + hasMentionSuggestions: false, + lastEditDueToNav: false, + cursorPosition: 1, + inputLength: 3, +} + +describe('shouldInterceptChatInputKey', () => { + test('intercepts keypad Enter while slash suggestions are visible', () => { + expect( + shouldInterceptChatInputKey( + { name: 'kpenter', sequence: '\x1b[57414u' }, + { ...baseState, hasSlashSuggestions: true }, + ), + ).toBe(true) + }) + + test('intercepts raw application keypad Enter while mention suggestions are visible', () => { + expect( + shouldInterceptChatInputKey( + { sequence: '\x1bOM' }, + { ...baseState, hasMentionSuggestions: true }, + ), + ).toBe(true) + }) + + test('does not intercept keypad Enter without visible suggestions', () => { + expect( + shouldInterceptChatInputKey( + { name: 'kpenter', sequence: '\x1b[57414u' }, + baseState, + ), + ).toBe(false) + }) +}) diff --git a/cli/src/utils/__tests__/keyboard-actions.test.ts b/cli/src/utils/__tests__/keyboard-actions.test.ts index c518b47ea7..59fd46f55f 100644 --- a/cli/src/utils/__tests__/keyboard-actions.test.ts +++ b/cli/src/utils/__tests__/keyboard-actions.test.ts @@ -26,6 +26,8 @@ const downKey = createKey({ name: 'down' }) const tabKey = createKey({ name: 'tab' }) const shiftTabKey = createKey({ name: 'tab', shift: true }) const enterKey = createKey({ name: 'return' }) +const keypadEnterKey = createKey({ name: 'kpenter', sequence: '\x1b[57414u' }) +const rawApplicationKeypadEnterKey = createKey({ sequence: '\x1bOM' }) const backspaceKey = createKey({ name: 'backspace' }) const defaultState = createDefaultChatKeyboardState() @@ -533,6 +535,44 @@ describe('resolveChatKeyboardAction', () => { }) }) + test('keypad enter without active menu does nothing', () => { + expect(resolveChatKeyboardAction(keypadEnterKey, defaultState)).toEqual({ + type: 'none', + }) + }) + + test('raw application keypad enter without active menu does nothing', () => { + expect( + resolveChatKeyboardAction(rawApplicationKeypadEnterKey, defaultState), + ).toEqual({ + type: 'none', + }) + }) + + test('keypad enter selects an active slash menu item', () => { + const state: ChatKeyboardState = { + ...defaultState, + slashMenuActive: true, + slashMatchesLength: 3, + } + expect(resolveChatKeyboardAction(keypadEnterKey, state)).toEqual({ + type: 'slash-menu-select', + }) + }) + + test('raw application keypad enter selects an active slash menu item', () => { + const state: ChatKeyboardState = { + ...defaultState, + slashMenuActive: true, + slashMatchesLength: 3, + } + expect( + resolveChatKeyboardAction(rawApplicationKeypadEnterKey, state), + ).toEqual({ + type: 'slash-menu-select', + }) + }) + test('shift+enter does nothing even in menu', () => { const shiftEnter = createKey({ name: 'return', shift: true }) const state: ChatKeyboardState = { diff --git a/cli/src/utils/__tests__/terminal-enter-detection.test.ts b/cli/src/utils/__tests__/terminal-enter-detection.test.ts new file mode 100644 index 0000000000..29e07bb2c1 --- /dev/null +++ b/cli/src/utils/__tests__/terminal-enter-detection.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' + +import { + isLinefeedActingAsEnter, + isPlainEnterKey, + markReturnKeySeenForKey, + resetReturnKeySeenForTests, + shouldMarkReturnKeySeen, +} from '../terminal-enter-detection' + +describe('terminal enter detection', () => { + beforeEach(() => { + resetReturnKeySeenForTests(false) + }) + + afterEach(() => { + resetReturnKeySeenForTests() + }) + + test('marks real carriage-return Enter as return seen', () => { + expect(shouldMarkReturnKeySeen({ name: 'return', sequence: '\r' })).toBe( + true, + ) + + markReturnKeySeenForKey({ name: 'return', sequence: '\r' }) + + expect(isLinefeedActingAsEnter()).toBe(false) + }) + + test('marks Kitty CSI-u Return as return seen', () => { + expect( + shouldMarkReturnKeySeen({ name: 'return', sequence: '\x1b[13u' }), + ).toBe(true) + + markReturnKeySeenForKey({ name: 'return', sequence: '\x1b[13u' }) + + expect(isLinefeedActingAsEnter()).toBe(false) + }) + + test('does not mark keypad Enter escape sequences as return seen', () => { + expect( + shouldMarkReturnKeySeen({ name: 'kpenter', sequence: '\x1b[57414u' }), + ).toBe(false) + expect(shouldMarkReturnKeySeen({ name: '', sequence: '\x1bOM' })).toBe( + false, + ) + + markReturnKeySeenForKey({ name: 'kpenter', sequence: '\x1b[57414u' }) + markReturnKeySeenForKey({ name: '', sequence: '\x1bOM' }) + + expect(isLinefeedActingAsEnter()).toBe(true) + }) + + test('recognizes keypad Enter as plain Enter', () => { + expect( + isPlainEnterKey({ name: 'kpenter', sequence: '\x1b[57414u' }), + ).toBe(true) + expect(isPlainEnterKey({ name: '', sequence: '\x1bOM' })).toBe(true) + }) + + test('plain Enter detection records return before later linefeed checks', () => { + expect(isLinefeedActingAsEnter()).toBe(true) + expect(isPlainEnterKey({ name: 'return', sequence: '\r' })).toBe(true) + expect(isLinefeedActingAsEnter()).toBe(false) + expect(isPlainEnterKey({ name: 'linefeed', sequence: '\n' })).toBe(false) + }) + + test('does not recognize modified keypad Enter as plain Enter', () => { + expect( + isPlainEnterKey({ + name: 'kpenter', + sequence: '\x1b[57414u', + shift: true, + }), + ).toBe(false) + }) +}) diff --git a/cli/src/utils/chat-input-key-intercept.ts b/cli/src/utils/chat-input-key-intercept.ts new file mode 100644 index 0000000000..d0053946b2 --- /dev/null +++ b/cli/src/utils/chat-input-key-intercept.ts @@ -0,0 +1,52 @@ +import { isPlainEnterKey } from './terminal-enter-detection' + +type ChatInputKey = { + name?: string + sequence?: string + shift?: boolean + ctrl?: boolean + meta?: boolean + option?: boolean +} + +type ChatInputKeyInterceptState = { + hasSlashSuggestions: boolean + hasMentionSuggestions: boolean + lastEditDueToNav: boolean + cursorPosition: number + inputLength: number +} + +export function shouldInterceptChatInputKey( + key: ChatInputKey, + state: ChatInputKeyInterceptState, +): boolean { + const isPlainEnter = isPlainEnterKey(key) + const isTab = key.name === 'tab' && !key.ctrl && !key.meta && !key.option + const isUp = key.name === 'up' && !key.ctrl && !key.meta && !key.option + const isDown = key.name === 'down' && !key.ctrl && !key.meta && !key.option + const isUpDown = isUp || isDown + + const hasSuggestions = + state.hasSlashSuggestions || state.hasMentionSuggestions + if (hasSuggestions) { + if (isUpDown && state.lastEditDueToNav) { + return true + } + if (isPlainEnter || isTab || isUpDown) { + return true + } + } + + const historyUpEnabled = state.lastEditDueToNav || state.cursorPosition === 0 + const historyDownEnabled = + state.lastEditDueToNav || state.cursorPosition === state.inputLength + if (isUp && historyUpEnabled) { + return true + } + if (isDown && historyDownEnabled) { + return true + } + + return false +} diff --git a/cli/src/utils/keyboard-actions.ts b/cli/src/utils/keyboard-actions.ts index 8a11ba782c..39de9dda5b 100644 --- a/cli/src/utils/keyboard-actions.ts +++ b/cli/src/utils/keyboard-actions.ts @@ -1,5 +1,5 @@ import { getInputModeConfig, type InputMode } from './input-modes' -import { isLinefeedActingAsEnter } from './terminal-enter-detection' +import { isPlainEnterKey } from './terminal-enter-detection' import type { KeyEvent } from '@opentui/core' @@ -131,11 +131,7 @@ export function resolveChatKeyboardAction( const isTab = key.name === 'tab' && !hasModifier(key) const isShiftTab = key.name === 'tab' && key.shift && !key.ctrl && !key.meta && !key.option - const isEnter = - (key.name === 'return' || key.name === 'enter' || - (key.name === 'linefeed' && isLinefeedActingAsEnter())) && - !key.shift && - !hasModifier(key) + const isEnter = isPlainEnterKey(key) const isPageUp = key.name === 'pageup' && !hasModifier(key) const isPageDown = key.name === 'pagedown' && !hasModifier(key) diff --git a/cli/src/utils/keypad-keys.ts b/cli/src/utils/keypad-keys.ts new file mode 100644 index 0000000000..966e176972 --- /dev/null +++ b/cli/src/utils/keypad-keys.ts @@ -0,0 +1,47 @@ +type KeypadKey = { + name?: string + sequence?: string +} + +const APPLICATION_KEYPAD_DIGITS = 'pqrstuvwxy' +const KEYPAD_OPERATOR_NAMES: Record = { + kpdecimal: '.', + kpdivide: '/', + kpmultiply: '*', + kpminus: '-', + kpplus: '+', + kpequal: '=', + kpseparator: ',', +} + +const APPLICATION_KEYPAD_OPERATORS: Record = { + n: '.', + o: '/', + j: '*', + m: '-', + k: '+', + X: '=', + l: ',', +} + +export function isKeypadEnter(key: KeypadKey): boolean { + return key.name === 'kpenter' || key.sequence === '\x1bOM' +} + +export function getKeypadPrintableSequence(key: KeypadKey): string | null { + const kittyDigit = /^kp([0-9])$/.exec(key.name ?? '')?.[1] + if (kittyDigit !== undefined) return kittyDigit + + const kittyOperator = key.name ? KEYPAD_OPERATOR_NAMES[key.name] : undefined + if (kittyOperator !== undefined) return kittyOperator + + if (!key.sequence?.startsWith('\x1bO') || key.sequence.length !== 3) { + return null + } + + const applicationKey = key.sequence[2] ?? '' + const applicationDigit = APPLICATION_KEYPAD_DIGITS.indexOf(applicationKey) + if (applicationDigit >= 0) return String(applicationDigit) + + return APPLICATION_KEYPAD_OPERATORS[applicationKey] ?? null +} diff --git a/cli/src/utils/terminal-enter-detection.ts b/cli/src/utils/terminal-enter-detection.ts index d2f7d0a7aa..3b94bd3beb 100644 --- a/cli/src/utils/terminal-enter-detection.ts +++ b/cli/src/utils/terminal-enter-detection.ts @@ -1,3 +1,5 @@ +import { isKeypadEnter } from './keypad-keys' + /** * Most terminals send \r for Enter and \n for Ctrl+J. A few niche Linux * terminal emulators send \n for Enter instead, making the two @@ -5,13 +7,57 @@ * ever seen a \r ("return") key event. On macOS, Enter always sends \r. */ -let hasSeenReturnKey = process.platform === 'darwin' +type EnterDetectionKey = { + name?: string + sequence?: string + shift?: boolean + ctrl?: boolean + meta?: boolean + option?: boolean +} + +const defaultHasSeenReturnKey = process.platform === 'darwin' + +let hasSeenReturnKey = defaultHasSeenReturnKey + +export function shouldMarkReturnKeySeen(key: EnterDetectionKey): boolean { + return (key.name === 'return' || key.name === 'enter') && !isKeypadEnter(key) +} + +export function isPlainEnterKey(key: EnterDetectionKey): boolean { + // Some local interceptors consume Enter before the global keyboard hooks see + // it, so record non-keypad Return here before consulting the linefeed fallback. + markReturnKeySeenForKey(key) + + return ( + (key.name === 'return' || + key.name === 'enter' || + isKeypadEnter(key) || + (key.name === 'linefeed' && isLinefeedActingAsEnter())) && + !key.shift && + !key.ctrl && + !key.meta && + !key.option + ) +} export function markReturnKeySeen(): void { hasSeenReturnKey = true } +export function markReturnKeySeenForKey(key: EnterDetectionKey): void { + if (shouldMarkReturnKeySeen(key)) { + markReturnKeySeen() + } +} + /** True when a "linefeed" (\n) key event should be treated as Enter. */ export function isLinefeedActingAsEnter(): boolean { return !hasSeenReturnKey } + +export function resetReturnKeySeenForTests( + hasSeenReturn: boolean = defaultHasSeenReturnKey, +): void { + hasSeenReturnKey = hasSeenReturn +}