Initial Commit
This commit is contained in:
497
src/tui/screens/ActionWizard.tsx
Normal file
497
src/tui/screens/ActionWizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
407
src/tui/screens/Invitation.tsx
Normal file
407
src/tui/screens/Invitation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
182
src/tui/screens/SeedInput.tsx
Normal file
182
src/tui/screens/SeedInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
255
src/tui/screens/TemplateList.tsx
Normal file
255
src/tui/screens/TemplateList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
372
src/tui/screens/Transaction.tsx
Normal file
372
src/tui/screens/Transaction.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
285
src/tui/screens/WalletState.tsx
Normal file
285
src/tui/screens/WalletState.tsx
Normal 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
10
src/tui/screens/index.tsx
Normal 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';
|
||||
Reference in New Issue
Block a user