fix: install menu escape navigation

This commit is contained in:
Matthew Breedlove
2026-03-06 17:49:58 -05:00
parent fe9cc15818
commit db950f3f7a
4 changed files with 231 additions and 8 deletions
+45 -6
View File
@@ -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<void>;
cancelScreen?: Exclude<AppScreen, 'confirm'>;
}
export function getConfirmCancelScreen(confirmDialog: ConfirmDialogState | null): Exclude<AppScreen, 'confirm'> {
return confirmDialog?.cancelScreen ?? 'main';
}
export function clearInstallMenuSelection(menuSelections: Record<string, number>): Record<string, number> {
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<Settings | null>(null);
const [originalSettings, setOriginalSettings] = useState<Settings | null>(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<AppScreen>('main');
const [selectedLine, setSelectedLine] = useState(0);
const [menuSelections, setMenuSelections] = useState<Record<string, number>>({});
const [confirmDialog, setConfirmDialog] = useState<{ message: string; action: () => Promise<void> } | null>(null);
const [confirmDialog, setConfirmDialog] = useState<ConfirmDialogState | null>(null);
const [isClaudeInstalled, setIsClaudeInstalled] = useState(false);
const [terminalWidth, setTerminalWidth] = useState(process.stdout.columns || 80);
const [powerlineFontStatus, setPowerlineFontStatus] = useState<PowerlineFontStatus>({ 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 <Text>Loading settings...</Text>;
}
@@ -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' && (
+39
View File
@@ -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);
});
});
+12 -2
View File
@@ -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<InstallMenuProps> = ({
@@ -21,8 +23,15 @@ export const InstallMenu: React.FC<InstallMenuProps> = ({
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<InstallMenuProps> = ({
onSelect(line);
}}
initialSelection={initialSelection}
showBackButton={true}
/>
@@ -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();
}
});
});