diff --git a/packages/cli/src/ui/components/RewindConfirmation.tsx b/packages/cli/src/ui/components/RewindConfirmation.tsx index 5ff7e5e10c..bbfbf9dbee 100644 --- a/packages/cli/src/ui/components/RewindConfirmation.tsx +++ b/packages/cli/src/ui/components/RewindConfirmation.tsx @@ -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'; @@ -58,6 +58,7 @@ export const RewindConfirmation: React.FC = ({ terminalWidth, timestamp, }) => { + const isScreenReaderEnabled = useIsScreenReaderEnabled(); useKeypress( (key) => { if (keyMatchers[Command.ESCAPE](key)) { @@ -83,6 +84,53 @@ export const RewindConfirmation: React.FC = ({ option.value !== RewindOutcome.RevertOnly, ); }, [stats]); + if (isScreenReaderEnabled) { + return ( + + Confirm Rewind + + {stats && ( + + + {stats.fileCount === 1 + ? `File: ${stats.details?.at(0)?.fileName}` + : `${stats.fileCount} files affected`} + + Lines added: {stats.addedLines} + Lines removed: {stats.removedLines} + {timestamp && ({formatTimeAgo(timestamp)})} + + Note: Rewinding does not affect files edited manually or by the + shell tool. + + + )} + + {!stats && ( + + No code changes to revert. + {timestamp && ( + + {' '} + ({formatTimeAgo(timestamp)}) + + )} + + )} + + Select an action: + + Use arrow keys to navigate, Enter to confirm, Esc to cancel. + + + + + ); + } return ( { + const actual = await vi.importActual('ink'); + return { ...actual, useIsScreenReaderEnabled: vi.fn(() => false) }; +}); + vi.mock('./CliSpinner.js', () => ({ CliSpinner: () => 'MockSpinner', })); @@ -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( + , + ); + await waitUntilReady(); + expect(lastFrame()).toContain('Rewind'); + expect(lastFrame()).toContain('Hello'); + unmount(); + }); + }); + describe('Rendering', () => { it.each([ { name: 'nothing interesting for empty conversation', messages: [] }, @@ -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( + , + ); + 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(); +}); diff --git a/packages/cli/src/ui/components/RewindViewer.tsx b/packages/cli/src/ui/components/RewindViewer.tsx index 048511dd77..26f7282f61 100644 --- a/packages/cli/src/ui/components/RewindViewer.tsx +++ b/packages/cli/src/ui/components/RewindViewer.tsx @@ -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, @@ -50,6 +50,7 @@ export const RewindViewer: React.FC = ({ }) => { const [isRewinding, setIsRewinding] = useState(false); const { terminalWidth, terminalHeight } = useUIState(); + const isScreenReaderEnabled = useIsScreenReaderEnabled(); const { selectedMessageId, getStats, @@ -128,7 +129,6 @@ export const RewindViewer: React.FC = ({ 5, terminalHeight - DIALOG_PADDING - HEADER_HEIGHT - CONTROLS_HEIGHT - 2, ); - const maxItemsToShow = Math.max(1, Math.floor(listHeight / 4)); if (selectedMessageId) { @@ -182,6 +182,41 @@ export const RewindViewer: React.FC = ({ ); } + if (isScreenReaderEnabled) { + return ( + + Rewind - Select a conversation point: + { + 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}; + }} + /> + + Press Esc to exit, Enter to select, arrow keys to navigate. + + + ); + } + return (