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

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>
);
}