/ hooks / useExitOnCtrlCD.ts
useExitOnCtrlCD.ts
 1  import { useCallback, useMemo, useState } from 'react'
 2  import useApp from '../ink/hooks/use-app.js'
 3  import type { KeybindingContextName } from '../keybindings/types.js'
 4  import { useDoublePress } from './useDoublePress.js'
 5  
 6  export type ExitState = {
 7    pending: boolean
 8    keyName: 'Ctrl-C' | 'Ctrl-D' | null
 9  }
10  
11  type KeybindingOptions = {
12    context?: KeybindingContextName
13    isActive?: boolean
14  }
15  
16  type UseKeybindingsHook = (
17    handlers: Record<string, () => void>,
18    options?: KeybindingOptions,
19  ) => void
20  
21  /**
22   * Handle ctrl+c and ctrl+d for exiting the application.
23   *
24   * Uses a time-based double-press mechanism:
25   * - First press: Shows "Press X again to exit" message
26   * - Second press within timeout: Exits the application
27   *
28   * Note: We use time-based double-press rather than the chord system because
29   * we want the first ctrl+c to also trigger interrupt (handled elsewhere).
30   * The chord system would prevent the first press from firing any action.
31   *
32   * These keys are hardcoded and cannot be rebound via keybindings.json.
33   *
34   * @param useKeybindingsHook - The useKeybindings hook to use for registering handlers
35   *                            (dependency injection to avoid import cycles)
36   * @param onInterrupt - Optional callback for features to handle interrupt (ctrl+c).
37   *                      Return true if handled, false to fall through to double-press exit.
38   * @param onExit - Optional custom exit handler
39   * @param isActive - Whether the keybinding is active (default true). Set false
40   *                   while an embedded TextInput is focused — TextInput's own
41   *                   ctrl+c/d handlers will manage cancel/exit, and Dialog's
42   *                   handler would otherwise double-fire (child useInput runs
43   *                   before parent useKeybindings, so both see every keypress).
44   */
45  export function useExitOnCtrlCD(
46    useKeybindingsHook: UseKeybindingsHook,
47    onInterrupt?: () => boolean,
48    onExit?: () => void,
49    isActive = true,
50  ): ExitState {
51    const { exit } = useApp()
52    const [exitState, setExitState] = useState<ExitState>({
53      pending: false,
54      keyName: null,
55    })
56  
57    const exitFn = useMemo(() => onExit ?? exit, [onExit, exit])
58  
59    // Double-press handler for ctrl+c
60    const handleCtrlCDoublePress = useDoublePress(
61      pending => setExitState({ pending, keyName: 'Ctrl-C' }),
62      exitFn,
63    )
64  
65    // Double-press handler for ctrl+d
66    const handleCtrlDDoublePress = useDoublePress(
67      pending => setExitState({ pending, keyName: 'Ctrl-D' }),
68      exitFn,
69    )
70  
71    // Handler for app:interrupt (ctrl+c by default)
72    // Let features handle interrupt first via callback
73    const handleInterrupt = useCallback(() => {
74      if (onInterrupt?.()) return // Feature handled it
75      handleCtrlCDoublePress()
76    }, [handleCtrlCDoublePress, onInterrupt])
77  
78    // Handler for app:exit (ctrl+d by default)
79    // This also uses double-press to confirm exit
80    const handleExit = useCallback(() => {
81      handleCtrlDDoublePress()
82    }, [handleCtrlDDoublePress])
83  
84    const handlers = useMemo(
85      () => ({
86        'app:interrupt': handleInterrupt,
87        'app:exit': handleExit,
88      }),
89      [handleInterrupt, handleExit],
90    )
91  
92    useKeybindingsHook(handlers, { context: 'Global', isActive })
93  
94    return exitState
95  }