/** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type React from 'react'; import { useState, useMemo } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { Command } from '../key/keyMatchers.js'; import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; /** * Hook entry type matching HookRegistryEntry from core */ export interface HookEntry { config: { command?: string; type: string; name?: string; description?: string; timeout?: number; }; source: string; eventName: string; matcher?: string; sequential?: boolean; enabled: boolean; } interface HooksDialogProps { hooks: readonly HookEntry[]; onClose: () => void; /** Maximum number of hooks to display at once before scrolling. Default: 8 */ maxVisibleHooks?: number; } /** Maximum hooks to show at once before scrolling is needed */ const DEFAULT_MAX_VISIBLE_HOOKS = 8; /** * Dialog component for displaying hooks in a styled box. * Replaces inline chat history display with a modal-style dialog. * Supports scrolling with up/down arrow keys when there are many hooks. */ export const HooksDialog: React.FC = ({ hooks, onClose, maxVisibleHooks = DEFAULT_MAX_VISIBLE_HOOKS, }) => { const keyMatchers = useKeyMatchers(); const [scrollOffset, setScrollOffset] = useState(0); // Flatten hooks with their event names for easier scrolling const flattenedHooks = useMemo(() => { const result: Array<{ type: 'header' | 'hook'; eventName: string; hook?: HookEntry; }> = []; // Group hooks by event name const hooksByEvent = hooks.reduce( (acc, hook) => { if (!acc[hook.eventName]) { acc[hook.eventName] = []; } acc[hook.eventName].push(hook); return acc; }, {} as Record, ); // Flatten into displayable items Object.entries(hooksByEvent).forEach(([eventName, eventHooks]) => { result.push({ type: 'header', eventName }); eventHooks.forEach((hook) => { result.push({ type: 'hook', eventName, hook }); }); }); return result; }, [hooks]); const totalItems = flattenedHooks.length; const needsScrolling = totalItems > maxVisibleHooks; const maxScrollOffset = Math.max(0, totalItems - maxVisibleHooks); // Handle keyboard navigation useKeypress( (key) => { if (keyMatchers[Command.ESCAPE](key)) { onClose(); return true; } // Scroll navigation if (needsScrolling) { if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) { setScrollOffset((prev) => Math.max(0, prev - 1)); return true; } if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) { setScrollOffset((prev) => Math.min(maxScrollOffset, prev + 1)); return true; } } return false; }, { isActive: true }, ); // Get visible items based on scroll offset const visibleItems = needsScrolling ? flattenedHooks.slice(scrollOffset, scrollOffset + maxVisibleHooks) : flattenedHooks; const showScrollUp = needsScrolling && scrollOffset > 0; const showScrollDown = needsScrolling && scrollOffset < maxScrollOffset; return ( {hooks.length === 0 ? ( <> No hooks configured. ) : ( <> {/* Security Warning */} Security Warning: Hooks can execute arbitrary commands on your system. Only use hooks from sources you trust. Review hook scripts carefully. {/* Learn more link */} Learn more:{' '} https://geminicli.com/docs/hooks {/* Configured Hooks heading */} Configured Hooks {/* Scroll up indicator */} {showScrollUp && ( )} {/* Visible hooks */} {visibleItems.map((item, index) => { if (item.type === 'header') { return ( {item.eventName} ); } const hook = item.hook!; const hookName = hook.config.name || hook.config.command || 'unknown'; const hookKey = `${item.eventName}:${hook.source}:${hook.config.name ?? ''}:${hook.config.command ?? ''}`; const statusColor = hook.enabled ? theme.status.success : theme.text.secondary; const statusText = hook.enabled ? 'enabled' : 'disabled'; return ( {hookName} {` [${statusText}]`} {hook.config.description && ( {hook.config.description} )} Source: {hook.source} {hook.config.name && hook.config.command && ` | Command: ${hook.config.command}`} {hook.matcher && ` | Matcher: ${hook.matcher}`} {hook.sequential && ` | Sequential`} {hook.config.timeout && ` | Timeout: ${hook.config.timeout}s`} ); })} {/* Scroll down indicator */} {showScrollDown && ( )} {/* Tips */} Tip: Use /hooks enable {''} or{' '} /hooks disable {''} to toggle individual hooks. Use /hooks enable-all or{' '} /hooks disable-all to toggle all hooks at once. )} ); };