Skip to content

Commit dc4445a

Browse files
committed
refactor(cli): consolidate keyboard handling into useChatKeyboard hook
- Add useChatKeyboard hook with unified keyboard event handling - Add keyboard-actions.ts with pure action resolver function - Add comprehensive unit tests for keyboard action resolution - Fix escape key behavior in non-default input modes while streaming - Remove scattered useKeyboard calls from individual components - Remove handleSuggestionMenuKey prop drilling through ChatInputBar
1 parent 65eacef commit dc4445a

File tree

9 files changed

+1271
-593
lines changed

9 files changed

+1271
-593
lines changed

cli/src/chat.tsx

Lines changed: 176 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { RECONNECTION_MESSAGE_DURATION_MS } from '@codebuff/sdk'
2-
import { useKeyboard } from '@opentui/react'
32
import { useQueryClient } from '@tanstack/react-query'
43
import {
54
useCallback,
@@ -28,14 +27,14 @@ import { useElapsedTime } from './hooks/use-elapsed-time'
2827
import { useEvent } from './hooks/use-event'
2928
import { useExitHandler } from './hooks/use-exit-handler'
3029
import { useInputHistory } from './hooks/use-input-history'
31-
import { useKeyboardHandlers } from './hooks/use-keyboard-handlers'
30+
import { useChatKeyboard, type ChatKeyboardHandlers } from './hooks/use-chat-keyboard'
31+
import { type ChatKeyboardState, createDefaultChatKeyboardState } from './utils/keyboard-actions'
3232
import { useMessageQueue } from './hooks/use-message-queue'
3333
import { useQueueControls } from './hooks/use-queue-controls'
3434
import { useQueueUi } from './hooks/use-queue-ui'
3535
import { useChatScrollbox } from './hooks/use-scroll-management'
3636
import { useSendMessage } from './hooks/use-send-message'
3737
import { useSuggestionEngine } from './hooks/use-suggestion-engine'
38-
import { useSuggestionMenuHandlers } from './hooks/use-suggestion-menu-handlers'
3938
import { useTerminalDimensions } from './hooks/use-terminal-dimensions'
4039
import { useTheme } from './hooks/use-theme'
4140
import { useTimeout } from './hooks/use-timeout'
@@ -60,7 +59,7 @@ import type { SendMessageFn } from './types/contracts/send-message'
6059
import type { User } from './utils/auth'
6160
import type { AgentMode } from './utils/constants'
6261
import type { FileTreeNode } from '@codebuff/common/util/file'
63-
import type { KeyEvent, ScrollBoxRenderable } from '@opentui/core'
62+
import type { ScrollBoxRenderable } from '@opentui/core'
6463
import type { UseMutationResult } from '@tanstack/react-query'
6564
import type { Dispatch, SetStateAction } from 'react'
6665

@@ -420,6 +419,8 @@ export const Chat = ({
420419

421420
const localAgents = useMemo(() => loadLocalAgents(), [])
422421
const inputMode = useChatStore((state) => state.inputMode)
422+
const setInputMode = useChatStore((state) => state.setInputMode)
423+
const askUserState = useChatStore((state) => state.askUserState)
423424

424425
const {
425426
slashContext,
@@ -486,21 +487,6 @@ export const Chat = ({
486487
setAgentSelectedIndex,
487488
])
488489

489-
const { handleSuggestionMenuKey: handleSuggestionMenuKeyInternal } =
490-
useSuggestionMenuHandlers({
491-
slashContext,
492-
mentionContext,
493-
slashMatches,
494-
agentMatches,
495-
fileMatches,
496-
slashSelectedIndex,
497-
agentSelectedIndex,
498-
inputValue,
499-
setInputValue,
500-
setSlashSelectedIndex,
501-
setAgentSelectedIndex,
502-
disableSlashMenu: getInputModeConfig(inputMode).disableSlashSuggestions,
503-
})
504490
const openFileMenuWithTab = useCallback(() => {
505491
const safeCursor = Math.max(0, Math.min(cursorPosition, inputValue.length))
506492

@@ -527,60 +513,6 @@ export const Chat = ({
527513
setForceFileOnlyMentions(true)
528514
}, [cursorPosition, inputValue, setInputValue])
529515

530-
const handleSuggestionMenuKey = useCallback(
531-
(key: KeyEvent): boolean => {
532-
// In bash mode at cursor position 0, backspace should exit bash mode
533-
const inputMode = useChatStore.getState().inputMode
534-
// Exit special modes on backspace at position 0
535-
if (
536-
inputMode !== 'default' &&
537-
cursorPosition === 0 &&
538-
key.name === 'backspace'
539-
) {
540-
useChatStore.getState().setInputMode('default')
541-
return true
542-
}
543-
544-
if (handleSuggestionMenuKeyInternal(key)) {
545-
return true
546-
}
547-
548-
const isPlainTab =
549-
key &&
550-
key.name === 'tab' &&
551-
!key.shift &&
552-
!key.ctrl &&
553-
!key.meta &&
554-
!key.option
555-
556-
if (isPlainTab && !mentionContext.active) {
557-
// Only open file menu if there's a word at cursor to complete
558-
const safeCursor = Math.max(
559-
0,
560-
Math.min(cursorPosition, inputValue.length),
561-
)
562-
let wordStart = safeCursor
563-
while (wordStart > 0 && !/\s/.test(inputValue[wordStart - 1])) {
564-
wordStart--
565-
}
566-
const hasWordAtCursor = wordStart < safeCursor
567-
568-
if (hasWordAtCursor) {
569-
openFileMenuWithTab()
570-
return true
571-
}
572-
}
573-
574-
return false
575-
},
576-
[
577-
handleSuggestionMenuKeyInternal,
578-
mentionContext.active,
579-
openFileMenuWithTab,
580-
inputValue,
581-
],
582-
)
583-
584516
const { saveToHistory, navigateUp, navigateDown } = useInputHistory(
585517
inputValue,
586518
setInputValue,
@@ -719,6 +651,7 @@ export const Chat = ({
719651
closeFeedback,
720652
saveCurrentInput,
721653
restoreSavedInput,
654+
setFeedbackText,
722655
} = useFeedbackStore(
723656
useShallow((state) => ({
724657
feedbackMode: state.feedbackMode,
@@ -727,6 +660,7 @@ export const Chat = ({
727660
closeFeedback: state.closeFeedback,
728661
saveCurrentInput: state.saveCurrentInput,
729662
restoreSavedInput: state.restoreSavedInput,
663+
setFeedbackText: state.setFeedbackText,
730664
})),
731665
)
732666

@@ -866,28 +800,182 @@ export const Chat = ({
866800
agentSelectedIndex === totalMentionMatches - 1) ||
867801
(!slashContext.active && !mentionContext.active)))
868802

869-
useKeyboardHandlers({
803+
// Build keyboard state from store values
804+
const chatKeyboardState: ChatKeyboardState = useMemo(() => ({
805+
...createDefaultChatKeyboardState(),
806+
inputMode,
807+
inputValue,
808+
cursorPosition,
870809
isStreaming,
871810
isWaitingForResponse,
872-
abortControllerRef,
811+
feedbackMode,
873812
focusedAgentId,
874-
setFocusedAgentId,
875-
setInputFocused,
876-
inputRef,
877-
navigateUp,
878-
navigateDown,
879-
toggleAgentMode,
880-
onCtrlC: handleCtrlC,
881-
onInterrupt: () => {
813+
slashMenuActive: slashContext.active,
814+
mentionMenuActive: mentionContext.active,
815+
slashSelectedIndex,
816+
agentSelectedIndex,
817+
slashMatchesLength: slashMatches.length,
818+
totalMentionMatches: agentMatches.length + fileMatches.length,
819+
disableSlashSuggestions: getInputModeConfig(inputMode).disableSlashSuggestions,
820+
historyNavUpEnabled,
821+
historyNavDownEnabled,
822+
nextCtrlCWillExit,
823+
queuePaused,
824+
queuedCount,
825+
}), [
826+
inputMode,
827+
inputValue,
828+
cursorPosition,
829+
isStreaming,
830+
isWaitingForResponse,
831+
feedbackMode,
832+
focusedAgentId,
833+
slashContext.active,
834+
mentionContext.active,
835+
slashSelectedIndex,
836+
agentSelectedIndex,
837+
slashMatches.length,
838+
agentMatches.length,
839+
fileMatches.length,
840+
historyNavUpEnabled,
841+
historyNavDownEnabled,
842+
nextCtrlCWillExit,
843+
queuePaused,
844+
queuedCount,
845+
])
846+
847+
// Keyboard handlers
848+
const chatKeyboardHandlers: ChatKeyboardHandlers = useMemo(() => ({
849+
onExitInputMode: () => setInputMode('default'),
850+
onExitFeedbackMode: handleCloseFeedback,
851+
onClearFeedbackInput: () => {
852+
setFeedbackText('')
853+
useFeedbackStore.getState().setFeedbackCursor(0)
854+
useFeedbackStore.getState().setFeedbackCategory('other')
855+
},
856+
onClearInput: () => setInputValue({ text: '', cursorPosition: 0, lastEditDueToNav: false }),
857+
onBackspaceExitMode: () => setInputMode('default'),
858+
onInterruptStream: () => {
859+
abortControllerRef.current?.abort()
882860
if (queuedMessages.length > 0) {
883861
pauseQueue()
884862
}
885863
},
886-
historyNavUpEnabled,
887-
historyNavDownEnabled,
888-
disabled: feedbackMode,
889-
inputValue,
864+
onSlashMenuDown: () => setSlashSelectedIndex((prev) => prev + 1),
865+
onSlashMenuUp: () => setSlashSelectedIndex((prev) => prev - 1),
866+
onSlashMenuTab: () => setSlashSelectedIndex((prev) => (prev + 1) % slashMatches.length),
867+
onSlashMenuShiftTab: () => setSlashSelectedIndex((prev) => (slashMatches.length + prev - 1) % slashMatches.length),
868+
onSlashMenuSelect: () => {
869+
const selected = slashMatches[slashSelectedIndex] || slashMatches[0]
870+
if (!selected || slashContext.startIndex < 0) return
871+
const before = inputValue.slice(0, slashContext.startIndex)
872+
const after = inputValue.slice(slashContext.startIndex + 1 + slashContext.query.length)
873+
const replacement = `/${selected.id} `
874+
setInputValue({
875+
text: before + replacement + after,
876+
cursorPosition: before.length + replacement.length,
877+
lastEditDueToNav: false,
878+
})
879+
setSlashSelectedIndex(0)
880+
},
881+
onMentionMenuDown: () => setAgentSelectedIndex((prev) => prev + 1),
882+
onMentionMenuUp: () => setAgentSelectedIndex((prev) => prev - 1),
883+
onMentionMenuTab: () => {
884+
const totalMatches = agentMatches.length + fileMatches.length
885+
setAgentSelectedIndex((prev) => (prev + 1) % totalMatches)
886+
},
887+
onMentionMenuShiftTab: () => {
888+
const totalMatches = agentMatches.length + fileMatches.length
889+
setAgentSelectedIndex((prev) => (totalMatches + prev - 1) % totalMatches)
890+
},
891+
onMentionMenuSelect: () => {
892+
if (mentionContext.startIndex < 0) return
893+
894+
const trySelectAtIndex = (index: number): boolean => {
895+
let replacement: string
896+
if (index < agentMatches.length) {
897+
const selected = agentMatches[index]
898+
if (!selected) return false
899+
replacement = `@${selected.displayName} `
900+
} else {
901+
const fileIndex = index - agentMatches.length
902+
const selectedFile = fileMatches[fileIndex]
903+
if (!selectedFile) return false
904+
replacement = `@${selectedFile.filePath} `
905+
}
906+
const before = inputValue.slice(0, mentionContext.startIndex)
907+
const after = inputValue.slice(mentionContext.startIndex + 1 + mentionContext.query.length)
908+
setInputValue({
909+
text: before + replacement + after,
910+
cursorPosition: before.length + replacement.length,
911+
lastEditDueToNav: false,
912+
})
913+
setAgentSelectedIndex(0)
914+
return true
915+
}
916+
917+
// Try current selection, fall back to first item
918+
trySelectAtIndex(agentSelectedIndex) || trySelectAtIndex(0)
919+
},
920+
onOpenFileMenuWithTab: () => {
921+
const safeCursor = Math.max(0, Math.min(cursorPosition, inputValue.length))
922+
let wordStart = safeCursor
923+
while (wordStart > 0 && !/\s/.test(inputValue[wordStart - 1]!)) {
924+
wordStart--
925+
}
926+
if (wordStart < safeCursor) {
927+
openFileMenuWithTab()
928+
return true
929+
}
930+
return false
931+
},
932+
onHistoryUp: navigateUp,
933+
onHistoryDown: navigateDown,
934+
onToggleAgentMode: toggleAgentMode,
935+
onUnfocusAgent: () => {
936+
setFocusedAgentId(null)
937+
setInputFocused(true)
938+
inputRef.current?.focus()
939+
},
940+
onClearQueue: clearQueue,
941+
onExitAppWarning: () => handleCtrlC(),
942+
onExitApp: () => handleCtrlC(),
943+
}), [
944+
setInputMode,
945+
handleCloseFeedback,
946+
setFeedbackText,
890947
setInputValue,
948+
abortControllerRef,
949+
queuedMessages.length,
950+
pauseQueue,
951+
setSlashSelectedIndex,
952+
slashMatches,
953+
slashSelectedIndex,
954+
slashContext,
955+
inputValue,
956+
setAgentSelectedIndex,
957+
agentMatches,
958+
fileMatches,
959+
agentSelectedIndex,
960+
mentionContext,
961+
cursorPosition,
962+
openFileMenuWithTab,
963+
saveCurrentInput,
964+
navigateUp,
965+
navigateDown,
966+
toggleAgentMode,
967+
setFocusedAgentId,
968+
setInputFocused,
969+
inputRef,
970+
handleCtrlC,
971+
clearQueue,
972+
])
973+
974+
// Use the chat keyboard hook
975+
useChatKeyboard({
976+
state: chatKeyboardState,
977+
handlers: chatKeyboardHandlers,
978+
disabled: askUserState !== null,
891979
})
892980

893981
const { tree: messageTree, topLevelMessages } = useMemo(
@@ -1065,6 +1153,7 @@ export const Chat = ({
10651153
inputRef={inputRef}
10661154
inputPlaceholder={inputPlaceholder}
10671155
inputWidth={inputWidth}
1156+
lastEditDueToNav={lastEditDueToNav}
10681157
agentMode={agentMode}
10691158
toggleAgentMode={toggleAgentMode}
10701159
setAgentMode={setAgentMode}
@@ -1076,7 +1165,6 @@ export const Chat = ({
10761165
fileSuggestionItems={fileSuggestionItems}
10771166
slashSelectedIndex={slashSelectedIndex}
10781167
agentSelectedIndex={agentSelectedIndex}
1079-
handleSuggestionMenuKey={handleSuggestionMenuKey}
10801168
theme={theme}
10811169
terminalHeight={terminalHeight}
10821170
separatorWidth={separatorWidth}

0 commit comments

Comments
 (0)