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
16 changes: 13 additions & 3 deletions .github/actions/setup-bun-compile-runtime/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
112 changes: 98 additions & 14 deletions cli/src/components/__tests__/multiline-input.test.tsx
Original file line number Diff line number Diff line change
@@ -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.
*
Expand All @@ -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
}
Expand Down Expand Up @@ -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 (你)', () => {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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 =
Expand All @@ -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 =
Expand Down Expand Up @@ -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', () => {
Expand Down
5 changes: 3 additions & 2 deletions cli/src/components/ask-user/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -338,7 +339,7 @@ export const MultipleChoiceForm: React.FC<MultipleChoiceFormProps> = ({
}
return
}
if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
if (isPlainEnterKey(key) || key.name === 'space') {
preventDefault()
handleSubmit()
return
Expand Down Expand Up @@ -442,7 +443,7 @@ export const MultipleChoiceForm: React.FC<MultipleChoiceFormProps> = ({
return
}

if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
if (isPlainEnterKey(key) || key.name === 'space') {
preventDefault()

if (expandedIndex === null) {
Expand Down
12 changes: 10 additions & 2 deletions cli/src/components/chat-history-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
formatRelativeTime,
getAllChats,
} from '../utils/chat-history'
import { isPlainEnterKey } from '../utils/terminal-enter-detection'

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

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

// 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('')
Expand All @@ -189,7 +197,7 @@ export const ChatHistoryScreen: React.FC<ChatHistoryScreenProps> = ({
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)
Expand Down
41 changes: 8 additions & 33 deletions cli/src/components/chat-input-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
})
},
)

Expand Down
4 changes: 2 additions & 2 deletions cli/src/components/feedback-input-mode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -120,8 +121,7 @@ const FeedbackTextSection: React.FC<FeedbackTextSectionProps> = ({
}}
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)
Expand Down
Loading
Loading