Initial Commit

This commit is contained in:
2026-01-29 07:13:33 +00:00
commit 399e93f714
34 changed files with 7663 additions and 0 deletions

204
src/tui/App.tsx Normal file
View File

@@ -0,0 +1,204 @@
/**
* 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 { WalletController } from '../controllers/wallet-controller.js';
import type { InvitationController } from '../controllers/invitation-controller.js';
import { colors, logoSmall } from './theme.js';
// Screen imports (will be created)
import { SeedInputScreen } from './screens/SeedInput.js';
import { WalletStateScreen } from './screens/WalletState.js';
import { TemplateListScreen } from './screens/TemplateList.js';
import { ActionWizardScreen } from './screens/ActionWizard.js';
import { InvitationScreen } from './screens/Invitation.js';
import { TransactionScreen } from './screens/Transaction.js';
/**
* Props for the App component.
*/
interface AppProps {
walletController: WalletController;
invitationController: InvitationController;
}
/**
* 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();
useInput((input, key) => {
if (!dialog?.visible) 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: dialog?.visible ?? false });
if (!dialog?.visible) 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%"
>
<Box
flexDirection="column"
borderStyle="double"
borderColor={borderColor}
paddingX={2}
paddingY={1}
width={60}
>
<Text color={borderColor} bold>
{dialog.type === 'error' ? '✗ Error' :
dialog.type === 'confirm' ? '? Confirm' :
' Info'}
</Text>
<Box marginY={1}>
<Text wrap="wrap">{dialog.message}</Text>
</Box>
<Text color={colors.textMuted}>
{dialog.type === 'confirm'
? 'Press Y to confirm, N or ESC to cancel'
: 'Press Enter or ESC to close'}
</Text>
</Box>
</Box>
);
}
/**
* Main content wrapper with global keybindings.
*/
function MainContent(): React.ReactElement {
const { exit } = useApp();
const { goBack, canGoBack } = 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 (input === 'q' || (key.ctrl && input === 'c')) {
appContext.exit();
exit();
}
// Go back on Escape
if (key.escape && canGoBack) {
goBack();
}
});
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({ walletController, invitationController }: AppProps): React.ReactElement {
const { exit } = useApp();
const handleExit = () => {
// Cleanup controllers if needed
walletController.stop();
exit();
};
return (
<AppProvider
walletController={walletController}
invitationController={invitationController}
onExit={handleExit}
>
<NavigationProvider initialScreen="seed-input">
<MainContent />
</NavigationProvider>
</AppProvider>
);
}

View File

@@ -0,0 +1,75 @@
/**
* Button component with focus styling.
*/
import React from 'react';
import { Box, Text, useFocus } from 'ink';
import { colors } from '../theme.js';
/**
* Props for the Button component.
*/
interface ButtonProps {
/** Button label */
label: string;
/** Whether button is focused */
focused?: boolean;
/** Whether button is disabled */
disabled?: boolean;
/** Optional keyboard shortcut hint */
shortcut?: string;
}
/**
* Button component with focus highlighting.
*/
export function Button({
label,
focused = false,
disabled = false,
shortcut,
}: ButtonProps): React.ReactElement {
const bgColor = disabled
? colors.textMuted
: focused
? colors.focus
: colors.secondary;
const textColor = disabled
? colors.bg
: focused
? colors.bg
: colors.text;
return (
<Box>
<Box paddingX={1} marginRight={1}>
<Text
backgroundColor={bgColor}
color={textColor}
bold={focused}
>
{` ${label} `}
</Text>
</Box>
{shortcut && (
<Text color={colors.textMuted} dimColor>({shortcut})</Text>
)}
</Box>
);
}
/**
* Button row component for multiple buttons.
*/
interface ButtonRowProps {
children: React.ReactNode;
}
export function ButtonRow({ children }: ButtonRowProps): React.ReactElement {
return (
<Box marginTop={1} gap={2}>
{children}
</Box>
);
}

View File

@@ -0,0 +1,243 @@
/**
* Dialog components for modals, confirmations, and input dialogs.
*/
import React, { useState } from 'react';
import { Box, Text, useInput } from 'ink';
import TextInput from 'ink-text-input';
import { colors } from '../theme.js';
/**
* Base dialog wrapper props.
*/
interface DialogWrapperProps {
/** Dialog title */
title: string;
/** Border color */
borderColor?: string;
/** Dialog content */
children: React.ReactNode;
/** Dialog width */
width?: number;
}
/**
* Base dialog wrapper component.
*/
function DialogWrapper({
title,
borderColor = colors.primary,
children,
width = 60,
}: DialogWrapperProps): React.ReactElement {
return (
<Box
flexDirection="column"
borderStyle="double"
borderColor={borderColor}
paddingX={2}
paddingY={1}
width={width}
>
<Text color={borderColor} bold>{title}</Text>
<Box marginY={1} flexDirection="column">
{children}
</Box>
</Box>
);
}
/**
* Props for InputDialog component.
*/
interface InputDialogProps {
/** Dialog title */
title: string;
/** Input prompt/label */
prompt: string;
/** Initial value */
initialValue?: string;
/** Placeholder text */
placeholder?: string;
/** Submit handler */
onSubmit: (value: string) => void;
/** Cancel handler */
onCancel: () => void;
/** Whether dialog is visible/active */
isActive?: boolean;
}
/**
* Input dialog for getting text input from user.
*/
export function InputDialog({
title,
prompt,
initialValue = '',
placeholder,
onSubmit,
onCancel,
isActive = true,
}: InputDialogProps): React.ReactElement {
const [value, setValue] = useState(initialValue);
useInput((input, key) => {
if (!isActive) return;
if (key.escape) {
onCancel();
}
}, { isActive });
const handleSubmit = (val: string) => {
onSubmit(val);
};
return (
<DialogWrapper title={title} borderColor={colors.primary}>
<Text>{prompt}</Text>
<Box marginTop={1} borderStyle="single" borderColor={colors.focus} paddingX={1}>
<TextInput
value={value}
onChange={setValue}
onSubmit={handleSubmit}
placeholder={placeholder}
focus={isActive}
/>
</Box>
<Box marginTop={1}>
<Text color={colors.textMuted}>Enter to submit Esc to cancel</Text>
</Box>
</DialogWrapper>
);
}
/**
* Props for ConfirmDialog component.
*/
interface ConfirmDialogProps {
/** Dialog title */
title: string;
/** Confirmation message */
message: string;
/** Confirm handler */
onConfirm: () => void;
/** Cancel handler */
onCancel: () => void;
/** Whether dialog is visible/active */
isActive?: boolean;
/** Confirm button label */
confirmLabel?: string;
/** Cancel button label */
cancelLabel?: string;
}
/**
* Confirmation dialog with Yes/No options.
*/
export function ConfirmDialog({
title,
message,
onConfirm,
onCancel,
isActive = true,
confirmLabel = 'Yes',
cancelLabel = 'No',
}: ConfirmDialogProps): React.ReactElement {
const [selected, setSelected] = useState<'confirm' | 'cancel'>('confirm');
useInput((input, key) => {
if (!isActive) return;
if (key.leftArrow || key.rightArrow || key.tab) {
setSelected(prev => prev === 'confirm' ? 'cancel' : 'confirm');
} else if (key.return) {
if (selected === 'confirm') {
onConfirm();
} else {
onCancel();
}
} else if (key.escape || input === 'n' || input === 'N') {
onCancel();
} else if (input === 'y' || input === 'Y') {
onConfirm();
}
}, { isActive });
return (
<DialogWrapper title={title} borderColor={colors.warning}>
<Text wrap="wrap">{message}</Text>
<Box marginTop={1} gap={2}>
<Text
backgroundColor={selected === 'confirm' ? colors.focus : colors.secondary}
color={selected === 'confirm' ? colors.bg : colors.text}
bold={selected === 'confirm'}
>
{` ${confirmLabel} `}
</Text>
<Text
backgroundColor={selected === 'cancel' ? colors.focus : colors.secondary}
color={selected === 'cancel' ? colors.bg : colors.text}
bold={selected === 'cancel'}
>
{` ${cancelLabel} `}
</Text>
</Box>
<Box marginTop={1}>
<Text color={colors.textMuted}>Y/N or Tab to switch Enter to select</Text>
</Box>
</DialogWrapper>
);
}
/**
* Props for MessageDialog component.
*/
interface MessageDialogProps {
/** Dialog title */
title: string;
/** Message content */
message: string;
/** Close handler */
onClose: () => void;
/** Dialog type for styling */
type?: 'info' | 'error' | 'success';
/** Whether dialog is visible/active */
isActive?: boolean;
}
/**
* Simple message dialog (info, error, success).
*/
export function MessageDialog({
title,
message,
onClose,
type = 'info',
isActive = true,
}: MessageDialogProps): React.ReactElement {
useInput((input, key) => {
if (!isActive) return;
if (key.return || key.escape) {
onClose();
}
}, { isActive });
const borderColor = type === 'error' ? colors.error :
type === 'success' ? colors.success :
colors.info;
const icon = type === 'error' ? '✗' :
type === 'success' ? '✓' :
'';
return (
<DialogWrapper title={`${icon} ${title}`} borderColor={borderColor}>
<Text wrap="wrap">{message}</Text>
<Box marginTop={1}>
<Text color={colors.textMuted}>Press Enter or Esc to close</Text>
</Box>
</DialogWrapper>
);
}

View File

@@ -0,0 +1,109 @@
/**
* Text input component with focus styling.
*/
import React from 'react';
import { Box, Text } from 'ink';
import TextInput from 'ink-text-input';
import { colors } from '../theme.js';
/**
* Props for the Input component.
*/
interface InputProps {
/** Current value */
value: string;
/** Change handler */
onChange: (value: string) => void;
/** Submit handler (Enter key) */
onSubmit?: (value: string) => void;
/** Placeholder text */
placeholder?: string;
/** Label shown above input */
label?: string;
/** Whether input is focused */
focus?: boolean;
/** Whether to mask input (for passwords) */
mask?: string;
/** Whether input is disabled */
disabled?: boolean;
}
/**
* Text input component with label and focus styling.
*/
export function Input({
value,
onChange,
onSubmit,
placeholder,
label,
focus = true,
mask,
disabled = false,
}: InputProps): React.ReactElement {
const borderColor = focus ? colors.focus : colors.border;
return (
<Box flexDirection="column">
{label && (
<Text color={colors.text} bold>{label}</Text>
)}
<Box
borderStyle="single"
borderColor={borderColor}
paddingX={1}
>
{disabled ? (
<Text color={colors.textMuted}>{value || placeholder || ''}</Text>
) : (
<TextInput
value={value}
onChange={onChange}
onSubmit={onSubmit}
placeholder={placeholder}
focus={focus}
mask={mask}
/>
)}
</Box>
</Box>
);
}
/**
* Multi-line text display (read-only, styled like input).
*/
interface TextDisplayProps {
/** Text content */
content: string;
/** Label shown above */
label?: string;
/** Whether to show border */
border?: boolean;
}
export function TextDisplay({
content,
label,
border = true
}: TextDisplayProps): React.ReactElement {
return (
<Box flexDirection="column">
{label && (
<Text color={colors.text} bold>{label}</Text>
)}
{border ? (
<Box
borderStyle="single"
borderColor={colors.border}
paddingX={1}
>
<Text>{content}</Text>
</Box>
) : (
<Text>{content}</Text>
)}
</Box>
);
}

