mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 12:54:07 -07:00
Reapply "feat(accessibility): implement centralized screen reader layout (#9263)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
@@ -6,12 +6,19 @@
|
|||||||
|
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { render } from 'ink-testing-library';
|
import { render } from 'ink-testing-library';
|
||||||
import { Text } from 'ink';
|
import { Text, useIsScreenReaderEnabled } from 'ink';
|
||||||
import { App } from './App.js';
|
import { App } from './App.js';
|
||||||
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
|
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
|
||||||
import { StreamingState } from './types.js';
|
import { StreamingState } from './types.js';
|
||||||
|
|
||||||
// Mock components to isolate App component testing
|
vi.mock('ink', async (importOriginal) => {
|
||||||
|
const original = await importOriginal<typeof import('ink')>();
|
||||||
|
return {
|
||||||
|
...original,
|
||||||
|
useIsScreenReaderEnabled: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('./components/MainContent.js', () => ({
|
vi.mock('./components/MainContent.js', () => ({
|
||||||
MainContent: () => <Text>MainContent</Text>,
|
MainContent: () => <Text>MainContent</Text>,
|
||||||
}));
|
}));
|
||||||
@@ -32,6 +39,10 @@ vi.mock('./components/QuittingDisplay.js', () => ({
|
|||||||
QuittingDisplay: () => <Text>Quitting...</Text>,
|
QuittingDisplay: () => <Text>Quitting...</Text>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('./components/Footer.js', () => ({
|
||||||
|
Footer: () => <Text>Footer</Text>,
|
||||||
|
}));
|
||||||
|
|
||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
const mockUIState: Partial<UIState> = {
|
const mockUIState: Partial<UIState> = {
|
||||||
streamingState: StreamingState.Idle,
|
streamingState: StreamingState.Idle,
|
||||||
@@ -122,4 +133,30 @@ describe('App', () => {
|
|||||||
|
|
||||||
expect(lastFrame()).toContain('Press Ctrl+D again to exit.');
|
expect(lastFrame()).toContain('Press Ctrl+D again to exit.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should render ScreenReaderAppLayout when screen reader is enabled', () => {
|
||||||
|
(useIsScreenReaderEnabled as vi.Mock).mockReturnValue(true);
|
||||||
|
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<UIStateContext.Provider value={mockUIState as UIState}>
|
||||||
|
<App />
|
||||||
|
</UIStateContext.Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(lastFrame()).toContain(
|
||||||
|
'Notifications\nFooter\nMainContent\nComposer',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render DefaultAppLayout when screen reader is not enabled', () => {
|
||||||
|
(useIsScreenReaderEnabled as vi.Mock).mockReturnValue(false);
|
||||||
|
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<UIStateContext.Provider value={mockUIState as UIState}>
|
||||||
|
<App />
|
||||||
|
</UIStateContext.Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(lastFrame()).toContain('MainContent\nNotifications\nComposer');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,18 +4,16 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Box, Text } from 'ink';
|
import { useIsScreenReaderEnabled } from 'ink';
|
||||||
import { StreamingContext } from './contexts/StreamingContext.js';
|
|
||||||
import { Notifications } from './components/Notifications.js';
|
|
||||||
import { MainContent } from './components/MainContent.js';
|
|
||||||
import { DialogManager } from './components/DialogManager.js';
|
|
||||||
import { Composer } from './components/Composer.js';
|
|
||||||
import { useUIState } from './contexts/UIStateContext.js';
|
import { useUIState } from './contexts/UIStateContext.js';
|
||||||
|
import { StreamingContext } from './contexts/StreamingContext.js';
|
||||||
import { QuittingDisplay } from './components/QuittingDisplay.js';
|
import { QuittingDisplay } from './components/QuittingDisplay.js';
|
||||||
import { theme } from './semantic-colors.js';
|
import { ScreenReaderAppLayout } from './layouts/ScreenReaderAppLayout.js';
|
||||||
|
import { DefaultAppLayout } from './layouts/DefaultAppLayout.js';
|
||||||
|
|
||||||
export const App = () => {
|
export const App = () => {
|
||||||
const uiState = useUIState();
|
const uiState = useUIState();
|
||||||
|
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||||
|
|
||||||
if (uiState.quittingMessages) {
|
if (uiState.quittingMessages) {
|
||||||
return <QuittingDisplay />;
|
return <QuittingDisplay />;
|
||||||
@@ -23,35 +21,7 @@ export const App = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<StreamingContext.Provider value={uiState.streamingState}>
|
<StreamingContext.Provider value={uiState.streamingState}>
|
||||||
<Box flexDirection="column" width="90%">
|
{isScreenReaderEnabled ? <ScreenReaderAppLayout /> : <DefaultAppLayout />}
|
||||||
<MainContent />
|
|
||||||
|
|
||||||
<Box flexDirection="column" ref={uiState.mainControlsRef}>
|
|
||||||
<Notifications />
|
|
||||||
|
|
||||||
{uiState.dialogsVisible ? (
|
|
||||||
<DialogManager addItem={uiState.historyManager.addItem} />
|
|
||||||
) : (
|
|
||||||
<Composer />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{uiState.dialogsVisible && uiState.ctrlCPressedOnce && (
|
|
||||||
<Box marginTop={1}>
|
|
||||||
<Text color={theme.status.warning}>
|
|
||||||
Press Ctrl+C again to exit.
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{uiState.dialogsVisible && uiState.ctrlDPressedOnce && (
|
|
||||||
<Box marginTop={1}>
|
|
||||||
<Text color={theme.status.warning}>
|
|
||||||
Press Ctrl+D again to exit.
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</StreamingContext.Provider>
|
</StreamingContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { LoadingIndicator } from './LoadingIndicator.js';
|
import { LoadingIndicator } from './LoadingIndicator.js';
|
||||||
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
|
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
|
||||||
@@ -12,7 +12,7 @@ import { AutoAcceptIndicator } from './AutoAcceptIndicator.js';
|
|||||||
import { ShellModeIndicator } from './ShellModeIndicator.js';
|
import { ShellModeIndicator } from './ShellModeIndicator.js';
|
||||||
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
|
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
|
||||||
import { InputPrompt, calculatePromptWidths } from './InputPrompt.js';
|
import { InputPrompt, calculatePromptWidths } from './InputPrompt.js';
|
||||||
import { Footer, type FooterProps } from './Footer.js';
|
import { Footer } from './Footer.js';
|
||||||
import { ShowMoreLines } from './ShowMoreLines.js';
|
import { ShowMoreLines } from './ShowMoreLines.js';
|
||||||
import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
|
import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
|
||||||
import { OverflowProvider } from '../contexts/OverflowContext.js';
|
import { OverflowProvider } from '../contexts/OverflowContext.js';
|
||||||
@@ -30,9 +30,10 @@ import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
|
|||||||
export const Composer = () => {
|
export const Composer = () => {
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
|
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||||
const uiState = useUIState();
|
const uiState = useUIState();
|
||||||
const uiActions = useUIActions();
|
const uiActions = useUIActions();
|
||||||
const { vimEnabled, vimMode } = useVimMode();
|
const { vimEnabled } = useVimMode();
|
||||||
const terminalWidth = process.stdout.columns;
|
const terminalWidth = process.stdout.columns;
|
||||||
const isNarrow = isNarrowWidth(terminalWidth);
|
const isNarrow = isNarrowWidth(terminalWidth);
|
||||||
const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5));
|
const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5));
|
||||||
@@ -45,26 +46,6 @@ export const Composer = () => {
|
|||||||
[uiState.terminalWidth],
|
[uiState.terminalWidth],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build footer props from context values
|
|
||||||
const footerProps: Omit<FooterProps, 'vimMode'> = {
|
|
||||||
model: config.getModel(),
|
|
||||||
targetDir: config.getTargetDir(),
|
|
||||||
debugMode: config.getDebugMode(),
|
|
||||||
branchName: uiState.branchName,
|
|
||||||
debugMessage: uiState.debugMessage,
|
|
||||||
corgiMode: uiState.corgiMode,
|
|
||||||
errorCount: uiState.errorCount,
|
|
||||||
showErrorDetails: uiState.showErrorDetails,
|
|
||||||
showMemoryUsage:
|
|
||||||
config.getDebugMode() || settings.merged.ui?.showMemoryUsage || false,
|
|
||||||
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
|
|
||||||
nightly: uiState.nightly,
|
|
||||||
isTrustedFolder: uiState.isTrustedFolder,
|
|
||||||
hideCWD: settings.merged.ui?.footer?.hideCWD || false,
|
|
||||||
hideSandboxStatus: settings.merged.ui?.footer?.hideSandboxStatus || false,
|
|
||||||
hideModelInfo: settings.merged.ui?.footer?.hideModelInfo || false,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
{!uiState.embeddedShellFocused && (
|
{!uiState.embeddedShellFocused && (
|
||||||
@@ -176,9 +157,7 @@ export const Composer = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!settings.merged.ui?.hideFooter && (
|
{!settings.merged.ui?.hideFooter && !isScreenReaderEnabled && <Footer />}
|
||||||
<Footer {...footerProps} vimMode={vimEnabled ? vimMode : undefined} />
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { useUIState } from '../contexts/UIStateContext.js';
|
||||||
|
import { theme } from '../semantic-colors.js';
|
||||||
|
|
||||||
|
export const ExitWarning: React.FC = () => {
|
||||||
|
const uiState = useUIState();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{uiState.dialogsVisible && uiState.ctrlCPressedOnce && (
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={theme.status.warning}>Press Ctrl+C again to exit.</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{uiState.dialogsVisible && uiState.ctrlDPressedOnce && (
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={theme.status.warning}>Press Ctrl+D again to exit.</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,6 +10,11 @@ import { Footer } from './Footer.js';
|
|||||||
import * as useTerminalSize from '../hooks/useTerminalSize.js';
|
import * as useTerminalSize from '../hooks/useTerminalSize.js';
|
||||||
import { tildeifyPath } from '@google/gemini-cli-core';
|
import { tildeifyPath } from '@google/gemini-cli-core';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { type UIState, UIStateContext } from '../contexts/UIStateContext.js';
|
||||||
|
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||||
|
import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||||
|
import type { LoadedSettings } from '../../config/settings.js';
|
||||||
|
import { VimModeProvider } from '../contexts/VimModeContext.js';
|
||||||
|
|
||||||
vi.mock('../hooks/useTerminalSize.js');
|
vi.mock('../hooks/useTerminalSize.js');
|
||||||
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
|
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
|
||||||
@@ -33,50 +38,93 @@ const defaultProps = {
|
|||||||
targetDir:
|
targetDir:
|
||||||
'/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long',
|
'/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long',
|
||||||
branchName: 'main',
|
branchName: 'main',
|
||||||
debugMode: false,
|
|
||||||
debugMessage: '',
|
|
||||||
corgiMode: false,
|
|
||||||
errorCount: 0,
|
|
||||||
showErrorDetails: false,
|
|
||||||
showMemoryUsage: false,
|
|
||||||
promptTokenCount: 100,
|
|
||||||
nightly: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderWithWidth = (width: number, props = defaultProps) => {
|
const createMockConfig = (overrides = {}) => ({
|
||||||
|
getModel: vi.fn(() => defaultProps.model),
|
||||||
|
getTargetDir: vi.fn(() => defaultProps.targetDir),
|
||||||
|
getDebugMode: vi.fn(() => false),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||||
|
({
|
||||||
|
sessionStats: {
|
||||||
|
lastPromptTokenCount: 100,
|
||||||
|
},
|
||||||
|
branchName: defaultProps.branchName,
|
||||||
|
...overrides,
|
||||||
|
}) as UIState;
|
||||||
|
|
||||||
|
const createDefaultSettings = (
|
||||||
|
options: {
|
||||||
|
showMemoryUsage?: boolean;
|
||||||
|
hideCWD?: boolean;
|
||||||
|
hideSandboxStatus?: boolean;
|
||||||
|
hideModelInfo?: boolean;
|
||||||
|
} = {},
|
||||||
|
): LoadedSettings =>
|
||||||
|
({
|
||||||
|
merged: {
|
||||||
|
ui: {
|
||||||
|
showMemoryUsage: options.showMemoryUsage,
|
||||||
|
footer: {
|
||||||
|
hideCWD: options.hideCWD,
|
||||||
|
hideSandboxStatus: options.hideSandboxStatus,
|
||||||
|
hideModelInfo: options.hideModelInfo,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}) as never;
|
||||||
|
|
||||||
|
const renderWithWidth = (
|
||||||
|
width: number,
|
||||||
|
uiState: UIState,
|
||||||
|
settings: LoadedSettings = createDefaultSettings(),
|
||||||
|
) => {
|
||||||
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
|
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
|
||||||
return render(<Footer {...props} />);
|
return render(
|
||||||
|
<ConfigContext.Provider value={createMockConfig() as never}>
|
||||||
|
<SettingsContext.Provider value={settings}>
|
||||||
|
<VimModeProvider settings={settings}>
|
||||||
|
<UIStateContext.Provider value={uiState}>
|
||||||
|
<Footer />
|
||||||
|
</UIStateContext.Provider>
|
||||||
|
</VimModeProvider>
|
||||||
|
</SettingsContext.Provider>
|
||||||
|
</ConfigContext.Provider>,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('<Footer />', () => {
|
describe('<Footer />', () => {
|
||||||
it('renders the component', () => {
|
it('renders the component', () => {
|
||||||
const { lastFrame } = renderWithWidth(120);
|
const { lastFrame } = renderWithWidth(120, createMockUIState());
|
||||||
expect(lastFrame()).toBeDefined();
|
expect(lastFrame()).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('path display', () => {
|
describe('path display', () => {
|
||||||
it('should display shortened path on a wide terminal', () => {
|
it('should display shortened path on a wide terminal', () => {
|
||||||
const { lastFrame } = renderWithWidth(120);
|
const { lastFrame } = renderWithWidth(120, createMockUIState());
|
||||||
const tildePath = tildeifyPath(defaultProps.targetDir);
|
const tildePath = tildeifyPath(defaultProps.targetDir);
|
||||||
const expectedPath = '...' + tildePath.slice(tildePath.length - 48 + 3);
|
const expectedPath = '...' + tildePath.slice(tildePath.length - 48 + 3);
|
||||||
expect(lastFrame()).toContain(expectedPath);
|
expect(lastFrame()).toContain(expectedPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display only the base directory name on a narrow terminal', () => {
|
it('should display only the base directory name on a narrow terminal', () => {
|
||||||
const { lastFrame } = renderWithWidth(79);
|
const { lastFrame } = renderWithWidth(79, createMockUIState());
|
||||||
const expectedPath = path.basename(defaultProps.targetDir);
|
const expectedPath = path.basename(defaultProps.targetDir);
|
||||||
expect(lastFrame()).toContain(expectedPath);
|
expect(lastFrame()).toContain(expectedPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use wide layout at 80 columns', () => {
|
it('should use wide layout at 80 columns', () => {
|
||||||
const { lastFrame } = renderWithWidth(80);
|
const { lastFrame } = renderWithWidth(80, createMockUIState());
|
||||||
const tildePath = tildeifyPath(defaultProps.targetDir);
|
const tildePath = tildeifyPath(defaultProps.targetDir);
|
||||||
const expectedPath = '...' + tildePath.slice(tildePath.length - 32 + 3);
|
const expectedPath = '...' + tildePath.slice(tildePath.length - 32 + 3);
|
||||||
expect(lastFrame()).toContain(expectedPath);
|
expect(lastFrame()).toContain(expectedPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use narrow layout at 79 columns', () => {
|
it('should use narrow layout at 79 columns', () => {
|
||||||
const { lastFrame } = renderWithWidth(79);
|
const { lastFrame } = renderWithWidth(79, createMockUIState());
|
||||||
const expectedPath = path.basename(defaultProps.targetDir);
|
const expectedPath = path.basename(defaultProps.targetDir);
|
||||||
expect(lastFrame()).toContain(expectedPath);
|
expect(lastFrame()).toContain(expectedPath);
|
||||||
const tildePath = tildeifyPath(defaultProps.targetDir);
|
const tildePath = tildeifyPath(defaultProps.targetDir);
|
||||||
@@ -86,39 +134,45 @@ describe('<Footer />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('displays the branch name when provided', () => {
|
it('displays the branch name when provided', () => {
|
||||||
const { lastFrame } = renderWithWidth(120);
|
const { lastFrame } = renderWithWidth(120, createMockUIState());
|
||||||
expect(lastFrame()).toContain(`(${defaultProps.branchName}*)`);
|
expect(lastFrame()).toContain(`(${defaultProps.branchName}*)`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not display the branch name when not provided', () => {
|
it('does not display the branch name when not provided', () => {
|
||||||
const { lastFrame } = renderWithWidth(120, {
|
const { lastFrame } = renderWithWidth(
|
||||||
...defaultProps,
|
120,
|
||||||
|
createMockUIState({
|
||||||
branchName: undefined,
|
branchName: undefined,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
expect(lastFrame()).not.toContain(`(${defaultProps.branchName}*)`);
|
expect(lastFrame()).not.toContain(`(${defaultProps.branchName}*)`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('displays the model name and context percentage', () => {
|
it('displays the model name and context percentage', () => {
|
||||||
const { lastFrame } = renderWithWidth(120);
|
const { lastFrame } = renderWithWidth(120, createMockUIState());
|
||||||
expect(lastFrame()).toContain(defaultProps.model);
|
expect(lastFrame()).toContain(defaultProps.model);
|
||||||
expect(lastFrame()).toMatch(/\(\d+% context[\s\S]*left\)/);
|
expect(lastFrame()).toMatch(/\(\d+% context[\s\S]*left\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('sandbox and trust info', () => {
|
describe('sandbox and trust info', () => {
|
||||||
it('should display untrusted when isTrustedFolder is false', () => {
|
it('should display untrusted when isTrustedFolder is false', () => {
|
||||||
const { lastFrame } = renderWithWidth(120, {
|
const { lastFrame } = renderWithWidth(
|
||||||
...defaultProps,
|
120,
|
||||||
|
createMockUIState({
|
||||||
isTrustedFolder: false,
|
isTrustedFolder: false,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
expect(lastFrame()).toContain('untrusted');
|
expect(lastFrame()).toContain('untrusted');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display custom sandbox info when SANDBOX env is set', () => {
|
it('should display custom sandbox info when SANDBOX env is set', () => {
|
||||||
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
|
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
|
||||||
const { lastFrame } = renderWithWidth(120, {
|
const { lastFrame } = renderWithWidth(
|
||||||
...defaultProps,
|
120,
|
||||||
|
createMockUIState({
|
||||||
isTrustedFolder: undefined,
|
isTrustedFolder: undefined,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
expect(lastFrame()).toContain('test');
|
expect(lastFrame()).toContain('test');
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
});
|
});
|
||||||
@@ -126,10 +180,12 @@ describe('<Footer />', () => {
|
|||||||
it('should display macOS Seatbelt info when SANDBOX is sandbox-exec', () => {
|
it('should display macOS Seatbelt info when SANDBOX is sandbox-exec', () => {
|
||||||
vi.stubEnv('SANDBOX', 'sandbox-exec');
|
vi.stubEnv('SANDBOX', 'sandbox-exec');
|
||||||
vi.stubEnv('SEATBELT_PROFILE', 'test-profile');
|
vi.stubEnv('SEATBELT_PROFILE', 'test-profile');
|
||||||
const { lastFrame } = renderWithWidth(120, {
|
const { lastFrame } = renderWithWidth(
|
||||||
...defaultProps,
|
120,
|
||||||
|
createMockUIState({
|
||||||
isTrustedFolder: true,
|
isTrustedFolder: true,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
expect(lastFrame()).toMatch(/macOS Seatbelt.*\(test-profile\)/s);
|
expect(lastFrame()).toMatch(/macOS Seatbelt.*\(test-profile\)/s);
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
});
|
});
|
||||||
@@ -137,20 +193,24 @@ describe('<Footer />', () => {
|
|||||||
it('should display "no sandbox" when SANDBOX is not set and folder is trusted', () => {
|
it('should display "no sandbox" when SANDBOX is not set and folder is trusted', () => {
|
||||||
// Clear any SANDBOX env var that might be set.
|
// Clear any SANDBOX env var that might be set.
|
||||||
vi.stubEnv('SANDBOX', '');
|
vi.stubEnv('SANDBOX', '');
|
||||||
const { lastFrame } = renderWithWidth(120, {
|
const { lastFrame } = renderWithWidth(
|
||||||
...defaultProps,
|
120,
|
||||||
|
createMockUIState({
|
||||||
isTrustedFolder: true,
|
isTrustedFolder: true,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
expect(lastFrame()).toContain('no sandbox');
|
expect(lastFrame()).toContain('no sandbox');
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should prioritize untrusted message over sandbox info', () => {
|
it('should prioritize untrusted message over sandbox info', () => {
|
||||||
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
|
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
|
||||||
const { lastFrame } = renderWithWidth(120, {
|
const { lastFrame } = renderWithWidth(
|
||||||
...defaultProps,
|
120,
|
||||||
|
createMockUIState({
|
||||||
isTrustedFolder: false,
|
isTrustedFolder: false,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
expect(lastFrame()).toContain('untrusted');
|
expect(lastFrame()).toContain('untrusted');
|
||||||
expect(lastFrame()).not.toMatch(/test-sandbox/s);
|
expect(lastFrame()).not.toMatch(/test-sandbox/s);
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
@@ -159,52 +219,51 @@ describe('<Footer />', () => {
|
|||||||
|
|
||||||
describe('footer configuration filtering (golden snapshots)', () => {
|
describe('footer configuration filtering (golden snapshots)', () => {
|
||||||
it('renders complete footer with all sections visible (baseline)', () => {
|
it('renders complete footer with all sections visible (baseline)', () => {
|
||||||
const { lastFrame } = renderWithWidth(120, {
|
const { lastFrame } = renderWithWidth(120, createMockUIState());
|
||||||
...defaultProps,
|
|
||||||
hideCWD: false,
|
|
||||||
hideSandboxStatus: false,
|
|
||||||
hideModelInfo: false,
|
|
||||||
});
|
|
||||||
expect(lastFrame()).toMatchSnapshot('complete-footer-wide');
|
expect(lastFrame()).toMatchSnapshot('complete-footer-wide');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders footer with all optional sections hidden (minimal footer)', () => {
|
it('renders footer with all optional sections hidden (minimal footer)', () => {
|
||||||
const { lastFrame } = renderWithWidth(120, {
|
const { lastFrame } = renderWithWidth(
|
||||||
...defaultProps,
|
120,
|
||||||
|
createMockUIState(),
|
||||||
|
createDefaultSettings({
|
||||||
hideCWD: true,
|
hideCWD: true,
|
||||||
hideSandboxStatus: true,
|
hideSandboxStatus: true,
|
||||||
hideModelInfo: true,
|
hideModelInfo: true,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
expect(lastFrame()).toMatchSnapshot('footer-minimal');
|
expect(lastFrame()).toMatchSnapshot('footer-minimal');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders footer with only model info hidden (partial filtering)', () => {
|
it('renders footer with only model info hidden (partial filtering)', () => {
|
||||||
const { lastFrame } = renderWithWidth(120, {
|
const { lastFrame } = renderWithWidth(
|
||||||
...defaultProps,
|
120,
|
||||||
|
createMockUIState(),
|
||||||
|
createDefaultSettings({
|
||||||
hideCWD: false,
|
hideCWD: false,
|
||||||
hideSandboxStatus: false,
|
hideSandboxStatus: false,
|
||||||
hideModelInfo: true,
|
hideModelInfo: true,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
expect(lastFrame()).toMatchSnapshot('footer-no-model');
|
expect(lastFrame()).toMatchSnapshot('footer-no-model');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders footer with CWD and model info hidden to test alignment (only sandbox visible)', () => {
|
it('renders footer with CWD and model info hidden to test alignment (only sandbox visible)', () => {
|
||||||
const { lastFrame } = renderWithWidth(120, {
|
const { lastFrame } = renderWithWidth(
|
||||||
...defaultProps,
|
120,
|
||||||
|
createMockUIState(),
|
||||||
|
createDefaultSettings({
|
||||||
hideCWD: true,
|
hideCWD: true,
|
||||||
hideSandboxStatus: false,
|
hideSandboxStatus: false,
|
||||||
hideModelInfo: true,
|
hideModelInfo: true,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
expect(lastFrame()).toMatchSnapshot('footer-only-sandbox');
|
expect(lastFrame()).toMatchSnapshot('footer-only-sandbox');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders complete footer in narrow terminal (baseline narrow)', () => {
|
it('renders complete footer in narrow terminal (baseline narrow)', () => {
|
||||||
const { lastFrame } = renderWithWidth(79, {
|
const { lastFrame } = renderWithWidth(79, createMockUIState());
|
||||||
...defaultProps,
|
|
||||||
hideCWD: false,
|
|
||||||
hideSandboxStatus: false,
|
|
||||||
hideModelInfo: false,
|
|
||||||
});
|
|
||||||
expect(lastFrame()).toMatchSnapshot('complete-footer-narrow');
|
expect(lastFrame()).toMatchSnapshot('complete-footer-narrow');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,43 +19,50 @@ import { DebugProfiler } from './DebugProfiler.js';
|
|||||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||||
|
|
||||||
export interface FooterProps {
|
import { useUIState } from '../contexts/UIStateContext.js';
|
||||||
model: string;
|
import { useConfig } from '../contexts/ConfigContext.js';
|
||||||
targetDir: string;
|
import { useSettings } from '../contexts/SettingsContext.js';
|
||||||
branchName?: string;
|
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||||
debugMode: boolean;
|
|
||||||
debugMessage: string;
|
|
||||||
corgiMode: boolean;
|
|
||||||
errorCount: number;
|
|
||||||
showErrorDetails: boolean;
|
|
||||||
showMemoryUsage?: boolean;
|
|
||||||
promptTokenCount: number;
|
|
||||||
nightly: boolean;
|
|
||||||
vimMode?: string;
|
|
||||||
isTrustedFolder?: boolean;
|
|
||||||
hideCWD?: boolean;
|
|
||||||
hideSandboxStatus?: boolean;
|
|
||||||
hideModelInfo?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Footer: React.FC<FooterProps> = ({
|
export const Footer: React.FC = () => {
|
||||||
|
const uiState = useUIState();
|
||||||
|
const config = useConfig();
|
||||||
|
const settings = useSettings();
|
||||||
|
const { vimEnabled, vimMode } = useVimMode();
|
||||||
|
|
||||||
|
const {
|
||||||
model,
|
model,
|
||||||
targetDir,
|
targetDir,
|
||||||
branchName,
|
|
||||||
debugMode,
|
debugMode,
|
||||||
|
branchName,
|
||||||
debugMessage,
|
debugMessage,
|
||||||
corgiMode,
|
corgiMode,
|
||||||
errorCount,
|
errorCount,
|
||||||
showErrorDetails,
|
showErrorDetails,
|
||||||
showMemoryUsage,
|
|
||||||
promptTokenCount,
|
promptTokenCount,
|
||||||
nightly,
|
nightly,
|
||||||
vimMode,
|
|
||||||
isTrustedFolder,
|
isTrustedFolder,
|
||||||
hideCWD = false,
|
} = {
|
||||||
hideSandboxStatus = false,
|
model: config.getModel(),
|
||||||
hideModelInfo = false,
|
targetDir: config.getTargetDir(),
|
||||||
}) => {
|
debugMode: config.getDebugMode(),
|
||||||
|
branchName: uiState.branchName,
|
||||||
|
debugMessage: uiState.debugMessage,
|
||||||
|
corgiMode: uiState.corgiMode,
|
||||||
|
errorCount: uiState.errorCount,
|
||||||
|
showErrorDetails: uiState.showErrorDetails,
|
||||||
|
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
|
||||||
|
nightly: uiState.nightly,
|
||||||
|
isTrustedFolder: uiState.isTrustedFolder,
|
||||||
|
};
|
||||||
|
|
||||||
|
const showMemoryUsage =
|
||||||
|
config.getDebugMode() || settings.merged.ui?.showMemoryUsage || false;
|
||||||
|
const hideCWD = settings.merged.ui?.footer?.hideCWD || false;
|
||||||
|
const hideSandboxStatus =
|
||||||
|
settings.merged.ui?.footer?.hideSandboxStatus || false;
|
||||||
|
const hideModelInfo = settings.merged.ui?.footer?.hideModelInfo || false;
|
||||||
|
|
||||||
const { columns: terminalWidth } = useTerminalSize();
|
const { columns: terminalWidth } = useTerminalSize();
|
||||||
|
|
||||||
const isNarrow = isNarrowWidth(terminalWidth);
|
const isNarrow = isNarrowWidth(terminalWidth);
|
||||||
@@ -67,6 +74,7 @@ export const Footer: React.FC<FooterProps> = ({
|
|||||||
: shortenPath(tildeifyPath(targetDir), pathLength);
|
: shortenPath(tildeifyPath(targetDir), pathLength);
|
||||||
|
|
||||||
const justifyContent = hideCWD && hideModelInfo ? 'center' : 'space-between';
|
const justifyContent = hideCWD && hideModelInfo ? 'center' : 'space-between';
|
||||||
|
const displayVimMode = vimEnabled ? vimMode : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -75,10 +83,12 @@ export const Footer: React.FC<FooterProps> = ({
|
|||||||
flexDirection={isNarrow ? 'column' : 'row'}
|
flexDirection={isNarrow ? 'column' : 'row'}
|
||||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||||
>
|
>
|
||||||
{(debugMode || vimMode || !hideCWD) && (
|
{(debugMode || displayVimMode || !hideCWD) && (
|
||||||
<Box>
|
<Box>
|
||||||
{debugMode && <DebugProfiler />}
|
{debugMode && <DebugProfiler />}
|
||||||
{vimMode && <Text color={theme.text.secondary}>[{vimMode}] </Text>}
|
{displayVimMode && (
|
||||||
|
<Text color={theme.text.secondary}>[{displayVimMode}] </Text>
|
||||||
|
)}
|
||||||
{!hideCWD &&
|
{!hideCWD &&
|
||||||
(nightly ? (
|
(nightly ? (
|
||||||
<Gradient colors={theme.ui.gradient}>
|
<Gradient colors={theme.ui.gradient}>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type React from 'react';
|
||||||
|
import { Box } from 'ink';
|
||||||
|
import { Notifications } from '../components/Notifications.js';
|
||||||
|
import { MainContent } from '../components/MainContent.js';
|
||||||
|
import { DialogManager } from '../components/DialogManager.js';
|
||||||
|
import { Composer } from '../components/Composer.js';
|
||||||
|
import { ExitWarning } from '../components/ExitWarning.js';
|
||||||
|
import { useUIState } from '../contexts/UIStateContext.js';
|
||||||
|
|
||||||
|
export const DefaultAppLayout: React.FC = () => {
|
||||||
|
const uiState = useUIState();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" width="90%">
|
||||||
|
<MainContent />
|
||||||
|
|
||||||
|
<Box flexDirection="column" ref={uiState.mainControlsRef}>
|
||||||
|
<Notifications />
|
||||||
|
|
||||||
|
{uiState.dialogsVisible ? (
|
||||||
|
<DialogManager addItem={uiState.historyManager.addItem} />
|
||||||
|
) : (
|
||||||
|
<Composer />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ExitWarning />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type React from 'react';
|
||||||
|
import { Box } from 'ink';
|
||||||
|
import { Notifications } from '../components/Notifications.js';
|
||||||
|
import { MainContent } from '../components/MainContent.js';
|
||||||
|
import { DialogManager } from '../components/DialogManager.js';
|
||||||
|
import { Composer } from '../components/Composer.js';
|
||||||
|
import { Footer } from '../components/Footer.js';
|
||||||
|
import { ExitWarning } from '../components/ExitWarning.js';
|
||||||
|
import { useUIState } from '../contexts/UIStateContext.js';
|
||||||
|
|
||||||
|
export const ScreenReaderAppLayout: React.FC = () => {
|
||||||
|
const uiState = useUIState();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" width="90%" height="100%">
|
||||||
|
<Notifications />
|
||||||
|
<Footer />
|
||||||
|
<Box flexGrow={1} overflow="hidden">
|
||||||
|
<MainContent />
|
||||||
|
</Box>
|
||||||
|
{uiState.dialogsVisible ? (
|
||||||
|
<DialogManager addItem={uiState.historyManager.addItem} />
|
||||||
|
) : (
|
||||||
|
<Composer />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ExitWarning />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user