From 27a56f4f7aa8223edf14f3592484251a90c24b9f Mon Sep 17 00:00:00 2001 From: Jack Wotherspoon Date: Sat, 14 Mar 2026 21:45:03 +0100 Subject: [PATCH] chore: use reducer --- ...policiesCommand.ts => policiesCommand.tsx} | 13 ++- .../cli/src/ui/components/PoliciesDialog.tsx | 103 +++++++++++++----- 2 files changed, 81 insertions(+), 35 deletions(-) rename packages/cli/src/ui/commands/{policiesCommand.ts => policiesCommand.tsx} (92%) diff --git a/packages/cli/src/ui/commands/policiesCommand.ts b/packages/cli/src/ui/commands/policiesCommand.tsx similarity index 92% rename from packages/cli/src/ui/commands/policiesCommand.ts rename to packages/cli/src/ui/commands/policiesCommand.tsx index 55f1644730..5e60042490 100644 --- a/packages/cli/src/ui/commands/policiesCommand.ts +++ b/packages/cli/src/ui/commands/policiesCommand.tsx @@ -4,7 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; import type { Config, PolicyRule } from '@google/gemini-cli-core'; import { CommandKind, type SlashCommand } from './types.js'; import { MessageType } from '../types.js'; @@ -66,11 +65,13 @@ const policiesDialogAction: NonNullable = async ( return { type: 'custom_dialog' as const, - component: React.createElement(PoliciesDialog, { - rules, - toolDisplayNames, - onClose: () => context.ui.removeComponent(), - }), + component: ( + context.ui.removeComponent()} + /> + ), }; }; diff --git a/packages/cli/src/ui/components/PoliciesDialog.tsx b/packages/cli/src/ui/components/PoliciesDialog.tsx index 3fad7e7295..086e69957d 100644 --- a/packages/cli/src/ui/components/PoliciesDialog.tsx +++ b/packages/cli/src/ui/components/PoliciesDialog.tsx @@ -5,7 +5,7 @@ */ import type React from 'react'; -import { useState, useMemo, useEffect, useCallback } from 'react'; +import { useState, useMemo, useEffect, useCallback, useReducer } from 'react'; import { Box, Text } from 'ink'; import { AsyncFzf, type FzfResultItem } from 'fzf'; import { PolicyDecision, type PolicyRule } from '@google/gemini-cli-core'; @@ -25,6 +25,67 @@ import { isNarrowWidth } from '../utils/isNarrowWidth.js'; const ITEM_HEIGHT = 2; +interface NavigationState { + activeTabIndex: number; + activeIndex: number; + scrollOffset: number; +} + +type NavigationAction = + | { type: 'MOVE_UP'; maxItemsToShow: number } + | { type: 'MOVE_DOWN'; maxItemsToShow: number; totalItems: number } + | { type: 'CYCLE_TAB'; direction: number; numTabs: number } + | { type: 'RESET_SCROLL' }; + +function navigationReducer( + state: NavigationState, + action: NavigationAction, +): NavigationState { + switch (action.type) { + case 'MOVE_UP': { + if (state.activeIndex <= 0) return state; + const nextIndex = state.activeIndex - 1; + return { + ...state, + activeIndex: nextIndex, + scrollOffset: + nextIndex < state.scrollOffset ? nextIndex : state.scrollOffset, + }; + } + case 'MOVE_DOWN': { + if (state.activeIndex >= action.totalItems - 1) return state; + const nextIndex = state.activeIndex + 1; + return { + ...state, + activeIndex: nextIndex, + scrollOffset: + nextIndex >= state.scrollOffset + action.maxItemsToShow + ? nextIndex - action.maxItemsToShow + 1 + : state.scrollOffset, + }; + } + case 'CYCLE_TAB': { + return { + ...state, + activeTabIndex: + (state.activeTabIndex + action.direction + action.numTabs) % + action.numTabs, + activeIndex: 0, + scrollOffset: 0, + }; + } + case 'RESET_SCROLL': { + return { + ...state, + activeIndex: 0, + scrollOffset: 0, + }; + } + default: + return state; + } +} + interface PoliciesDialogProps { rules: readonly PolicyRule[]; toolDisplayNames: Map; @@ -58,9 +119,10 @@ export function PoliciesDialog({ const { terminalHeight, terminalWidth, staticExtraHeight, constrainHeight } = useUIState(); - const [activeTabIndex, setActiveTabIndex] = useState(0); - const [activeIndex, setActiveIndex] = useState(0); - const [scrollOffset, setScrollOffset] = useState(0); + const [{ activeTabIndex, activeIndex, scrollOffset }, dispatch] = useReducer( + navigationReducer, + { activeTabIndex: 0, activeIndex: 0, scrollOffset: 0 }, + ); const [searchQuery, setSearchQuery] = useState(''); const [filteredItems, setFilteredItems] = useState( null, @@ -187,16 +249,9 @@ export function PoliciesDialog({ // Items to display (filtered or all) const displayItems = filteredItems ?? allTabItems; - // Reset scroll when tab changes - useEffect(() => { - setActiveIndex(0); - setScrollOffset(0); - }, [activeTabIndex]); - // Reset scroll when filtered items change useEffect(() => { - setActiveIndex(0); - setScrollOffset(0); + dispatch({ type: 'RESET_SCROLL' }); }, [filteredItems]); // Search buffer @@ -232,31 +287,21 @@ export function PoliciesDialog({ (key.name === 'tab' && key.shift) ? -1 : 1; - setActiveTabIndex( - (prev) => (prev + direction + TABS.length) % TABS.length, - ); + dispatch({ type: 'CYCLE_TAB', direction, numTabs: TABS.length }); return; } // Up/Down navigation (no wrap-around) if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) { - if (activeIndex > 0) { - const newIndex = activeIndex - 1; - setActiveIndex(newIndex); - if (newIndex < scrollOffset) { - setScrollOffset(newIndex); - } - } + dispatch({ type: 'MOVE_UP', maxItemsToShow: effectiveMaxItemsToShow }); return; } if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) { - if (activeIndex < displayItems.length - 1) { - const newIndex = activeIndex + 1; - setActiveIndex(newIndex); - if (newIndex >= scrollOffset + effectiveMaxItemsToShow) { - setScrollOffset(newIndex - effectiveMaxItemsToShow + 1); - } - } + dispatch({ + type: 'MOVE_DOWN', + maxItemsToShow: effectiveMaxItemsToShow, + totalItems: displayItems.length, + }); return; }