Compare commits

...

4 Commits

14 changed files with 2329 additions and 608 deletions

View File

@@ -118,16 +118,10 @@ export class AppService extends EventEmitter<AppEventMap> {
const invitationsDb = this.storage.child('invitations'); const invitationsDb = this.storage.child('invitations');
// Load invitations from storage // Load invitations from storage
console.time('loadInvitations');
const invitations = await invitationsDb.all() as { key: string; value: XOInvitation }[]; const invitations = await invitationsDb.all() as { key: string; value: XOInvitation }[];
console.timeEnd('loadInvitations');
console.time('createInvitations');
await Promise.all(invitations.map(async ({ key }) => { await Promise.all(invitations.map(async ({ key }) => {
await this.createInvitation(key); await this.createInvitation(key);
})); }));
console.timeEnd('createInvitations');
} }
} }

View File

@@ -1,4 +1,5 @@
import type { AppendInvitationParameters, Engine, FindSuitableResourcesParameters } from '@xo-cash/engine'; import type { AcceptInvitationParameters, AppendInvitationParameters, Engine, FindSuitableResourcesParameters } from '@xo-cash/engine';
import { hasInvitationExpired } from '@xo-cash/engine';
import type { XOInvitation, XOInvitationCommit, XOInvitationInput, XOInvitationOutput, XOInvitationVariable } from '@xo-cash/types'; import type { XOInvitation, XOInvitationCommit, XOInvitationInput, XOInvitationOutput, XOInvitationVariable } from '@xo-cash/types';
import type { UnspentOutputData } from '@xo-cash/state'; import type { UnspentOutputData } from '@xo-cash/state';
@@ -31,12 +32,14 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
// Try to get the invitation from the storage // Try to get the invitation from the storage
const invitationFromStorage = await dependencies.storage.get(invitation); const invitationFromStorage = await dependencies.storage.get(invitation);
if (invitationFromStorage) { if (invitationFromStorage) {
console.log(`Invitation found in storage: ${invitation}`);
return this.create(invitationFromStorage, dependencies); return this.create(invitationFromStorage, dependencies);
} }
// Try to get the invitation from the sync server // Try to get the invitation from the sync server
const invitationFromSyncServer = await dependencies.syncServer.getInvitation(invitation); const invitationFromSyncServer = await dependencies.syncServer.getInvitation(invitation);
if (invitationFromSyncServer) { if (invitationFromSyncServer && invitationFromSyncServer.invitationIdentifier === invitation) {
console.log(`Invitation found in sync server: ${invitation}`);
return this.create(invitationFromSyncServer, dependencies); return this.create(invitationFromSyncServer, dependencies);
} }
@@ -86,6 +89,21 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
*/ */
private storage: Storage; private storage: Storage;
/**
* True after we have successfully called sign() on this invitation (session-only, not persisted).
*/
private _weHaveSigned = false;
/**
* True after we have successfully called broadcast() on this invitation (session-only, not persisted).
*/
private _broadcasted = false;
/**
* The status of the invitation (last emitted word: pending, actionable, signed, ready, complete, expired, unknown).
*/
public status: string = 'unknown';
/** /**
* Create an invitation and start the SSE Session required for it. * Create an invitation and start the SSE Session required for it.
*/ */
@@ -108,28 +126,28 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
async start(): Promise<void> { async start(): Promise<void> {
// Connect to the sync server and get the invitation (in parallel) // Connect to the sync server and get the invitation (in parallel)
console.time(`connectAndGetInvitation-${this.data.invitationIdentifier}`);
const [_, invitation] = await Promise.all([ const [_, invitation] = await Promise.all([
this.syncServer.connect(), this.syncServer.connect(),
this.syncServer.getInvitation(this.data.invitationIdentifier), this.syncServer.getInvitation(this.data.invitationIdentifier),
]); ]);
console.timeEnd(`connectAndGetInvitation-${this.data.invitationIdentifier}`);
// There is a chance we get SSE messages before the invitation is returned, so we want to combine any commits // There is a chance we get SSE messages before the invitation is returned, so we want to combine any commits
const sseCommits = this.data.commits; const sseCommits = this.data.commits;
console.time(`mergeCommits-${this.data.invitationIdentifier}`);
// Merge the commits // Merge the commits
const combinedCommits = this.mergeCommits(sseCommits, invitation?.commits ?? []); const combinedCommits = this.mergeCommits(sseCommits, invitation?.commits ?? []);
console.timeEnd(`mergeCommits-${this.data.invitationIdentifier}`);
console.time(`setInvitationData-${this.data.invitationIdentifier}`);
// Set the invitation data with the combined commits // Set the invitation data with the combined commits
this.data = { ...this.data, ...invitation, commits: combinedCommits }; this.data = { ...this.data, ...invitation, commits: combinedCommits };
// Store the invitation in the storage // Store the invitation in the storage
await this.storage.set(this.data.invitationIdentifier, this.data); await this.storage.set(this.data.invitationIdentifier, this.data);
console.timeEnd(`setInvitationData-${this.data.invitationIdentifier}`);
// Publish the invitation to the sync server
this.syncServer.publishInvitation(this.data);
// Compute and emit initial status
await this.updateStatus();
} }
/** /**
@@ -155,8 +173,8 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
// Set the new commits // Set the new commits
this.data = { ...this.data, commits: newCommits }; this.data = { ...this.data, commits: newCommits };
// Calculate the new status of the invitation // Calculate the new status of the invitation (fire-and-forget; handler is sync)
this.updateStatus(); this.updateStatus().catch(() => {});
// Emit the updated event // Emit the updated event
this.emit('invitation-updated', this.data); this.emit('invitation-updated', this.data);
@@ -185,26 +203,79 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
// Return the merged commits // Return the merged commits
return Array.from(initialMap.values()); return Array.from(initialMap.values());
} }
/** /**
* Update the status of the invitation based on the filled in information * Compute the invitation status as a single word: expired | complete | ready | signed | actionable | unknown.
*/ */
private updateStatus(): void { private async computeStatus(): Promise<string> {
// Calculate the status of the invitation try {
this.emit('invitation-status-changed', 'pending - this is temporary'); return await this.computeStatusInternal();
} catch (err) {
return `error (${err instanceof Error ? err.message : String(err)})`;
}
}
/**
* Internal status computation: returns a single word.
* NOTE: This could be a Enum-like object as well. May be a nice improvement. - DO NOT USE TS ENUM, THEY ARENT NATIVELY SUPPORTED IN NODE.JS
* - expired: any commit has expired
* - complete: we have broadcast this invitation
* - ready: no missing requirements and we have signed (ready to broadcast)
* - signed: we have signed but there are still missing parts (waiting for others)
* - actionable: you can provide data (missing requirements and/or you can sign)
* - unknown: template/action not found or error
*/
private async computeStatusInternal(): Promise<string> {
if (hasInvitationExpired(this.data)) {
return 'expired';
}
if (this._broadcasted) {
return 'complete';
}
let missingReqs;
try {
missingReqs = await this.engine.listMissingRequirements(this.data);
} catch {
return 'unknown';
}
const hasMissing =
(missingReqs.variables?.length ?? 0) > 0 ||
(missingReqs.inputs?.length ?? 0) > 0 ||
(missingReqs.outputs?.length ?? 0) > 0 ||
(missingReqs.roles !== undefined && Object.keys(missingReqs.roles).length > 0);
if (!hasMissing && this._weHaveSigned) {
return 'ready';
}
if (hasMissing && this._weHaveSigned) {
return 'signed';
}
return 'actionable';
}
/**
* Update the status of the invitation and emit the new single-word status.
*/
private async updateStatus(): Promise<void> {
const status = await this.computeStatus();
this.status = status;
this.emit('invitation-status-changed', status);
} }
/** /**
* Accept the invitation * Accept the invitation
*/ */
async accept(): Promise<void> { async accept(acceptParams?: AcceptInvitationParameters): Promise<void> {
// Accept the invitation // Accept the invitation
this.data = await this.engine.acceptInvitation(this.data); this.data = await this.engine.acceptInvitation(this.data, acceptParams);
// Sync the invitation to the sync server // Sync the invitation to the sync server
this.syncServer.publishInvitation(this.data); this.syncServer.publishInvitation(this.data);
// Update the status of the invitation // Update the status of the invitation
this.updateStatus(); await this.updateStatus();
} }
/** /**
@@ -220,26 +291,26 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
// Store the signed invitation in the storage // Store the signed invitation in the storage
await this.storage.set(this.data.invitationIdentifier, signedInvitation); await this.storage.set(this.data.invitationIdentifier, signedInvitation);
this.data = signedInvitation;
this._weHaveSigned = true;
// Update the status of the invitation // Update the status of the invitation
this.updateStatus(); await this.updateStatus();
} }
/** /**
* Broadcast the invitation * Broadcast the invitation
*/ */
async broadcast(): Promise<void> { async broadcast(): Promise<void> {
// Broadcast the invitation // Broadcast the transaction (executeAction returns transaction hash when broadcastTransaction: true)
const broadcastedInvitation = await this.engine.executeAction(this.data, { await this.engine.executeAction(this.data, {
broadcastTransaction: true, broadcastTransaction: true,
}); });
// Store the broadcasted invitation in the storage this._broadcasted = true;
await this.storage.set(this.data.invitationIdentifier, broadcastedInvitation);
// TODO: Am I supposed to do something here?
// Update the status of the invitation // Update the status of the invitation
this.updateStatus(); await this.updateStatus();
} }
// ============================================================================ // ============================================================================
@@ -260,7 +331,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
await this.storage.set(this.data.invitationIdentifier, this.data); await this.storage.set(this.data.invitationIdentifier, this.data);
// Update the status of the invitation // Update the status of the invitation
this.updateStatus(); await this.updateStatus();
} }
/** /**
@@ -295,7 +366,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
const { unspentOutputs } = await this.engine.findSuitableResources(this.data, options); const { unspentOutputs } = await this.engine.findSuitableResources(this.data, options);
// Update the status of the invitation // Update the status of the invitation
this.updateStatus(); await this.updateStatus();
// Return the suitable resources // Return the suitable resources
return unspentOutputs; return unspentOutputs;

View File

@@ -18,6 +18,8 @@ import { ActionWizardScreen } from './screens/action-wizard/ActionWizardScreen.j
import { InvitationScreen } from './screens/Invitation.js'; import { InvitationScreen } from './screens/Invitation.js';
import { TransactionScreen } from './screens/Transaction.js'; import { TransactionScreen } from './screens/Transaction.js';
import { MessageDialog } from './components/Dialog.js';
/** /**
* Props for the App component. * Props for the App component.
*/ */
@@ -107,28 +109,14 @@ function DialogOverlay(): React.ReactElement | null {
width="100%" width="100%"
height="100%" height="100%"
> >
<Box <MessageDialog
flexDirection="column" title={dialog.type === 'error' ? '✗ Error' :
borderStyle="double"
borderColor={borderColor}
paddingX={2}
paddingY={1}
width={60}
>
<Text color={borderColor} bold>
{dialog.type === 'error' ? '✗ Error' :
dialog.type === 'confirm' ? '? Confirm' : dialog.type === 'confirm' ? '? Confirm' :
' Info'} ' Info'}
</Text> message={dialog.message}
<Box marginY={1}> onClose={dialog.onCancel ?? (() => {})}
<Text wrap="wrap">{dialog.message}</Text> type={dialog.type as 'error' | 'info' | 'success'}
</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> </Box>
); );
} }

