11import { RECONNECTION_MESSAGE_DURATION_MS } from '@codebuff/sdk'
2- import { useKeyboard } from '@opentui/react'
32import { useQueryClient } from '@tanstack/react-query'
43import {
54 useCallback ,
@@ -28,14 +27,14 @@ import { useElapsedTime } from './hooks/use-elapsed-time'
2827import { useEvent } from './hooks/use-event'
2928import { useExitHandler } from './hooks/use-exit-handler'
3029import { 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'
3232import { useMessageQueue } from './hooks/use-message-queue'
3333import { useQueueControls } from './hooks/use-queue-controls'
3434import { useQueueUi } from './hooks/use-queue-ui'
3535import { useChatScrollbox } from './hooks/use-scroll-management'
3636import { useSendMessage } from './hooks/use-send-message'
3737import { useSuggestionEngine } from './hooks/use-suggestion-engine'
38- import { useSuggestionMenuHandlers } from './hooks/use-suggestion-menu-handlers'
3938import { useTerminalDimensions } from './hooks/use-terminal-dimensions'
4039import { useTheme } from './hooks/use-theme'
4140import { useTimeout } from './hooks/use-timeout'
@@ -60,7 +59,7 @@ import type { SendMessageFn } from './types/contracts/send-message'
6059import type { User } from './utils/auth'
6160import type { AgentMode } from './utils/constants'
6261import type { FileTreeNode } from '@codebuff/common/util/file'
63- import type { KeyEvent , ScrollBoxRenderable } from '@opentui/core'
62+ import type { ScrollBoxRenderable } from '@opentui/core'
6463import type { UseMutationResult } from '@tanstack/react-query'
6564import 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