useGlobalInput.ts 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. import { useEffect, useRef } from "react"
  2. import { useInput } from "ink"
  3. import type { WebviewMessage } from "@roo-code/types"
  4. import { matchesGlobalSequence } from "@/lib/utils/input.js"
  5. import type { ModeResult } from "../components/autocomplete/index.js"
  6. import { useUIStateStore } from "../stores/uiStateStore.js"
  7. import { useCLIStore } from "../store.js"
  8. export interface UseGlobalInputOptions {
  9. canToggleFocus: boolean
  10. isScrollAreaActive: boolean
  11. pickerIsOpen: boolean
  12. availableModes: ModeResult[]
  13. currentMode: string | null
  14. mode: string
  15. sendToExtension: ((msg: WebviewMessage) => void) | null
  16. showInfo: (msg: string, duration?: number) => void
  17. exit: () => void
  18. cleanup: () => Promise<void>
  19. toggleFocus: () => void
  20. closePicker: () => void
  21. }
  22. /**
  23. * Hook to handle global keyboard shortcuts.
  24. *
  25. * Shortcuts:
  26. * - Ctrl+C: Double-press to exit
  27. * - Tab: Toggle focus between scroll area and input
  28. * - Ctrl+M: Cycle through available modes
  29. * - Ctrl+T: Toggle TODO list viewer
  30. * - Escape: Cancel task (when loading) or close TODO viewer
  31. */
  32. export function useGlobalInput({
  33. canToggleFocus,
  34. isScrollAreaActive: _isScrollAreaActive,
  35. pickerIsOpen,
  36. availableModes,
  37. currentMode,
  38. mode,
  39. sendToExtension,
  40. showInfo,
  41. exit,
  42. cleanup,
  43. toggleFocus,
  44. closePicker,
  45. }: UseGlobalInputOptions): void {
  46. const { isLoading, currentTodos } = useCLIStore()
  47. const {
  48. showTodoViewer,
  49. setShowTodoViewer,
  50. showExitHint: _showExitHint,
  51. setShowExitHint,
  52. pendingExit,
  53. setPendingExit,
  54. } = useUIStateStore()
  55. // Track Ctrl+C presses for "press again to exit" behavior
  56. const exitHintTimeout = useRef<NodeJS.Timeout | null>(null)
  57. // Cleanup timeout on unmount
  58. useEffect(() => {
  59. return () => {
  60. if (exitHintTimeout.current) {
  61. clearTimeout(exitHintTimeout.current)
  62. }
  63. }
  64. }, [])
  65. // Handle global keyboard shortcuts
  66. useInput((input, key) => {
  67. // Tab to toggle focus between scroll area and input (only when input is available)
  68. if (key.tab && canToggleFocus && !pickerIsOpen) {
  69. toggleFocus()
  70. return
  71. }
  72. // Ctrl+M to cycle through modes (only when not loading and we have available modes)
  73. // Uses centralized global input sequence detection
  74. if (matchesGlobalSequence(input, key, "ctrl-m")) {
  75. // Don't allow mode switching while a task is in progress (loading)
  76. if (isLoading) {
  77. showInfo("Cannot switch modes while task is in progress", 2000)
  78. return
  79. }
  80. // Need at least 2 modes to cycle
  81. if (availableModes.length < 2) {
  82. return
  83. }
  84. // Find current mode index
  85. const currentModeSlug = currentMode || mode
  86. const currentIndex = availableModes.findIndex((m) => m.slug === currentModeSlug)
  87. const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % availableModes.length
  88. const nextMode = availableModes[nextIndex]
  89. if (nextMode && sendToExtension) {
  90. sendToExtension({ type: "mode", text: nextMode.slug })
  91. showInfo(`Switched to ${nextMode.name}`, 2000)
  92. }
  93. return
  94. }
  95. // Ctrl+T to toggle TODO list viewer
  96. if (matchesGlobalSequence(input, key, "ctrl-t")) {
  97. // Close picker if open
  98. if (pickerIsOpen) {
  99. closePicker()
  100. }
  101. // Toggle TODO viewer
  102. setShowTodoViewer(!showTodoViewer)
  103. if (!showTodoViewer && currentTodos.length === 0) {
  104. showInfo("No TODO list available", 2000)
  105. setShowTodoViewer(false)
  106. }
  107. return
  108. }
  109. // Escape key to close TODO viewer
  110. if (key.escape && showTodoViewer) {
  111. setShowTodoViewer(false)
  112. return
  113. }
  114. // Escape key to cancel/pause task when loading (streaming)
  115. if (key.escape && isLoading && sendToExtension) {
  116. // If picker is open, let the picker handle escape first
  117. if (pickerIsOpen) {
  118. return
  119. }
  120. // Send cancel message to extension (same as webview-ui Cancel button)
  121. sendToExtension({ type: "cancelTask" })
  122. return
  123. }
  124. // Ctrl+C to exit
  125. if (key.ctrl && input === "c") {
  126. // If picker is open, close it first
  127. if (pickerIsOpen) {
  128. closePicker()
  129. return
  130. }
  131. if (pendingExit) {
  132. // Second press - exit immediately
  133. if (exitHintTimeout.current) {
  134. clearTimeout(exitHintTimeout.current)
  135. }
  136. cleanup().finally(() => {
  137. exit()
  138. process.exit(0)
  139. })
  140. } else {
  141. // First press - show hint and wait for second press
  142. setPendingExit(true)
  143. setShowExitHint(true)
  144. exitHintTimeout.current = setTimeout(() => {
  145. setPendingExit(false)
  146. setShowExitHint(false)
  147. exitHintTimeout.current = null
  148. }, 2000)
  149. }
  150. }
  151. })
  152. }