Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 49 additions & 1 deletion packages/cli/src/ui/components/RewindConfirmation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { Box, Text } from 'ink';
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import type React from 'react';
import { useMemo } from 'react';
import { theme } from '../semantic-colors.js';
Expand Down Expand Up @@ -58,6 +58,7 @@ export const RewindConfirmation: React.FC<RewindConfirmationProps> = ({
terminalWidth,
timestamp,
}) => {
const isScreenReaderEnabled = useIsScreenReaderEnabled();
useKeypress(
(key) => {
if (keyMatchers[Command.ESCAPE](key)) {
Expand All @@ -83,6 +84,53 @@ export const RewindConfirmation: React.FC<RewindConfirmationProps> = ({
option.value !== RewindOutcome.RevertOnly,
);
}, [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 (
<Box
Expand Down
64 changes: 63 additions & 1 deletion packages/cli/src/ui/components/RewindViewer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* 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 { renderWithProviders } from '../../test-utils/render.js';
import { RewindViewer } from './RewindViewer.js';
Expand All @@ -14,6 +14,11 @@ import type {
MessageRecord,
} 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', () => ({
CliSpinner: () => 'MockSpinner',
}));
Expand Down Expand Up @@ -71,6 +76,35 @@ describe('RewindViewer', () => {
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', () => {
it.each([
{ name: 'nothing interesting for empty conversation', messages: [] },
Expand Down Expand Up @@ -400,3 +434,31 @@ describe('RewindViewer', () => {
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();
});
39 changes: 37 additions & 2 deletions packages/cli/src/ui/components/RewindViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import type React 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 {
type ConversationRecord,
Expand Down Expand Up @@ -50,6 +50,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
}) => {
const [isRewinding, setIsRewinding] = useState(false);
const { terminalWidth, terminalHeight } = useUIState();
const isScreenReaderEnabled = useIsScreenReaderEnabled();
const {
selectedMessageId,
getStats,
Expand Down Expand Up @@ -128,7 +129,6 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
5,
terminalHeight - DIALOG_PADDING - HEADER_HEIGHT - CONTROLS_HEIGHT - 2,
);

const maxItemsToShow = Math.max(1, Math.floor(listHeight / 4));

if (selectedMessageId) {
Expand Down Expand Up @@ -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 (
<Box
borderStyle="round"
Expand Down
Loading