diff --git a/src/tui/App.tsx b/src/tui/App.tsx index b1eb521..aead558 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -63,15 +63,47 @@ interface FlashMessage { color: 'green' | 'red'; } +type AppScreen = 'main' + | 'lines' + | 'items' + | 'colorLines' + | 'colors' + | 'terminalWidth' + | 'terminalConfig' + | 'globalOverrides' + | 'confirm' + | 'powerline' + | 'install'; + +interface ConfirmDialogState { + message: string; + action: () => Promise; + cancelScreen?: Exclude; +} + +export function getConfirmCancelScreen(confirmDialog: ConfirmDialogState | null): Exclude { + return confirmDialog?.cancelScreen ?? 'main'; +} + +export function clearInstallMenuSelection(menuSelections: Record): Record { + if (menuSelections.install === undefined) { + return menuSelections; + } + + const next = { ...menuSelections }; + delete next.install; + return next; +} + export const App: React.FC = () => { const { exit } = useApp(); const [settings, setSettings] = useState(null); const [originalSettings, setOriginalSettings] = useState(null); const [hasChanges, setHasChanges] = useState(false); - const [screen, setScreen] = useState<'main' | 'lines' | 'items' | 'colorLines' | 'colors' | 'terminalWidth' | 'terminalConfig' | 'globalOverrides' | 'confirm' | 'powerline' | 'install'>('main'); + const [screen, setScreen] = useState('main'); const [selectedLine, setSelectedLine] = useState(0); const [menuSelections, setMenuSelections] = useState>({}); - const [confirmDialog, setConfirmDialog] = useState<{ message: string; action: () => Promise } | null>(null); + const [confirmDialog, setConfirmDialog] = useState(null); const [isClaudeInstalled, setIsClaudeInstalled] = useState(false); const [terminalWidth, setTerminalWidth] = useState(process.stdout.columns || 80); const [powerlineFontStatus, setPowerlineFontStatus] = useState({ installed: false }); @@ -163,6 +195,7 @@ export const App: React.FC = () => { setConfirmDialog({ message, + cancelScreen: 'install', action: async () => { await installStatusLine(useBunx); setIsClaudeInstalled(true); @@ -176,13 +209,20 @@ export const App: React.FC = () => { }, []); const handleNpxInstall = useCallback(() => { + setMenuSelections(prev => ({ ...prev, install: 0 })); handleInstallSelection(CCSTATUSLINE_COMMANDS.NPM, 'npx', false); }, [handleInstallSelection]); const handleBunxInstall = useCallback(() => { + setMenuSelections(prev => ({ ...prev, install: 1 })); handleInstallSelection(CCSTATUSLINE_COMMANDS.BUNX, 'bunx', true); }, [handleInstallSelection]); + const handleInstallMenuCancel = useCallback(() => { + setMenuSelections(clearInstallMenuSelection); + setScreen('main'); + }, []); + if (!settings) { return Loading settings...; } @@ -440,7 +480,7 @@ export const App: React.FC = () => { message={confirmDialog.message} onConfirm={() => void confirmDialog.action()} onCancel={() => { - setScreen('main'); + setScreen(getConfirmCancelScreen(confirmDialog)); setConfirmDialog(null); }} /> @@ -451,9 +491,8 @@ export const App: React.FC = () => { existingStatusLine={existingStatusLine} onSelectNpx={handleNpxInstall} onSelectBunx={handleBunxInstall} - onCancel={() => { - setScreen('main'); - }} + onCancel={handleInstallMenuCancel} + initialSelection={menuSelections.install} /> )} {screen === 'powerline' && ( diff --git a/src/tui/__tests__/App.test.ts b/src/tui/__tests__/App.test.ts new file mode 100644 index 0000000..201bb84 --- /dev/null +++ b/src/tui/__tests__/App.test.ts @@ -0,0 +1,39 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import { + clearInstallMenuSelection, + getConfirmCancelScreen +} from '../App'; + +describe('App confirm navigation helpers', () => { + it('defaults confirmation cancel navigation to the main menu', () => { + expect(getConfirmCancelScreen(null)).toBe('main'); + expect(getConfirmCancelScreen({ + message: 'Confirm install?', + action: () => Promise.resolve() + })).toBe('main'); + }); + + it('returns to the install menu when the confirm dialog requests it', () => { + expect(getConfirmCancelScreen({ + message: 'Confirm install?', + action: () => Promise.resolve(), + cancelScreen: 'install' + })).toBe('install'); + }); + + it('clears saved install selection when leaving the install menu', () => { + expect(clearInstallMenuSelection({ + main: 5, + install: 1 + })).toEqual({ main: 5 }); + + const menuSelections = { main: 5 }; + + expect(clearInstallMenuSelection(menuSelections)).toBe(menuSelections); + }); +}); \ No newline at end of file diff --git a/src/tui/components/InstallMenu.tsx b/src/tui/components/InstallMenu.tsx index edc316f..093ab52 100644 --- a/src/tui/components/InstallMenu.tsx +++ b/src/tui/components/InstallMenu.tsx @@ -1,6 +1,7 @@ import { Box, - Text + Text, + useInput } from 'ink'; import React from 'react'; @@ -14,6 +15,7 @@ export interface InstallMenuProps { onSelectNpx: () => void; onSelectBunx: () => void; onCancel: () => void; + initialSelection?: number; } export const InstallMenu: React.FC = ({ @@ -21,8 +23,15 @@ export const InstallMenu: React.FC = ({ existingStatusLine, onSelectNpx, onSelectBunx, - onCancel + onCancel, + initialSelection = 0 }) => { + useInput((_, key) => { + if (key.escape) { + onCancel(); + } + }); + function onSelect(value: string) { switch (value) { case 'npx': @@ -82,6 +91,7 @@ export const InstallMenu: React.FC = ({ onSelect(line); }} + initialSelection={initialSelection} showBackButton={true} /> diff --git a/src/tui/components/__tests__/InstallMenu.test.tsx b/src/tui/components/__tests__/InstallMenu.test.tsx new file mode 100644 index 0000000..fa4fef7 --- /dev/null +++ b/src/tui/components/__tests__/InstallMenu.test.tsx @@ -0,0 +1,135 @@ +import { render } from 'ink'; +import { PassThrough } from 'node:stream'; +import React from 'react'; +import stripAnsi from 'strip-ansi'; +import { + describe, + expect, + it, + vi +} from 'vitest'; + +import { InstallMenu } from '../InstallMenu'; + +class MockTtyStream extends PassThrough { + isTTY = true; + columns = 120; + rows = 40; + + setRawMode() { + return this; + } + + ref() { + return this; + } + + unref() { + return this; + } +} + +interface CapturedWriteStream extends NodeJS.WriteStream { getOutput: () => string } + +function createMockStdin(): NodeJS.ReadStream { + return new MockTtyStream() as unknown as NodeJS.ReadStream; +} + +function createMockStdout(): CapturedWriteStream { + const stream = new MockTtyStream(); + const chunks: string[] = []; + + stream.on('data', (chunk: Buffer | string) => { + chunks.push(chunk.toString()); + }); + + return Object.assign(stream as unknown as NodeJS.WriteStream, { + getOutput() { + return stripAnsi(chunks.join('')); + } + }); +} + +function flushInk() { + return new Promise((resolve) => { + setTimeout(resolve, 25); + }); +} + +describe('InstallMenu', () => { + it('calls onCancel when escape is pressed', async () => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const onCancel = vi.fn(); + const instance = render( + React.createElement(InstallMenu, { + bunxAvailable: true, + existingStatusLine: null, + onSelectNpx: vi.fn(), + onSelectBunx: vi.fn(), + onCancel + }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await flushInk(); + + stdin.write('\u001B'); + await flushInk(); + + expect(onCancel).toHaveBeenCalledTimes(1); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); + + it('respects the provided initial selection', async () => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const instance = render( + React.createElement(InstallMenu, { + bunxAvailable: true, + existingStatusLine: null, + onSelectNpx: vi.fn(), + onSelectBunx: vi.fn(), + onCancel: vi.fn(), + initialSelection: 1 + }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await flushInk(); + + expect(stdout.getOutput()).toContain('▶ bunx - Bun Package Execute'); + expect(stdout.getOutput()).not.toContain('▶ npx - Node Package Execute'); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); +}); \ No newline at end of file