mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
Fix(accessibility): add screen reader support to RewindViewer (#20750)
This commit is contained in:
committed by
GitHub
parent
0452f787b2
commit
d97eaf3420
@@ -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 type React from 'react';
|
import type React from 'react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
@@ -58,6 +58,7 @@ export const RewindConfirmation: React.FC<RewindConfirmationProps> = ({
|
|||||||
terminalWidth,
|
terminalWidth,
|
||||||
timestamp,
|
timestamp,
|
||||||
}) => {
|
}) => {
|
||||||
|
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||||
useKeypress(
|
useKeypress(
|
||||||
(key) => {
|
(key) => {
|
||||||
if (keyMatchers[Command.ESCAPE](key)) {
|
if (keyMatchers[Command.ESCAPE](key)) {
|
||||||
@@ -83,6 +84,53 @@ export const RewindConfirmation: React.FC<RewindConfirmationProps> = ({
|
|||||||
option.value !== RewindOutcome.RevertOnly,
|
option.value !== RewindOutcome.RevertOnly,
|
||||||
);
|
);
|
||||||
}, [stats]);
|
}, [stats]);
|
||||||
|
if (isScreenReaderEnabled) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" width={terminalWidth}>
|
||||||
|
<Text bold>Confirm Rewind</Text>
|
||||||
|
|
||||||
|
{stats && (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text>
|
||||||
|
{stats.fileCount === 1
|
||||||
|
? `File: ${stats.details?.at(0)?.fileName}`
|
||||||
|
: `${stats.fileCount} files affected`}
|
||||||
|
</Text>
|
||||||
|
<Text>Lines added: {stats.addedLines}</Text>
|
||||||
|
<Text>Lines removed: {stats.removedLines}</Text>
|
||||||
|
{timestamp && <Text>({formatTimeAgo(timestamp)})</Text>}
|
||||||
|
<Text>
|
||||||
|
Note: Rewinding does not affect files edited manually or by the
|
||||||
|
shell tool.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!stats && (
|
||||||
|
<Box>
|
||||||
|
<Text color={theme.text.secondary}>No code changes to revert.</Text>
|
||||||
|
{timestamp && (
|
||||||
|
<Text color={theme.text.secondary}>
|
||||||
|
{' '}
|
||||||
|
({formatTimeAgo(timestamp)})
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text>Select an action:</Text>
|
||||||
|
<Text color={theme.text.secondary}>
|
||||||
|
Use arrow keys to navigate, Enter to confirm, Esc to cancel.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<RadioButtonSelect
|
||||||
|
items={options}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
isFocused={true}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||||
import { act } from 'react';
|
import { act } from 'react';
|
||||||
import { renderWithProviders } from '../../test-utils/render.js';
|
import { renderWithProviders } from '../../test-utils/render.js';
|
||||||
import { RewindViewer } from './RewindViewer.js';
|
import { RewindViewer } from './RewindViewer.js';
|
||||||
@@ -14,6 +14,11 @@ import type {
|
|||||||
MessageRecord,
|
MessageRecord,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
|
|
||||||
|
vi.mock('ink', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('ink')>('ink');
|
||||||
|
return { ...actual, useIsScreenReaderEnabled: vi.fn(() => false) };
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('./CliSpinner.js', () => ({
|
vi.mock('./CliSpinner.js', () => ({
|
||||||
CliSpinner: () => 'MockSpinner',
|
CliSpinner: () => 'MockSpinner',
|
||||||
}));
|
}));
|
||||||
@@ -71,6 +76,35 @@ describe('RewindViewer', () => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Screen Reader Accessibility', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
const { useIsScreenReaderEnabled } = await import('ink');
|
||||||
|
vi.mocked(useIsScreenReaderEnabled).mockReturnValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
const { useIsScreenReaderEnabled } = await import('ink');
|
||||||
|
vi.mocked(useIsScreenReaderEnabled).mockReturnValue(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the rewind viewer with conversation items', async () => {
|
||||||
|
const conversation = createConversation([
|
||||||
|
{ type: 'user', content: 'Hello', id: '1', timestamp: '1' },
|
||||||
|
]);
|
||||||
|
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||||
|
<RewindViewer
|
||||||
|
conversation={conversation}
|
||||||
|
onExit={vi.fn()}
|
||||||
|
onRewind={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await waitUntilReady();
|
||||||
|
expect(lastFrame()).toContain('Rewind');
|
||||||
|
expect(lastFrame()).toContain('Hello');
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Rendering', () => {
|
describe('Rendering', () => {
|
||||||
it.each([
|
it.each([
|
||||||
{ name: 'nothing interesting for empty conversation', messages: [] },
|
{ name: 'nothing interesting for empty conversation', messages: [] },
|
||||||
@@ -400,3 +434,31 @@ describe('RewindViewer', () => {
|
|||||||
unmount2();
|
unmount2();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
it('renders accessible screen reader view when screen reader is enabled', async () => {
|
||||||
|
const { useIsScreenReaderEnabled } = await import('ink');
|
||||||
|
vi.mocked(useIsScreenReaderEnabled).mockReturnValue(true);
|
||||||
|
|
||||||
|
const messages: MessageRecord[] = [
|
||||||
|
{ type: 'user', content: 'Hello world', id: '1', timestamp: '1' },
|
||||||
|
{ type: 'user', content: 'Second message', id: '2', timestamp: '2' },
|
||||||
|
];
|
||||||
|
const conversation = createConversation(messages);
|
||||||
|
const onExit = vi.fn();
|
||||||
|
const onRewind = vi.fn();
|
||||||
|
|
||||||
|
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||||
|
<RewindViewer
|
||||||
|
conversation={conversation}
|
||||||
|
onExit={onExit}
|
||||||
|
onRewind={onRewind}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await waitUntilReady();
|
||||||
|
|
||||||
|
const frame = lastFrame();
|
||||||
|
expect(frame).toContain('Rewind - Select a conversation point:');
|
||||||
|
expect(frame).toContain('Stay at current position');
|
||||||
|
|
||||||
|
vi.mocked(useIsScreenReaderEnabled).mockReturnValue(false);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
|
||||||
import { useUIState } from '../contexts/UIStateContext.js';
|
import { useUIState } from '../contexts/UIStateContext.js';
|
||||||
import {
|
import {
|
||||||
type ConversationRecord,
|
type ConversationRecord,
|
||||||
@@ -50,6 +50,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [isRewinding, setIsRewinding] = useState(false);
|
const [isRewinding, setIsRewinding] = useState(false);
|
||||||
const { terminalWidth, terminalHeight } = useUIState();
|
const { terminalWidth, terminalHeight } = useUIState();
|
||||||
|
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||||
const {
|
const {
|
||||||
selectedMessageId,
|
selectedMessageId,
|
||||||
getStats,
|
getStats,
|
||||||
@@ -128,7 +129,6 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
|||||||
5,
|
5,
|
||||||
terminalHeight - DIALOG_PADDING - HEADER_HEIGHT - CONTROLS_HEIGHT - 2,
|
terminalHeight - DIALOG_PADDING - HEADER_HEIGHT - CONTROLS_HEIGHT - 2,
|
||||||
);
|
);
|
||||||
|
|
||||||
const maxItemsToShow = Math.max(1, Math.floor(listHeight / 4));
|
const maxItemsToShow = Math.max(1, Math.floor(listHeight / 4));
|
||||||
|
|
||||||
if (selectedMessageId) {
|
if (selectedMessageId) {
|
||||||
@@ -182,6 +182,41 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isScreenReaderEnabled) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" width={terminalWidth}>
|
||||||
|
<Text bold>Rewind - Select a conversation point:</Text>
|
||||||
|
<BaseSelectionList
|
||||||
|
items={items}
|
||||||
|
initialIndex={items.length - 1}
|
||||||
|
isFocused={true}
|
||||||
|
showNumbers={true}
|
||||||
|
wrapAround={false}
|
||||||
|
onSelect={(item: MessageRecord) => {
|
||||||
|
if (item?.id) {
|
||||||
|
if (item.id === 'current-position') {
|
||||||
|
onExit();
|
||||||
|
} else {
|
||||||
|
selectMessage(item.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
renderItem={(itemWrapper) => {
|
||||||
|
const item = itemWrapper.value;
|
||||||
|
const text =
|
||||||
|
item.id === 'current-position'
|
||||||
|
? 'Stay at current position'
|
||||||
|
: getCleanedRewindText(item);
|
||||||
|
return <Text>{text}</Text>;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text color={theme.text.secondary}>
|
||||||
|
Press Esc to exit, Enter to select, arrow keys to navigate.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
borderStyle="round"
|
borderStyle="round"
|
||||||
|
|||||||
Reference in New Issue
Block a user