View File

@@ -2,8 +2,8 @@
* Dialog components for modals, confirmations, and input dialogs. * Dialog components for modals, confirmations, and input dialogs.
*/ */
import React, { useState } from 'react'; import React, { useRef, useState } from 'react';
import { Box, Text, useInput } from 'ink'; import { Box, Text, useInput, measureElement } from 'ink';
import TextInput from 'ink-text-input'; import TextInput from 'ink-text-input';
import { colors } from '../theme.js'; import { colors } from '../theme.js';
@@ -23,32 +23,61 @@ interface DialogWrapperProps {
backgroundColor?: string; backgroundColor?: string;
} }
/**
* Base dialog wrapper component.
*/
function DialogWrapper({ function DialogWrapper({
title, title,
borderColor = colors.primary, borderColor = colors.primary,
children, children,
width = 60, width = 60,
backgroundColor = colors.bg,
}: DialogWrapperProps): React.ReactElement { }: DialogWrapperProps): React.ReactElement {
const ref = useRef<any>(null);
const [height, setHeight] = useState<number | null>(null);
// measure after render
React.useLayoutEffect(() => {
if (ref.current) {
const { height } = measureElement(ref.current);
setHeight(height);
}
}, [children, title, width]);
return ( return (
<Box flexDirection="column">
{/* Opaque backing layer */}
{height !== null && (
<Box <Box
position="absolute"
flexDirection="column"
width={width}
height={height}
>
{Array.from({ length: height }).map((_, i) => (
<Text key={i}>{' '.repeat(width)}</Text>
))}
</Box>
)}
{/* Actual dialog */}
<Box
ref={ref}
flexDirection="column" flexDirection="column"
borderStyle="double" borderStyle="double"
borderColor={borderColor} borderColor={borderColor}
// backgroundColor={backgroundColor || 'white'}
backgroundColor="white"
paddingX={2} paddingX={2}
paddingY={1} paddingY={1}
width={width} width={width}
> >
<Text color={borderColor} bold>{title}</Text> <Text color={borderColor} bold>
{title}
</Text>
<Box marginY={1} flexDirection="column"> <Box marginY={1} flexDirection="column">
{children} {children}
</Box> </Box>
</Box> </Box>
</Box>
); );
} }

View File

