/** * Component that registers global keybinding handlers. * * Must be rendered inside KeybindingSetup to have access to the keybinding context. * This component renders nothing - it just registers the keybinding handlers. */ import { feature } from 'bun:bundle'; import { useCallback } from 'react'; import { instances } from '@anthropic/ink'; import { useKeybinding } from '../keybindings/useKeybinding.js'; import type { Screen } from '../screens/REPL.js'; import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, } from '../services/analytics/index.js'; import { useAppState, useSetAppState } from '../state/AppState.js'; import { count } from '../utils/array.js'; import { getTerminalPanel } from '../utils/terminalPanel.js'; type Props = { screen: Screen; setScreen: React.Dispatch>; showAllInTranscript: boolean; setShowAllInTranscript: React.Dispatch>; messageCount: number; onEnterTranscript?: () => void; onExitTranscript?: () => void; virtualScrollActive?: boolean; searchBarOpen?: boolean; }; /** * Registers global keybinding handlers for: * - ctrl+t: Toggle todo list * - ctrl+o: Toggle transcript mode * - ctrl+e: Toggle showing all messages in transcript * - ctrl+c/escape: Exit transcript mode */ export function GlobalKeybindingHandlers({ screen, setScreen, showAllInTranscript, setShowAllInTranscript, messageCount, onEnterTranscript, onExitTranscript, virtualScrollActive, searchBarOpen = false, }: Props): null { const expandedView = useAppState(s => s.expandedView); const setAppState = useSetAppState(); // Toggle todo list (ctrl+t) - cycles through views const handleToggleTodos = useCallback(() => { logEvent('tengu_toggle_todos', { is_expanded: expandedView === 'tasks', }); setAppState(prev => { const { getAllInProcessTeammateTasks } = // eslint-disable-next-line @typescript-eslint/no-require-imports require('../tasks/InProcessTeammateTask/InProcessTeammateTask.js') as typeof import('../tasks/InProcessTeammateTask/InProcessTeammateTask.js'); const hasTeammates = count(getAllInProcessTeammateTasks(prev.tasks), t => t.status === 'running') > 0; if (hasTeammates) { // Both exist: none → tasks → teammates → none switch (prev.expandedView) { case 'none': return { ...prev, expandedView: 'tasks' as const }; case 'tasks': return { ...prev, expandedView: 'teammates' as const }; case 'teammates': return { ...prev, expandedView: 'none' as const }; } } // Only tasks: none ↔ tasks return { ...prev, expandedView: prev.expandedView === 'tasks' ? ('none' as const) : ('tasks' as const), }; }); }, [expandedView, setAppState]); // Toggle transcript mode (ctrl+o). Two-way prompt ↔ transcript. // Brief view has its own dedicated toggle on ctrl+shift+b. const isBriefOnlyState = useAppState(s => s.isBriefOnly); const handleToggleTranscript = useCallback(() => { if (feature('KAIROS') || feature('KAIROS_BRIEF')) { // Escape hatch: GB kill-switch while defaultView=chat was persisted // can leave isBriefOnly stuck on, showing a blank filterForBriefTool // view. Users will reach for ctrl+o — clear the stuck state first. // Only needed in the prompt screen — transcript mode already ignores // isBriefOnly (Messages.tsx filter is gated on !isTranscriptMode). /* eslint-disable @typescript-eslint/no-require-imports */ const { isBriefEnabled } = require('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js'); /* eslint-enable @typescript-eslint/no-require-imports */ if (!isBriefEnabled() && isBriefOnlyState && screen !== 'transcript') { setAppState(prev => { if (!prev.isBriefOnly) return prev; return { ...prev, isBriefOnly: false }; }); return; } } const isEnteringTranscript = screen !== 'transcript'; logEvent('tengu_toggle_transcript', { is_entering: isEnteringTranscript, show_all: showAllInTranscript, message_count: messageCount, }); setScreen(s => (s === 'transcript' ? 'prompt' : 'transcript')); setShowAllInTranscript(false); if (isEnteringTranscript && onEnterTranscript) { onEnterTranscript(); } if (!isEnteringTranscript && onExitTranscript) { onExitTranscript(); } }, [ screen, setScreen, isBriefOnlyState, showAllInTranscript, setShowAllInTranscript, messageCount, setAppState, onEnterTranscript, onExitTranscript, ]); // Toggle showing all messages in transcript mode (ctrl+e) const handleToggleShowAll = useCallback(() => { logEvent('tengu_transcript_toggle_show_all', { is_expanding: !showAllInTranscript, message_count: messageCount, }); setShowAllInTranscript(prev => !prev); }, [showAllInTranscript, setShowAllInTranscript, messageCount]); // Exit transcript mode (ctrl+c or escape) const handleExitTranscript = useCallback(() => { logEvent('tengu_transcript_exit', { show_all: showAllInTranscript, message_count: messageCount, }); setScreen('prompt'); setShowAllInTranscript(false); if (onExitTranscript) { onExitTranscript(); } }, [setScreen, showAllInTranscript, setShowAllInTranscript, messageCount, onExitTranscript]); // Toggle brief-only view (ctrl+shift+b). Pure display filter toggle — // does not touch opt-in state. Asymmetric gate (mirrors /brief): OFF // transition always allowed so the same key that got you in gets you // out even if the GB kill-switch fires mid-session. const handleToggleBrief = useCallback(() => { if (feature('KAIROS') || feature('KAIROS_BRIEF')) { /* eslint-disable @typescript-eslint/no-require-imports */ const { isBriefEnabled } = require('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js'); /* eslint-enable @typescript-eslint/no-require-imports */ if (!isBriefEnabled() && !isBriefOnlyState) return; const next = !isBriefOnlyState; logEvent('tengu_brief_mode_toggled', { enabled: next, gated: false, source: 'keybinding' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }); setAppState(prev => { if (prev.isBriefOnly === next) return prev; return { ...prev, isBriefOnly: next }; }); } }, [isBriefOnlyState, setAppState]); // Register keybinding handlers useKeybinding('app:toggleTodos', handleToggleTodos, { context: 'Global', }); useKeybinding('app:toggleTranscript', handleToggleTranscript, { context: 'Global', }); useKeybinding('app:toggleBrief', handleToggleBrief, { context: 'Global', isActive: feature('KAIROS') ? true : feature('KAIROS_BRIEF') ? true : false, }); // Register teammate keybinding useKeybinding( 'app:toggleTeammatePreview', () => { setAppState(prev => ({ ...prev, showTeammateMessagePreview: !prev.showTeammateMessagePreview, })); }, { context: 'Global', }, ); // Toggle built-in terminal panel (meta+j). // toggle() blocks in spawnSync until the user detaches from tmux. const handleToggleTerminal = useCallback(() => { if (feature('TERMINAL_PANEL')) { if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_terminal_panel', false)) { return; } getTerminalPanel().toggle(); } }, []); useKeybinding('app:toggleTerminal', handleToggleTerminal, { context: 'Global', }); // Clear screen and force full redraw (ctrl+l). Recovery path when the // terminal was cleared externally (macOS Cmd+K) and Ink's diff engine // thinks unchanged cells don't need repainting. const handleRedraw = useCallback(() => { instances.get(process.stdout)?.forceRedraw(); }, []); useKeybinding('app:redraw', handleRedraw, { context: 'Global' }); // Transcript-specific bindings (only active when in transcript mode) const isInTranscript = screen === 'transcript'; useKeybinding('transcript:toggleShowAll', handleToggleShowAll, { context: 'Transcript', isActive: isInTranscript && !virtualScrollActive, }); useKeybinding('transcript:exit', handleExitTranscript, { context: 'Transcript', // Bar-open is a mode (owns keystrokes). Navigating (highlights // visible, n/N active, bar closed) is NOT — Esc exits transcript // directly, same as less q. useSearchInput doesn't stopPropagation, // so without this gate its onCancel AND this handler would both // fire on one Esc (child registers first, fires first, bubbles). isActive: isInTranscript && !searchBarOpen, }); return null; }