diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx
index 667168dbc5..21aa6ee5c0 100644
--- a/packages/cli/src/ui/components/Footer.test.tsx
+++ b/packages/cli/src/ui/components/Footer.test.tsx
@@ -132,9 +132,7 @@ describe('', () => {
const output = lastFrame();
expect(output).toBeDefined();
// Should contain some part of the path, likely shortened
- expect(output).toContain(
- path.join('directories', 'to', 'make', 'it', 'long'),
- );
+ expect(output).toContain(path.join('make', 'it'));
unmount();
});
@@ -149,9 +147,38 @@ describe('', () => {
await waitUntilReady();
const output = lastFrame();
expect(output).toBeDefined();
- expect(output).toContain(
- path.join('directories', 'to', 'make', 'it', 'long'),
+ expect(output).toContain(path.join('make', 'it'));
+ unmount();
+ });
+
+ it('should not truncate high-priority items on narrow terminals (regression)', async () => {
+ const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ ,
+ {
+ width: 60,
+ uiState: {
+ sessionStats: mockSessionStats,
+ },
+ settings: createMockSettings({
+ general: {
+ vimMode: true,
+ },
+ ui: {
+ footer: {
+ showLabels: true,
+ items: ['workspace', 'model-name'],
+ },
+ },
+ }),
+ },
);
+ await waitUntilReady();
+ const output = lastFrame();
+ // [INSERT] is high priority and should be fully visible
+ // (Note: VimModeProvider defaults to 'INSERT' mode when enabled)
+ expect(output).toContain('[INSERT]');
+ // Other items should be present but might be shortened
+ expect(output).toContain('gemini-pro');
unmount();
});
});
diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx
index 83a7c059b9..e5a1f9e8b6 100644
--- a/packages/cli/src/ui/components/Footer.tsx
+++ b/packages/cli/src/ui/components/Footer.tsx
@@ -103,6 +103,9 @@ export interface FooterRowItem {
key: string;
header: string;
element: React.ReactNode;
+ flexGrow?: number;
+ flexShrink?: number;
+ isFocused?: boolean;
}
const COLUMN_GAP = 3;
@@ -123,10 +126,20 @@ export const FooterRow: React.FC<{
}
elements.push(
-
+
{showLabels && (
- {item.header}
+
+ {item.header}
+
)}
{item.element}
@@ -138,6 +151,7 @@ export const FooterRow: React.FC<{
{elements}
@@ -408,41 +422,50 @@ export const Footer: React.FC = () => {
}
// --- Width Fitting Logic ---
- let currentWidth = 2; // Initial padding
const columnsToRender: FooterColumn[] = [];
let droppedAny = false;
+ let currentUsedWidth = 2; // Initial padding
- for (let i = 0; i < potentialColumns.length; i++) {
- const col = potentialColumns[i];
- const gap = columnsToRender.length > 0 ? (showLabels ? COLUMN_GAP : 3) : 0; // Use 3 for dot separator width
+ for (const col of potentialColumns) {
+ const gap = columnsToRender.length > 0 ? (showLabels ? COLUMN_GAP : 3) : 0;
const budgetWidth = col.id === 'workspace' ? 20 : col.width;
if (
col.isHighPriority ||
- currentWidth + gap + budgetWidth <= terminalWidth - 2
+ currentUsedWidth + gap + budgetWidth <= terminalWidth - 2
) {
columnsToRender.push(col);
- currentWidth += gap + budgetWidth;
+ currentUsedWidth += gap + budgetWidth;
} else {
droppedAny = true;
}
}
- const totalBudgeted = columnsToRender.reduce(
- (sum, c, idx) =>
- sum +
- (c.id === 'workspace' ? 20 : c.width) +
- (idx > 0 ? (showLabels ? COLUMN_GAP : 3) : 0),
- 2,
- );
- const excessSpace = Math.max(0, terminalWidth - totalBudgeted);
-
const rowItems: FooterRowItem[] = columnsToRender.map((col) => {
- const maxWidth = col.id === 'workspace' ? 20 + excessSpace : col.width;
+ const isWorkspace = col.id === 'workspace';
+
+ // Calculate exact space available for growth to prevent over-estimation truncation
+ const otherItemsWidth = columnsToRender
+ .filter((c) => c.id !== 'workspace')
+ .reduce((sum, c) => sum + c.width, 0);
+ const numItems = columnsToRender.length + (droppedAny ? 1 : 0);
+ const numGaps = numItems > 1 ? numItems - 1 : 0;
+ const gapsWidth = numGaps * (showLabels ? COLUMN_GAP : 3);
+ const ellipsisWidth = droppedAny ? 1 : 0;
+
+ const availableForWorkspace = Math.max(
+ 20,
+ terminalWidth - 2 - gapsWidth - otherItemsWidth - ellipsisWidth,
+ );
+
+ const estimatedWidth = isWorkspace ? availableForWorkspace : col.width;
+
return {
key: col.id,
header: col.header,
- element: col.element(maxWidth),
+ element: col.element(estimatedWidth),
+ flexGrow: isWorkspace ? 1 : 0,
+ flexShrink: isWorkspace ? 1 : 0,
};
});
@@ -451,6 +474,8 @@ export const Footer: React.FC = () => {
key: 'ellipsis',
header: '',
element: …,
+ flexGrow: 0,
+ flexShrink: 0,
});
}
diff --git a/packages/cli/src/ui/components/FooterConfigDialog.test.tsx b/packages/cli/src/ui/components/FooterConfigDialog.test.tsx
index c8b0b93659..3141c3a1d7 100644
--- a/packages/cli/src/ui/components/FooterConfigDialog.test.tsx
+++ b/packages/cli/src/ui/components/FooterConfigDialog.test.tsx
@@ -24,13 +24,14 @@ describe('', () => {
it('renders correctly with default settings', async () => {
const settings = createMockSettings();
- const { lastFrame, waitUntilReady } = renderWithProviders(
+ const renderResult = renderWithProviders(
,
{ settings },
);
- await waitUntilReady();
- expect(lastFrame()).toMatchSnapshot();
+ await renderResult.waitUntilReady();
+ expect(renderResult.lastFrame()).toMatchSnapshot();
+ await expect(renderResult).toMatchSvgSnapshot();
});
it('toggles an item when enter is pressed', async () => {
@@ -66,7 +67,7 @@ describe('', () => {
);
await waitUntilReady();
- // Initial order: workspace, branch, ...
+ // Initial order: workspace, git-branch, ...
const output = lastFrame();
const cwdIdx = output.indexOf('] workspace');
const branchIdx = output.indexOf('] git-branch');
@@ -108,22 +109,40 @@ describe('', () => {
it('highlights the active item in the preview', async () => {
const settings = createMockSettings();
- const { lastFrame, stdin, waitUntilReady } = renderWithProviders(
+ const renderResult = renderWithProviders(
,
{ settings },
);
+ const { lastFrame, stdin, waitUntilReady } = renderResult;
+
await waitUntilReady();
expect(lastFrame()).toContain('~/project/path');
- // Move focus down to 'git-branch'
+ // Move focus down to 'code-changes' (which has colored elements)
+ for (let i = 0; i < 8; i++) {
+ act(() => {
+ stdin.write('\u001b[B'); // Down arrow
+ });
+ }
+
+ await waitFor(() => {
+ // The selected indicator should be next to 'code-changes'
+ expect(lastFrame()).toMatch(/> \[ \] code-changes/);
+ });
+
+ // Toggle it on
act(() => {
- stdin.write('\u001b[B'); // Down arrow
+ stdin.write('\r');
});
await waitFor(() => {
- expect(lastFrame()).toContain('main');
+ // It should now be checked and appear in the preview
+ expect(lastFrame()).toMatch(/> \[✓\] code-changes/);
+ expect(lastFrame()).toContain('+12 -4');
});
+
+ await expect(renderResult).toMatchSvgSnapshot();
});
it('shows an empty preview when all items are deselected', async () => {
@@ -134,20 +153,64 @@ describe('', () => {
);
await waitUntilReady();
- for (let i = 0; i < 10; i++) {
+
+ // Default items are the first 5. We toggle them off.
+ for (let i = 0; i < 5; i++) {
+ act(() => {
+ stdin.write('\r'); // Toggle off
+ });
act(() => {
- stdin.write('\r'); // Toggle (deselect)
stdin.write('\u001b[B'); // Down arrow
});
}
+ await waitFor(
+ () => {
+ const output = lastFrame();
+ expect(output).toContain('Preview:');
+ expect(output).not.toContain('~/project/path');
+ expect(output).not.toContain('docker');
+ },
+ { timeout: 2000 },
+ );
+ });
+
+ it('moves item correctly after trying to move up at the top', async () => {
+ const settings = createMockSettings();
+ const { lastFrame, stdin, waitUntilReady } = renderWithProviders(
+ ,
+ { settings },
+ );
+ await waitUntilReady();
+
+ // Default initial items in mock settings are 'git-branch', 'workspace', ...
await waitFor(() => {
const output = lastFrame();
- expect(output).toContain('Preview:');
- expect(output).not.toContain('~/project/path');
- expect(output).not.toContain('docker');
- expect(output).not.toContain('gemini-2.5-pro');
- expect(output).not.toContain('1.2k left');
+ expect(output).toContain('] git-branch');
+ expect(output).toContain('] workspace');
+ });
+
+ const output = lastFrame();
+ const branchIdx = output.indexOf('] git-branch');
+ const workspaceIdx = output.indexOf('] workspace');
+ expect(workspaceIdx).toBeLessThan(branchIdx);
+
+ // Try to move workspace up (left arrow) while it's at the top
+ act(() => {
+ stdin.write('\u001b[D'); // Left arrow
+ });
+
+ // Move workspace down (right arrow)
+ act(() => {
+ stdin.write('\u001b[C'); // Right arrow
+ });
+
+ await waitFor(() => {
+ const outputAfter = lastFrame();
+ const bIdxAfter = outputAfter.indexOf('] git-branch');
+ const wIdxAfter = outputAfter.indexOf('] workspace');
+ // workspace should now be after git-branch
+ expect(bIdxAfter).toBeLessThan(wIdxAfter);
});
});
});
diff --git a/packages/cli/src/ui/components/FooterConfigDialog.tsx b/packages/cli/src/ui/components/FooterConfigDialog.tsx
index 8c43c0ee1e..c31dc73e45 100644
--- a/packages/cli/src/ui/components/FooterConfigDialog.tsx
+++ b/packages/cli/src/ui/components/FooterConfigDialog.tsx
@@ -5,119 +5,75 @@
*/
import type React from 'react';
-import { useCallback, useMemo, useReducer } from 'react';
+import { useCallback, useMemo, useReducer, useState } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useSettingsStore } from '../contexts/SettingsContext.js';
+import { useUIState } from '../contexts/UIStateContext.js';
import { useKeypress, type Key } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { FooterRow, type FooterRowItem } from './Footer.js';
import { ALL_ITEMS, resolveFooterState } from '../../config/footerItems.js';
import { SettingScope } from '../../config/settings.js';
+import { BaseSelectionList } from './shared/BaseSelectionList.js';
+import type { SelectionListItem } from '../hooks/useSelectionList.js';
+import { DialogFooter } from './shared/DialogFooter.js';
interface FooterConfigDialogProps {
onClose?: () => void;
}
+interface FooterConfigItem {
+ key: string;
+ id: string;
+ label: string;
+ description?: string;
+ type: 'config' | 'labels-toggle' | 'reset';
+}
+
interface FooterConfigState {
orderedIds: string[];
selectedIds: Set;
- activeIndex: number;
- scrollOffset: number;
}
type FooterConfigAction =
- | { type: 'MOVE_UP'; itemCount: number; maxToShow: number }
- | { type: 'MOVE_DOWN'; itemCount: number; maxToShow: number }
- | {
- type: 'MOVE_LEFT';
- items: Array<{ key: string }>;
- }
- | {
- type: 'MOVE_RIGHT';
- items: Array<{ key: string }>;
- }
- | { type: 'TOGGLE_ITEM'; items: Array<{ key: string }> }
- | { type: 'SET_STATE'; payload: Partial }
- | { type: 'RESET_INDEX' };
+ | { type: 'MOVE_ITEM'; id: string; direction: number }
+ | { type: 'TOGGLE_ITEM'; id: string }
+ | { type: 'SET_STATE'; payload: Partial };
function footerConfigReducer(
state: FooterConfigState,
action: FooterConfigAction,
): FooterConfigState {
switch (action.type) {
- case 'MOVE_UP': {
- const { itemCount, maxToShow } = action;
- const totalSlots = itemCount + 2; // +1 for showLabels, +1 for reset
- const newIndex =
- state.activeIndex > 0 ? state.activeIndex - 1 : totalSlots - 1;
- let newOffset = state.scrollOffset;
-
- if (newIndex < itemCount) {
- if (newIndex === itemCount - 1) {
- newOffset = Math.max(0, itemCount - maxToShow);
- } else if (newIndex < state.scrollOffset) {
- newOffset = newIndex;
- }
- }
- return { ...state, activeIndex: newIndex, scrollOffset: newOffset };
- }
- case 'MOVE_DOWN': {
- const { itemCount, maxToShow } = action;
- const totalSlots = itemCount + 2;
- const newIndex =
- state.activeIndex < totalSlots - 1 ? state.activeIndex + 1 : 0;
- let newOffset = state.scrollOffset;
-
- if (newIndex === 0) {
- newOffset = 0;
- } else if (
- newIndex < itemCount &&
- newIndex >= state.scrollOffset + maxToShow
+ case 'MOVE_ITEM': {
+ const currentIndex = state.orderedIds.indexOf(action.id);
+ const newIndex = currentIndex + action.direction;
+ if (
+ currentIndex === -1 ||
+ newIndex < 0 ||
+ newIndex >= state.orderedIds.length
) {
- newOffset = newIndex - maxToShow + 1;
+ return state;
}
- return { ...state, activeIndex: newIndex, scrollOffset: newOffset };
- }
- case 'MOVE_LEFT':
- case 'MOVE_RIGHT': {
- const direction = action.type === 'MOVE_LEFT' ? -1 : 1;
- const currentItem = action.items[state.activeIndex];
- if (!currentItem) return state;
-
- const currentId = currentItem.key;
- const currentIndex = state.orderedIds.indexOf(currentId);
- const newIndex = currentIndex + direction;
-
- if (newIndex < 0 || newIndex >= state.orderedIds.length) return state;
-
const newOrderedIds = [...state.orderedIds];
[newOrderedIds[currentIndex], newOrderedIds[newIndex]] = [
newOrderedIds[newIndex],
newOrderedIds[currentIndex],
];
-
- return { ...state, orderedIds: newOrderedIds, activeIndex: newIndex };
+ return { ...state, orderedIds: newOrderedIds };
}
case 'TOGGLE_ITEM': {
- const isSystemFocused = state.activeIndex >= action.items.length;
- if (isSystemFocused) return state;
-
- const item = action.items[state.activeIndex];
- if (!item) return state;
-
const nextSelected = new Set(state.selectedIds);
- if (nextSelected.has(item.key)) {
- nextSelected.delete(item.key);
+ if (nextSelected.has(action.id)) {
+ nextSelected.delete(action.id);
} else {
- nextSelected.add(item.key);
+ nextSelected.add(action.id);
}
return { ...state, selectedIds: nextSelected };
}
case 'SET_STATE':
return { ...state, ...action.payload };
- case 'RESET_INDEX':
- return { ...state, activeIndex: 0, scrollOffset: 0 };
default:
return state;
}
@@ -127,40 +83,54 @@ export const FooterConfigDialog: React.FC = ({
onClose,
}) => {
const { settings, setSetting } = useSettingsStore();
- const maxItemsToShow = 10;
+ const { constrainHeight, terminalHeight, staticExtraHeight } = useUIState();
+ const [state, dispatch] = useReducer(footerConfigReducer, undefined, () =>
+ resolveFooterState(settings.merged),
+ );
- const [state, dispatch] = useReducer(footerConfigReducer, undefined, () => ({
- ...resolveFooterState(settings.merged),
- activeIndex: 0,
- scrollOffset: 0,
- }));
+ const { orderedIds, selectedIds } = state;
+ const [focusKey, setFocusKey] = useState(orderedIds[0]);
- const { orderedIds, selectedIds, activeIndex, scrollOffset } = state;
-
- // Prepare items
- const listItems = useMemo(
- () =>
- orderedIds
- .map((id: string) => {
- const item = ALL_ITEMS.find((i) => i.id === id);
- if (!item) return null;
- return {
+ const listItems = useMemo((): Array> => {
+ const items: Array> = orderedIds
+ .map((id: string) => {
+ const item = ALL_ITEMS.find((i) => i.id === id);
+ if (!item) return null;
+ return {
+ key: id,
+ value: {
key: id,
+ id,
label: item.id,
description: item.description as string,
- };
- })
- .filter((i): i is NonNullable => i !== null),
- [orderedIds],
- );
+ type: 'config' as const,
+ },
+ };
+ })
+ .filter((i): i is NonNullable => i !== null);
- const maxLabelWidth = useMemo(
- () => listItems.reduce((max, item) => Math.max(max, item.label.length), 0),
- [listItems],
- );
+ items.push({
+ key: 'show-labels',
+ value: {
+ key: 'show-labels',
+ id: 'show-labels',
+ label: 'Show footer labels',
+ type: 'labels-toggle',
+ },
+ });
- const isResetFocused = activeIndex === listItems.length + 1;
- const isShowLabelsFocused = activeIndex === listItems.length;
+ items.push({
+ key: 'reset',
+ value: {
+ key: 'reset',
+ id: 'reset',
+ label: 'Reset to default footer',
+ type: 'reset',
+ },
+ });
+
+ return items;
+ }, [orderedIds]);
const handleSaveAndClose = useCallback(() => {
const finalItems = orderedIds.filter((id: string) => selectedIds.has(id));
@@ -179,14 +149,9 @@ export const FooterConfigDialog: React.FC = ({
const handleResetToDefaults = useCallback(() => {
setSetting(SettingScope.User, 'ui.footer.items', undefined);
- dispatch({
- type: 'SET_STATE',
- payload: {
- ...resolveFooterState(settings.merged),
- activeIndex: 0,
- scrollOffset: 0,
- },
- });
+ const newState = resolveFooterState(settings.merged);
+ dispatch({ type: 'SET_STATE', payload: newState });
+ setFocusKey(newState.orderedIds[0]);
}, [setSetting, settings.merged]);
const handleToggleLabels = useCallback(() => {
@@ -194,6 +159,23 @@ export const FooterConfigDialog: React.FC = ({
setSetting(SettingScope.User, 'ui.footer.showLabels', !current);
}, [setSetting, settings.merged.ui.footer.showLabels]);
+ const handleSelect = useCallback(
+ (item: FooterConfigItem) => {
+ if (item.type === 'config') {
+ dispatch({ type: 'TOGGLE_ITEM', id: item.id });
+ } else if (item.type === 'labels-toggle') {
+ handleToggleLabels();
+ } else if (item.type === 'reset') {
+ handleResetToDefaults();
+ }
+ },
+ [handleResetToDefaults, handleToggleLabels],
+ );
+
+ const handleHighlight = useCallback((item: FooterConfigItem) => {
+ setFocusKey(item.key);
+ }, []);
+
useKeypress(
(key: Key) => {
if (keyMatchers[Command.ESCAPE](key)) {
@@ -201,43 +183,18 @@ export const FooterConfigDialog: React.FC = ({
return true;
}
- if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
- dispatch({
- type: 'MOVE_UP',
- itemCount: listItems.length,
- maxToShow: maxItemsToShow,
- });
- return true;
- }
-
- if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
- dispatch({
- type: 'MOVE_DOWN',
- itemCount: listItems.length,
- maxToShow: maxItemsToShow,
- });
- return true;
- }
-
if (keyMatchers[Command.MOVE_LEFT](key)) {
- dispatch({ type: 'MOVE_LEFT', items: listItems });
- return true;
+ if (focusKey && orderedIds.includes(focusKey)) {
+ dispatch({ type: 'MOVE_ITEM', id: focusKey, direction: -1 });
+ return true;
+ }
}
if (keyMatchers[Command.MOVE_RIGHT](key)) {
- dispatch({ type: 'MOVE_RIGHT', items: listItems });
- return true;
- }
-
- if (keyMatchers[Command.RETURN](key) || key.name === 'space') {
- if (isResetFocused) {
- handleResetToDefaults();
- } else if (isShowLabelsFocused) {
- handleToggleLabels();
- } else {
- dispatch({ type: 'TOGGLE_ITEM', items: listItems });
+ if (focusKey && orderedIds.includes(focusKey)) {
+ dispatch({ type: 'MOVE_ITEM', id: focusKey, direction: 1 });
+ return true;
}
- return true;
}
return false;
@@ -245,17 +202,11 @@ export const FooterConfigDialog: React.FC = ({
{ isActive: true, priority: true },
);
- const visibleItems = listItems.slice(
- scrollOffset,
- scrollOffset + maxItemsToShow,
- );
-
- const activeId = listItems[activeIndex]?.key;
const showLabels = settings.merged.ui.footer.showLabels !== false;
// Preview logic
const previewContent = useMemo(() => {
- if (isResetFocused) {
+ if (focusKey === 'reset') {
return (
Default footer (uses legacy settings)
@@ -269,8 +220,9 @@ export const FooterConfigDialog: React.FC = ({
if (itemsToPreview.length === 0) return null;
const itemColor = showLabels ? theme.text.primary : theme.ui.comment;
+
const getColor = (id: string, defaultColor?: string) =>
- id === activeId ? 'white' : defaultColor || itemColor;
+ defaultColor || itemColor;
// Mock data for preview (headers come from ALL_ITEMS)
const mockData: Record = {
@@ -312,16 +264,43 @@ export const FooterConfigDialog: React.FC = ({
key: id,
header: ALL_ITEMS.find((i) => i.id === id)?.header ?? id,
element: mockData[id],
+ flexGrow: 1,
+ isFocused: id === focusKey,
}));
return (
-
-
-
-
+
+
);
- }, [orderedIds, selectedIds, activeId, isResetFocused, showLabels]);
+ }, [orderedIds, selectedIds, focusKey, showLabels]);
+
+ const availableTerminalHeight = constrainHeight
+ ? terminalHeight - staticExtraHeight
+ : Number.MAX_SAFE_INTEGER;
+
+ const BORDER_HEIGHT = 2; // Outer round border
+ const STATIC_ELEMENTS = 13; // Text, margins, preview box, dialog footer
+
+ // Default padding adds 2 lines (top and bottom)
+ let includePadding = true;
+ if (availableTerminalHeight < BORDER_HEIGHT + 2 + STATIC_ELEMENTS + 6) {
+ includePadding = false;
+ }
+
+ const effectivePaddingY = includePadding ? 2 : 0;
+ const availableListSpace = Math.max(
+ 0,
+ availableTerminalHeight -
+ BORDER_HEIGHT -
+ effectivePaddingY -
+ STATIC_ELEMENTS,
+ );
+
+ const maxItemsToShow = Math.max(
+ 1,
+ Math.min(listItems.length, Math.floor(availableListSpace / 2)),
+ );
return (
= ({
borderStyle="round"
borderColor={theme.border.default}
paddingX={2}
- paddingY={1}
+ paddingY={includePadding ? 1 : 0}
width="100%"
>
Configure Footer{'\n'}
@@ -337,59 +316,65 @@ export const FooterConfigDialog: React.FC = ({
Select which items to display in the footer.
-
- {visibleItems.length === 0 ? (
- No items found.
- ) : (
- visibleItems.map((item, idx) => {
- const index = scrollOffset + idx;
- const isFocused = index === activeIndex;
- const isChecked = selectedIds.has(item.key);
+
+
+ items={listItems}
+ onSelect={handleSelect}
+ onHighlight={handleHighlight}
+ focusKey={focusKey}
+ showNumbers={false}
+ maxItemsToShow={maxItemsToShow}
+ showScrollArrows={true}
+ selectedIndicator=">"
+ renderItem={(item, { isSelected, titleColor }) => {
+ const configItem = item.value;
+ const isChecked =
+ configItem.type === 'config'
+ ? selectedIds.has(configItem.id)
+ : configItem.type === 'labels-toggle'
+ ? showLabels
+ : false;
return (
-
-
- {isFocused ? '> ' : ' '}
-
-
- [{isChecked ? '✓' : ' '}]{' '}
- {item.label.padEnd(maxLabelWidth + 1)}
-
- {item.description}
+
+
+ {configItem.type !== 'reset' && (
+
+ [{isChecked ? '✓' : ' '}]
+
+ )}
+
+ {configItem.type !== 'reset' ? ' ' : ''}
+ {configItem.label}
+
+
+ {configItem.description && (
+
+ {' '}
+ {configItem.description}
+
+ )}
);
- })
- )}
+ }}
+ />
-
-
-
- {isShowLabelsFocused ? '> ' : ' '}
-
-
- [{showLabels ? '✓' : ' '}] Show footer labels
-
-
-
-
- {isResetFocused ? '> ' : ' '}
-
-
- Reset to default footer
-
-
-
-
-
-
- ↑/↓ navigate · ←/→ reorder · enter/space select · esc close
-
-
+
= ({
flexDirection="column"
>
Preview:
- {previewContent}
+
+ {previewContent}
+
);
diff --git a/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap
index f2892488c3..2d98d66f03 100644
--- a/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap
@@ -1,26 +1,26 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[` > displays "Limit reached" message when remaining is 0 1`] = `
-" workspace (/directory) sandbox /model /stats
- ~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro limit reached
+" workspace (/directory) sandbox /model /stats
+ ~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro limit reached
"
`;
exports[` > displays the usage indicator when usage is low 1`] = `
-" workspace (/directory) sandbox /model /stats
- ~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 85%
+" workspace (/directory) sandbox /model /stats
+ ~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 85%
"
`;
exports[` > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `
-" workspace (/directory) sandbox /model context
- ...me/more/directories/to/make/it/long no sandbox gemini-pro 14%
+" workspace (/directory) sandbox /model context
+ ...me/more/directories/to/make/it/long no sandbox gemini-pro 14%
"
`;
exports[` > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `
-" workspace (/directory) sandbox /model context
- ~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 14% used
+" workspace (/directory) sandbox /model context
+ ~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 14% used
"
`;
@@ -33,13 +33,13 @@ exports[` > footer configuration filtering (golden snapshots) > render
exports[` > footer configuration filtering (golden snapshots) > renders footer with all optional sections hidden (minimal footer) > footer-minimal 1`] = `""`;
exports[` > footer configuration filtering (golden snapshots) > renders footer with only model info hidden (partial filtering) > footer-no-model 1`] = `
-" workspace (/directory) sandbox
- ~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox
+" workspace (/directory) sandbox
+ ~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox
"
`;
exports[` > hides the usage indicator when usage is not near limit 1`] = `
-" workspace (/directory) sandbox /model /stats
- ~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 15%
+" workspace (/directory) sandbox /model /stats
+ ~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 15%
"
`;
diff --git a/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog--FooterConfigDialog-highlights-the-active-item-in-the-preview.snap.svg b/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog--FooterConfigDialog-highlights-the-active-item-in-the-preview.snap.svg
new file mode 100644
index 0000000000..7cec49200d
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog--FooterConfigDialog-highlights-the-active-item-in-the-preview.snap.svg
@@ -0,0 +1,159 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog--FooterConfigDialog-renders-correctly-with-default-settings.snap.svg b/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog--FooterConfigDialog-renders-correctly-with-default-settings.snap.svg
new file mode 100644
index 0000000000..ae9b8eecea
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog--FooterConfigDialog-renders-correctly-with-default-settings.snap.svg
@@ -0,0 +1,154 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog.test.tsx.snap
index a55f40d1e2..f2fee0a8c3 100644
--- a/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog.test.tsx.snap
@@ -1,5 +1,48 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+exports[` > highlights the active item in the preview 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Configure Footer │
+│ │
+│ Select which items to display in the footer. │
+│ │
+│ [✓] workspace │
+│ Current working directory │
+│ [✓] git-branch │
+│ Current git branch name (not shown when unavailable) │
+│ [✓] sandbox │
+│ Sandbox type and trust indicator │
+│ [✓] model-name │
+│ Current model identifier │
+│ [✓] quota │
+│ Remaining usage on daily limit (not shown when unavailable) │
+│ [ ] context-used │
+│ Percentage of context window used │
+│ [ ] memory-usage │
+│ Memory used by the application │
+│ [ ] session-id │
+│ Unique identifier for the current session │
+│ > [✓] code-changes │
+│ Lines added/removed in the session (not shown when zero) │
+│ [ ] token-count │
+│ Total tokens used in the session (not shown when zero) │
+│ [✓] Show footer labels │
+│ │
+│ Reset to default footer │
+│ │
+│ │
+│ Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close │
+│ │
+│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
+│ │ Preview: │ │
+│ │ workspace (/directory) branch sandbox /model /stats diff │ │
+│ │ ~/project/path main docker gemini-2.5-pro 97% +12 -4 │ │
+│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
+
exports[` > renders correctly with default settings 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
@@ -7,28 +50,82 @@ exports[` > renders correctly with default settings 1`] =
│ │
│ Select which items to display in the footer. │
│ │
-│ > [✓] workspace Current working directory │
-│ [✓] git-branch Current git branch name (not shown when unavailable) │
-│ [✓] sandbox Sandbox type and trust indicator │
-│ [✓] model-name Current model identifier │
-│ [✓] quota Remaining usage on daily limit (not shown when unavailable) │
-│ [ ] context-used Percentage of context window used │
-│ [ ] memory-usage Memory used by the application │
-│ [ ] session-id Unique identifier for the current session │
-│ [ ] code-changes Lines added/removed in the session (not shown when zero) │
-│ [ ] token-count Total tokens used in the session (not shown when zero) │
-│ │
+│ > [✓] workspace │
+│ Current working directory │
+│ [✓] git-branch │
+│ Current git branch name (not shown when unavailable) │
+│ [✓] sandbox │
+│ Sandbox type and trust indicator │
+│ [✓] model-name │
+│ Current model identifier │
+│ [✓] quota │
+│ Remaining usage on daily limit (not shown when unavailable) │
+│ [ ] context-used │
+│ Percentage of context window used │
+│ [ ] memory-usage │
+│ Memory used by the application │
+│ [ ] session-id │
+│ Unique identifier for the current session │
+│ [ ] code-changes │
+│ Lines added/removed in the session (not shown when zero) │
+│ [ ] token-count │
+│ Total tokens used in the session (not shown when zero) │
│ [✓] Show footer labels │
+│ │
│ Reset to default footer │
│ │
-│ ↑/↓ navigate · ←/→ reorder · enter/space select · esc close │
+│ │
+│ Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close │
│ │
│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ Preview: │ │
-│ │ workspace (/directory) branch sandbox /model /stats │ │
-│ │ ~/project/path main docker gemini-2.5-pro 97% │ │
+│ │ workspace (/directory) branch sandbox /model /stats │ │
+│ │ ~/project/path main docker gemini-2.5-pro 97% │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
+
+exports[` > renders correctly with default settings 2`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Configure Footer │
+│ │
+│ Select which items to display in the footer. │
+│ │
+│ > [✓] workspace │
+│ Current working directory │
+│ [✓] git-branch │
+│ Current git branch name (not shown when unavailable) │
+│ [✓] sandbox │
+│ Sandbox type and trust indicator │
+│ [✓] model-name │
+│ Current model identifier │
+│ [✓] quota │
+│ Remaining usage on daily limit (not shown when unavailable) │
+│ [ ] context-used │
+│ Percentage of context window used │
+│ [ ] memory-usage │
+│ Memory used by the application │
+│ [ ] session-id │
+│ Unique identifier for the current session │
+│ [ ] code-changes │
+│ Lines added/removed in the session (not shown when zero) │
+│ [ ] token-count │
+│ Total tokens used in the session (not shown when zero) │
+│ [✓] Show footer labels │
+│ │
+│ Reset to default footer │
+│ │
+│ │
+│ Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close │
+│ │
+│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
+│ │ Preview: │ │
+│ │ workspace (/directory) branch sandbox /model /stats │ │
+│ │ ~/project/path main docker gemini-2.5-pro 97% │ │
+│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
diff --git a/packages/cli/src/ui/components/shared/BaseSelectionList.tsx b/packages/cli/src/ui/components/shared/BaseSelectionList.tsx
index 1467bb357e..7efb40b3ae 100644
--- a/packages/cli/src/ui/components/shared/BaseSelectionList.tsx
+++ b/packages/cli/src/ui/components/shared/BaseSelectionList.tsx
@@ -33,6 +33,7 @@ export interface BaseSelectionListProps<
wrapAround?: boolean;
focusKey?: string;
priority?: boolean;
+ selectedIndicator?: string;
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
}
@@ -65,6 +66,7 @@ export function BaseSelectionList<
wrapAround = true,
focusKey,
priority,
+ selectedIndicator = '●',
renderItem,
}: BaseSelectionListProps): React.JSX.Element {
const { activeIndex } = useSelectionList({
@@ -148,7 +150,7 @@ export function BaseSelectionList<
color={isSelected ? theme.ui.focus : theme.text.primary}
aria-hidden
>
- {isSelected ? '●' : ' '}
+ {isSelected ? selectedIndicator : ' '}
diff --git a/packages/cli/src/ui/hooks/useSelectionList.test.tsx b/packages/cli/src/ui/hooks/useSelectionList.test.tsx
index 6b9342d025..4151375280 100644
--- a/packages/cli/src/ui/hooks/useSelectionList.test.tsx
+++ b/packages/cli/src/ui/hooks/useSelectionList.test.tsx
@@ -81,6 +81,8 @@ describe('useSelectionList', () => {
isFocused?: boolean;
showNumbers?: boolean;
wrapAround?: boolean;
+ focusKey?: string;
+ priority?: boolean;
}) => {
let hookResult: ReturnType;
function TestComponent(props: typeof initialProps) {
@@ -771,6 +773,67 @@ describe('useSelectionList', () => {
});
});
+ describe('Programmatic Focus (focusKey)', () => {
+ it('should change the activeIndex when a valid focusKey is provided', async () => {
+ const { result, rerender, waitUntilReady } =
+ await renderSelectionListHook({
+ items,
+ onSelect: mockOnSelect,
+ });
+ expect(result.current.activeIndex).toBe(0);
+
+ await rerender({ focusKey: 'C' });
+ await waitUntilReady();
+ expect(result.current.activeIndex).toBe(2);
+ });
+
+ it('should ignore a focusKey that does not exist', async () => {
+ const { result, rerender, waitUntilReady } =
+ await renderSelectionListHook({
+ items,
+ onSelect: mockOnSelect,
+ });
+ expect(result.current.activeIndex).toBe(0);
+
+ await rerender({ focusKey: 'UNKNOWN' });
+ await waitUntilReady();
+ expect(result.current.activeIndex).toBe(0);
+ });
+
+ it('should ignore a focusKey that points to a disabled item', async () => {
+ const { result, rerender, waitUntilReady } =
+ await renderSelectionListHook({
+ items, // B is disabled
+ onSelect: mockOnSelect,
+ });
+ expect(result.current.activeIndex).toBe(0);
+
+ await rerender({ focusKey: 'B' });
+ await waitUntilReady();
+ expect(result.current.activeIndex).toBe(0);
+ });
+
+ it('should handle clearing the focusKey', async () => {
+ const { result, rerender, waitUntilReady } =
+ await renderSelectionListHook({
+ items,
+ onSelect: mockOnSelect,
+ focusKey: 'C',
+ });
+ expect(result.current.activeIndex).toBe(2);
+
+ await rerender({ focusKey: undefined });
+ await waitUntilReady();
+ // Should remain at 2
+ expect(result.current.activeIndex).toBe(2);
+
+ // We can then change it again to something else
+ await rerender({ focusKey: 'D' });
+ await waitUntilReady();
+ expect(result.current.activeIndex).toBe(3);
+ });
+ });
+
describe('Reactivity (Dynamic Updates)', () => {
it('should update activeIndex when initialIndex prop changes', async () => {
const { result, rerender } = await renderSelectionListHook({
diff --git a/packages/cli/src/ui/hooks/useSelectionList.ts b/packages/cli/src/ui/hooks/useSelectionList.ts
index 80ca40a0ed..f74c1b1dc2 100644
--- a/packages/cli/src/ui/hooks/useSelectionList.ts
+++ b/packages/cli/src/ui/hooks/useSelectionList.ts
@@ -213,8 +213,7 @@ function selectionListReducer(
case 'INITIALIZE': {
const { initialIndex, items, wrapAround } = action.payload;
const activeKey =
- initialIndex === state.initialIndex &&
- state.activeIndex !== state.initialIndex
+ initialIndex === state.initialIndex
? state.items[state.activeIndex]?.key
: undefined;