159
src/tui/components/List.tsx Normal file
View File

@@ -0,0 +1,159 @@
/**
* Selectable list component with keyboard navigation.
*/
import React from 'react';
import { Box, Text, useInput } from 'ink';
import { colors } from '../theme.js';
/**
* List item type.
*/
export interface ListItem<T = unknown> {
/** Unique key for the item */
key: string;
/** Display label */
label: string;
/** Optional secondary text */
description?: string;
/** Optional value associated with item */
value?: T;
/** Whether item is disabled */
disabled?: boolean;
}
/**
* Props for the List component.
*/
interface ListProps<T> {
/** List items */
items: ListItem<T>[];
/** Currently selected index */
selectedIndex: number;
/** Selection change handler */
onSelect: (index: number) => void;
/** Item activation handler (Enter key) */
onActivate?: (item: ListItem<T>, index: number) => void;
/** Whether list is focused */
focus?: boolean;
/** Maximum visible items (for scrolling) */
maxVisible?: number;
/** Optional label */
label?: string;
/** Show border */
border?: boolean;
}
/**
* Selectable list with keyboard navigation.
*/
export function List<T>({
items,
selectedIndex,
onSelect,
onActivate,
focus = true,
maxVisible = 10,
label,
border = true,
}: ListProps<T>): React.ReactElement {
// Handle keyboard input
useInput((input, key) => {
if (!focus) return;
if (key.upArrow || input === 'k') {
const newIndex = selectedIndex > 0 ? selectedIndex - 1 : items.length - 1;
onSelect(newIndex);
} else if (key.downArrow || input === 'j') {
const newIndex = selectedIndex < items.length - 1 ? selectedIndex + 1 : 0;
onSelect(newIndex);
} else if (key.return && onActivate && items[selectedIndex]) {
const item = items[selectedIndex];
if (item && !item.disabled) {
onActivate(item, selectedIndex);
}
}
}, { isActive: focus });
// Calculate visible range for scrolling
const startIndex = Math.max(0, Math.min(selectedIndex - Math.floor(maxVisible / 2), items.length - maxVisible));
const visibleItems = items.slice(startIndex, startIndex + maxVisible);
const borderColor = focus ? colors.focus : colors.border;
const content = (
<Box flexDirection="column">
{visibleItems.map((item, visibleIndex) => {
const actualIndex = startIndex + visibleIndex;
const isSelected = actualIndex === selectedIndex;
return (
<Box key={item.key}>
<Text
color={item.disabled ? colors.textMuted : isSelected ? colors.bg : colors.text}
backgroundColor={isSelected ? colors.focus : undefined}
bold={isSelected}
dimColor={item.disabled}
>
{isSelected ? '▸ ' : ' '}
{item.label}
</Text>
{item.description && (
<Text color={colors.textMuted} dimColor> - {item.description}</Text>
)}
</Box>
);
})}
{items.length === 0 && (
<Text color={colors.textMuted} dimColor>No items</Text>
)}
</Box>
);
return (
<Box flexDirection="column">
{label && <Text color={colors.text} bold>{label}</Text>}
{border ? (
<Box
borderStyle="single"
borderColor={borderColor}
paddingX={1}
flexDirection="column"
>
{content}
</Box>
) : content}
{items.length > maxVisible && (
<Text color={colors.textMuted} dimColor>
{startIndex + 1}-{Math.min(startIndex + maxVisible, items.length)} of {items.length}
</Text>
)}
</Box>
);
}
/**
* Simple inline list for displaying items without selection.
*/
interface SimpleListProps {
items: string[];
label?: string;
bullet?: string;
}
export function SimpleList({
items,
label,
bullet = '•'
}: SimpleListProps): React.ReactElement {
return (
<Box flexDirection="column">
{label && <Text color={colors.text} bold>{label}</Text>}
{items.map((item, index) => (
<Text key={index} color={colors.text}>
{bullet} {item}
</Text>
))}
</Box>
);
}

View File

@@ -0,0 +1,130 @@
/**
* Progress bar and step indicator components.
*/
import React from 'react';
import { Box, Text } from 'ink';
import { colors } from '../theme.js';
/**
* Props for ProgressBar component.
*/
interface ProgressBarProps {
/** Current progress (0-100) */
percent: number;
/** Bar width in characters */
width?: number;
/** Show percentage text */
showPercent?: boolean;
/** Bar character */
character?: string;
}
/**
* Simple progress bar component.
*/
export function ProgressBar({
percent,
width = 40,
showPercent = true,
character = '█',
}: ProgressBarProps): React.ReactElement {
const clampedPercent = Math.max(0, Math.min(100, percent));
const filled = Math.round((clampedPercent / 100) * width);
const empty = width - filled;
return (
<Box>
<Text color={colors.success}>{character.repeat(filled)}</Text>
<Text color={colors.textMuted} dimColor>{'░'.repeat(empty)}</Text>
{showPercent && (
<Text color={colors.text}> {Math.round(clampedPercent)}%</Text>
)}
</Box>
);
}
/**
* Step definition for StepIndicator.
*/
export interface Step {
/** Step label */
label: string;
/** Whether step is completed */
completed?: boolean;
/** Whether step is current */
current?: boolean;
}
/**
* Props for StepIndicator component.
*/
interface StepIndicatorProps {
/** Steps to display */
steps: Step[];
/** Current step index (0-based) */
currentStep: number;
}
/**
* Step indicator showing progress through a multi-step wizard.
*/
export function StepIndicator({
steps,
currentStep,
}: StepIndicatorProps): React.ReactElement {
return (
<Box flexDirection="column" marginBottom={1}>
<Box gap={1}>
{steps.map((step, index) => {
const isCompleted = index < currentStep;
const isCurrent = index === currentStep;
const color = isCompleted ? colors.success :
isCurrent ? colors.focus :
colors.textMuted;
const icon = isCompleted ? '✓' :
isCurrent ? '▸' :
'○';
return (
<Box key={index}>
<Text color={color} bold={isCurrent}>
{icon} {step.label}
</Text>
{index < steps.length - 1 && (
<Text color={colors.textMuted}> </Text>
)}
</Box>
);
})}
</Box>
<Text color={colors.textMuted}>
Step {currentStep + 1} of {steps.length}
</Text>
</Box>
);
}
/**
* Loading spinner with optional message.
*/
interface LoadingProps {
/** Loading message */
message?: string;
}
export function Loading({ message = 'Loading...' }: LoadingProps): React.ReactElement {
// Simple spinner using Ink's spinner component
const Spinner = require('ink-spinner').default;
return (
<Box>
<Text color={colors.primary}>
<Spinner type="dots" />
</Text>
<Text color={colors.text}> {message}</Text>
</Box>
);
}

View File

