/** * Input Layer System — stack-based keyboard input capture for dialogs and overlays. * * Only "capturing" components (dialogs, overlays, import flows) register layers. * When any layer exists on the stack, all non-capturing input handlers are blocked. * * Hooks: * - `useInputLayer(id)` — push a capturing layer (dialogs/overlays). * - `useLayeredInput(id, …)` — handle input for a specific capturing layer. * - `useBlockableInput(…)` — handle input for screens / global keys; auto-blocked * when any capturing layer is on the stack. * - `useIsInputCaptured()` — returns true when a capturing layer is present * (useful for disabling `focus` on child components). */ import React, { createContext, useContext, useCallback, useEffect, useMemo, useRef, useState, type ReactNode, } from 'react'; import { useInput } from 'ink'; // ── Context ────────────────────────────────────────────────────────────────── interface InputLayerContextType { /** Push a capturing layer. Returns a cleanup that pops it. */ push: (layerId: string) => () => void; /** True when `layerId` is the topmost entry in the stack. */ isTop: (layerId: string) => boolean; /** True when the stack has no entries (no dialog/overlay is capturing). */ isStackEmpty: () => boolean; /** Monotonic counter — bumped on every push/pop so consumers re-render. */ version: number; } const InputLayerContext = createContext(null); // ── Provider ───────────────────────────────────────────────────────────────── interface InputLayerProviderProps { children: ReactNode; } /** * Wraps the component tree and provides the input-layer stack. * * Place this inside your outermost providers but above any component * that calls the input-layer hooks. */ export function InputLayerProvider({ children }: InputLayerProviderProps): React.ReactElement { const stackRef = useRef([]); const [version, setVersion] = useState(0); const bump = useCallback(() => setVersion((v) => v + 1), []); const push = useCallback( (layerId: string): (() => void) => { stackRef.current = [...stackRef.current, layerId]; bump(); return () => { stackRef.current = stackRef.current.filter((id) => id !== layerId); bump(); }; }, [bump], ); const isTop = useCallback( (layerId: string): boolean => { const s = stackRef.current; return s.length > 0 && s[s.length - 1] === layerId; }, [], ); const isStackEmpty = useCallback( (): boolean => stackRef.current.length === 0, [], ); const value = useMemo( () => ({ push, isTop, isStackEmpty, version }), [push, isTop, isStackEmpty, version], ); return ( {children} ); } // ── Hooks ──────────────────────────────────────────────────────────────────── /** * Register a **capturing** layer (dialog / overlay / import flow). * * Pushes on mount, pops on unmount. While this layer is present every * `useBlockableInput` handler in the tree is automatically disabled. * * @returns `{ isActive }` — true only when this layer is the topmost. */ export function useInputLayer(layerId: string): { isActive: boolean } { const ctx = useContext(InputLayerContext); if (!ctx) { throw new Error('useInputLayer must be used within an InputLayerProvider'); } const { push } = ctx; useEffect(() => { const pop = push(layerId); return pop; }, [push, layerId]); return { isActive: ctx.isTop(layerId) }; } /** * Input handler for a **capturing** layer. * * Only fires when `layerId` is the topmost entry in the stack. */ export function useLayeredInput( layerId: string, handler: (input: string, key: any) => void, options?: { isActive?: boolean }, ): void { const ctx = useContext(InputLayerContext); if (!ctx) { throw new Error('useLayeredInput must be used within an InputLayerProvider'); } const isTopLayer = ctx.isTop(layerId); const externalActive = options?.isActive !== false; useInput(handler, { isActive: isTopLayer && externalActive }); } /** * Input handler for **non-capturing** components (screens, global keys). * * Fires only when the capture stack is empty (no dialog/overlay is open). * This is the hook screens should use instead of raw `useInput`. */ export function useBlockableInput( handler: (input: string, key: any) => void, options?: { isActive?: boolean }, ): void { const ctx = useContext(InputLayerContext); const nothingCapturing = ctx ? ctx.isStackEmpty() : true; const externalActive = options?.isActive !== false; useInput(handler, { isActive: nothingCapturing && externalActive }); } /** * Returns `true` when any capturing layer is on the stack. * * Use this to disable `focus` props on child components (e.g. ScrollableList) * so their internal `useInput` handlers don't fire while a dialog is open. */ export function useIsInputCaptured(): boolean { const ctx = useContext(InputLayerContext); return ctx ? !ctx.isStackEmpty() : false; }