Files
xo-cli/src/tui/App.tsx

206 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Main App component for the XO Wallet CLI.
* Uses Ink for terminal rendering with React components.
*/
import React from 'react';
import { Box, Text, useApp, useInput } from 'ink';
import { NavigationProvider, useNavigation } from './hooks/useNavigation.js';
import { AppProvider, useAppContext, useDialog, useStatus } from './hooks/useAppContext.js';
import type { AppConfig } from '../app.js';
import { colors, logoSmall } from './theme.js';
// Screen imports
import { SeedInputScreen } from './screens/SeedInput.js';
import { WalletStateScreen } from './screens/WalletState.js';
import { TemplateListScreen } from './screens/TemplateList.js';
import { ActionWizardScreen } from './screens/action-wizard/ActionWizardScreen.js';
import { InvitationScreen } from './screens/invitations/InvitationScreen.js';
import { TransactionScreen } from './screens/Transaction.js';
import { MessageDialog } from './components/Dialog.js';
/**
* Props for the App component.
*/
interface AppProps {
config: AppConfig;
}
/**
* Router component that renders the current screen.
*/
function Router(): React.ReactElement {
const { screen } = useNavigation();
switch (screen) {
case 'seed-input':
return <SeedInputScreen />;
case 'wallet':
return <WalletStateScreen />;
case 'templates':
return <TemplateListScreen />;
case 'wizard':
return <ActionWizardScreen />;
case 'invitations':
return <InvitationScreen />;
case 'transaction':
return <TransactionScreen />;
default:
return <Text color={colors.error}>Unknown screen: {screen}</Text>;
}
}
/**
* Status bar component shown at the bottom of the screen.
*/
function StatusBar(): React.ReactElement {
const { status } = useStatus();
const { screen, canGoBack } = useNavigation();
return (
<Box
borderStyle="single"
borderColor={colors.border}
paddingX={1}
justifyContent="space-between"
>
<Text color={colors.primary} bold>{logoSmall}</Text>
<Text color={colors.textMuted}>{status}</Text>
<Text color={colors.textMuted}>
{canGoBack ? 'ESC: Back | ' : ''}q: Quit
</Text>
</Box>
);
}
/**
* Dialog overlay component for modals.
*/
function DialogOverlay(): React.ReactElement | null {
const { dialog, setDialog } = useDialog();
// 'custom' dialogs are rendered and managed by the screen itself;
// we only handle input for the built-in dialog types.
const isBuiltInDialog = dialog?.visible === true && dialog.type !== 'custom';
useInput((input, key) => {
if (!isBuiltInDialog) return;
if (key.return || input === 'y' || input === 'Y') {
if (dialog.type === 'confirm' && dialog.onConfirm) {
dialog.onConfirm();
} else {
dialog.onCancel?.();
}
} else if (key.escape || input === 'n' || input === 'N') {
dialog.onCancel?.();
}
}, { isActive: isBuiltInDialog });
if (!isBuiltInDialog) return null;
const borderColor = dialog.type === 'error' ? colors.error :
dialog.type === 'confirm' ? colors.warning :
colors.info;
return (
<Box
position="absolute"
flexDirection="column"
alignItems="center"
justifyContent="center"
width="100%"
height="100%"
>
<MessageDialog
title={dialog.type === 'error' ? '✗ Error' :
dialog.type === 'confirm' ? '? Confirm' :
' Info'}
message={dialog.message}
onClose={dialog.onCancel ?? (() => {})}
type={dialog.type as 'error' | 'info' | 'success'}
/>
</Box>
);
}
/**
* Main content wrapper with global keybindings.
*/
function MainContent(): React.ReactElement {
const { exit } = useApp();
const { goBack, canGoBack } = useNavigation();
const { screen } = useNavigation();
const { dialog } = useDialog();
const appContext = useAppContext();
// Global keybindings (disabled when dialog is shown)
useInput((input, key) => {
// Don't handle global keys when dialog is shown
if (dialog?.visible) return;
// Quit on 'q' or Ctrl+C
if (
// Commenting out 'q'. Its annoying me - It activates in text inputs.
// input === 'q'
(key.ctrl && input === 'c')
) {
appContext.exit();
exit();
}
// Go back on Escape
if (key.escape && canGoBack) {
goBack();
// If we went back to the seed input screen, remove the current engine
// TODO: This was to support going back to seed input then re-opening your seed, but there is a bug in the engine which prevents it from closing the current
// storage instance, giving us an error about the database already being opened.
if (screen === 'seed-input') {
appContext.appService?.engine.stop();
appContext.appService = null;
}
}
});
return (
<Box flexDirection="column" height="100%">
{/* Main content area */}
<Box flexDirection="column" flexGrow={1}>
<Router />
</Box>
{/* Status bar */}
<StatusBar />
{/* Dialog overlay */}
<DialogOverlay />
</Box>
);
}
/**
* Main App component.
* Sets up providers and renders the main content.
*/
export function App({ config }: AppProps): React.ReactElement {
const { exit } = useApp();
// Cleanup will be handled by React when components unmount
const handleExit = () => {
exit();
};
return (
<AppProvider
config={config}
onExit={handleExit}
>
<NavigationProvider initialScreen="seed-input">
<MainContent />
</NavigationProvider>
</AppProvider>
);
}