Skip to content

Commit d97eaf3

Browse files
authored
Fix(accessibility): add screen reader support to RewindViewer (#20750)
1 parent 0452f78 commit d97eaf3

3 files changed

Lines changed: 149 additions & 4 deletions

File tree

packages/cli/src/ui/components/RewindConfirmation.tsx

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
import { Box, Text } from 'ink';
7+
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
88
import type React from 'react';
99
import { useMemo } from 'react';
1010
import { theme } from '../semantic-colors.js';
@@ -58,6 +58,7 @@ export const RewindConfirmation: React.FC<RewindConfirmationProps> = ({
5858
terminalWidth,
5959
timestamp,
6060
}) => {
61+
const isScreenReaderEnabled = useIsScreenReaderEnabled();
6162
useKeypress(
6263
(key) => {
6364
if (keyMatchers[Command.ESCAPE](key)) {
@@ -83,6 +84,53 @@ export const RewindConfirmation: React.FC<RewindConfirmationProps> = ({
8384
option.value !== RewindOutcome.RevertOnly,
8485
);
8586
}, [stats]);
87+
if (isScreenReaderEnabled) {
88+
return (
89+
<Box flexDirection="column" width={terminalWidth}>
90+
<Text bold>Confirm Rewind</Text>
91+
92+
{stats && (
93+
<Box flexDirection="column">
94+
<Text>
95+
{stats.fileCount === 1
96+
? `File: ${stats.details?.at(0)?.fileName}`
97+
: `${stats.fileCount} files affected`}
98+
</Text>
99+
<Text>Lines added: {stats.addedLines}</Text>
100+
<Text>Lines removed: {stats.removedLines}</Text>
101+
{timestamp && <Text>({formatTimeAgo(timestamp)})</Text>}
102+
<Text>
103+
Note: Rewinding does not affect files edited manually or by the
104+
shell tool.
105+
</Text>
106+
</Box>
107+
)}
108+
109+
{!stats && (
110+
<Box>
111+
<Text color={theme.text.secondary}>No code changes to revert.</Text>
112+
{timestamp && (
113+
<Text color={theme.text.secondary}>
114+
{' '}
115+
({formatTimeAgo(timestamp)})
116+
</Text>
117+
)}
118+
</Box>
119+
)}
120+
121+
<Text>Select an action:</Text>
122+
<Text color={theme.text.secondary}>
123+
Use arrow keys to navigate, Enter to confirm, Esc to cancel.
124+
</Text>
125+
126+
<RadioButtonSelect
127+
items={options}
128+
onSelect={handleSelect}
129+
isFocused={true}
130+
/>
131+
</Box>
132+
);
133+
}
86134

87135
return (
88136
<Box

packages/cli/src/ui/components/RewindViewer.test.tsx

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
import { describe, it, expect, vi, afterEach } from 'vitest';
7+
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
88
import { act } from 'react';
99
import { renderWithProviders } from '../../test-utils/render.js';
1010
import { RewindViewer } from './RewindViewer.js';
@@ -14,6 +14,11 @@ import type {
1414
MessageRecord,
1515
} from '@google/gemini-cli-core';
1616

17+
vi.mock('ink', async () => {
18+
const actual = await vi.importActual<typeof import('ink')>('ink');
19+
return { ...actual, useIsScreenReaderEnabled: vi.fn(() => false) };
20+
});
21+
1722
vi.mock('./CliSpinner.js', () => ({
1823
CliSpinner: () => 'MockSpinner',
1924
}));
@@ -71,6 +76,35 @@ describe('RewindViewer', () => {
7176
vi.restoreAllMocks();
7277
});
7378

79+
describe('Screen Reader Accessibility', () => {
80+
beforeEach(async () => {
81+
const { useIsScreenReaderEnabled } = await import('ink');
82+
vi.mocked(useIsScreenReaderEnabled).mockReturnValue(true);
83+
});
84+
85+
afterEach(async () => {
86+
const { useIsScreenReaderEnabled } = await import('ink');
87+
vi.mocked(useIsScreenReaderEnabled).mockReturnValue(false);
88+
});
89+
90+
it('renders the rewind viewer with conversation items', async () => {
91+
const conversation = createConversation([
92+
{ type: 'user', content: 'Hello', id: '1', timestamp: '1' },
93+
]);
94+
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
95+
<RewindViewer
96+
conversation={conversation}
97+
onExit={vi.fn()}
98+
onRewind={vi.fn()}
99+
/>,
100+
);
101+
await waitUntilReady();
102+
expect(lastFrame()).toContain('Rewind');
103+
expect(lastFrame()).toContain('Hello');
104+
unmount();
105+
});
106+
});
107+
74108
describe('Rendering', () => {
75109
it.each([
76110
{ name: 'nothing interesting for empty conversation', messages: [] },
@@ -400,3 +434,31 @@ describe('RewindViewer', () => {
400434
unmount2();
401435
});
402436
});
437+
it('renders accessible screen reader view when screen reader is enabled', async () => {
438+
const { useIsScreenReaderEnabled } = await import('ink');
439+
vi.mocked(useIsScreenReaderEnabled).mockReturnValue(true);
440+
441+
const messages: MessageRecord[] = [
442+
{ type: 'user', content: 'Hello world', id: '1', timestamp: '1' },
443+
{ type: 'user', content: 'Second message', id: '2', timestamp: '2' },
444+
];
445+
const conversation = createConversation(messages);
446+
const onExit = vi.fn();
447+
const onRewind = vi.fn();
448+
449+
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
450+
<RewindViewer
451+
conversation={conversation}
452+
onExit={onExit}
453+
onRewind={onRewind}
454+
/>,
455+
);
456+
await waitUntilReady();
457+
458+
const frame = lastFrame();
459+
expect(frame).toContain('Rewind - Select a conversation point:');
460+
expect(frame).toContain('Stay at current position');
461+
462+
vi.mocked(useIsScreenReaderEnabled).mockReturnValue(false);
463+
unmount();
464+
});

packages/cli/src/ui/components/RewindViewer.tsx

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import type React from 'react';
88
import { useMemo, useState } from 'react';
9-
import { Box, Text } from 'ink';
9+
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
1010
import { useUIState } from '../contexts/UIStateContext.js';
1111
import {
1212
type ConversationRecord,
@@ -50,6 +50,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
5050
}) => {
5151
const [isRewinding, setIsRewinding] = useState(false);
5252
const { terminalWidth, terminalHeight } = useUIState();
53+
const isScreenReaderEnabled = useIsScreenReaderEnabled();
5354
const {
5455
selectedMessageId,
5556
getStats,
@@ -128,7 +129,6 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
128129
5,
129130
terminalHeight - DIALOG_PADDING - HEADER_HEIGHT - CONTROLS_HEIGHT - 2,
130131
);
131-
132132
const maxItemsToShow = Math.max(1, Math.floor(listHeight / 4));
133133

134134
if (selectedMessageId) {
@@ -182,6 +182,41 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
182182
);
183183
}
184184

185+
if (isScreenReaderEnabled) {
186+
return (
187+
<Box flexDirection="column" width={terminalWidth}>
188+
<Text bold>Rewind - Select a conversation point:</Text>
189+
<BaseSelectionList
190+
items={items}
191+
initialIndex={items.length - 1}
192+
isFocused={true}
193+
showNumbers={true}
194+
wrapAround={false}
195+
onSelect={(item: MessageRecord) => {
196+
if (item?.id) {
197+
if (item.id === 'current-position') {
198+
onExit();
199+
} else {
200+
selectMessage(item.id);
201+
}
202+
}
203+
}}
204+
renderItem={(itemWrapper) => {
205+
const item = itemWrapper.value;
206+
const text =
207+
item.id === 'current-position'
208+
? 'Stay at current position'
209+
: getCleanedRewindText(item);
210+
return <Text>{text}</Text>;
211+
}}
212+
/>
213+
<Text color={theme.text.secondary}>
214+
Press Esc to exit, Enter to select, arrow keys to navigate.
215+
</Text>
216+
</Box>
217+
);
218+
}
219+
185220
return (
186221
<Box
187222
borderStyle="round"

0 commit comments

Comments
 (0)