/** * Main App component for the XO Wallet CLI. * Uses Ink for terminal rendering with React components. */ import React, { useEffect, useState } from 'react'; import { Box, Text, useApp } from 'ink'; import { NavigationProvider, useNavigation } from './hooks/useNavigation.js'; import { AppProvider, useAppContext, useDialog, useStatus } from './hooks/useAppContext.js'; import { InputLayerProvider, useBlockableInput } from './hooks/useInputLayer.js'; import type { AppConfig } from '../app.js'; import type { GitUpdateService, UpdateCheck, UpdateLaunchRequest, } from '../updater/git-update-service.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 { ConfirmDialog, MessageDialog } from './components/Dialog.js'; /** * Props for the App component. */ interface AppProps { config: AppConfig; updater: GitUpdateService; onUpdateAndRestart: (request: UpdateLaunchRequest) => void; } /** * Router component that renders the current screen. */ function Router(): React.ReactElement { const { screen } = useNavigation(); switch (screen) { case 'seed-input': return ; case 'wallet': return ; case 'templates': return ; case 'wizard': return ; case 'invitations': return ; default: return Unknown screen: {screen}; } } /** * Status bar component shown at the bottom of the screen. */ function StatusBar({ update }: { update: UpdateCheck | null }): React.ReactElement { const { status } = useStatus(); const { screen, canGoBack } = useNavigation(); return ( {logoSmall} {update?.status === 'available' ? 'Update available · press U' : status} {canGoBack ? 'ESC: Back | ' : ''}q: Quit ); } /** * Dialog overlay component for modals. */ function DialogOverlay(): React.ReactElement | null { const { dialog } = useDialog(); if (!dialog?.visible) return null; const contents = dialog.type === 'confirm' ? ( {})} onCancel={dialog.onCancel ?? (() => {})} confirmLabel={dialog.confirmLabel} cancelLabel={dialog.cancelLabel} /> ) : ( {})} type={dialog.type} /> ); return ( {contents} ); } /** * Main content wrapper with global keybindings. */ function MainContent({ updater, onUpdateAndRestart, }: Pick): React.ReactElement { const { goBack, canGoBack } = useNavigation(); const { screen } = useNavigation(); const appContext = useAppContext(); const [update, setUpdate] = useState(null); useEffect(() => { let active = true; updater.check().then((result) => { if (active) setUpdate(result); }).catch(() => { // Update checks are advisory and must never prevent wallet use. }); return () => { active = false; }; }, [updater]); // Global keybindings — auto-blocked when any dialog/overlay is capturing input. useBlockableInput((input, key) => { // Quit on Ctrl+C if (key.ctrl && input === 'c') { void appContext.exit(); return; } if ((input === 'u' || input === 'U') && update?.status === 'available') { if (!update.canApply) { appContext.showError(update.blockReason ?? 'This update cannot be applied automatically.'); return; } void appContext.confirm( `Branch: ${update.branch}\nInstalled: ${update.currentCommit.slice(0, 7)}\nAvailable: ${update.targetCommit.slice(0, 7)}\n\n${update.summary}`, { title: 'XO update available', confirmLabel: 'Update and restart', cancelLabel: 'Later', }, ).then(async (confirmed) => { if (!confirmed) return; onUpdateAndRestart(update); await appContext.exit(); }); return; } // 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 ( {/* Main content area */} {/* Status bar */} {/* Dialog overlay */} ); } /** * Main App component. * Sets up providers and renders the main content. */ export function App({ config, updater, onUpdateAndRestart }: AppProps): React.ReactElement { const { exit } = useApp(); // Cleanup will be handled by React when components unmount const handleExit = () => { exit(); }; return ( ); }