/** * 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 isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant useAppState(s => s.isBriefOnly) : false 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() && isBriefOnly && 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, isBriefOnly, 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() && !isBriefOnly) return const next = !isBriefOnly 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 } }) } }, [isBriefOnly, setAppState]) // Register keybinding handlers useKeybinding('app:toggleTodos', handleToggleTodos, { context: 'Global', }) useKeybinding('app:toggleTranscript', handleToggleTranscript, { context: 'Global', }) if (feature('KAIROS') || feature('KAIROS_BRIEF')) { // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant useKeybinding('app:toggleBrief', handleToggleBrief, { context: 'Global', }) } // 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 }