@@ -1,13 +1,462 @@
/** /**
* Selectable list component with keyboard navigation. * List components with keyboard navigation.
*
* Provides:
* - ScrollableList: Full-featured list with grouping, filtering, and custom rendering
* - List: Basic selectable list (legacy, kept for backward compatibility)
* - SimpleList: Non-selectable list for display only
*/ */
import React from 'react'; import React, { useState, useMemo, useCallback } from 'react';
import { Box, Text, useInput } from 'ink'; import { Box, Text, useInput } from 'ink';
import TextInput from 'ink-text-input';
import { colors } from '../theme.js'; import { colors } from '../theme.js';
// =============================================================================
// Types
// =============================================================================
/** /**
* List item type. * Base list item data interface.
* Used by ScrollableList for item data.
*/
export interface ListItemData<T = unknown> {
/** Unique key for the item */
key: string;
/** Display label */
label: string;
/** Optional secondary text/description */
description?: string;
/** Optional value associated with item */
value?: T;
/** Whether item is disabled (can't be activated) */
disabled?: boolean;
/** Whether item should be hidden (not rendered, skipped in navigation) */
hidden?: boolean;
/** Custom color name for the item (semantic: 'info', 'warning', 'success', 'error', 'muted') */
color?: string;
/** Group identifier for grouping items */
group?: string;
}
/**
* Group definition for organizing list items.
*/
export interface ListGroup {
/** Unique group identifier */
id: string;
/** Optional header text to display above group */
label?: string;
/** Whether to show a separator after this group */
separator?: boolean;
}
/**
* Props for ScrollableList component.
*/
export interface ScrollableListProps<T> {
/** Array of list items */
items: ListItemData<T>[];
/** Currently selected index */
selectedIndex: number;
/** Handler called when selection changes */
onSelect: (index: number) => void;
/** Handler called when item is activated (Enter key) */
onActivate?: (item: ListItemData<T>, index: number) => void;
/** Whether the list is focused for keyboard input */
focus?: boolean;
/** Maximum number of visible items (enables scrolling). Default: 10 */
maxVisible?: number;
/** Whether to show a border around the list */
border?: boolean;
/** Optional label/title for the list */
label?: string;
/** Message to show when list is empty */
emptyMessage?: string;
/** Group definitions for organizing items */
groups?: ListGroup[];
/** Whether to enable filtering/search */
filterable?: boolean;
/** Placeholder text for filter input */
filterPlaceholder?: string;
/** Handler called when filter text changes */
onFilterChange?: (filter: string) => void;
/** Custom render function for items */
renderItem?: (item: ListItemData<T>, isSelected: boolean, isFocused: boolean) => React.ReactNode;
/** Whether to wrap around when navigating past ends */
wrapNavigation?: boolean;
/** Whether to show the scroll position indicator (e.g., "1-5 of 10"). Default: true */
showScrollIndicator?: boolean;
}
// =============================================================================
// Helper Functions
// =============================================================================
/**
* Map semantic color names to theme colors.
*/
function getColorFromName(colorName: string | undefined): string {
switch (colorName) {
case 'info':
return colors.info as string;
case 'warning':
return colors.warning as string;
case 'success':
return colors.success as string;
case 'error':
return colors.error as string;
case 'muted':
return colors.textMuted as string;
case 'accent':
return colors.accent as string;
case 'primary':
return colors.primary as string;
default:
return colors.text as string;
}
}
/**
* Find the next valid (non-hidden) index in a direction.
*
* @param items - Array of items
* @param currentIndex - Current index
* @param direction - Direction to search (1 for down, -1 for up)
* @param wrap - Whether to wrap around at ends
* @returns Next valid index, or current if none found
*/
function findNextValidIndex<T>(
items: ListItemData<T>[],
currentIndex: number,
direction: 1 | -1,
wrap: boolean = false
): number {
if (items.length === 0) return 0;
// Count visible items
const visibleIndices = items
.map((item, idx) => ({ item, idx }))
.filter(({ item }) => !item.hidden)
.map(({ idx }) => idx);
if (visibleIndices.length === 0) return currentIndex;
// Find current position in visible indices
const currentVisiblePos = visibleIndices.indexOf(currentIndex);
if (currentVisiblePos === -1) {
// Current index is hidden, find nearest visible
return visibleIndices[0] ?? 0;
}
// Calculate next position
let nextVisiblePos = currentVisiblePos + direction;
if (wrap) {
if (nextVisiblePos < 0) nextVisiblePos = visibleIndices.length - 1;
if (nextVisiblePos >= visibleIndices.length) nextVisiblePos = 0;
} else {
nextVisiblePos = Math.max(0, Math.min(visibleIndices.length - 1, nextVisiblePos));
}
return visibleIndices[nextVisiblePos] ?? 0;
}
/**
* Calculate scroll window for visible items.
*/
function calculateScrollWindow(
selectedIndex: number,
totalItems: number,
maxVisible: number
): { startIndex: number; endIndex: number } {
const halfWindow = Math.floor(maxVisible / 2);
let startIndex = Math.max(0, selectedIndex - halfWindow);
let endIndex = Math.min(totalItems, startIndex + maxVisible);
// Adjust start if we're near the end
if (endIndex - startIndex < maxVisible) {
startIndex = Math.max(0, endIndex - maxVisible);
}
return { startIndex, endIndex };
}
// =============================================================================
// ScrollableList Component
// =============================================================================
/**
* Full-featured scrollable list with grouping, filtering, and custom rendering.
*/
export function ScrollableList<T>({
items,
selectedIndex,
onSelect,
onActivate,
focus = true,
maxVisible = 10,
border = false,
label,
emptyMessage = 'No items',
groups,
filterable = false,
filterPlaceholder = 'Filter...',
onFilterChange,
renderItem,
wrapNavigation = false,
showScrollIndicator = true,
}: ScrollableListProps<T>): React.ReactElement {
// Filter state
const [filterText, setFilterText] = useState('');
const [isFiltering, setIsFiltering] = useState(false);
// Filter items based on filter text
const filteredItems = useMemo(() => {
if (!filterText.trim()) return items;
const lowerFilter = filterText.toLowerCase();
return items.map(item => ({
...item,
hidden: item.hidden || !item.label.toLowerCase().includes(lowerFilter),
}));
}, [items, filterText]);
// Get visible (non-hidden) items count
const visibleCount = useMemo(() =>
filteredItems.filter(item => !item.hidden).length,
[filteredItems]
);
// Handle keyboard navigation
useInput((input, key) => {
if (!focus) return;
// Toggle filter mode with '/'
if (filterable && input === '/' && !isFiltering) {
setIsFiltering(true);
return;
}
// Exit filter mode with Escape
if (isFiltering && key.escape) {
setIsFiltering(false);
setFilterText('');
onFilterChange?.('');
return;
}
// Don't process navigation when filtering
if (isFiltering) return;
// Navigation
if (key.upArrow || input === 'k') {
const newIndex = findNextValidIndex(filteredItems, selectedIndex, -1, wrapNavigation);
onSelect(newIndex);
} else if (key.downArrow || input === 'j') {
const newIndex = findNextValidIndex(filteredItems, selectedIndex, 1, wrapNavigation);
onSelect(newIndex);
} else if (key.return && onActivate) {
const item = filteredItems[selectedIndex];
if (item && !item.disabled && !item.hidden) {
onActivate(item, selectedIndex);
}
}
}, { isActive: focus && !isFiltering });
// Handle filter text change
const handleFilterChange = useCallback((value: string) => {
setFilterText(value);
onFilterChange?.(value);
}, [onFilterChange]);
// Handle filter submit (Enter in filter mode)
const handleFilterSubmit = useCallback(() => {
setIsFiltering(false);
}, []);
// Render a single item
const renderListItem = (item: ListItemData<T>, index: number) => {
if (item.hidden) return null;
const isSelected = index === selectedIndex;
const isFocused = focus && isSelected;
// Use custom render if provided
if (renderItem) {
return (
<Box key={item.key}>
{renderItem(item, isSelected, isFocused)}
</Box>
);
}
// Default rendering
const itemColor = isFocused
? colors.focus
: item.disabled
? colors.textMuted
: getColorFromName(item.color);
return (
<Box key={item.key}>
<Text
color={itemColor as string}
bold={isSelected}
dimColor={item.disabled}
>
{isFocused ? '▸ ' : ' '}
{item.label}
</Text>
{item.description && (
<Text color={colors.textMuted} dimColor> {item.description}</Text>
)}
</Box>
);
};
// Get all visible (non-hidden) items with their original indices
const visibleItemsWithIndices = useMemo(() => {
return filteredItems
.map((item, idx) => ({ item, idx }))
.filter(({ item }) => !item.hidden);
}, [filteredItems]);
// Calculate scroll window based on visible items only
const scrollWindow = useMemo(() => {
// Find position of selected index in visible items
const selectedVisiblePos = visibleItemsWithIndices.findIndex(({ idx }) => idx === selectedIndex);
const effectivePos = selectedVisiblePos >= 0 ? selectedVisiblePos : 0;
const halfWindow = Math.floor(maxVisible / 2);
let start = Math.max(0, effectivePos - halfWindow);
let end = Math.min(visibleItemsWithIndices.length, start + maxVisible);
// Adjust start if we're near the end
if (end - start < maxVisible) {
start = Math.max(0, end - maxVisible);
}
return { start, end };
}, [visibleItemsWithIndices, selectedIndex, maxVisible]);
// Get the slice of visible items to display
const displayItems = useMemo(() => {
return visibleItemsWithIndices.slice(scrollWindow.start, scrollWindow.end);
}, [visibleItemsWithIndices, scrollWindow]);
// Render content based on grouping
const renderContent = () => {
// Show empty message if no visible items
if (visibleCount === 0) {
return <Text color={colors.textMuted} dimColor>{emptyMessage}</Text>;
}
// If groups are defined, render grouped (but still respect maxVisible)
if (groups && groups.length > 0) {
// Get display item indices for quick lookup
const displayIndices = new Set(displayItems.map(({ idx }) => idx));
return (
<Box flexDirection="column">
{groups.map((group, groupIndex) => {
// Filter to only items that are in this group AND in the display window
const groupItems = displayItems.filter(({ item }) => item.group === group.id);
if (groupItems.length === 0) return null;
return (
<Box key={group.id} flexDirection="column">
{/* Group label */}
{group.label && (
<Text color={colors.textMuted} bold>{group.label}</Text>
)}
{/* Group items */}
{groupItems.map(({ item, idx }) => renderListItem(item, idx))}
{/* Separator - only show if there are more groups with items after this */}
{group.separator && groupIndex < groups.length - 1 && (
<Box marginY={1}>
<Text color={colors.textMuted}></Text>
</Box>
)}
</Box>
);
})}
</Box>
);
}
// No grouping - render with scroll window
return (
<Box flexDirection="column">
{displayItems.map(({ item, idx }) => renderListItem(item, idx))}
</Box>
);
};
const borderColor = focus ? colors.focus : colors.border;
const content = (
<Box flexDirection="column">
{/* Filter input */}
{filterable && isFiltering && (
<Box marginBottom={1}>
<Text color={colors.info}>Filter: </Text>
<TextInput
value={filterText}
onChange={handleFilterChange}
onSubmit={handleFilterSubmit}
placeholder={filterPlaceholder}
focus={isFiltering}
/>
</Box>
)}
{/* List content */}
{renderContent()}
{/* Scroll indicator */}
{showScrollIndicator && visibleCount > maxVisible && (
<Text color={colors.textMuted} dimColor>
{scrollWindow.start + 1}-{scrollWindow.end} of {visibleCount}
</Text>
)}
{/* Filter hint */}
{filterable && !isFiltering && (
<Text color={colors.textMuted} dimColor>
Press '/' to filter
</Text>
)}
</Box>
);
return (
<Box flexDirection="column">
{label && <Text color={colors.primary} bold>{label}</Text>}
{border ? (
<Box
borderStyle="single"
borderColor={borderColor}
paddingX={1}
flexDirection="column"
>
{content}
</Box>
) : content}
</Box>
);
}
// =============================================================================
// Legacy List Component (kept for backward compatibility)
// =============================================================================
/**
* Legacy list item type.
* @deprecated Use ListItemData instead
*/ */
export interface ListItem<T = unknown> { export interface ListItem<T = unknown> {
/** Unique key for the item */ /** Unique key for the item */
@@ -23,7 +472,8 @@ export interface ListItem<T = unknown> {
} }
/** /**
* Props for the List component. * Props for the legacy List component.
* @deprecated Use ScrollableListProps instead
*/ */
interface ListProps<T> { interface ListProps<T> {
/** List items */ /** List items */
@@ -46,6 +496,7 @@ interface ListProps<T> {
/** /**
* Selectable list with keyboard navigation. * Selectable list with keyboard navigation.
* @deprecated Use ScrollableList instead
*/ */
export function List<T>({ export function List<T>({
items, items,
@@ -132,8 +583,12 @@ export function List<T>({
); );
} }
// =============================================================================
// SimpleList Component
// =============================================================================
/** /**
* Simple inline list for displaying items without selection. * Props for SimpleList component.
*/ */
interface SimpleListProps { interface SimpleListProps {
items: string[]; items: string[];
@@ -141,6 +596,9 @@ interface SimpleListProps {
bullet?: string; bullet?: string;
} }
/**
* Simple inline list for displaying items without selection.
*/
export function SimpleList({ export function SimpleList({
items, items,
label, label,

View File

@@ -5,6 +5,14 @@
export { Screen } from './Screen.js'; export { Screen } from './Screen.js';
export { Input, TextDisplay } from './Input.js'; export { Input, TextDisplay } from './Input.js';
export { Button, ButtonRow } from './Button.js'; export { Button, ButtonRow } from './Button.js';
export { List, SimpleList, type ListItem } from './List.js'; export {
List,
SimpleList,
ScrollableList,
type ListItem,
type ListItemData,
type ListGroup,
type ScrollableListProps,
} from './List.js';
export { InputDialog, ConfirmDialog, MessageDialog } from './Dialog.js'; export { InputDialog, ConfirmDialog, MessageDialog } from './Dialog.js';
export { ProgressBar, StepIndicator, Loading, type Step } from './ProgressBar.js'; export { ProgressBar, StepIndicator, Loading, type Step } from './ProgressBar.js';

File diff suppressed because it is too large Load Diff

View File

@@ -6,8 +6,9 @@
* - Select a template and action to start a new transaction * - Select a template and action to start a new transaction
*/ */
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Box, Text, useInput } from 'ink'; import { Box, Text, useInput } from 'ink';
import { ScrollableList, type ListItemData } from '../components/List.js';
import { useNavigation } from '../hooks/useNavigation.js'; import { useNavigation } from '../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../hooks/useAppContext.js'; import { useAppContext, useStatus } from '../hooks/useAppContext.js';
import { colors, logoSmall } from '../theme.js'; import { colors, logoSmall } from '../theme.js';
@@ -16,18 +17,15 @@ import { colors, logoSmall } from '../theme.js';
import { generateTemplateIdentifier } from '@xo-cash/engine'; import { generateTemplateIdentifier } from '@xo-cash/engine';
import type { XOTemplate } from '@xo-cash/types'; import type { XOTemplate } from '@xo-cash/types';
/** // Import utility functions
* A unique starting action (deduplicated by action identifier). import {
* Multiple roles that can start the same action are counted formatTemplateListItem,
* but not shown as separate entries — role selection happens formatActionListItem,
* inside the Action Wizard. deduplicateStartingActions,
*/ getTemplateRoles,
interface UniqueStartingAction { getRolesForAction,
actionIdentifier: string; type UniqueStartingAction,
name: string; } from '../../utils/template-utils.js';
description?: string;
roleCount: number;
}
/** /**
* Template item with metadata. * Template item with metadata.
@@ -38,6 +36,16 @@ interface TemplateItem {
startingActions: UniqueStartingAction[]; startingActions: UniqueStartingAction[];
} }
/**
* Template list item with TemplateItem value.
*/
type TemplateListItem = ListItemData<TemplateItem>;
/**
* Action list item with UniqueStartingAction value.
*/
type ActionListItem = ListItemData<UniqueStartingAction>;
/** /**
* Template List Screen Component. * Template List Screen Component.
* Displays templates and their starting actions. * Displays templates and their starting actions.
@@ -74,27 +82,13 @@ export function TemplateListScreen(): React.ReactElement {
const templateIdentifier = generateTemplateIdentifier(template); const templateIdentifier = generateTemplateIdentifier(template);
const rawStartingActions = await appService.engine.listStartingActions(templateIdentifier); const rawStartingActions = await appService.engine.listStartingActions(templateIdentifier);
// Deduplicate by action identifier — role selection // Use utility function to deduplicate actions
// is handled inside the Action Wizard, not here. const startingActions = deduplicateStartingActions(template, rawStartingActions);
const actionMap = new Map<string, UniqueStartingAction>();
for (const sa of rawStartingActions) {
if (actionMap.has(sa.action)) {
actionMap.get(sa.action)!.roleCount++;
} else {
const actionDef = template.actions?.[sa.action];
actionMap.set(sa.action, {
actionIdentifier: sa.action,
name: actionDef?.name || sa.action,
description: actionDef?.description,
roleCount: 1,
});
}
}
return { return {
template, template,
templateIdentifier, templateIdentifier,
startingActions: Array.from(actionMap.values()), startingActions,
}; };
}) })
); );
@@ -119,15 +113,59 @@ export function TemplateListScreen(): React.ReactElement {
const currentTemplate = templates[selectedTemplateIndex]; const currentTemplate = templates[selectedTemplateIndex];
const currentActions = currentTemplate?.startingActions ?? []; const currentActions = currentTemplate?.startingActions ?? [];
/**
* Build template list items for ScrollableList.
*/
const templateListItems = useMemo((): TemplateListItem[] => {
return templates.map((item, index) => {
const formatted = formatTemplateListItem(item.template, index);
return {
key: item.templateIdentifier,
label: formatted.label,
description: formatted.description,
value: item,
hidden: !formatted.isValid,
};
});
}, [templates]);
/**
* Build action list items for ScrollableList.
*/
const actionListItems = useMemo((): ActionListItem[] => {
return currentActions.map((action, index) => {
const formatted = formatActionListItem(
action.actionIdentifier,
currentTemplate?.template?.actions?.[action.actionIdentifier],
action.roleCount,
index
);
return {
key: action.actionIdentifier,
label: formatted.label,
description: formatted.description,
value: action,
hidden: !formatted.isValid,
};
});
}, [currentActions, currentTemplate]);
/**
* Handle template selection change.
*/
const handleTemplateSelect = useCallback((index: number) => {
setSelectedTemplateIndex(index);
setSelectedActionIndex(0); // Reset action selection when template changes
}, []);
/** /**
* Handles action selection. * Handles action selection.
* Navigates to the Action Wizard where the user will choose their role. * Navigates to the Action Wizard where the user will choose their role.
*/ */
const handleActionSelect = useCallback(() => { const handleActionActivate = useCallback((item: ActionListItem, index: number) => {
if (!currentTemplate || currentActions.length === 0) return; if (!currentTemplate || !item.value) return;
const action = currentActions[selectedActionIndex]; const action = item.value;
if (!action) return;
// Navigate to the Action Wizard — role selection happens there // Navigate to the Action Wizard — role selection happens there
navigate('wizard', { navigate('wizard', {
@@ -135,7 +173,7 @@ export function TemplateListScreen(): React.ReactElement {
actionIdentifier: action.actionIdentifier, actionIdentifier: action.actionIdentifier,
template: currentTemplate.template, template: currentTemplate.template,
}); });
}, [currentTemplate, currentActions, selectedActionIndex, navigate]); }, [currentTemplate, navigate]);
// Handle keyboard navigation // Handle keyboard navigation
useInput((input, key) => { useInput((input, key) => {
@@ -144,124 +182,111 @@ export function TemplateListScreen(): React.ReactElement {
setFocusedPanel(prev => prev === 'templates' ? 'actions' : 'templates'); setFocusedPanel(prev => prev === 'templates' ? 'actions' : 'templates');
return; 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();
}
}); });
/**
* Render custom template list item.
*/
const renderTemplateItem = useCallback((
item: TemplateListItem,
isSelected: boolean,
isFocused: boolean
): React.ReactNode => {
return ( return (
<Box flexDirection='column' flexGrow={1}> <Text
color={isFocused ? colors.focus : colors.text}
bold={isSelected}
>
{isFocused ? '▸ ' : ' '}
{item.label}
</Text>
);
}, []);
/**
* Render custom action list item.
*/
const renderActionItem = useCallback((
item: ActionListItem,
isSelected: boolean,
isFocused: boolean
): React.ReactNode => {
return (
<Text
color={isFocused ? colors.focus : colors.text}
bold={isSelected}
>
{isFocused ? '▸ ' : ' '}
{item.label}
</Text>
);
}, []);
return (
<Box flexDirection="column" flexGrow={1}>
{/* Header */} {/* Header */}
<Box borderStyle='single' borderColor={colors.secondary} paddingX={1}> <Box borderStyle="single" borderColor={colors.secondary} paddingX={1}>
<Text color={colors.primary} bold>{logoSmall} - Select Template & Action</Text> <Text color={colors.primary} bold>{logoSmall} - Select Template & Action</Text>
</Box> </Box>
{/* Main content - two columns */} {/* Main content - two columns */}
<Box flexDirection='row' marginTop={1} flexGrow={1}> <Box flexDirection="row" marginTop={1} flexGrow={1}>
{/* Left column: Template list */} {/* Left column: Template list */}
<Box flexDirection='column' width='40%' paddingRight={1}> <Box flexDirection="column" width="40%" paddingRight={1}>
<Box <Box
borderStyle='single' borderStyle="single"
borderColor={focusedPanel === 'templates' ? colors.focus : colors.primary} borderColor={focusedPanel === 'templates' ? colors.focus : colors.primary}
flexDirection='column' flexDirection="column"
paddingX={1} paddingX={1}
flexGrow={1} flexGrow={1}
> >
<Text color={colors.primary} bold> Templates </Text> <Text color={colors.primary} bold> Templates </Text>
<Box marginTop={1} flexDirection='column'> {isLoading ? (
<Box marginTop={1}>
{(() => { <Text color={colors.textMuted}>Loading...</Text>
// Loading State
if (isLoading) {
return <Text color={colors.textMuted}>Loading...</Text>;
}
// No templates state
if (templates.length === 0) {
return <Text color={colors.textMuted}>No templates imported</Text>;
}
// Templates state
return 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>
) : (
<ScrollableList
items={templateListItems}
selectedIndex={selectedTemplateIndex}
onSelect={handleTemplateSelect}
focus={focusedPanel === 'templates'}
emptyMessage="No templates imported"
renderItem={renderTemplateItem}
/>
)}
</Box> </Box>
</Box> </Box>
{/* Right column: Actions list */} {/* Right column: Actions list */}
<Box flexDirection='column' width='60%' paddingLeft={1}> <Box flexDirection="column" width="60%" paddingLeft={1}>
<Box <Box
borderStyle='single' borderStyle="single"
borderColor={focusedPanel === 'actions' ? colors.focus : colors.border} borderColor={focusedPanel === 'actions' ? colors.focus : colors.border}
flexDirection='column' flexDirection="column"
paddingX={1} paddingX={1}
flexGrow={1} flexGrow={1}
> >
<Text color={colors.primary} bold> Starting Actions </Text> <Text color={colors.primary} bold> Starting Actions </Text>
<Box marginTop={1} flexDirection='column'> {isLoading ? (
<Box marginTop={1}>
{(() => { <Text color={colors.textMuted}>Loading...</Text>
// Loading state
if (isLoading) {
return <Text color={colors.textMuted}>Loading...</Text>;
}
// No template selected state
if (!currentTemplate) {
return <Text color={colors.textMuted}>Select a template...</Text>;
}
// No starting actions state
if (currentActions.length === 0) {
return <Text color={colors.textMuted}>No starting actions available</Text>;
}
// Starting actions state
return currentActions.map((action, index) => (
<Text
key={action.actionIdentifier}
color={index === selectedActionIndex ? colors.focus : colors.text}
bold={index === selectedActionIndex}
>
{index === selectedActionIndex && focusedPanel === 'actions' ? '▸ ' : ' '}
{index + 1}. {action.name}
{action.roleCount > 1
? ` (${action.roleCount} roles)`
: ''}
</Text>
));
})()}
</Box> </Box>
) : !currentTemplate ? (
<Box marginTop={1}>
<Text color={colors.textMuted}>Select a template...</Text>
</Box>
) : (
<ScrollableList
items={actionListItems}
selectedIndex={selectedActionIndex}
onSelect={setSelectedActionIndex}
onActivate={handleActionActivate}
focus={focusedPanel === 'actions'}
emptyMessage="No starting actions available"
renderItem={renderActionItem}
/>
)}
</Box> </Box>
</Box> </Box>
</Box> </Box>
@@ -269,18 +294,18 @@ export function TemplateListScreen(): React.ReactElement {
{/* Description box */} {/* Description box */}
<Box marginTop={1}> <Box marginTop={1}>
<Box <Box
borderStyle='single' borderStyle="single"
borderColor={colors.border} borderColor={colors.border}
flexDirection='column' flexDirection="column"
paddingX={1} paddingX={1}
paddingY={1} paddingY={1}
width='100%' width="100%"
> >
<Text color={colors.primary} bold> Description </Text> <Text color={colors.primary} bold> Description </Text>
{/* Show template description when templates panel is focused */} {/* Show template description when templates panel is focused */}
{focusedPanel === 'templates' && currentTemplate ? ( {focusedPanel === 'templates' && currentTemplate ? (
<Box marginTop={1} flexDirection='column'> <Box marginTop={1} flexDirection="column">
<Text color={colors.text} bold> <Text color={colors.text} bold>
{currentTemplate.template.name || 'Unnamed Template'} {currentTemplate.template.name || 'Unnamed Template'}
</Text> </Text>
@@ -293,11 +318,11 @@ export function TemplateListScreen(): React.ReactElement {
</Text> </Text>
)} )}
{currentTemplate.template.roles && ( {currentTemplate.template.roles && (
<Box marginTop={1} flexDirection='column'> <Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Roles:</Text> <Text color={colors.text}>Roles:</Text>
{Object.entries(currentTemplate.template.roles).map(([roleId, role]) => ( {getTemplateRoles(currentTemplate.template).map((role) => (
<Text key={roleId} color={colors.textMuted}> <Text key={role.roleId} color={colors.textMuted}>
{' '}- {role.name || roleId}: {role.description || 'No description'} {' '}- {role.name}: {role.description || 'No description'}
</Text> </Text>
))} ))}
</Box> </Box>
@@ -309,14 +334,13 @@ export function TemplateListScreen(): React.ReactElement {
{/* Show action description when actions panel is focused */} {/* Show action description when actions panel is focused */}
{focusedPanel === 'actions' && currentTemplate && currentActions.length > 0 ? ( {focusedPanel === 'actions' && currentTemplate && currentActions.length > 0 ? (
<Box marginTop={1} flexDirection='column'> <Box marginTop={1} flexDirection="column">
{(() => { {(() => {
const action = currentActions[selectedActionIndex]; const action = currentActions[selectedActionIndex];
if (!action) return null; if (!action) return null;
// Collect all roles that can start this action // Get roles that can start this action using utility function
const startEntries = (currentTemplate.template.start ?? []) const availableRoles = getRolesForAction(currentTemplate.template, action.actionIdentifier);
.filter((s) => s.action === action.actionIdentifier);
return ( return (
<> <>
@@ -328,18 +352,15 @@ export function TemplateListScreen(): React.ReactElement {
</Text> </Text>
{/* List available roles for this action */} {/* List available roles for this action */}
{startEntries.length > 0 && ( {availableRoles.length > 0 && (
<Box marginTop={1} flexDirection='column'> <Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Available Roles:</Text> <Text color={colors.text}>Available Roles:</Text>
{startEntries.map((entry) => { {availableRoles.map((role) => (
const roleDef = currentTemplate.template.roles?.[entry.role]; <Text key={role.roleId} color={colors.textMuted}>
return ( {' '}- {role.name}
<Text key={entry.role} color={colors.textMuted}> {role.description ? `: ${role.description}` : ''}
{' '}- {roleDef?.name || entry.role}
{roleDef?.description ? `: ${roleDef.description}` : ''}
</Text> </Text>
); ))}
})}
</Box> </Box>
)} )}
</> </>

View File

@@ -7,25 +7,59 @@
* - Navigation to other actions * - Navigation to other actions
*/ */
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Box, Text, useInput } from 'ink'; import { Box, Text, useInput } from 'ink';
import SelectInput from 'ink-select-input'; import { ScrollableList, type ListItemData } from '../components/List.js';
import { useNavigation } from '../hooks/useNavigation.js'; import { useNavigation } from '../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../hooks/useAppContext.js'; import { useAppContext, useStatus } from '../hooks/useAppContext.js';
import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js'; import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js';
import type { HistoryItem } from '../../services/history.js'; import type { HistoryItem } from '../../services/history.js';
// Import utility functions
import {
formatHistoryListItem,
getHistoryItemColorName,
formatHistoryDate,
type HistoryColorName,
} from '../../utils/history-utils.js';
/**
* Map history color name to theme color.
*/
function getHistoryColor(colorName: HistoryColorName): string {
switch (colorName) {
case 'info':
return colors.info as string;
case 'warning':
return colors.warning as string;
case 'success':
return colors.success as string;
case 'error':
return colors.error as string;
case 'muted':
return colors.textMuted as string;
case 'text':
default:
return colors.text as string;
}
}
/** /**
* Menu action items. * Menu action items.
*/ */
const menuItems = [ const menuItems: ListItemData<string>[] = [
{ label: 'New Transaction (from template)', value: 'new-tx' }, { key: 'new-tx', label: 'New Transaction (from template)', value: 'new-tx' },
{ label: 'Import Invitation', value: 'import' }, { key: 'import', label: 'Import Invitation', value: 'import' },
{ label: 'View Invitations', value: 'invitations' }, { key: 'invitations', label: 'View Invitations', value: 'invitations' },
{ label: 'Generate New Address', value: 'new-address' }, { key: 'new-address', label: 'Generate New Address', value: 'new-address' },
{ label: 'Refresh', value: 'refresh' }, { key: 'refresh', label: 'Refresh', value: 'refresh' },
]; ];
/**
* History list item with HistoryItem value.
*/
type HistoryListItem = ListItemData<HistoryItem>;
/** /**
* Wallet State Screen Component. * Wallet State Screen Component.
* Displays wallet balance, history, and action menu. * Displays wallet balance, history, and action menu.
@@ -39,6 +73,7 @@ export function WalletStateScreen(): React.ReactElement {
const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null); const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null);
const [history, setHistory] = useState<HistoryItem[]>([]); const [history, setHistory] = useState<HistoryItem[]>([]);
const [focusedPanel, setFocusedPanel] = useState<'menu' | 'history'>('menu'); const [focusedPanel, setFocusedPanel] = useState<'menu' | 'history'>('menu');
const [selectedMenuIndex, setSelectedMenuIndex] = useState(0);
const [selectedHistoryIndex, setSelectedHistoryIndex] = useState(0); const [selectedHistoryIndex, setSelectedHistoryIndex] = useState(0);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -124,10 +159,10 @@ export function WalletStateScreen(): React.ReactElement {
}, [appService, setStatus, showInfo, showError, refresh]); }, [appService, setStatus, showInfo, showError, refresh]);
/** /**
* Handles menu selection. * Handles menu action.
*/ */
const handleMenuSelect = useCallback((item: { value: string }) => { const handleMenuAction = useCallback((action: string) => {
switch (item.value) { switch (action) {
case 'new-tx': case 'new-tx':
navigate('templates'); navigate('templates');
break; break;
@@ -146,46 +181,131 @@ export function WalletStateScreen(): React.ReactElement {
} }
}, [navigate, generateNewAddress, refresh]); }, [navigate, generateNewAddress, refresh]);
/**
* Handle menu item activation.
*/
const handleMenuItemActivate = useCallback((item: ListItemData<string>, index: number) => {
if (item.value) {
handleMenuAction(item.value);
}
}, [handleMenuAction]);
/**
* Build history list items for ScrollableList.
*/
const historyListItems = useMemo((): HistoryListItem[] => {
return history.map(item => {
const formatted = formatHistoryListItem(item, false);
return {
key: item.id,
label: formatted.label,
description: formatted.description,
value: item,
color: formatted.color,
hidden: !formatted.isValid,
};
});
}, [history]);
// Handle keyboard navigation between panels // Handle keyboard navigation between panels
useInput((input, key) => { useInput((input, key) => {
if (key.tab) { if (key.tab) {
setFocusedPanel(prev => prev === 'menu' ? 'history' : 'menu'); setFocusedPanel(prev => prev === 'menu' ? 'history' : 'menu');
} }
// Navigate history items when focused
if (focusedPanel === 'history' && history.length > 0) {
if (key.upArrow) {
setSelectedHistoryIndex(prev => Math.max(0, prev - 1));
} else if (key.downArrow) {
setSelectedHistoryIndex(prev => Math.min(history.length - 1, prev + 1));
}
}
}); });
/**
* Render custom history list item.
*/
const renderHistoryItem = useCallback((
item: HistoryListItem,
isSelected: boolean,
isFocused: boolean
): React.ReactNode => {
const historyItem = item.value;
if (!historyItem) return null;
const colorName = getHistoryItemColorName(historyItem.type, isFocused);
const itemColor = isFocused ? colors.focus : getHistoryColor(colorName);
const dateStr = formatHistoryDate(historyItem.timestamp);
const indicator = isFocused ? '▸ ' : ' ';
// Format based on type
if (historyItem.type === 'invitation_created') {
return ( return (
<Box flexDirection='column' flexGrow={1}> <Box flexDirection="row" justifyContent="space-between">
<Text color={itemColor}>
{indicator}[Invitation] {historyItem.description}
</Text>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>
);
} else if (historyItem.type === 'utxo_reserved') {
const sats = historyItem.valueSatoshis ?? 0n;
return (
<Box flexDirection="row" justifyContent="space-between">
<Box>
<Text color={itemColor}>
{indicator}[Reserved] {formatSatoshis(sats)}
</Text>
<Text color={colors.textMuted}> {historyItem.description}</Text>
</Box>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>
);
} else if (historyItem.type === 'utxo_received') {
const sats = historyItem.valueSatoshis ?? 0n;
const reservedTag = historyItem.reserved ? ' [Reserved]' : '';
return (
<Box flexDirection="row" justifyContent="space-between">
<Box flexDirection="row">
<Text color={itemColor}>
{indicator}{formatSatoshis(sats)}
</Text>
<Text color={colors.textMuted}>
{' '}{historyItem.description}{reservedTag}
</Text>
</Box>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>
);
}
// Fallback for other types
return (
<Box flexDirection="row" justifyContent="space-between">
<Text color={itemColor}>
{indicator}{historyItem.type}: {historyItem.description}
</Text>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>
);
}, []);
return (
<Box flexDirection="column" flexGrow={1}>
{/* Header */} {/* Header */}
<Box borderStyle='single' borderColor={colors.secondary} paddingX={1}> <Box borderStyle="single" borderColor={colors.secondary} paddingX={1}>
<Text color={colors.primary} bold>{logoSmall} - Wallet Overview</Text> <Text color={colors.primary} bold>{logoSmall} - Wallet Overview</Text>
</Box> </Box>
{/* Main content */} {/* Main content */}
<Box flexDirection='row' marginTop={1} flexGrow={1}> <Box flexDirection="row" marginTop={1} flexGrow={1}>
{/* Left column: Balance */} {/* Left column: Balance */}
<Box <Box
flexDirection='column' flexDirection="column"
width='50%' width="50%"
paddingRight={1} paddingRight={1}
> >
<Box <Box
borderStyle='single' borderStyle="single"
borderColor={colors.primary} borderColor={colors.primary}
flexDirection='column' flexDirection="column"
paddingX={1} paddingX={1}
paddingY={1} paddingY={1}
> >
<Text color={colors.primary} bold> Balance </Text> <Text color={colors.primary} bold> Balance </Text>
<Box marginTop={1} flexDirection='column'> <Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Total Balance:</Text> <Text color={colors.text}>Total Balance:</Text>
{balance ? ( {balance ? (
<> <>
@@ -205,135 +325,58 @@ export function WalletStateScreen(): React.ReactElement {
{/* Right column: Actions menu */} {/* Right column: Actions menu */}
<Box <Box
flexDirection='column' flexDirection="column"
width='50%' width="50%"
paddingLeft={1} paddingLeft={1}
> >
<Box <Box
borderStyle='single' borderStyle="single"
borderColor={focusedPanel === 'menu' ? colors.focus : colors.border} borderColor={focusedPanel === 'menu' ? colors.focus : colors.border}
flexDirection='column' flexDirection="column"
paddingX={1} paddingX={1}
> >
<Text color={colors.primary} bold> Actions </Text> <Text color={colors.primary} bold> Actions </Text>
<Box marginTop={1}> <ScrollableList
<SelectInput
items={menuItems} items={menuItems}
onSelect={handleMenuSelect} selectedIndex={selectedMenuIndex}
isFocused={focusedPanel === 'menu'} onSelect={setSelectedMenuIndex}
indicatorComponent={({ isSelected }) => ( onActivate={handleMenuItemActivate}
<Text color={isSelected ? colors.focus : colors.text}> focus={focusedPanel === 'menu'}
{isSelected ? '▸ ' : ' '} emptyMessage="No actions"
</Text>
)}
itemComponent={({ isSelected, label }) => (
<Text
color={isSelected ? colors.text : colors.textMuted}
bold={isSelected}
>
{label}
</Text>
)}
/> />
</Box> </Box>
</Box> </Box>
</Box> </Box>
</Box>
{/* Wallet History */} {/* Wallet History */}
<Box marginTop={1} flexGrow={1}> <Box marginTop={1} flexGrow={1}>
<Box <Box
borderStyle='single' borderStyle="single"
borderColor={focusedPanel === 'history' ? colors.focus : colors.border} borderColor={focusedPanel === 'history' ? colors.focus : colors.border}
flexDirection='column' flexDirection="column"
paddingX={1} paddingX={1}
width='100%' width="100%"
height={14} height={14}
overflow='hidden' overflow="hidden"
> >
<Text color={colors.primary} bold> Wallet History {history.length > 0 ? `(${selectedHistoryIndex + 1}/${history.length})` : ''}</Text> <Text color={colors.primary} bold> Wallet History {history.length > 0 ? `(${selectedHistoryIndex + 1}/${history.length})` : ''}</Text>
<Box marginTop={1} flexDirection='column'>
{isLoading ? ( {isLoading ? (
<Box marginTop={1}>
<Text color={colors.textMuted}>Loading...</Text> <Text color={colors.textMuted}>Loading...</Text>
) : history.length === 0 ? ( </Box>
<Text color={colors.textMuted}>No history found</Text>
) : ( ) : (
// Show a scrolling window of items <ScrollableList
(() => { items={historyListItems}
const maxVisible = 10; selectedIndex={selectedHistoryIndex}
const halfWindow = Math.floor(maxVisible / 2); onSelect={setSelectedHistoryIndex}
let startIndex = Math.max(0, selectedHistoryIndex - halfWindow); focus={focusedPanel === 'history'}
const endIndex = Math.min(history.length, startIndex + maxVisible); maxVisible={10}
// Adjust start if we're near the end emptyMessage="No history found"
if (endIndex - startIndex < maxVisible) { renderItem={renderHistoryItem}
startIndex = Math.max(0, endIndex - maxVisible); />
}
const visibleItems = history.slice(startIndex, endIndex);
return visibleItems.map((item, idx) => {
const actualIndex = startIndex + idx;
const isSelected = actualIndex === selectedHistoryIndex && focusedPanel === 'history';
const indicator = isSelected ? '▸ ' : ' ';
const dateStr = item.timestamp
? new Date(item.timestamp).toLocaleDateString()
: '';
// Format the history item based on type
if (item.type === 'invitation_created') {
return (
<Box key={item.id} flexDirection='row' justifyContent='space-between'>
<Text color={isSelected ? colors.focus : colors.text}>
{indicator}[Invitation] {item.description}
</Text>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>
);
} else if (item.type === 'utxo_reserved') {
const sats = item.valueSatoshis ?? 0n;
return (
<Box key={item.id} flexDirection='row' justifyContent='space-between'>
<Box>
<Text color={isSelected ? colors.focus : colors.warning}>
{indicator}[Reserved] {formatSatoshis(sats)}
</Text>
<Text color={colors.textMuted}> {item.description}</Text>
</Box>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>
);
} else if (item.type === 'utxo_received') {
const sats = item.valueSatoshis ?? 0n;
const reservedTag = item.reserved ? ' [Reserved]' : '';
return (
<Box key={item.id} flexDirection='row' justifyContent='space-between'>
<Box flexDirection='row'>
<Text color={isSelected ? colors.focus : colors.success}>
{indicator}{formatSatoshis(sats)}
</Text>
<Text color={colors.textMuted}>
{' '}{item.description}{reservedTag}
</Text>
</Box>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>
);
}
// Fallback for other types
return (
<Box key={item.id} flexDirection='row' justifyContent='space-between'>
<Text color={isSelected ? colors.focus : colors.text}>
{indicator}{item.type}: {item.description}
</Text>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>
);
});
})()
)} )}
</Box> </Box>
</Box> </Box>
</Box>
{/* Help text */} {/* Help text */}
<Box marginTop={1}> <Box marginTop={1}>

View File

@@ -3,7 +3,7 @@ import { useNavigation } from '../../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../../hooks/useAppContext.js'; import { useAppContext, useStatus } from '../../hooks/useAppContext.js';
import { formatSatoshis } from '../../theme.js'; import { formatSatoshis } from '../../theme.js';
import { copyToClipboard } from '../../utils/clipboard.js'; import { copyToClipboard } from '../../utils/clipboard.js';
import type { XOTemplate, XOInvitation } from '@xo-cash/types'; import type { XOTemplate, XOInvitation, XOTemplateTransactionOutput } from '@xo-cash/types';
import type { import type {
WizardStep, WizardStep,
VariableInput, VariableInput,
@@ -323,6 +323,8 @@ export function useActionWizard() {
actionIdentifier, actionIdentifier,
}); });
console.log(xoInvitation)
// Wrap and track // Wrap and track
const invitationInstance = const invitationInstance =
await appService.createInvitation(xoInvitation); await appService.createInvitation(xoInvitation);
@@ -358,8 +360,9 @@ export function useActionWizard() {
setStatus('Adding required outputs...'); setStatus('Adding required outputs...');
const outputsToAdd = transaction.outputs.map( const outputsToAdd = transaction.outputs.map(
(outputId: string) => ({ (output: XOTemplateTransactionOutput) => ({
outputIdentifier: outputId, outputIdentifier: output.output,
roleIdentifier: roleIdentifier,
}) })
); );

259
src/utils/history-utils.ts Normal file
View File

@@ -0,0 +1,259 @@
/**
* History utility functions.
*
* Pure functions for parsing and formatting wallet history data.
* These functions have no React dependencies and can be used
* in both TUI and CLI contexts.
*/
import type { HistoryItem, HistoryItemType } from '../services/history.js';
/**
* Color names for history item types.
* These are semantic color names that can be mapped to actual colors
* by the consuming application (TUI or CLI).
*/
export type HistoryColorName = 'info' | 'warning' | 'success' | 'error' | 'muted' | 'text';
/**
* Formatted history list item data.
*/
export interface FormattedHistoryItem {
/** The display label for the history item */
label: string;
/** Optional secondary description */
description?: string;
/** The formatted date string */
dateStr?: string;
/** The semantic color name for this item type */
color: HistoryColorName;
/** The history item type */
type: HistoryItemType;
/** Whether the item data is valid */
isValid: boolean;
}
/**
* Get the semantic color name for a history item type.
*
* @param type - The history item type
* @param isSelected - Whether the item is currently selected
* @returns A semantic color name
*/
export function getHistoryItemColorName(type: HistoryItemType, isSelected: boolean = false): HistoryColorName {
if (isSelected) return 'info'; // Use focus color when selected
switch (type) {
case 'invitation_created':
return 'text';
case 'utxo_reserved':
return 'warning';
case 'utxo_received':
return 'success';
default:
return 'text';
}
}
/**
* Format a satoshi value for display.
*
* @param satoshis - The value in satoshis
* @returns Formatted string with BCH amount
*/
export function formatSatoshisValue(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)`;
}
/**
* Format a timestamp for display.
*
* @param timestamp - Unix timestamp in milliseconds
* @returns Formatted date string or undefined
*/
export function formatHistoryDate(timestamp?: number): string | undefined {
if (!timestamp) return undefined;
return new Date(timestamp).toLocaleDateString();
}
/**
* Format a history item for display in a list.
*
* @param item - The history item to format
* @param isSelected - Whether the item is currently selected
* @returns Formatted item data for display
*/
export function formatHistoryListItem(
item: HistoryItem | null | undefined,
isSelected: boolean = false
): FormattedHistoryItem {
if (!item) {
return {
label: '',
description: undefined,
dateStr: undefined,
color: 'muted',
type: 'utxo_received',
isValid: false,
};
}
const dateStr = formatHistoryDate(item.timestamp);
const color = getHistoryItemColorName(item.type, isSelected);
switch (item.type) {
case 'invitation_created':
return {
label: `[Invitation] ${item.description}`,
description: undefined,
dateStr,
color,
type: item.type,
isValid: true,
};
case 'utxo_reserved': {
const satsStr = item.valueSatoshis !== undefined
? formatSatoshisValue(item.valueSatoshis)
: 'Unknown amount';
return {
label: `[Reserved] ${satsStr}`,
description: item.description,
dateStr,
color,
type: item.type,
isValid: true,
};
}
case 'utxo_received': {
const satsStr = item.valueSatoshis !== undefined
? formatSatoshisValue(item.valueSatoshis)
: 'Unknown amount';
const reservedTag = item.reserved ? ' [Reserved]' : '';
return {
label: satsStr,
description: `${item.description}${reservedTag}`,
dateStr,
color,
type: item.type,
isValid: true,
};
}
default:
return {
label: `${item.type}: ${item.description}`,
description: undefined,
dateStr,
color: 'text',
type: item.type,
isValid: true,
};
}
}
/**
* Get a type label for display.
*
* @param type - The history item type
* @returns Human-readable type label
*/
export function getHistoryTypeLabel(type: HistoryItemType): string {
switch (type) {
case 'invitation_created':
return 'Invitation';
case 'utxo_reserved':
return 'Reserved';
case 'utxo_received':
return 'Received';
default:
return type;
}
}
/**
* Calculate scrolling window indices for a list.
*
* @param selectedIndex - Currently selected index
* @param totalItems - Total number of items
* @param maxVisible - Maximum visible items
* @returns Start and end indices for the visible window
*/
export function calculateScrollWindow(
selectedIndex: number,
totalItems: number,
maxVisible: number
): { startIndex: number; endIndex: number } {
const halfWindow = Math.floor(maxVisible / 2);
let startIndex = Math.max(0, selectedIndex - halfWindow);
const endIndex = Math.min(totalItems, startIndex + maxVisible);
// Adjust start if we're near the end
if (endIndex - startIndex < maxVisible) {
startIndex = Math.max(0, endIndex - maxVisible);
}
return { startIndex, endIndex };
}
/**
* Check if a history item is a UTXO-related event.
*
* @param item - The history item to check
* @returns True if the item is UTXO-related
*/
export function isUtxoEvent(item: HistoryItem): boolean {
return item.type === 'utxo_received' || item.type === 'utxo_reserved';
}
/**
* Filter history items by type.
*
* @param items - Array of history items
* @param types - Types to include
* @returns Filtered array
*/
export function filterHistoryByType(
items: HistoryItem[],
types: HistoryItemType[]
): HistoryItem[] {
return items.filter(item => types.includes(item.type));
}
/**
* Get summary statistics for history items.
*
* @param items - Array of history items
* @returns Summary statistics
*/
export function getHistorySummary(items: HistoryItem[]): {
totalReceived: bigint;
totalReserved: bigint;
invitationCount: number;
utxoCount: number;
} {
let totalReceived = 0n;
let totalReserved = 0n;
let invitationCount = 0;
let utxoCount = 0;
for (const item of items) {
switch (item.type) {
case 'invitation_created':
invitationCount++;
break;
case 'utxo_reserved':
totalReserved += item.valueSatoshis ?? 0n;
break;
case 'utxo_received':
totalReceived += item.valueSatoshis ?? 0n;
utxoCount++;
break;
}
}
return { totalReceived, totalReserved, invitationCount, utxoCount };
}

View File

@@ -0,0 +1,264 @@
/**
* Invitation utility functions.
*
* Pure functions for parsing and formatting invitation data.
* These functions have no React dependencies and can be used
* in both TUI and CLI contexts.
*/
import type { Invitation } from '../services/invitation.js';
import type { XOTemplate } from '@xo-cash/types';
/**
* Color names for invitation states.
* These are semantic color names that can be mapped to actual colors
* by the consuming application (TUI or CLI).
*/
export type StateColorName = 'info' | 'warning' | 'success' | 'error' | 'muted';
/**
* Input data extracted from invitation commits.
*/
export interface InvitationInput {
inputIdentifier?: string;
roleIdentifier?: string;
entityIdentifier: string;
}
/**
* Output data extracted from invitation commits.
*/
export interface InvitationOutput {
outputIdentifier?: string;
roleIdentifier?: string;
valueSatoshis?: bigint;
entityIdentifier: string;
}
/**
* Variable data extracted from invitation commits.
*/
export interface InvitationVariable {
variableIdentifier: string;
value: unknown;
roleIdentifier?: string;
entityIdentifier: string;
}
/**
* Formatted invitation list item data.
*/
export interface FormattedInvitationItem {
/** The display label for the invitation */
label: string;
/** The current status of the invitation */
status: string;
/** The semantic color name for the status */
statusColor: StateColorName;
/** Whether the invitation data is valid */
isValid: boolean;
}
/**
* Get the current state/status of an invitation.
*
* @param invitation - The invitation to get state for
* @returns The status string
*/
export function getInvitationState(invitation: Invitation): string {
return invitation.status;
}
/**
* Get the semantic color name for an invitation state.
*
* @param state - The invitation state string
* @returns A semantic color name
*/
export function getStateColorName(state: string): StateColorName {
switch (state) {
case 'created':
case 'published':
return 'info';
case 'pending':
return 'warning';
case 'ready':
case 'signed':
case 'broadcast':
case 'completed':
return 'success';
case 'expired':
case 'error':
return 'error';
default:
return 'muted';
}
}
/**
* Extract all inputs from invitation commits.
*
* @param invitation - The invitation to extract inputs from
* @returns Array of input data
*/
export function getInvitationInputs(invitation: Invitation): InvitationInput[] {
const inputs: InvitationInput[] = [];
for (const commit of invitation.data.commits || []) {
for (const input of commit.data?.inputs || []) {
inputs.push({
inputIdentifier: input.inputIdentifier,
roleIdentifier: input.roleIdentifier,
entityIdentifier: commit.entityIdentifier,
});
}
}
return inputs;
}
/**
* Extract all outputs from invitation commits.
*
* @param invitation - The invitation to extract outputs from
* @returns Array of output data
*/
export function getInvitationOutputs(invitation: Invitation): InvitationOutput[] {
const outputs: InvitationOutput[] = [];
for (const commit of invitation.data.commits || []) {
for (const output of commit.data?.outputs || []) {
outputs.push({
outputIdentifier: output.outputIdentifier,
roleIdentifier: output.roleIdentifier,
valueSatoshis: output.valueSatoshis,
entityIdentifier: commit.entityIdentifier,
});
}
}
return outputs;
}
/**
* Extract all variables from invitation commits.
*
* @param invitation - The invitation to extract variables from
* @returns Array of variable data
*/
export function getInvitationVariables(invitation: Invitation): InvitationVariable[] {
const variables: InvitationVariable[] = [];
for (const commit of invitation.data.commits || []) {
for (const variable of commit.data?.variables || []) {
variables.push({
variableIdentifier: variable.variableIdentifier,
value: variable.value,
roleIdentifier: variable.roleIdentifier,
entityIdentifier: commit.entityIdentifier,
});
}
}
return variables;
}
/**
* Get the user's role from commits (the role they have accepted).
*
* @param invitation - The invitation to check
* @param userEntityId - The user's entity identifier
* @returns The role identifier if found, null otherwise
*/
export function getUserRole(invitation: Invitation, userEntityId: string | null): string | null {
if (!userEntityId) return null;
for (const commit of invitation.data.commits || []) {
if (commit.entityIdentifier === userEntityId) {
// Check inputs for role
for (const input of commit.data?.inputs || []) {
if (input.roleIdentifier) return input.roleIdentifier;
}
// Check outputs for role
for (const output of commit.data?.outputs || []) {
if (output.roleIdentifier) return output.roleIdentifier;
}
// Check variables for role
for (const variable of commit.data?.variables || []) {
if (variable.roleIdentifier) return variable.roleIdentifier;
}
}
}
return null;
}
/**
* Format an invitation for display in a list.
*
* @param invitation - The invitation to format
* @param template - Optional template for additional info (name)
* @returns Formatted item data for display
*/
export function formatInvitationListItem(
invitation: Invitation,
template?: XOTemplate | null
): FormattedInvitationItem {
// Validate that we have the minimum required data
const invitationId = invitation?.data?.invitationIdentifier;
const actionId = invitation?.data?.actionIdentifier;
if (!invitationId || !actionId) {
return {
label: '',
status: 'error',
statusColor: 'error',
isValid: false,
};
}
const state = getInvitationState(invitation);
const templateName = template?.name ?? 'Unknown';
const shortId = formatInvitationId(invitationId, 8);
return {
label: `[${state}] ${templateName}-${actionId} (${shortId})`,
status: state,
statusColor: getStateColorName(state),
isValid: true,
};
}
/**
* Format an invitation ID for display (truncated).
*
* @param id - The full invitation ID
* @param maxLength - Maximum length for display
* @returns Truncated ID string
*/
export function formatInvitationId(id: string, maxLength: number = 16): string {
if (id.length <= maxLength) return id;
const half = Math.floor((maxLength - 3) / 2);
return `${id.slice(0, half)}...${id.slice(-half)}`;
}
/**
* Get all unique entity identifiers from an invitation's commits.
*
* @param invitation - The invitation to check
* @returns Array of unique entity identifiers
*/
export function getInvitationParticipants(invitation: Invitation): string[] {
const participants = new Set<string>();
for (const commit of invitation.data.commits || []) {
if (commit.entityIdentifier) {
participants.add(commit.entityIdentifier);
}
}
return Array.from(participants);
}
/**
* Check if a user is a participant in an invitation.
*
* @param invitation - The invitation to check
* @param userEntityId - The user's entity identifier
* @returns True if the user has made at least one commit
*/
export function isUserParticipant(invitation: Invitation, userEntityId: string | null): boolean {
if (!userEntityId) return false;
return getInvitationParticipants(invitation).includes(userEntityId);
}

View File

@@ -1,7 +1,7 @@
import type { XOInvitation } from "@xo-cash/types"; import type { XOInvitation } from "@xo-cash/types";
import { EventEmitter } from "./event-emitter.js"; import { EventEmitter } from "./event-emitter.js";
import { SSESession, type SSEvent } from "./sse-client.js"; import { SSESession, type SSEvent } from "./sse-client.js";
import { decodeExtendedJsonObject, encodeExtendedJson } from "./ext-json.js"; import { decodeExtendedJson, decodeExtendedJsonObject, encodeExtendedJson, encodeExtendedJsonObject } from "./ext-json.js";
export type SyncServerEventMap = { export type SyncServerEventMap = {
'connected': void; 'connected': void;
@@ -66,7 +66,7 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
throw new Error(`Failed to get invitation: ${response.statusText}`); throw new Error(`Failed to get invitation: ${response.statusText}`);
} }
const invitation = decodeExtendedJsonObject(await response.text()) as XOInvitation | undefined; const invitation = decodeExtendedJson(await response.text()) as XOInvitation | undefined;
return invitation; return invitation;
} }
@@ -92,7 +92,7 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
// Read the returned JSON // Read the returned JSON
// TODO: This should use zod to verify the response // TODO: This should use zod to verify the response
const data = decodeExtendedJsonObject(await response.text()) as XOInvitation; const data = decodeExtendedJson(await response.text()) as XOInvitation;
return data; return data;
} }

246
src/utils/template-utils.ts Normal file
View File

@@ -0,0 +1,246 @@
/**
* Template utility functions.
*
* Pure functions for parsing and formatting template data.
* These functions have no React dependencies and can be used
* in both TUI and CLI contexts.
*/
import type { XOTemplate, XOTemplateAction } from '@xo-cash/types';
/**
* Formatted template list item data.
*/
export interface FormattedTemplateItem {
/** The display label for the template */
label: string;
/** The template description */
description?: string;
/** Whether the template data is valid */
isValid: boolean;
}
/**
* Formatted action list item data.
*/
export interface FormattedActionItem {
/** The display label for the action */
label: string;
/** The action description */
description?: string;
/** Number of roles that can start this action */
roleCount: number;
/** Whether the action data is valid */
isValid: boolean;
}
/**
* A unique starting action (deduplicated by action identifier).
* Multiple roles that can start the same action are counted
* but not shown as separate entries.
*/
export interface UniqueStartingAction {
actionIdentifier: string;
name: string;
description?: string;
roleCount: number;
}
/**
* Role information from a template.
*/
export interface TemplateRole {
roleId: string;
name: string;
description?: string;
}
/**
* Format a template for display in a list.
*
* @param template - The template to format
* @param index - Optional index for numbered display
* @returns Formatted item data for display
*/
export function formatTemplateListItem(
template: XOTemplate | null | undefined,
index?: number
): FormattedTemplateItem {
if (!template) {
return {
label: '',
description: undefined,
isValid: false,
};
}
const name = template.name || 'Unnamed Template';
const prefix = index !== undefined ? `${index + 1}. ` : '';
return {
label: `${prefix}${name}`,
description: template.description,
isValid: true,
};
}
/**
* Format an action for display in a list.
*
* @param actionId - The action identifier
* @param action - The action definition from the template
* @param roleCount - Number of roles that can start this action
* @param index - Optional index for numbered display
* @returns Formatted item data for display
*/
export function formatActionListItem(
actionId: string,
action: XOTemplateAction | null | undefined,
roleCount: number = 1,
index?: number
): FormattedActionItem {
if (!actionId) {
return {
label: '',
description: undefined,
roleCount: 0,
isValid: false,
};
}
const name = action?.name || actionId;
const prefix = index !== undefined ? `${index + 1}. ` : '';
const roleSuffix = roleCount > 1 ? ` (${roleCount} roles)` : '';
return {
label: `${prefix}${name}${roleSuffix}`,
description: action?.description,
roleCount,
isValid: true,
};
}
/**
* Deduplicate starting actions from a template.
* Multiple roles that can start the same action are counted
* but returned as a single entry.
*
* @param template - The template to process
* @param startingActions - Array of { action, role } pairs
* @returns Array of unique starting actions with role counts
*/
export function deduplicateStartingActions(
template: XOTemplate,
startingActions: Array<{ action: string; role: string }>
): UniqueStartingAction[] {
const actionMap = new Map<string, UniqueStartingAction>();
for (const sa of startingActions) {
if (actionMap.has(sa.action)) {
actionMap.get(sa.action)!.roleCount++;
} else {
const actionDef = template.actions?.[sa.action];
actionMap.set(sa.action, {
actionIdentifier: sa.action,
name: actionDef?.name || sa.action,
description: actionDef?.description,
roleCount: 1,
});
}
}
return Array.from(actionMap.values());
}
/**
* Get all roles from a template.
*
* @param template - The template to process
* @returns Array of role information
*/
export function getTemplateRoles(template: XOTemplate): TemplateRole[] {
if (!template.roles) return [];
return Object.entries(template.roles).map(([roleId, role]) => {
// Handle case where role might be a string instead of object
const roleObj = typeof role === 'object' ? role : null;
return {
roleId,
name: roleObj?.name || roleId,
description: roleObj?.description,
};
});
}
/**
* Get roles that can start a specific action.
*
* @param template - The template to check
* @param actionIdentifier - The action to check
* @returns Array of role information for roles that can start this action
*/
export function getRolesForAction(
template: XOTemplate,
actionIdentifier: string
): TemplateRole[] {
const startEntries = (template.start ?? [])
.filter((s) => s.action === actionIdentifier);
return startEntries.map((entry) => {
const roleDef = template.roles?.[entry.role];
const roleObj = typeof roleDef === 'object' ? roleDef : null;
return {
roleId: entry.role,
name: roleObj?.name || entry.role,
description: roleObj?.description,
};
});
}
/**
* Get template name safely.
*
* @param template - The template
* @returns The template name or a default
*/
export function getTemplateName(template: XOTemplate | null | undefined): string {
return template?.name || 'Unknown Template';
}
/**
* Get template description safely.
*
* @param template - The template
* @returns The template description or undefined
*/
export function getTemplateDescription(template: XOTemplate | null | undefined): string | undefined {
return template?.description;
}
/**
* Get action name safely.
*
* @param template - The template containing the action
* @param actionId - The action identifier
* @returns The action name or the action ID as fallback
*/
export function getActionName(
template: XOTemplate | null | undefined,
actionId: string
): string {
return template?.actions?.[actionId]?.name || actionId;
}
/**
* Get action description safely.
*
* @param template - The template containing the action
* @param actionId - The action identifier
* @returns The action description or undefined
*/
export function getActionDescription(
template: XOTemplate | null | undefined,
actionId: string
): string | undefined {
return template?.actions?.[actionId]?.description;
}