@@ -0,0 +1,71 @@
/**
* Screen wrapper component providing consistent layout.
*/
import React, { type ReactNode } from 'react';
import { Box, Text } from 'ink';
import { colors } from '../theme.js';
/**
* Props for the Screen component.
*/
interface ScreenProps {
/** Screen title displayed in header */
title: string;
/** Optional subtitle */
subtitle?: string;
/** Screen content */
children: ReactNode;
/** Optional footer content */
footer?: ReactNode;
/** Optional help text shown at bottom */
helpText?: string;
}
/**
* Screen wrapper component.
* Provides consistent header, content area, and footer layout.
*/
export function Screen({
title,
subtitle,
children,
footer,
helpText
}: ScreenProps): React.ReactElement {
return (
<Box flexDirection="column" flexGrow={1}>
{/* Header */}
<Box
borderStyle="single"
borderColor={colors.primary}
paddingX={1}
marginBottom={1}
>
<Box flexDirection="column">
<Text color={colors.primary} bold>{title}</Text>
{subtitle && <Text color={colors.textMuted}>{subtitle}</Text>}
</Box>
</Box>
{/* Content */}
<Box flexDirection="column" flexGrow={1} paddingX={1}>
{children}
</Box>
{/* Help text */}
{helpText && (
<Box paddingX={1} marginTop={1}>
<Text color={colors.textMuted} dimColor>{helpText}</Text>
</Box>
)}
{/* Footer */}
{footer && (
<Box paddingX={1} marginTop={1}>
{footer}
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,10 @@
/**
* Export all shared components.
*/
export { Screen } from './Screen.js';
export { Input, TextDisplay } from './Input.js';
export { Button, ButtonRow } from './Button.js';
export { List, SimpleList, type ListItem } from './List.js';
export { InputDialog, ConfirmDialog, MessageDialog } from './Dialog.js';
export { ProgressBar, StepIndicator, Loading, type Step } from './ProgressBar.js';

6
src/tui/hooks/index.ts Normal file
View File

@@ -0,0 +1,6 @@
/**
* Export all hooks.
*/
export { NavigationProvider, useNavigation } from './useNavigation.js';
export { AppProvider, useAppContext, useDialog, useStatus } from './useAppContext.js';

View File

@@ -0,0 +1,183 @@
/**
* App context hook for accessing controllers and app-level functions.
*/
import React, { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
import type { WalletController } from '../../controllers/wallet-controller.js';
import type { InvitationController } from '../../controllers/invitation-controller.js';
import type { AppContextType, DialogState } from '../types.js';
/**
* App context.
*/
const AppContext = createContext<AppContextType | null>(null);
/**
* Dialog context for managing modal dialogs.
*/
interface DialogContextType {
dialog: DialogState | null;
setDialog: (dialog: DialogState | null) => void;
}
const DialogContext = createContext<DialogContextType | null>(null);
/**
* Status context for managing status bar.
*/
interface StatusContextType {
status: string;
setStatus: (status: string) => void;
}
const StatusContext = createContext<StatusContextType | null>(null);
/**
* App provider props.
*/
interface AppProviderProps {
children: ReactNode;
walletController: WalletController;
invitationController: InvitationController;
onExit: () => void;
}
/**
* App provider component.
* Provides controllers, dialog management, and app-level functions to children.
*/
export function AppProvider({
children,
walletController,
invitationController,
onExit,
}: AppProviderProps): React.ReactElement {
const [dialog, setDialog] = useState<DialogState | null>(null);
const [status, setStatusState] = useState<string>('Ready');
const [isWalletInitialized, setWalletInitialized] = useState(false);
// Promise resolver for confirm dialogs
const [confirmResolver, setConfirmResolver] = useState<((value: boolean) => void) | null>(null);
/**
* Show an error dialog.
*/
const showError = useCallback((message: string) => {
setDialog({
visible: true,
type: 'error',
message,
onCancel: () => setDialog(null),
});
}, []);
/**
* Show an info dialog.
*/
const showInfo = useCallback((message: string) => {
setDialog({
visible: true,
type: 'info',
message,
onCancel: () => setDialog(null),
});
}, []);
/**
* Show a confirmation dialog and wait for user response.
*/
const confirm = useCallback((message: string): Promise<boolean> => {
return new Promise((resolve) => {
setConfirmResolver(() => resolve);
setDialog({
visible: true,
type: 'confirm',
message,
onConfirm: () => {
setDialog(null);
resolve(true);
},
onCancel: () => {
setDialog(null);
resolve(false);
},
});
});
}, []);
/**
* Update status bar message.
*/
const setStatus = useCallback((message: string) => {
setStatusState(message);
}, []);
const appValue: AppContextType = {
walletController,
invitationController,
showError,
showInfo,
confirm,
exit: onExit,
setStatus,
isWalletInitialized,
setWalletInitialized,
};
const dialogValue: DialogContextType = {
dialog,
setDialog,
};
const statusValue: StatusContextType = {
status,
setStatus,
};
return (
<AppContext.Provider value={appValue}>
<DialogContext.Provider value={dialogValue}>
<StatusContext.Provider value={statusValue}>
{children}
</StatusContext.Provider>
</DialogContext.Provider>
</AppContext.Provider>
);
}
/**
* Hook to access app context.
* @returns App context
* @throws Error if used outside AppProvider
*/
export function useAppContext(): AppContextType {
const context = useContext(AppContext);
if (!context) {
throw new Error('useAppContext must be used within an AppProvider');
}
return context;
}
/**
* Hook to access dialog context.
* @returns Dialog context
*/
export function useDialog(): DialogContextType {
const context = useContext(DialogContext);
if (!context) {
throw new Error('useDialog must be used within an AppProvider');
}
return context;
}
/**
* Hook to access status context.
* @returns Status context
*/
export function useStatus(): StatusContextType {
const context = useContext(StatusContext);
if (!context) {
throw new Error('useStatus must be used within an AppProvider');
}
return context;
}

View File

@@ -0,0 +1,87 @@
/**
* Navigation hook for managing screen navigation with history.
*/
import React, { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
import type { ScreenName, NavigationData, NavigationContextType } from '../types.js';
/**
* Navigation context.
*/
const NavigationContext = createContext<NavigationContextType | null>(null);
/**
* Navigation provider props.
*/
interface NavigationProviderProps {
children: ReactNode;
initialScreen?: ScreenName;
}
/**
* Navigation provider component.
* Manages navigation state and provides navigation functions to children.
*/
export function NavigationProvider({
children,
initialScreen = 'seed-input'
}: NavigationProviderProps): React.ReactElement {
const [screen, setScreen] = useState<ScreenName>(initialScreen);
const [data, setData] = useState<NavigationData>({});
const [history, setHistory] = useState<ScreenName[]>([]);
/**
* Navigate to a new screen, optionally with data.
*/
const navigate = useCallback((newScreen: ScreenName, newData?: NavigationData) => {
// Add current screen to history
setHistory(prev => [...prev, screen]);
// Set new screen and data
setScreen(newScreen);
setData(newData ?? {});
}, [screen]);
/**
* Go back to the previous screen.
*/
const goBack = useCallback(() => {
if (history.length === 0) return;
const newHistory = [...history];
const previousScreen = newHistory.pop();
if (previousScreen) {
setHistory(newHistory);
setScreen(previousScreen);
setData({});
}
}, [history]);
const value: NavigationContextType = {
screen,
data,
history,
navigate,
goBack,
canGoBack: history.length > 0,
};
return (
<NavigationContext.Provider value={value}>
{children}
</NavigationContext.Provider>
);
}
/**
* Hook to access navigation context.
* @returns Navigation context
* @throws Error if used outside NavigationProvider
*/
export function useNavigation(): NavigationContextType {
const context = useContext(NavigationContext);
if (!context) {
throw new Error('useNavigation must be used within a NavigationProvider');
}
return context;
}

View File

@@ -0,0 +1,497 @@
/**
* Action Wizard Screen - Step-by-step walkthrough for template actions.
*
* Guides users through:
* - Reviewing action requirements
* - Entering variables (e.g., requestedSatoshis)
* - Reviewing outputs
* - Creating and publishing invitation
*/
import React, { useState, useEffect, useCallback } from 'react';
import { Box, Text, useInput } from 'ink';
import TextInput from 'ink-text-input';
import { StepIndicator, type Step } from '../components/ProgressBar.js';
import { Button, ButtonRow } from '../components/Button.js';
import { useNavigation } from '../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
import { colors, logoSmall, formatSatoshis } from '../theme.js';
import { copyToClipboard } from '../utils/clipboard.js';
import type { XOTemplate } from '@xo-cash/types';
/**
* Wizard step types.
*/
type StepType = 'info' | 'variables' | 'review' | 'publish';
/**
* Wizard step definition.
*/
interface WizardStep {
name: string;
type: StepType;
}
/**
* Variable input state.
*/
interface VariableInput {
id: string;
name: string;
type: string;
hint?: string;
value: string;
}
/**
* Action Wizard Screen Component.
*/
export function ActionWizardScreen(): React.ReactElement {
const { navigate, goBack, data: navData } = useNavigation();
const { walletController, invitationController, showError, showInfo } = useAppContext();
const { setStatus } = useStatus();
// Extract navigation data
const templateIdentifier = navData.templateIdentifier as string | undefined;
const actionIdentifier = navData.actionIdentifier as string | undefined;
const roleIdentifier = navData.roleIdentifier as string | undefined;
const template = navData.template as XOTemplate | undefined;
// State
const [steps, setSteps] = useState<WizardStep[]>([]);
const [currentStep, setCurrentStep] = useState(0);
const [variables, setVariables] = useState<VariableInput[]>([]);
const [focusedInput, setFocusedInput] = useState(0);
const [focusedButton, setFocusedButton] = useState<'back' | 'cancel' | 'next'>('next');
const [focusArea, setFocusArea] = useState<'content' | 'buttons'>('content');
const [invitationId, setInvitationId] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
/**
* Initialize wizard on mount.
*/
useEffect(() => {
if (!template || !actionIdentifier || !roleIdentifier) {
showError('Missing wizard data');
goBack();
return;
}
// Build steps based on template
const action = template.actions?.[actionIdentifier];
const role = action?.roles?.[roleIdentifier];
const requirements = role?.requirements;
const wizardSteps: WizardStep[] = [
{ name: 'Welcome', type: 'info' },
];
// Add variables step if needed
if (requirements?.variables && requirements.variables.length > 0) {
wizardSteps.push({ name: 'Variables', type: 'variables' });
// Initialize variable inputs
const varInputs = requirements.variables.map(varId => {
const varDef = template.variables?.[varId];
return {
id: varId,
name: varDef?.name || varId,
type: varDef?.type || 'string',
hint: varDef?.hint,
value: '',
};
});
setVariables(varInputs);
}
wizardSteps.push({ name: 'Review', type: 'review' });
wizardSteps.push({ name: 'Publish', type: 'publish' });
setSteps(wizardSteps);
setStatus(`${actionIdentifier}/${roleIdentifier}`);
}, [template, actionIdentifier, roleIdentifier, showError, goBack, setStatus]);
/**
* Get current step data.
*/
const currentStepData = steps[currentStep];
/**
* Navigate to next step.
*/
const nextStep = useCallback(async () => {
if (currentStep >= steps.length - 1) return;
// If on review step, create invitation
if (currentStepData?.type === 'review') {
await createInvitation();
return;
}
setCurrentStep(prev => prev + 1);
setFocusArea('content');
setFocusedInput(0);
}, [currentStep, steps.length, currentStepData]);
/**
* Navigate to previous step.
*/
const previousStep = useCallback(() => {
if (currentStep <= 0) {
goBack();
return;
}
setCurrentStep(prev => prev - 1);
setFocusArea('content');
setFocusedInput(0);
}, [currentStep, goBack]);
/**
* Cancel wizard.
*/
const cancel = useCallback(() => {
goBack();
}, [goBack]);
/**
* Create invitation.
*/
const createInvitation = useCallback(async () => {
if (!templateIdentifier || !actionIdentifier || !roleIdentifier) return;
setIsCreating(true);
setStatus('Creating invitation...');
try {
// Create invitation
const tracked = await invitationController.createInvitation(
templateIdentifier,
actionIdentifier,
);
const invId = tracked.invitation.invitationIdentifier;
setInvitationId(invId);
// Add variables if any
if (variables.length > 0) {
const variableData = variables.map(v => ({
variableIdentifier: v.id,
value: v.type === 'number' || v.type === 'satoshis'
? BigInt(v.value || '0')
: v.value,
}));
await invitationController.addVariables(invId, variableData);
}
// Publish to sync server
await invitationController.publishAndSubscribe(invId);
setCurrentStep(prev => prev + 1);
setStatus('Invitation created');
} catch (error) {
showError(`Failed to create invitation: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setIsCreating(false);
}
}, [templateIdentifier, actionIdentifier, roleIdentifier, variables, invitationController, showError, setStatus]);
/**
* Copy invitation ID to clipboard.
*/
const copyId = useCallback(async () => {
if (!invitationId) return;
try {
await copyToClipboard(invitationId);
showInfo(`Copied to clipboard!\n\n${invitationId}`);
} catch (error) {
showError(`Failed to copy: ${error instanceof Error ? error.message : String(error)}`);
}
}, [invitationId, showInfo, showError]);
/**
* Update variable value.
*/
const updateVariable = useCallback((index: number, value: string) => {
setVariables(prev => {
const updated = [...prev];
const variable = updated[index];
if (variable) {
updated[index] = { ...variable, value };
}
return updated;
});
}, []);
// Handle keyboard navigation
useInput((input, key) => {
// Tab to switch between content and buttons
if (key.tab) {
if (focusArea === 'content') {
// In variables step, tab cycles through inputs first
if (currentStepData?.type === 'variables' && variables.length > 0) {
if (focusedInput < variables.length - 1) {
setFocusedInput(prev => prev + 1);
return;
}
}
setFocusArea('buttons');
setFocusedButton('next');
} else {
// Cycle through buttons
if (focusedButton === 'back') {
setFocusedButton('cancel');
} else if (focusedButton === 'cancel') {
setFocusedButton('next');
} else {
setFocusArea('content');
setFocusedInput(0);
}
}
return;
}
// Shift+Tab
if (key.shift && key.tab) {
if (focusArea === 'buttons') {
if (focusedButton === 'next') {
setFocusedButton('cancel');
} else if (focusedButton === 'cancel') {
setFocusedButton('back');
} else {
setFocusArea('content');
if (currentStepData?.type === 'variables' && variables.length > 0) {
setFocusedInput(variables.length - 1);
}
}
} else {
if (focusedInput > 0) {
setFocusedInput(prev => prev - 1);
} else {
setFocusArea('buttons');
setFocusedButton('back');
}
}
return;
}
// Arrow keys in buttons area
if (focusArea === 'buttons') {
if (key.leftArrow) {
setFocusedButton(prev =>
prev === 'next' ? 'cancel' : prev === 'cancel' ? 'back' : 'back'
);
} else if (key.rightArrow) {
setFocusedButton(prev =>
prev === 'back' ? 'cancel' : prev === 'cancel' ? 'next' : 'next'
);
}
}
// Enter on buttons
if (key.return && focusArea === 'buttons') {
if (focusedButton === 'back') previousStep();
else if (focusedButton === 'cancel') cancel();
else if (focusedButton === 'next') nextStep();
}
// 'c' to copy on publish step
if (input === 'c' && currentStepData?.type === 'publish' && invitationId) {
copyId();
}
});
// Get action details
const action = template?.actions?.[actionIdentifier ?? ''];
const actionName = action?.name || actionIdentifier || 'Unknown';
// Render step content
const renderStepContent = () => {
if (!currentStepData) return null;
switch (currentStepData.type) {
case 'info':
return (
<Box flexDirection="column">
<Text color={colors.primary} bold>Action: {actionName}</Text>
<Text color={colors.textMuted}>{action?.description || 'No description'}</Text>
<Box marginTop={1}>
<Text color={colors.text}>Your Role: </Text>
<Text color={colors.accent}>{roleIdentifier}</Text>
</Box>
{/* Show requirements */}
{action?.roles?.[roleIdentifier ?? '']?.requirements && (
<Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Requirements:</Text>
{action.roles[roleIdentifier ?? '']?.requirements?.variables?.map(v => (
<Text key={v} color={colors.textMuted}> Variable: {v}</Text>
))}
</Box>
)}
</Box>
);
case 'variables':
return (
<Box flexDirection="column">
<Text color={colors.text} bold>Enter required values:</Text>
<Box marginTop={1} flexDirection="column">
{variables.map((variable, index) => (
<Box key={variable.id} flexDirection="column" marginBottom={1}>
<Text color={colors.primary}>{variable.name}</Text>
{variable.hint && (
<Text color={colors.textMuted} dimColor>({variable.hint})</Text>
)}
<Box
borderStyle="single"
borderColor={focusArea === 'content' && focusedInput === index ? colors.focus : colors.border}
paddingX={1}
marginTop={1}
>
<TextInput
value={variable.value}
onChange={value => updateVariable(index, value)}
focus={focusArea === 'content' && focusedInput === index}
placeholder={`Enter ${variable.name}...`}
/>
</Box>
</Box>
))}
</Box>
</Box>
);
case 'review':
return (
<Box flexDirection="column">
<Text color={colors.text} bold>Review your invitation:</Text>
<Box marginTop={1} flexDirection="column">
<Text color={colors.textMuted}>Template: {template?.name}</Text>
<Text color={colors.textMuted}>Action: {actionName}</Text>
<Text color={colors.textMuted}>Role: {roleIdentifier}</Text>
{variables.length > 0 && (
<Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Variables:</Text>
{variables.map(v => (
<Text key={v.id} color={colors.textMuted}>
{' '}{v.name}: {v.value || '(empty)'}
</Text>
))}
</Box>
)}
</Box>
<Box marginTop={1}>
<Text color={colors.warning}>
Press Next to create and publish the invitation.
</Text>
</Box>
</Box>
);
case 'publish':
return (
<Box flexDirection="column">
<Text color={colors.success} bold> Invitation Created!</Text>
<Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Invitation ID:</Text>
<Box
borderStyle="single"
borderColor={colors.primary}
paddingX={1}
marginTop={1}
>
<Text color={colors.accent}>{invitationId}</Text>
</Box>
</Box>
<Box marginTop={1}>
<Text color={colors.textMuted}>
Share this ID with the other party to complete the transaction.
</Text>
</Box>
<Box marginTop={1}>
<Text color={colors.warning}>Press 'c' to copy ID to clipboard</Text>
</Box>
</Box>
);
default:
return null;
}
};
// Convert steps to StepIndicator format
const stepIndicatorSteps: Step[] = steps.map(s => ({ label: s.name }));
return (
<Box flexDirection="column" flexGrow={1}>
{/* Header */}
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1} flexDirection="column">
<Text color={colors.primary} bold>{logoSmall} - Action Wizard</Text>
<Text color={colors.textMuted}>
{template?.name} {'>'} {actionName} (as {roleIdentifier})
</Text>
</Box>
{/* Progress indicator */}
<Box marginTop={1} paddingX={1}>
<StepIndicator steps={stepIndicatorSteps} currentStep={currentStep} />
</Box>
{/* Content area */}
<Box
borderStyle="single"
borderColor={focusArea === 'content' ? colors.focus : colors.primary}
flexDirection="column"
paddingX={1}
paddingY={1}
marginTop={1}
marginX={1}
flexGrow={1}
>
<Text color={colors.primary} bold>
{' '}{currentStepData?.name} ({currentStep + 1}/{steps.length}){' '}
</Text>
<Box marginTop={1}>
{isCreating ? (
<Text color={colors.info}>Creating invitation...</Text>
) : (
renderStepContent()
)}
</Box>
</Box>
{/* Buttons */}
<Box marginTop={1} marginX={1} justifyContent="space-between">
<Box gap={1}>
<Button
label="Back"
focused={focusArea === 'buttons' && focusedButton === 'back'}
disabled={currentStepData?.type === 'publish'}
/>
<Button
label="Cancel"
focused={focusArea === 'buttons' && focusedButton === 'cancel'}
/>
</Box>
<Button
label={currentStepData?.type === 'publish' ? 'Done' : 'Next'}
focused={focusArea === 'buttons' && focusedButton === 'next'}
disabled={isCreating}
/>
</Box>
{/* Help text */}
<Box marginTop={1} marginX={1}>
<Text color={colors.textMuted} dimColor>
Tab: Navigate Enter: Select Esc: Back
{currentStepData?.type === 'publish' ? ' • c: Copy ID' : ''}
</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,407 @@
/**
* Invitation Screen - Manages invitations (create, import, view, monitor).
*
* Provides:
* - Import invitation by ID
* - View active invitations
* - Monitor invitation updates via SSE
* - Fill missing requirements
* - Sign and complete invitations
*/
import React, { useState, useEffect, useCallback } from 'react';
import { Box, Text, useInput } from 'ink';
import TextInput from 'ink-text-input';
import { InputDialog } from '../components/Dialog.js';
import { useNavigation } from '../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
import { colors, logoSmall, formatHex } from '../theme.js';
import { copyToClipboard } from '../utils/clipboard.js';
import type { TrackedInvitation, InvitationState } from '../../services/invitation-flow.js';
/**
* Get color for invitation state.
*/
function getStateColor(state: InvitationState): string {
switch (state) {
case 'created':
case 'published':
return colors.info as string;
case 'pending':
return colors.warning as string;
case 'ready':
case 'signed':
return colors.success as string;
case 'broadcast':
case 'completed':
return colors.success as string;
case 'expired':
case 'error':
return colors.error as string;
default:
return colors.textMuted as string;
}
}
/**
* Action menu items.
*/
const actionItems = [
{ label: 'Import Invitation', value: 'import' },
{ label: 'Copy Invitation ID', value: 'copy' },
{ label: 'Accept Selected', value: 'accept' },
{ label: 'Sign & Complete', value: 'sign' },
{ label: 'View Transaction', value: 'transaction' },
{ label: 'Refresh', value: 'refresh' },
];
/**
* Invitation Screen Component.
*/
export function InvitationScreen(): React.ReactElement {
const { navigate, data: navData } = useNavigation();
const { invitationController, showError, showInfo } = useAppContext();
const { setStatus } = useStatus();
// State
const [invitations, setInvitations] = useState<TrackedInvitation[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
const [focusedPanel, setFocusedPanel] = useState<'list' | 'details' | 'actions'>('list');
const [showImportDialog, setShowImportDialog] = useState(false);
const [isLoading, setIsLoading] = useState(false);
// Check if we should open import dialog on mount
const initialMode = navData.mode as string | undefined;
/**
* Load invitations.
*/
const loadInvitations = useCallback(() => {
const tracked = invitationController.getAllInvitations();
setInvitations(tracked);
}, [invitationController]);
/**
* Set up event listeners and initial load.
*/
useEffect(() => {
loadInvitations();
// Listen for updates
const handleUpdate = () => {
loadInvitations();
};
invitationController.on('invitation-updated', handleUpdate);
invitationController.on('invitation-state-changed', handleUpdate);
// Show import dialog if mode is 'import'
if (initialMode === 'import') {
setShowImportDialog(true);
}
return () => {
invitationController.off('invitation-updated', handleUpdate);
invitationController.off('invitation-state-changed', handleUpdate);
};
}, [invitationController, loadInvitations, initialMode]);
// Get selected invitation
const selectedInvitation = invitations[selectedIndex];
/**
* Import invitation by ID.
*/
const importInvitation = useCallback(async (invitationId: string) => {
setShowImportDialog(false);
if (!invitationId.trim()) return;
try {
setIsLoading(true);
setStatus('Importing invitation...');
const tracked = await invitationController.importInvitation(invitationId);
await invitationController.publishAndSubscribe(tracked.invitation.invitationIdentifier);
loadInvitations();
showInfo(`Invitation imported!\n\nTemplate: ${tracked.invitation.templateIdentifier}\nAction: ${tracked.invitation.actionIdentifier}`);
setStatus('Ready');
} catch (error) {
showError(`Failed to import: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setIsLoading(false);
}
}, [invitationController, loadInvitations, showInfo, showError, setStatus]);
/**
* Accept selected invitation.
*/
const acceptInvitation = useCallback(async () => {
if (!selectedInvitation) {
showError('No invitation selected');
return;
}
try {
setIsLoading(true);
setStatus('Accepting invitation...');
await invitationController.acceptInvitation(selectedInvitation.invitation.invitationIdentifier);
loadInvitations();
showInfo('Invitation accepted! You are now a participant.');
setStatus('Ready');
} catch (error) {
showError(`Failed to accept: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setIsLoading(false);
}
}, [selectedInvitation, invitationController, loadInvitations, showInfo, showError, setStatus]);
/**
* Sign selected invitation.
*/
const signInvitation = useCallback(async () => {
if (!selectedInvitation) {
showError('No invitation selected');
return;
}
try {
setIsLoading(true);
setStatus('Signing invitation...');
await invitationController.signInvitation(selectedInvitation.invitation.invitationIdentifier);
loadInvitations();
showInfo('Invitation signed!');
setStatus('Ready');
} catch (error) {
showError(`Failed to sign: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setIsLoading(false);
}
}, [selectedInvitation, invitationController, loadInvitations, showInfo, showError, setStatus]);
/**
* Copy invitation ID.
*/
const copyId = useCallback(async () => {
if (!selectedInvitation) {
showError('No invitation selected');
return;
}
try {
await copyToClipboard(selectedInvitation.invitation.invitationIdentifier);
showInfo(`Copied!\n\n${selectedInvitation.invitation.invitationIdentifier}`);
} catch (error) {
showError(`Failed to copy: ${error instanceof Error ? error.message : String(error)}`);
}
}, [selectedInvitation, showInfo, showError]);
/**
* Handle action selection.
*/
const handleAction = useCallback((action: string) => {
switch (action) {
case 'import':
setShowImportDialog(true);
break;
case 'copy':
copyId();
break;
case 'accept':
acceptInvitation();
break;
case 'sign':
signInvitation();
break;
case 'transaction':
if (selectedInvitation) {
navigate('transaction', { invitationId: selectedInvitation.invitation.invitationIdentifier });
}
break;
case 'refresh':
loadInvitations();
break;
}
}, [selectedInvitation, copyId, acceptInvitation, signInvitation, navigate, loadInvitations]);
// Handle keyboard navigation
useInput((input, key) => {
// Don't handle input while dialog is open
if (showImportDialog) return;
// Tab to switch panels
if (key.tab) {
setFocusedPanel(prev => {
if (prev === 'list') return 'details';
if (prev === 'details') return 'actions';
return 'list';
});
return;
}
// Up/Down navigation
if (key.upArrow || input === 'k') {
if (focusedPanel === 'list') {
setSelectedIndex(prev => Math.max(0, prev - 1));
} else if (focusedPanel === 'actions') {
setSelectedActionIndex(prev => Math.max(0, prev - 1));
}
} else if (key.downArrow || input === 'j') {
if (focusedPanel === 'list') {
setSelectedIndex(prev => Math.min(invitations.length - 1, prev + 1));
} else if (focusedPanel === 'actions') {
setSelectedActionIndex(prev => Math.min(actionItems.length - 1, prev + 1));
}
}
// Enter to select action
if (key.return && focusedPanel === 'actions') {
const action = actionItems[selectedActionIndex];
if (action) {
handleAction(action.value);
}
}
// 'c' to copy
if (input === 'c') {
copyId();
}
// 'i' to import
if (input === 'i') {
setShowImportDialog(true);
}
}, { isActive: !showImportDialog });
return (
<Box flexDirection="column" flexGrow={1}>
{/* Header */}
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1}>
<Text color={colors.primary} bold>{logoSmall} - Invitations</Text>
</Box>
{/* Main content - three columns */}
<Box flexDirection="row" marginTop={1} flexGrow={1}>
{/* Left column: Invitation list */}
<Box flexDirection="column" width="40%" paddingRight={1}>
<Box
borderStyle="single"
borderColor={focusedPanel === 'list' ? colors.focus : colors.primary}
flexDirection="column"
paddingX={1}
flexGrow={1}
>
<Text color={colors.primary} bold> Active Invitations </Text>
<Box marginTop={1} flexDirection="column">
{invitations.length === 0 ? (
<Text color={colors.textMuted}>No invitations</Text>
) : (
invitations.map((inv, index) => (
<Text
key={inv.invitation.invitationIdentifier}
color={index === selectedIndex ? colors.focus : colors.text}
bold={index === selectedIndex}
>
{index === selectedIndex && focusedPanel === 'list' ? '▸ ' : ' '}
<Text color={getStateColor(inv.state)}>[{inv.state}]</Text>
{' '}{formatHex(inv.invitation.invitationIdentifier, 12)}
</Text>
))
)}
</Box>
</Box>
</Box>
{/* Middle column: Details */}
<Box flexDirection="column" width="40%" paddingX={1}>
<Box
borderStyle="single"
borderColor={focusedPanel === 'details' ? colors.focus : colors.border}
flexDirection="column"
paddingX={1}
flexGrow={1}
>
<Text color={colors.primary} bold> Details </Text>
<Box marginTop={1} flexDirection="column">
{selectedInvitation ? (
<>
<Text color={colors.text}>ID: {formatHex(selectedInvitation.invitation.invitationIdentifier, 20)}</Text>
<Text color={colors.text}>
State: <Text color={getStateColor(selectedInvitation.state)}>{selectedInvitation.state}</Text>
</Text>
<Text color={colors.textMuted}>
Template: {selectedInvitation.invitation.templateIdentifier?.slice(0, 20)}...
</Text>
<Text color={colors.textMuted}>
Action: {selectedInvitation.invitation.actionIdentifier}
</Text>
<Box marginTop={1}>
<Text color={colors.warning}>Press 'c' to copy ID</Text>
</Box>
</>
) : (
<Text color={colors.textMuted}>Select an invitation</Text>
)}
</Box>
</Box>
</Box>
{/* Right column: Actions */}
<Box flexDirection="column" width="20%" paddingLeft={1}>
<Box
borderStyle="single"
borderColor={focusedPanel === 'actions' ? colors.focus : colors.border}
flexDirection="column"
paddingX={1}
flexGrow={1}
>
<Text color={colors.primary} bold> Actions </Text>
<Box marginTop={1} flexDirection="column">
{actionItems.map((item, index) => (
<Text
key={item.value}
color={index === selectedActionIndex && focusedPanel === 'actions' ? colors.focus : colors.text}
bold={index === selectedActionIndex && focusedPanel === 'actions'}
>
{index === selectedActionIndex && focusedPanel === 'actions' ? '▸ ' : ' '}
{item.label}
</Text>
))}
</Box>
</Box>
</Box>
</Box>
{/* Help text */}
<Box marginTop={1}>
<Text color={colors.textMuted} dimColor>
Tab: Switch panel : Navigate Enter: Select i: Import c: Copy ID Esc: Back
</Text>
</Box>
{/* Import dialog */}
{showImportDialog && (
<Box
position="absolute"
flexDirection="column"
alignItems="center"
justifyContent="center"
width="100%"
height="100%"
>
<InputDialog
title="Import Invitation"
prompt="Enter Invitation ID:"
placeholder="Paste invitation ID..."
onSubmit={importInvitation}
onCancel={() => setShowImportDialog(false)}
isActive={showImportDialog}
/>
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,182 @@
/**
* Seed Input Screen - Initial screen for wallet seed phrase entry.
*
* Allows users to enter their BIP39 seed phrase to initialize the wallet.
*/
import React, { useState, useCallback } from 'react';
import { Box, Text, useInput } from 'ink';
import TextInput from 'ink-text-input';
import { Screen } from '../components/Screen.js';
import { Button, ButtonRow } from '../components/Button.js';
import { useNavigation } from '../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
import { colors, logo } from '../theme.js';
/**
* Status message type.
*/
type StatusType = 'idle' | 'loading' | 'error' | 'success';
/**
* Seed Input Screen Component.
* Provides seed phrase entry for wallet initialization.
*/
export function SeedInputScreen(): React.ReactElement {
const { navigate } = useNavigation();
const { walletController, showError, setWalletInitialized } = useAppContext();
const { setStatus } = useStatus();
// State
const [seedPhrase, setSeedPhrase] = useState('');
const [statusMessage, setStatusMessage] = useState('');
const [statusType, setStatusType] = useState<StatusType>('idle');
const [focusedElement, setFocusedElement] = useState<'input' | 'button'>('input');
const [isSubmitting, setIsSubmitting] = useState(false);
/**
* Shows a status message with the given type.
*/
const showStatus = useCallback((message: string, type: StatusType) => {
setStatusMessage(message);
setStatusType(type);
}, []);
/**
* Handles seed phrase submission.
*/
const handleSubmit = useCallback(async () => {
const seed = seedPhrase.trim();
// Basic validation
if (!seed) {
showStatus('Please enter your seed phrase', 'error');
return;
}
const wordCount = seed.split(/\s+/).length;
if (wordCount !== 12 && wordCount !== 24) {
showStatus(`Invalid seed phrase. Expected 12 or 24 words, got ${wordCount}`, 'error');
return;
}
// Show loading status
showStatus('Initializing wallet...', 'loading');
setStatus('Initializing wallet...');
setIsSubmitting(true);
try {
// Initialize wallet via controller
await walletController.initialize(seed);
showStatus('Wallet initialized successfully!', 'success');
setStatus('Wallet ready');
setWalletInitialized(true);
// Clear sensitive data before navigating
setSeedPhrase('');
// Navigate to wallet state screen
setTimeout(() => {
navigate('wallet');
}, 500);
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to initialize wallet';
showStatus(message, 'error');
setStatus('Initialization failed');
setIsSubmitting(false);
}
}, [seedPhrase, walletController, navigate, showStatus, setStatus, setWalletInitialized]);
// Handle keyboard navigation
useInput((input, key) => {
if (isSubmitting) return;
// Tab to switch focus
if (key.tab) {
setFocusedElement(prev => prev === 'input' ? 'button' : 'input');
}
// Enter on button submits
if (key.return && focusedElement === 'button') {
handleSubmit();
}
});
// Get status color
const statusColor = statusType === 'error' ? colors.error :
statusType === 'success' ? colors.success :
statusType === 'loading' ? colors.info :
colors.textMuted;
// Get border color based on status
const inputBorderColor = statusType === 'error' ? colors.error :
statusType === 'success' ? colors.success :
focusedElement === 'input' ? colors.focus :
colors.border;
return (
<Box flexDirection="column" alignItems="center" paddingY={1}>
{/* Logo */}
<Box marginBottom={1}>
<Text color={colors.primary}>{logo}</Text>
</Box>
{/* Title */}
<Text color={colors.text} bold>Welcome to XO Wallet CLI</Text>
<Text color={colors.textMuted}>Enter your seed phrase to get started</Text>
{/* Spacer */}
<Box marginY={1} />
{/* Input section */}
<Box flexDirection="column" width={64}>
<Text color={colors.text} bold>Seed Phrase (12 or 24 words):</Text>
<Box
borderStyle="single"
borderColor={inputBorderColor}
paddingX={1}
marginTop={1}
>
<TextInput
value={seedPhrase}
onChange={setSeedPhrase}
onSubmit={handleSubmit}
placeholder="Enter your seed phrase..."
focus={focusedElement === 'input' && !isSubmitting}
/>
</Box>
{/* Status message */}
<Box marginTop={1} height={1}>
{statusMessage && (
<Text color={statusColor}>
{statusType === 'loading' && '⏳ '}
{statusType === 'error' && '✗ '}
{statusType === 'success' && '✓ '}
{statusMessage}
</Text>
)}
</Box>
{/* Submit button */}
<Box justifyContent="center" marginTop={1}>
<Button
label="Continue"
focused={focusedElement === 'button'}
disabled={isSubmitting}
shortcut="Enter"
/>
</Box>
</Box>
{/* Help text */}
<Box marginTop={2}>
<Text color={colors.textMuted} dimColor>
Tab: navigate Enter: submit q: quit
</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,255 @@
/**
* Template List Screen - Lists available templates and starting actions.
*
* Allows users to:
* - View imported templates
* - Select a template and action to start a new transaction
*/
import React, { useState, useEffect, useCallback } from 'react';
import { Box, Text, useInput } from 'ink';
import { Screen } from '../components/Screen.js';
import { useNavigation } from '../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
import { colors, logoSmall } from '../theme.js';
import type { XOTemplate, XOTemplateStartingActions } from '@xo-cash/types';
/**
* Template item with metadata.
*/
interface TemplateItem {
template: XOTemplate;
templateIdentifier: string;
startingActions: XOTemplateStartingActions;
}
/**
* Template List Screen Component.
* Displays templates and their starting actions.
*/
export function TemplateListScreen(): React.ReactElement {
const { navigate } = useNavigation();
const { walletController, showError } = useAppContext();
const { setStatus } = useStatus();
// State
const [templates, setTemplates] = useState<TemplateItem[]>([]);
const [selectedTemplateIndex, setSelectedTemplateIndex] = useState(0);
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
const [focusedPanel, setFocusedPanel] = useState<'templates' | 'actions'>('templates');
const [isLoading, setIsLoading] = useState(true);
/**
* Loads templates from the wallet controller.
*/
const loadTemplates = useCallback(async () => {
try {
setIsLoading(true);
setStatus('Loading templates...');
const templateList = await walletController.getTemplates();
const { generateTemplateIdentifier } = await import('@xo-cash/engine');
const loadedTemplates = await Promise.all(
templateList.map(async (template) => {
const templateIdentifier = generateTemplateIdentifier(template);
const startingActions = await walletController.getStartingActions(templateIdentifier);
return { template, templateIdentifier, startingActions };
})
);
setTemplates(loadedTemplates);
setSelectedTemplateIndex(0);
setSelectedActionIndex(0);
setStatus('Ready');
setIsLoading(false);
} catch (error) {
showError(`Failed to load templates: ${error instanceof Error ? error.message : String(error)}`);
setIsLoading(false);
}
}, [walletController, setStatus, showError]);
// Load templates on mount
useEffect(() => {
loadTemplates();
}, [loadTemplates]);
// Get current template and its actions
const currentTemplate = templates[selectedTemplateIndex];
const currentActions = currentTemplate?.startingActions ?? [];
/**
* Handles action selection.
*/
const handleActionSelect = useCallback(() => {
if (!currentTemplate || currentActions.length === 0) return;
const action = currentActions[selectedActionIndex];
if (!action) return;
// Navigate to action wizard with selected template and action
navigate('wizard', {
templateIdentifier: currentTemplate.templateIdentifier,
actionIdentifier: action.action,
roleIdentifier: action.role,
template: currentTemplate.template,
});
}, [currentTemplate, currentActions, selectedActionIndex, navigate]);
// Handle keyboard navigation
useInput((input, key) => {
// Tab to switch panels
if (key.tab) {
setFocusedPanel(prev => prev === 'templates' ? 'actions' : 'templates');
return;
}
// Up/Down navigation
if (key.upArrow || input === 'k') {
if (focusedPanel === 'templates') {
setSelectedTemplateIndex(prev => Math.max(0, prev - 1));
setSelectedActionIndex(0); // Reset action selection when template changes
} else {
setSelectedActionIndex(prev => Math.max(0, prev - 1));
}
} else if (key.downArrow || input === 'j') {
if (focusedPanel === 'templates') {
setSelectedTemplateIndex(prev => Math.min(templates.length - 1, prev + 1));
setSelectedActionIndex(0); // Reset action selection when template changes
} else {
setSelectedActionIndex(prev => Math.min(currentActions.length - 1, prev + 1));
}
}
// Enter to select action
if (key.return && focusedPanel === 'actions') {
handleActionSelect();
}
});
return (
<Box flexDirection="column" flexGrow={1}>
{/* Header */}
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1}>
<Text color={colors.primary} bold>{logoSmall} - Select Template & Action</Text>
</Box>
{/* Main content - two columns */}
<Box flexDirection="row" marginTop={1} flexGrow={1}>
{/* Left column: Template list */}
<Box flexDirection="column" width="40%" paddingRight={1}>
<Box
borderStyle="single"
borderColor={focusedPanel === 'templates' ? colors.focus : colors.primary}
flexDirection="column"
paddingX={1}
flexGrow={1}
>
<Text color={colors.primary} bold> Templates </Text>
<Box marginTop={1} flexDirection="column">
{isLoading ? (
<Text color={colors.textMuted}>Loading...</Text>
) : templates.length === 0 ? (
<Text color={colors.textMuted}>No templates imported</Text>
) : (
templates.map((item, index) => (
<Text
key={item.templateIdentifier}
color={index === selectedTemplateIndex ? colors.focus : colors.text}
bold={index === selectedTemplateIndex}
>
{index === selectedTemplateIndex && focusedPanel === 'templates' ? '▸ ' : ' '}
{index + 1}. {item.template.name || 'Unnamed Template'}
</Text>
))
)}
</Box>
</Box>
</Box>
{/* Right column: Actions list */}
<Box flexDirection="column" width="60%" paddingLeft={1}>
<Box
borderStyle="single"
borderColor={focusedPanel === 'actions' ? colors.focus : colors.border}
flexDirection="column"
paddingX={1}
flexGrow={1}
>
<Text color={colors.primary} bold> Starting Actions </Text>
<Box marginTop={1} flexDirection="column">
{!currentTemplate ? (
<Text color={colors.textMuted}>Select a template...</Text>
) : currentActions.length === 0 ? (
<Text color={colors.textMuted}>No starting actions available</Text>
) : (
currentActions.map((action, index) => {
const actionDef = currentTemplate.template.actions?.[action.action];
const name = actionDef?.name || action.action;
return (
<Text
key={`${action.action}-${action.role}`}
color={index === selectedActionIndex ? colors.focus : colors.text}
bold={index === selectedActionIndex}
>
{index === selectedActionIndex && focusedPanel === 'actions' ? '▸ ' : ' '}
{index + 1}. {name} (as {action.role})
</Text>
);
})
)}
</Box>
</Box>
</Box>
</Box>
{/* Description box */}
<Box marginTop={1}>
<Box
borderStyle="single"
borderColor={colors.border}
flexDirection="column"
paddingX={1}
paddingY={1}
width="100%"
>
<Text color={colors.primary} bold> Description </Text>
{currentTemplate ? (
<Box marginTop={1} flexDirection="column">
<Text color={colors.text} bold>
{currentTemplate.template.name || 'Unnamed Template'}
</Text>
<Text color={colors.textMuted}>
{currentTemplate.template.description || 'No description available'}
</Text>
{currentTemplate.template.version !== undefined && (
<Text color={colors.text}>
Version: {currentTemplate.template.version}
</Text>
)}
{currentTemplate.template.roles && (
<Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Roles:</Text>
{Object.entries(currentTemplate.template.roles).map(([roleId, role]) => (
<Text key={roleId} color={colors.textMuted}>
{' '}- {role.name || roleId}: {role.description || 'No description'}
</Text>
))}
</Box>
)}
</Box>
) : (
<Text color={colors.textMuted}>Select a template to see details</Text>
)}
</Box>
</Box>
{/* Help text */}
<Box marginTop={1}>
<Text color={colors.textMuted} dimColor>
Tab: Switch list Enter: Select action : Navigate Esc: Back
</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,372 @@
/**
* Transaction Screen - Reviews and broadcasts transactions.
*
* Provides:
* - Transaction details review
* - Input/output inspection
* - Fee calculation display
* - Broadcast confirmation
*/
import React, { useState, useEffect, useCallback } from 'react';
import { Box, Text, useInput } from 'ink';
import { ConfirmDialog } from '../components/Dialog.js';
import { useNavigation } from '../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
import { colors, logoSmall, formatSatoshis, formatHex } from '../theme.js';
import { copyToClipboard } from '../utils/clipboard.js';
import type { XOInvitation } from '@xo-cash/types';
/**
* Action menu items.
*/
const actionItems = [
{ label: 'Broadcast Transaction', value: 'broadcast' },
{ label: 'Sign Transaction', value: 'sign' },
{ label: 'Copy Transaction Hex', value: 'copy' },
{ label: 'Back to Invitation', value: 'back' },
];
/**
* Transaction Screen Component.
*/
export function TransactionScreen(): React.ReactElement {
const { navigate, goBack, data: navData } = useNavigation();
const { invitationController, showError, showInfo, confirm } = useAppContext();
const { setStatus } = useStatus();
// Extract invitation ID from navigation data
const invitationId = navData.invitationId as string | undefined;
// State
const [invitation, setInvitation] = useState<XOInvitation | null>(null);
const [focusedPanel, setFocusedPanel] = useState<'inputs' | 'outputs' | 'actions'>('actions');
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [showBroadcastConfirm, setShowBroadcastConfirm] = useState(false);
/**
* Load invitation data.
*/
const loadInvitation = useCallback(() => {
if (!invitationId) {
showError('No invitation ID provided');
goBack();
return;
}
const tracked = invitationController.getInvitation(invitationId);
if (!tracked) {
showError('Invitation not found');
goBack();
return;
}
setInvitation(tracked.invitation);
}, [invitationId, invitationController, showError, goBack]);
// Load on mount
useEffect(() => {
loadInvitation();
}, [loadInvitation]);
/**
* Broadcast transaction.
*/
const broadcastTransaction = useCallback(async () => {
if (!invitationId) return;
setShowBroadcastConfirm(false);
setIsLoading(true);
setStatus('Broadcasting transaction...');
try {
const txHash = await invitationController.broadcastTransaction(invitationId);
showInfo(
`Transaction Broadcast Successful!\n\n` +
`Transaction Hash:\n${txHash}\n\n` +
`The transaction has been submitted to the network.`
);
navigate('wallet');
} catch (error) {
showError(`Failed to broadcast: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setIsLoading(false);
setStatus('Ready');
}
}, [invitationId, invitationController, showInfo, showError, navigate, setStatus]);
/**
* Sign transaction.
*/
const signTransaction = useCallback(async () => {
if (!invitationId) return;
setIsLoading(true);
setStatus('Signing transaction...');
try {
await invitationController.signInvitation(invitationId);
loadInvitation();
showInfo('Transaction signed successfully!');
} catch (error) {
showError(`Failed to sign: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setIsLoading(false);
setStatus('Ready');
}
}, [invitationId, invitationController, loadInvitation, showInfo, showError, setStatus]);
/**
* Copy transaction hex.
*/
const copyTransactionHex = useCallback(async () => {
if (!invitation) return;
try {
await copyToClipboard(invitation.invitationIdentifier);
showInfo(
`Copied Invitation ID!\n\n` +
`ID: ${invitation.invitationIdentifier}\n` +
`Commits: ${invitation.commits.length}`
);
} catch (error) {
showError(`Failed to copy: ${error instanceof Error ? error.message : String(error)}`);
}
}, [invitation, showInfo, showError]);
/**
* Handle action selection.
*/
const handleAction = useCallback((action: string) => {
switch (action) {
case 'broadcast':
setShowBroadcastConfirm(true);
break;
case 'sign':
signTransaction();
break;
case 'copy':
copyTransactionHex();
break;
case 'back':
goBack();
break;
}
}, [signTransaction, copyTransactionHex, goBack]);
// Handle keyboard navigation
useInput((input, key) => {
if (showBroadcastConfirm) return;
// Tab to switch panels
if (key.tab) {
setFocusedPanel(prev => {
if (prev === 'inputs') return 'outputs';
if (prev === 'outputs') return 'actions';
return 'inputs';
});
return;
}
// Up/Down in actions
if (focusedPanel === 'actions') {
if (key.upArrow || input === 'k') {
setSelectedActionIndex(prev => Math.max(0, prev - 1));
} else if (key.downArrow || input === 'j') {
setSelectedActionIndex(prev => Math.min(actionItems.length - 1, prev + 1));
}
}
// Enter to select
if (key.return && focusedPanel === 'actions') {
const action = actionItems[selectedActionIndex];
if (action) {
handleAction(action.value);
}
}
}, { isActive: !showBroadcastConfirm });
// Extract transaction data from invitation
const commits = invitation?.commits ?? [];
const inputs: Array<{ txid: string; index: number; value?: bigint }> = [];
const outputs: Array<{ value: bigint; lockingBytecode: string }> = [];
// Parse commits for inputs and outputs
for (const commit of commits) {
if (commit.data?.inputs) {
for (const input of commit.data.inputs) {
// Convert Uint8Array to hex string if needed
const txidHex = input.outpointTransactionHash
? typeof input.outpointTransactionHash === 'string'
? input.outpointTransactionHash
: Buffer.from(input.outpointTransactionHash).toString('hex')
: 'unknown';
inputs.push({
txid: txidHex,
index: input.outpointIndex ?? 0,
value: undefined, // libauth Input doesn't have valueSatoshis directly
});
}
}
if (commit.data?.outputs) {
for (const output of commit.data.outputs) {
// Convert Uint8Array to hex string if needed
const lockingBytecodeHex = output.lockingBytecode
? typeof output.lockingBytecode === 'string'
? output.lockingBytecode
: Buffer.from(output.lockingBytecode).toString('hex')
: 'unknown';
outputs.push({
value: output.valueSatoshis ?? 0n,
lockingBytecode: lockingBytecodeHex,
});
}
}
}
// Calculate totals
const totalIn = inputs.reduce((sum, i) => sum + (i.value ?? 0n), 0n);
const totalOut = outputs.reduce((sum, o) => sum + o.value, 0n);
const fee = totalIn - totalOut;
return (
<Box flexDirection="column" flexGrow={1}>
{/* Header */}
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1}>
<Text color={colors.primary} bold>{logoSmall} - Transaction Review</Text>
</Box>
{/* Summary box */}
<Box
borderStyle="single"
borderColor={colors.primary}
marginTop={1}
marginX={1}
paddingX={1}
flexDirection="column"
>
<Text color={colors.primary} bold> Transaction Summary </Text>
{invitation ? (
<Box flexDirection="column" marginTop={1}>
<Text color={colors.text}>Inputs: {inputs.length} | Outputs: {outputs.length} | Commits: {commits.length}</Text>
<Text color={colors.success}>Total In: {formatSatoshis(totalIn)}</Text>
<Text color={colors.warning}>Total Out: {formatSatoshis(totalOut)}</Text>
<Text color={colors.info}>Fee: {formatSatoshis(fee)}</Text>
</Box>
) : (
<Text color={colors.textMuted}>Loading...</Text>
)}
</Box>
{/* Inputs and Outputs */}
<Box flexDirection="row" marginTop={1} marginX={1} flexGrow={1}>
{/* Inputs */}
<Box
borderStyle="single"
borderColor={focusedPanel === 'inputs' ? colors.focus : colors.border}
width="50%"
flexDirection="column"
paddingX={1}
>
<Text color={colors.primary} bold> Inputs </Text>
<Box flexDirection="column" marginTop={1}>
{inputs.length === 0 ? (
<Text color={colors.textMuted}>No inputs</Text>
) : (
inputs.map((input, index) => (
<Box key={`${input.txid}-${input.index}`} flexDirection="column" marginBottom={1}>
<Text color={colors.text}>
{index + 1}. {formatHex(input.txid, 12)}:{input.index}
</Text>
{input.value !== undefined && (
<Text color={colors.textMuted}> {formatSatoshis(input.value)}</Text>
)}
</Box>
))
)}
</Box>
</Box>
{/* Outputs */}
<Box
borderStyle="single"
borderColor={focusedPanel === 'outputs' ? colors.focus : colors.border}
width="50%"
flexDirection="column"
paddingX={1}
marginLeft={1}
>
<Text color={colors.primary} bold> Outputs </Text>
<Box flexDirection="column" marginTop={1}>
{outputs.length === 0 ? (
<Text color={colors.textMuted}>No outputs</Text>
) : (
outputs.map((output, index) => (
<Box key={index} flexDirection="column" marginBottom={1}>
<Text color={colors.text}>
{index + 1}. {formatSatoshis(output.value)}
</Text>
<Text color={colors.textMuted}> {formatHex(output.lockingBytecode, 20)}</Text>
</Box>
))
)}
</Box>
</Box>
</Box>
{/* Actions */}
<Box
borderStyle="single"
borderColor={focusedPanel === 'actions' ? colors.focus : colors.border}
marginTop={1}
marginX={1}
paddingX={1}
flexDirection="column"
>
<Text color={colors.primary} bold> Actions </Text>
<Box flexDirection="column" marginTop={1}>
{actionItems.map((item, index) => (
<Text
key={item.value}
color={index === selectedActionIndex && focusedPanel === 'actions' ? colors.focus : colors.text}
bold={index === selectedActionIndex && focusedPanel === 'actions'}
>
{index === selectedActionIndex && focusedPanel === 'actions' ? '▸ ' : ' '}
{item.label}
</Text>
))}
</Box>
</Box>
{/* Help text */}
<Box marginTop={1} marginX={1}>
<Text color={colors.textMuted} dimColor>
Tab: Switch focus Enter: Select Esc: Back
</Text>
</Box>
{/* Broadcast confirmation dialog */}
{showBroadcastConfirm && (
<Box
position="absolute"
flexDirection="column"
alignItems="center"
justifyContent="center"
width="100%"
height="100%"
>
<ConfirmDialog
title="Broadcast Transaction"
message="Are you sure you want to broadcast this transaction? This action cannot be undone."
onConfirm={broadcastTransaction}
onCancel={() => setShowBroadcastConfirm(false)}
isActive={showBroadcastConfirm}
/>
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,285 @@
/**
* Wallet State Screen - Displays wallet balances and UTXOs.
*
* Shows:
* - Total balance
* - List of unspent outputs
* - Navigation to other actions
*/
import React, { useState, useEffect, useCallback } from 'react';
import { Box, Text, useInput } from 'ink';
import SelectInput from 'ink-select-input';
import { Screen } from '../components/Screen.js';
import { List, type ListItem } from '../components/List.js';
import { useNavigation } from '../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js';
/**
* Menu action items.
*/
const menuItems = [
{ label: 'New Transaction (from template)', value: 'new-tx' },
{ label: 'Import Invitation', value: 'import' },
{ label: 'View Invitations', value: 'invitations' },
{ label: 'Generate New Address', value: 'new-address' },
{ label: 'Refresh', value: 'refresh' },
];
/**
* UTXO display item.
*/
interface UTXOItem {
key: string;
satoshis: bigint;
txid: string;
index: number;
reserved: boolean;
}
/**
* Wallet State Screen Component.
* Displays wallet balance, UTXOs, and action menu.
*/
export function WalletStateScreen(): React.ReactElement {
const { navigate } = useNavigation();
const { walletController, showError, showInfo } = useAppContext();
const { setStatus } = useStatus();
// State
const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null);
const [utxos, setUtxos] = useState<UTXOItem[]>([]);
const [focusedPanel, setFocusedPanel] = useState<'menu' | 'utxos'>('menu');
const [selectedUtxoIndex, setSelectedUtxoIndex] = useState(0);
const [isLoading, setIsLoading] = useState(true);
/**
* Refreshes wallet state.
*/
const refresh = useCallback(async () => {
try {
setIsLoading(true);
setStatus('Loading wallet state...');
// Get balance
const balanceData = await walletController.getBalance();
setBalance({
totalSatoshis: balanceData.totalSatoshis,
utxoCount: balanceData.utxoCount,
});
// Get UTXOs
const utxoData = await walletController.getUnspentOutputs();
setUtxos(utxoData.map((utxo) => ({
key: `${utxo.outpointTransactionHash}:${utxo.outpointIndex}`,
satoshis: BigInt(utxo.valueSatoshis),
txid: utxo.outpointTransactionHash,
index: utxo.outpointIndex,
reserved: utxo.reserved ?? false,
})));
setStatus('Wallet ready');
setIsLoading(false);
} catch (error) {
showError(`Failed to load wallet state: ${error instanceof Error ? error.message : String(error)}`);
setIsLoading(false);
}
}, [walletController, setStatus, showError]);
// Load wallet state on mount
useEffect(() => {
refresh();
}, [refresh]);
/**
* Generates a new receiving address.
*/
const generateNewAddress = useCallback(async () => {
try {
setStatus('Generating new address...');
// Get the default P2PKH template
const templates = await walletController.getTemplates();
const p2pkhTemplate = templates.find(t => t.name?.includes('P2PKH'));
if (!p2pkhTemplate) {
showError('P2PKH template not found');
return;
}
// Generate a new locking bytecode
const { generateTemplateIdentifier } = await import('@xo-cash/engine');
const templateId = generateTemplateIdentifier(p2pkhTemplate);
const lockingBytecode = await walletController.generateLockingBytecode(
templateId,
'receiveOutput',
'receiver',
);
showInfo(`New address generated!\n\nLocking bytecode:\n${formatHex(lockingBytecode, 40)}`);
// Refresh to show updated state
await refresh();
} catch (error) {
showError(`Failed to generate address: ${error instanceof Error ? error.message : String(error)}`);
}
}, [walletController, setStatus, showInfo, showError, refresh]);
/**
* Handles menu selection.
*/
const handleMenuSelect = useCallback((item: { value: string }) => {
switch (item.value) {
case 'new-tx':
navigate('templates');
break;
case 'import':
navigate('invitations', { mode: 'import' });
break;
case 'invitations':
navigate('invitations', { mode: 'list' });
break;
case 'new-address':
generateNewAddress();
break;
case 'refresh':
refresh();
break;
}
}, [navigate, generateNewAddress, refresh]);
// Handle keyboard navigation between panels
useInput((input, key) => {
if (key.tab) {
setFocusedPanel(prev => prev === 'menu' ? 'utxos' : 'menu');
}
});
// Convert UTXOs to list items
const utxoListItems: ListItem[] = utxos.map((utxo, index) => ({
key: utxo.key,
label: `${formatSatoshis(utxo.satoshis)} | ${formatHex(utxo.txid, 16)}:${utxo.index}`,
description: utxo.reserved ? '[Reserved]' : undefined,
}));
return (
<Box flexDirection="column" flexGrow={1}>
{/* Header */}
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1}>
<Text color={colors.primary} bold>{logoSmall} - Wallet Overview</Text>
</Box>
{/* Main content */}
<Box flexDirection="row" marginTop={1} flexGrow={1}>
{/* Left column: Balance */}
<Box
flexDirection="column"
width="50%"
paddingRight={1}
>
<Box
borderStyle="single"
borderColor={colors.primary}
flexDirection="column"
paddingX={1}
paddingY={1}
>
<Text color={colors.primary} bold> Balance </Text>
<Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Total Balance:</Text>
{balance ? (
<>
<Text color={colors.success} bold>
{formatSatoshis(balance.totalSatoshis)}
</Text>
<Text color={colors.textMuted}>
UTXOs: {balance.utxoCount}
</Text>
</>
) : (
<Text color={colors.textMuted}>Loading...</Text>
)}
</Box>
</Box>
</Box>
{/* Right column: Actions menu */}
<Box
flexDirection="column"
width="50%"
paddingLeft={1}
>
<Box
borderStyle="single"
borderColor={focusedPanel === 'menu' ? colors.focus : colors.border}
flexDirection="column"
paddingX={1}
>
<Text color={colors.primary} bold> Actions </Text>
<Box marginTop={1}>
<SelectInput
items={menuItems}
onSelect={handleMenuSelect}
isFocused={focusedPanel === 'menu'}
indicatorComponent={({ isSelected }) => (
<Text color={isSelected ? colors.focus : colors.text}>
{isSelected ? '▸ ' : ' '}
</Text>
)}
itemComponent={({ isSelected, label }) => (
<Text
color={isSelected ? colors.text : colors.textMuted}
bold={isSelected}
>
{label}
</Text>
)}
/>
</Box>
</Box>
</Box>
</Box>
{/* UTXO list */}
<Box marginTop={1} flexGrow={1}>
<Box
borderStyle="single"
borderColor={focusedPanel === 'utxos' ? colors.focus : colors.border}
flexDirection="column"
paddingX={1}
width="100%"
>
<Text color={colors.primary} bold> Unspent Outputs (UTXOs) </Text>
<Box marginTop={1} flexDirection="column">
{isLoading ? (
<Text color={colors.textMuted}>Loading...</Text>
) : utxoListItems.length === 0 ? (
<Text color={colors.textMuted}>No unspent outputs found</Text>
) : (
utxoListItems.map((item, index) => (
<Box key={item.key}>
<Text color={index === selectedUtxoIndex && focusedPanel === 'utxos' ? colors.focus : colors.text}>
{index === selectedUtxoIndex && focusedPanel === 'utxos' ? '▸ ' : ' '}
{index + 1}. {item.label}
</Text>
{item.description && (
<Text color={colors.warning}> {item.description}</Text>
)}
</Box>
))
)}
</Box>
</Box>
</Box>
{/* Help text */}
<Box marginTop={1}>
<Text color={colors.textMuted} dimColor>
Tab: Switch focus Enter: Select : Navigate Esc: Back
</Text>
</Box>
</Box>
);
}

10
src/tui/screens/index.tsx Normal file
View File

@@ -0,0 +1,10 @@
/**
* Export all screen components.
*/
export { SeedInputScreen } from './SeedInput.js';
export { WalletStateScreen } from './WalletState.js';
export { TemplateListScreen } from './TemplateList.js';
export { ActionWizardScreen } from './ActionWizard.js';
export { InvitationScreen } from './Invitation.js';
export { TransactionScreen } from './Transaction.js';

113
src/tui/theme.ts Normal file
View File

@@ -0,0 +1,113 @@
/**
* Theme configuration for the CLI TUI using Ink.
* Defines colors, styles, and visual constants used throughout the application.
*/
import type { TextProps } from 'ink';
/**
* Color type - supports Ink color names.
*/
export type Color = TextProps['color'];
/**
* Color palette for the application.
* All colors are compatible with Ink's Text component.
*/
export const colors = {
// Primary colors
primary: 'cyan' as Color,
secondary: 'blue' as Color,
accent: 'magenta' as Color,
// Status colors
success: 'green' as Color,
warning: 'yellow' as Color,
error: 'red' as Color,
info: 'cyan' as Color,
// Text colors
text: 'white' as Color,
textMuted: 'gray' as Color,
textHighlight: 'whiteBright' as Color,
// Background colors
bg: 'black' as Color,
bgSelected: 'blue' as Color,
bgHover: 'gray' as Color,
// Border colors
border: 'cyan' as Color,
borderFocused: 'yellowBright' as Color,
borderMuted: 'gray' as Color,
// Focus highlight color (very visible)
focus: 'yellowBright' as Color,
} as const;
/**
* Layout constants for consistent spacing.
*/
export const layout = {
padding: {
small: 1,
medium: 2,
large: 3,
},
margin: {
small: 1,
medium: 2,
large: 3,
},
} as const;
/**
* ASCII art logo for the application header.
*/
export const logo = `
██╗ ██╗ ██████╗ ██╗ ██╗ █████╗ ██╗ ██╗ ███████╗████████╗
╚██╗██╔╝██╔═══██╗ ██║ ██║██╔══██╗██║ ██║ ██╔════╝╚══██╔══╝
╚███╔╝ ██║ ██║ ██║ █╗ ██║███████║██║ ██║ █████╗ ██║
██╔██╗ ██║ ██║ ██║███╗██║██╔══██║██║ ██║ ██╔══╝ ██║
██╔╝ ██╗╚██████╔╝ ╚███╔███╔╝██║ ██║███████╗███████╗███████╗ ██║
╚═╝ ╚═╝ ╚═════╝ ╚══╝╚══╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝ ╚═╝
`.trim();
/**
* Small logo for status bar.
*/
export const logoSmall = 'XO Wallet';
/**
* Helper to format satoshis for display.
* @param satoshis - Amount in satoshis
* @returns Formatted string with BCH amount
*/
export function formatSatoshis(satoshis: bigint | number): string {
const value = typeof satoshis === 'bigint' ? satoshis : BigInt(satoshis);
const bch = Number(value) / 100_000_000;
return `${bch.toFixed(8)} BCH (${value.toLocaleString()} sats)`;
}
/**
* Helper to truncate long strings with ellipsis.
* @param str - String to truncate
* @param maxLength - Maximum length
* @returns Truncated string
*/
export function truncate(str: string, maxLength: number): string {
if (str.length <= maxLength) return str;
return str.slice(0, maxLength - 3) + '...';
}
/**
* Helper to format a hex string for display.
* @param hex - Hex string
* @param maxLength - Maximum display length
* @returns Formatted hex string
*/
export function formatHex(hex: string, maxLength: number = 16): string {
if (hex.length <= maxLength) return hex;
const half = Math.floor((maxLength - 3) / 2);
return `${hex.slice(0, half)}...${hex.slice(-half)}`;
}

91
src/tui/types.ts Normal file
View File

@@ -0,0 +1,91 @@
/**
* Shared types for the CLI TUI.
*/
import type { WalletController } from '../controllers/wallet-controller.js';
import type { InvitationController } from '../controllers/invitation-controller.js';
/**
* Screen names for navigation.
*/
export type ScreenName =
| 'seed-input'
| 'wallet'
| 'templates'
| 'wizard'
| 'invitations'
| 'transaction';
/**
* Navigation context data that can be passed between screens.
*/
export interface NavigationData {
/** Template identifier for wizard */
templateId?: string;
/** Action identifier for wizard */
actionId?: string;
/** Role identifier for wizard */
roleId?: string;
/** Invitation ID for transaction screen */
invitationId?: string;
/** Any additional data */
[key: string]: unknown;
}
/**
* Navigation context interface.
*/
export interface NavigationContextType {
/** Current screen name */
screen: ScreenName;
/** Data passed to the current screen */
data: NavigationData;
/** Navigation history stack */
history: ScreenName[];
/** Navigate to a new screen */
navigate: (screen: ScreenName, data?: NavigationData) => void;
/** Go back to the previous screen */
goBack: () => void;
/** Check if we can go back */
canGoBack: boolean;
}
/**
* App context interface - provides access to controllers and app-level functions.
*/
export interface AppContextType {
/** Wallet controller for wallet operations */
walletController: WalletController;
/** Invitation controller for invitation operations */
invitationController: InvitationController;
/** Show an error message dialog */
showError: (message: string) => void;
/** Show an info message dialog */
showInfo: (message: string) => void;
/** Show a confirmation dialog */
confirm: (message: string) => Promise<boolean>;
/** Exit the application */
exit: () => void;
/** Update status bar message */
setStatus: (message: string) => void;
/** Whether the wallet is initialized */
isWalletInitialized: boolean;
/** Set wallet initialized state */
setWalletInitialized: (initialized: boolean) => void;
}
/**
* Dialog state for modals.
*/
export interface DialogState {
/** Whether dialog is visible */
visible: boolean;
/** Dialog type */
type: 'error' | 'info' | 'confirm';
/** Dialog message */
message: string;
/** Callback for confirm dialog */
onConfirm?: () => void;
/** Callback for cancel/dismiss */
onCancel?: () => void;
}

View File

@@ -0,0 +1,62 @@
/**
* Cross-platform clipboard utility with multiple fallback methods.
*/
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
/**
* Attempts to copy text to clipboard using multiple methods.
* Tries native commands first (most reliable), then clipboardy as fallback.
*
* @param text - The text to copy to clipboard
* @returns Promise that resolves on success, rejects with error message on failure
*/
export async function copyToClipboard(text: string): Promise<void> {
const platform = process.platform;
// Escape the text for shell commands
const escapedText = text.replace(/'/g, "'\\''");
// Try native commands first - they're more reliable
try {
if (platform === 'darwin') {
// macOS - use pbcopy directly
await execAsync(`printf '%s' '${escapedText}' | pbcopy`);
return;
} else if (platform === 'linux') {
// Linux - try xclip, then xsel
try {
await execAsync(`printf '%s' '${escapedText}' | xclip -selection clipboard`);
return;
} catch {
try {
await execAsync(`printf '%s' '${escapedText}' | xsel --clipboard --input`);
return;
} catch {
// Fall through to clipboardy
}
}
} else if (platform === 'win32') {
// Windows - use clip.exe
await execAsync(`echo|set /p="${text}" | clip`);
return;
}
} catch {
// Native command failed, try clipboardy
}
// Fallback to clipboardy
try {
const clipboard = await import('clipboardy');
await clipboard.default.write(text);
return;
} catch {
// clipboardy also failed
}
// All methods failed
throw new Error(`Clipboard not available. Install xclip or xsel on Linux.`);
}