Initial Commit
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
__sysdb__.sqlite
|
||||||
|
Electrum.sqlite
|
||||||
|
XO.sqlite
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
1817
package-lock.json
generated
Normal file
1817
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
package.json
Normal file
40
package.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "@xo-cash/cli",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com node dist/index.js",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"crypto",
|
||||||
|
"wallet",
|
||||||
|
"cli",
|
||||||
|
"tui"
|
||||||
|
],
|
||||||
|
"author": "General Protocols",
|
||||||
|
"license": "ISC",
|
||||||
|
"description": "XO Wallet CLI - Terminal User Interface for XO crypto wallet",
|
||||||
|
"dependencies": {
|
||||||
|
"@bitauth/libauth": "^3.0.0",
|
||||||
|
"@xo-cash/engine": "file:../engine",
|
||||||
|
"@xo-cash/state": "file:../state",
|
||||||
|
"@xo-cash/templates": "file:../templates",
|
||||||
|
"@xo-cash/types": "file:../types",
|
||||||
|
"clipboardy": "^5.1.0",
|
||||||
|
"ink": "^5.1.0",
|
||||||
|
"ink-select-input": "^6.0.0",
|
||||||
|
"ink-spinner": "^5.0.0",
|
||||||
|
"ink-text-input": "^6.0.0",
|
||||||
|
"react": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.0.10",
|
||||||
|
"@types/react": "^18.3.18",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
134
src/app.ts
Normal file
134
src/app.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* Application bootstrap and lifecycle management.
|
||||||
|
* Coordinates initialization of all CLI components.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { render, type Instance } from 'ink';
|
||||||
|
import { App as AppComponent } from './tui/App.js';
|
||||||
|
import { WalletController } from './controllers/wallet-controller.js';
|
||||||
|
import { InvitationController } from './controllers/invitation-controller.js';
|
||||||
|
import { SyncClient } from './services/sync-client.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration options for the CLI application.
|
||||||
|
*/
|
||||||
|
export interface AppConfig {
|
||||||
|
/** URL of the sync server (default: http://localhost:3000) */
|
||||||
|
syncServerUrl?: string;
|
||||||
|
/** Database path for wallet state storage */
|
||||||
|
databasePath?: string;
|
||||||
|
/** Database filename */
|
||||||
|
databaseFilename?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main application class that orchestrates all CLI components.
|
||||||
|
*/
|
||||||
|
export class App {
|
||||||
|
/** Ink render instance */
|
||||||
|
private inkInstance: Instance | null = null;
|
||||||
|
|
||||||
|
/** Wallet controller for engine operations */
|
||||||
|
private walletController: WalletController;
|
||||||
|
|
||||||
|
/** Invitation controller for collaborative transactions */
|
||||||
|
private invitationController: InvitationController;
|
||||||
|
|
||||||
|
/** HTTP client for sync server communication */
|
||||||
|
private syncClient: SyncClient;
|
||||||
|
|
||||||
|
/** Application configuration */
|
||||||
|
private config: Required<AppConfig>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new App instance.
|
||||||
|
* @param config - Application configuration options
|
||||||
|
*/
|
||||||
|
private constructor(config: AppConfig = {}) {
|
||||||
|
// Set default configuration
|
||||||
|
this.config = {
|
||||||
|
syncServerUrl: config.syncServerUrl ?? 'http://localhost:3000',
|
||||||
|
databasePath: config.databasePath ?? './',
|
||||||
|
databaseFilename: config.databaseFilename ?? 'xo-wallet',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize sync client
|
||||||
|
this.syncClient = new SyncClient(this.config.syncServerUrl);
|
||||||
|
|
||||||
|
// Initialize wallet controller (engine will be created when seed is provided)
|
||||||
|
this.walletController = new WalletController({
|
||||||
|
databasePath: this.config.databasePath,
|
||||||
|
databaseFilename: this.config.databaseFilename,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize invitation controller
|
||||||
|
this.invitationController = new InvitationController(
|
||||||
|
this.walletController,
|
||||||
|
this.syncClient,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory method to create and start the application.
|
||||||
|
* @param config - Application configuration options
|
||||||
|
* @returns Running App instance
|
||||||
|
*/
|
||||||
|
static async create(config: AppConfig = {}): Promise<App> {
|
||||||
|
const app = new App(config);
|
||||||
|
await app.start();
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the application.
|
||||||
|
* Renders the Ink-based TUI.
|
||||||
|
*/
|
||||||
|
async start(): Promise<void> {
|
||||||
|
// Render the Ink app
|
||||||
|
this.inkInstance = render(
|
||||||
|
React.createElement(AppComponent, {
|
||||||
|
walletController: this.walletController,
|
||||||
|
invitationController: this.invitationController,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for the app to exit
|
||||||
|
await this.inkInstance.waitUntilExit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the application and cleans up resources.
|
||||||
|
*/
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
// Stop the wallet engine if running
|
||||||
|
await this.walletController.stop();
|
||||||
|
|
||||||
|
// Unmount Ink app
|
||||||
|
if (this.inkInstance) {
|
||||||
|
this.inkInstance.unmount();
|
||||||
|
this.inkInstance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the wallet controller for external access.
|
||||||
|
*/
|
||||||
|
getWalletController(): WalletController {
|
||||||
|
return this.walletController;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the invitation controller for external access.
|
||||||
|
*/
|
||||||
|
getInvitationController(): InvitationController {
|
||||||
|
return this.invitationController;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the sync client for external access.
|
||||||
|
*/
|
||||||
|
getSyncClient(): SyncClient {
|
||||||
|
return this.syncClient;
|
||||||
|
}
|
||||||
|
}
|
||||||
292
src/controllers/invitation-controller.ts
Normal file
292
src/controllers/invitation-controller.ts
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
/**
|
||||||
|
* Invitation Controller - High-level interface for invitation management.
|
||||||
|
*
|
||||||
|
* Provides a simplified API for the TUI to interact with invitations,
|
||||||
|
* wrapping the InvitationFlowManager and coordinating with the WalletController.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import type { XOInvitation } from '@xo-cash/types';
|
||||||
|
import { InvitationFlowManager, type TrackedInvitation, type InvitationState } from '../services/invitation-flow.js';
|
||||||
|
import type { WalletController } from './wallet-controller.js';
|
||||||
|
import type { SyncClient } from '../services/sync-client.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events emitted by the invitation controller.
|
||||||
|
*/
|
||||||
|
export interface InvitationControllerEvents {
|
||||||
|
'invitation-created': (invitationId: string) => void;
|
||||||
|
'invitation-updated': (invitationId: string) => void;
|
||||||
|
'invitation-state-changed': (invitationId: string, state: InvitationState) => void;
|
||||||
|
'error': (error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller for managing invitations in the TUI.
|
||||||
|
*/
|
||||||
|
export class InvitationController extends EventEmitter {
|
||||||
|
/** Flow manager for invitation lifecycle */
|
||||||
|
private flowManager: InvitationFlowManager;
|
||||||
|
|
||||||
|
/** Wallet controller reference */
|
||||||
|
private walletController: WalletController;
|
||||||
|
|
||||||
|
/** Sync client reference */
|
||||||
|
private syncClient: SyncClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new invitation controller.
|
||||||
|
* @param walletController - Wallet controller instance
|
||||||
|
* @param syncClient - Sync client instance
|
||||||
|
*/
|
||||||
|
constructor(walletController: WalletController, syncClient: SyncClient) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.walletController = walletController;
|
||||||
|
this.syncClient = syncClient;
|
||||||
|
this.flowManager = new InvitationFlowManager(walletController, syncClient);
|
||||||
|
|
||||||
|
// Forward events from flow manager
|
||||||
|
this.flowManager.on('invitation-created', (invitation: XOInvitation) => {
|
||||||
|
this.emit('invitation-created', invitation.invitationIdentifier);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.flowManager.on('invitation-updated', (invitationId: string) => {
|
||||||
|
this.emit('invitation-updated', invitationId);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.flowManager.on('invitation-state-changed', (invitationId: string, state: InvitationState) => {
|
||||||
|
this.emit('invitation-state-changed', invitationId, state);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.flowManager.on('error', (_invitationId: string, error: Error) => {
|
||||||
|
this.emit('error', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Invitation Creation Flow
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new invitation from a template action.
|
||||||
|
* @param templateIdentifier - Template ID
|
||||||
|
* @param actionIdentifier - Action ID
|
||||||
|
* @returns Created tracked invitation
|
||||||
|
*/
|
||||||
|
async createInvitation(
|
||||||
|
templateIdentifier: string,
|
||||||
|
actionIdentifier: string,
|
||||||
|
): Promise<TrackedInvitation> {
|
||||||
|
return this.flowManager.createInvitation(templateIdentifier, actionIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishes an invitation to the sync server and starts listening for updates.
|
||||||
|
* @param invitationId - Invitation ID to publish
|
||||||
|
* @returns The invitation ID for sharing
|
||||||
|
*/
|
||||||
|
async publishAndSubscribe(invitationId: string): Promise<string> {
|
||||||
|
// Publish to sync server
|
||||||
|
await this.flowManager.publishInvitation(invitationId);
|
||||||
|
|
||||||
|
// Subscribe to SSE updates
|
||||||
|
await this.flowManager.subscribeToUpdates(invitationId);
|
||||||
|
|
||||||
|
return invitationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Invitation Import Flow
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports an invitation by ID from the sync server.
|
||||||
|
* @param invitationId - Invitation ID to import
|
||||||
|
* @returns Imported tracked invitation
|
||||||
|
*/
|
||||||
|
async importInvitation(invitationId: string): Promise<TrackedInvitation> {
|
||||||
|
return this.flowManager.importInvitation(invitationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepts an imported invitation (joins as participant).
|
||||||
|
* @param invitationId - Invitation ID to accept
|
||||||
|
* @returns Updated tracked invitation
|
||||||
|
*/
|
||||||
|
async acceptInvitation(invitationId: string): Promise<TrackedInvitation> {
|
||||||
|
return this.flowManager.acceptInvitation(invitationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Invitation Data Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends inputs to an invitation.
|
||||||
|
* @param invitationId - Invitation ID
|
||||||
|
* @param inputs - Inputs to add
|
||||||
|
*/
|
||||||
|
async addInputs(
|
||||||
|
invitationId: string,
|
||||||
|
inputs: Array<{
|
||||||
|
outpointTransactionHash: string;
|
||||||
|
outpointIndex: number;
|
||||||
|
sequenceNumber?: number;
|
||||||
|
}>,
|
||||||
|
): Promise<TrackedInvitation> {
|
||||||
|
return this.flowManager.appendToInvitation(invitationId, { inputs });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends outputs to an invitation.
|
||||||
|
* @param invitationId - Invitation ID
|
||||||
|
* @param outputs - Outputs to add
|
||||||
|
*/
|
||||||
|
async addOutputs(
|
||||||
|
invitationId: string,
|
||||||
|
outputs: Array<{
|
||||||
|
valueSatoshis?: bigint;
|
||||||
|
lockingBytecode?: Uint8Array;
|
||||||
|
outputIdentifier?: string;
|
||||||
|
roleIdentifier?: string;
|
||||||
|
}>,
|
||||||
|
): Promise<TrackedInvitation> {
|
||||||
|
return this.flowManager.appendToInvitation(invitationId, { outputs });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends variables to an invitation.
|
||||||
|
* @param invitationId - Invitation ID
|
||||||
|
* @param variables - Variables to add
|
||||||
|
*/
|
||||||
|
async addVariables(
|
||||||
|
invitationId: string,
|
||||||
|
variables: Array<{
|
||||||
|
variableIdentifier: string;
|
||||||
|
value: bigint | boolean | number | string;
|
||||||
|
roleIdentifier?: string;
|
||||||
|
}>,
|
||||||
|
): Promise<TrackedInvitation> {
|
||||||
|
return this.flowManager.appendToInvitation(invitationId, { variables });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Signing & Broadcasting
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs an invitation.
|
||||||
|
* @param invitationId - Invitation ID to sign
|
||||||
|
* @returns Updated tracked invitation
|
||||||
|
*/
|
||||||
|
async signInvitation(invitationId: string): Promise<TrackedInvitation> {
|
||||||
|
return this.flowManager.signInvitation(invitationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcasts the transaction for an invitation.
|
||||||
|
* @param invitationId - Invitation ID
|
||||||
|
* @returns Transaction hash
|
||||||
|
*/
|
||||||
|
async broadcastTransaction(invitationId: string): Promise<string> {
|
||||||
|
return this.flowManager.broadcastTransaction(invitationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Queries
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a tracked invitation by ID.
|
||||||
|
* @param invitationId - Invitation ID
|
||||||
|
* @returns Tracked invitation or undefined
|
||||||
|
*/
|
||||||
|
getInvitation(invitationId: string): TrackedInvitation | undefined {
|
||||||
|
return this.flowManager.get(invitationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all tracked invitations.
|
||||||
|
* @returns Array of tracked invitations
|
||||||
|
*/
|
||||||
|
getAllInvitations(): TrackedInvitation[] {
|
||||||
|
return this.flowManager.getAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the invitation data.
|
||||||
|
* @param invitationId - Invitation ID
|
||||||
|
* @returns The XOInvitation or undefined
|
||||||
|
*/
|
||||||
|
getInvitationData(invitationId: string): XOInvitation | undefined {
|
||||||
|
return this.flowManager.get(invitationId)?.invitation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the state of an invitation.
|
||||||
|
* @param invitationId - Invitation ID
|
||||||
|
* @returns Invitation state or undefined
|
||||||
|
*/
|
||||||
|
getInvitationState(invitationId: string): InvitationState | undefined {
|
||||||
|
return this.flowManager.get(invitationId)?.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets available roles for an invitation.
|
||||||
|
* @param invitationId - Invitation ID
|
||||||
|
* @returns Array of available role identifiers
|
||||||
|
*/
|
||||||
|
async getAvailableRoles(invitationId: string): Promise<string[]> {
|
||||||
|
const tracked = this.flowManager.get(invitationId);
|
||||||
|
if (!tracked) {
|
||||||
|
throw new Error(`Invitation not found: ${invitationId}`);
|
||||||
|
}
|
||||||
|
return this.walletController.getAvailableRoles(tracked.invitation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets missing requirements for an invitation.
|
||||||
|
* @param invitationId - Invitation ID
|
||||||
|
* @returns Missing requirements
|
||||||
|
*/
|
||||||
|
async getMissingRequirements(invitationId: string) {
|
||||||
|
const tracked = this.flowManager.get(invitationId);
|
||||||
|
if (!tracked) {
|
||||||
|
throw new Error(`Invitation not found: ${invitationId}`);
|
||||||
|
}
|
||||||
|
return this.walletController.getMissingRequirements(tracked.invitation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets requirements for an invitation.
|
||||||
|
* @param invitationId - Invitation ID
|
||||||
|
* @returns Requirements
|
||||||
|
*/
|
||||||
|
async getRequirements(invitationId: string) {
|
||||||
|
const tracked = this.flowManager.get(invitationId);
|
||||||
|
if (!tracked) {
|
||||||
|
throw new Error(`Invitation not found: ${invitationId}`);
|
||||||
|
}
|
||||||
|
return this.walletController.getRequirements(tracked.invitation);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cleanup
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops tracking an invitation.
|
||||||
|
* @param invitationId - Invitation ID to stop tracking
|
||||||
|
*/
|
||||||
|
stopTracking(invitationId: string): void {
|
||||||
|
this.flowManager.untrack(invitationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans up all resources.
|
||||||
|
*/
|
||||||
|
destroy(): void {
|
||||||
|
this.flowManager.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
408
src/controllers/wallet-controller.ts
Normal file
408
src/controllers/wallet-controller.ts
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
/**
|
||||||
|
* Wallet Controller - Orchestrates wallet operations via the XO Engine.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Initializes Engine with user seed
|
||||||
|
* - Exposes wallet state queries (balances, UTXOs)
|
||||||
|
* - Delegates template/invitation operations to Engine
|
||||||
|
* - Emits state change events for UI updates
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import { Engine } from '@xo-cash/engine';
|
||||||
|
import type { XOInvitation, XOTemplate, XOTemplateStartingActions } from '@xo-cash/types';
|
||||||
|
import type { UnspentOutputData, LockingBytecodeData } from '@xo-cash/state';
|
||||||
|
import { p2pkhTemplate } from '@xo-cash/templates';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration options for the wallet controller.
|
||||||
|
*/
|
||||||
|
export interface WalletControllerConfig {
|
||||||
|
/** Path for database storage */
|
||||||
|
databasePath?: string;
|
||||||
|
/** Database filename */
|
||||||
|
databaseFilename?: string;
|
||||||
|
/** Electrum application identifier */
|
||||||
|
electrumApplicationIdentifier?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Balance information for display.
|
||||||
|
*/
|
||||||
|
export interface WalletBalance {
|
||||||
|
/** Total satoshis across all UTXOs */
|
||||||
|
totalSatoshis: bigint;
|
||||||
|
/** Number of UTXOs */
|
||||||
|
utxoCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events emitted by the wallet controller.
|
||||||
|
*/
|
||||||
|
export interface WalletControllerEvents {
|
||||||
|
'initialized': () => void;
|
||||||
|
'state-updated': () => void;
|
||||||
|
'error': (error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller for wallet operations.
|
||||||
|
*/
|
||||||
|
export class WalletController extends EventEmitter {
|
||||||
|
/** The XO Engine instance */
|
||||||
|
private engine: Engine | null = null;
|
||||||
|
|
||||||
|
/** Controller configuration */
|
||||||
|
private config: WalletControllerConfig;
|
||||||
|
|
||||||
|
/** Whether the wallet is initialized */
|
||||||
|
private initialized: boolean = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new wallet controller.
|
||||||
|
* @param config - Controller configuration options
|
||||||
|
*/
|
||||||
|
constructor(config: WalletControllerConfig = {}) {
|
||||||
|
super();
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the wallet is initialized.
|
||||||
|
*/
|
||||||
|
isInitialized(): boolean {
|
||||||
|
return this.initialized && this.engine !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the wallet with a seed phrase.
|
||||||
|
* @param seed - BIP39 seed phrase
|
||||||
|
*/
|
||||||
|
async initialize(seed: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Create the engine with the provided seed
|
||||||
|
this.engine = await Engine.create(seed, {
|
||||||
|
databasePath: this.config.databasePath ?? './',
|
||||||
|
databaseFilename: this.config.databaseFilename ?? 'xo-wallet',
|
||||||
|
electrumApplicationIdentifier: this.config.electrumApplicationIdentifier ?? 'xo-wallet-cli',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Import the default P2PKH template
|
||||||
|
await this.engine.importTemplate(p2pkhTemplate);
|
||||||
|
|
||||||
|
// Set default locking parameters for P2PKH
|
||||||
|
await this.engine.setDefaultLockingParameters(
|
||||||
|
await this.getTemplateIdentifier(p2pkhTemplate),
|
||||||
|
'receiveOutput',
|
||||||
|
'receiver',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate an initial receiving address
|
||||||
|
const templateId = await this.getTemplateIdentifier(p2pkhTemplate);
|
||||||
|
await this.engine.generateLockingBytecode(templateId, 'receiveOutput', 'receiver');
|
||||||
|
|
||||||
|
this.initialized = true;
|
||||||
|
this.emit('initialized');
|
||||||
|
} catch (error) {
|
||||||
|
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the template identifier from a template.
|
||||||
|
* @param template - The XO template
|
||||||
|
* @returns The template identifier
|
||||||
|
*/
|
||||||
|
private async getTemplateIdentifier(template: XOTemplate): Promise<string> {
|
||||||
|
// Import the utility to generate template identifier
|
||||||
|
const { generateTemplateIdentifier } = await import('@xo-cash/engine');
|
||||||
|
return generateTemplateIdentifier(template);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the wallet engine and cleans up resources.
|
||||||
|
*/
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (this.engine) {
|
||||||
|
await this.engine.stop();
|
||||||
|
this.engine = null;
|
||||||
|
this.initialized = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the engine instance.
|
||||||
|
* @throws Error if engine is not initialized
|
||||||
|
*/
|
||||||
|
getEngine(): Engine {
|
||||||
|
if (!this.engine) {
|
||||||
|
throw new Error('Wallet not initialized. Please enter your seed phrase first.');
|
||||||
|
}
|
||||||
|
return this.engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Balance & UTXO Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the wallet balance.
|
||||||
|
* @returns Wallet balance information
|
||||||
|
*/
|
||||||
|
async getBalance(): Promise<WalletBalance> {
|
||||||
|
const engine = this.getEngine();
|
||||||
|
const utxos = await engine.listUnspentOutputsData();
|
||||||
|
|
||||||
|
const totalSatoshis = utxos.reduce(
|
||||||
|
(sum, utxo) => sum + BigInt(utxo.valueSatoshis),
|
||||||
|
BigInt(0),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalSatoshis,
|
||||||
|
utxoCount: utxos.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all unspent outputs.
|
||||||
|
* @returns Array of unspent output data
|
||||||
|
*/
|
||||||
|
async getUnspentOutputs(): Promise<UnspentOutputData[]> {
|
||||||
|
const engine = this.getEngine();
|
||||||
|
return engine.listUnspentOutputsData();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets locking bytecodes for a template.
|
||||||
|
* @param templateIdentifier - Template identifier
|
||||||
|
* @returns Array of locking bytecode data
|
||||||
|
*/
|
||||||
|
async getLockingBytecodes(templateIdentifier: string): Promise<LockingBytecodeData[]> {
|
||||||
|
const engine = this.getEngine();
|
||||||
|
return engine.listLockingBytecodesForTemplate(templateIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Template Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all imported templates.
|
||||||
|
* @returns Array of templates
|
||||||
|
*/
|
||||||
|
async getTemplates(): Promise<XOTemplate[]> {
|
||||||
|
const engine = this.getEngine();
|
||||||
|
return engine.listImportedTemplates();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a specific template by identifier.
|
||||||
|
* @param templateIdentifier - Template identifier
|
||||||
|
* @returns The template or undefined
|
||||||
|
*/
|
||||||
|
async getTemplate(templateIdentifier: string): Promise<XOTemplate | undefined> {
|
||||||
|
const engine = this.getEngine();
|
||||||
|
return engine.getTemplate(templateIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets starting actions for a template.
|
||||||
|
* @param templateIdentifier - Template identifier
|
||||||
|
* @returns Starting actions
|
||||||
|
*/
|
||||||
|
async getStartingActions(templateIdentifier: string): Promise<XOTemplateStartingActions> {
|
||||||
|
const engine = this.getEngine();
|
||||||
|
return engine.listStartingActions(templateIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports a template into the wallet.
|
||||||
|
* @param template - Template to import (JSON or object)
|
||||||
|
*/
|
||||||
|
async importTemplate(template: unknown): Promise<void> {
|
||||||
|
const engine = this.getEngine();
|
||||||
|
await engine.importTemplate(template);
|
||||||
|
this.emit('state-updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a new locking bytecode (receiving address).
|
||||||
|
* @param templateIdentifier - Template identifier
|
||||||
|
* @param outputIdentifier - Output identifier
|
||||||
|
* @param roleIdentifier - Role identifier
|
||||||
|
* @returns Generated locking bytecode as hex
|
||||||
|
*/
|
||||||
|
async generateLockingBytecode(
|
||||||
|
templateIdentifier: string,
|
||||||
|
outputIdentifier: string,
|
||||||
|
roleIdentifier?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const engine = this.getEngine();
|
||||||
|
const lockingBytecode = await engine.generateLockingBytecode(
|
||||||
|
templateIdentifier,
|
||||||
|
outputIdentifier,
|
||||||
|
roleIdentifier,
|
||||||
|
);
|
||||||
|
this.emit('state-updated');
|
||||||
|
return lockingBytecode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Invitation Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new invitation.
|
||||||
|
* @param templateIdentifier - Template identifier
|
||||||
|
* @param actionIdentifier - Action identifier
|
||||||
|
* @returns Created invitation
|
||||||
|
*/
|
||||||
|
async createInvitation(
|
||||||
|
templateIdentifier: string,
|
||||||
|
actionIdentifier: string,
|
||||||
|
): Promise<XOInvitation> {
|
||||||
|
const engine = this.getEngine();
|
||||||
|
return engine.createInvitation({
|
||||||
|
templateIdentifier,
|
||||||
|
actionIdentifier,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepts an invitation.
|
||||||
|
* @param invitation - Invitation to accept
|
||||||
|
* @returns Updated invitation
|
||||||
|
*/
|
||||||
|
async acceptInvitation(invitation: XOInvitation): Promise<XOInvitation> {
|
||||||
|
const engine = this.getEngine();
|
||||||
|
return engine.acceptInvitation(invitation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends data to an invitation.
|
||||||
|
* @param invitation - Invitation to append to
|
||||||
|
* @param params - Data to append
|
||||||
|
* @returns Updated invitation
|
||||||
|
*/
|
||||||
|
async appendInvitation(
|
||||||
|
invitation: XOInvitation,
|
||||||
|
params: {
|
||||||
|
inputs?: Array<{
|
||||||
|
outpointTransactionHash?: string;
|
||||||
|
outpointIndex?: number;
|
||||||
|
sequenceNumber?: number;
|
||||||
|
mergesWith?: { commitIdentifier: string; index: number };
|
||||||
|
unlockingBytecode?: Uint8Array;
|
||||||
|
}>;
|
||||||
|
outputs?: Array<{
|
||||||
|
valueSatoshis?: bigint;
|
||||||
|
lockingBytecode?: Uint8Array;
|
||||||
|
outputIdentifier?: string;
|
||||||
|
roleIdentifier?: string;
|
||||||
|
mergesWith?: { commitIdentifier: string; index: number };
|
||||||
|
}>;
|
||||||
|
variables?: Array<{
|
||||||
|
variableIdentifier: string;
|
||||||
|
value: bigint | boolean | number | string;
|
||||||
|
roleIdentifier?: string;
|
||||||
|
}>;
|
||||||
|
},
|
||||||
|
): Promise<XOInvitation> {
|
||||||
|
const engine = this.getEngine();
|
||||||
|
// Cast through unknown to handle strict type checking from engine's AppendInvitationParameters
|
||||||
|
// The engine expects Uint8Array for outpointTransactionHash but we accept string for convenience
|
||||||
|
return engine.appendInvitation(invitation, params as unknown as Parameters<typeof engine.appendInvitation>[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs an invitation.
|
||||||
|
* @param invitation - Invitation to sign
|
||||||
|
* @returns Signed invitation
|
||||||
|
*/
|
||||||
|
async signInvitation(invitation: XOInvitation): Promise<XOInvitation> {
|
||||||
|
const engine = this.getEngine();
|
||||||
|
return engine.signInvitation(invitation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an invitation.
|
||||||
|
* @param invitation - Invitation to validate
|
||||||
|
* @returns Whether the invitation is valid
|
||||||
|
*/
|
||||||
|
async isInvitationValid(invitation: XOInvitation): Promise<boolean> {
|
||||||
|
const engine = this.getEngine();
|
||||||
|
return engine.isInvitationValid(invitation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets available roles for an invitation.
|
||||||
|
* @param invitation - Invitation to check
|
||||||
|
* @returns Array of available role identifiers
|
||||||
|
*/
|
||||||
|
async getAvailableRoles(invitation: XOInvitation): Promise<string[]> {
|
||||||
|
const engine = this.getEngine();
|
||||||
|
return engine.listAvailableRoles(invitation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets requirements for an invitation.
|
||||||
|
* @param invitation - Invitation to check
|
||||||
|
* @returns Requirements information
|
||||||
|
*/
|
||||||
|
async getRequirements(invitation: XOInvitation) {
|
||||||
|
const engine = this.getEngine();
|
||||||
|
return engine.listRequirements(invitation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets missing requirements for an invitation.
|
||||||
|
* @param invitation - Invitation to check
|
||||||
|
* @returns Missing requirements information
|
||||||
|
*/
|
||||||
|
async getMissingRequirements(invitation: XOInvitation) {
|
||||||
|
const engine = this.getEngine();
|
||||||
|
return engine.listMissingRequirements(invitation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds suitable UTXOs for an invitation.
|
||||||
|
* @param invitation - Invitation to find resources for
|
||||||
|
* @param options - Search options
|
||||||
|
* @returns Suitable unspent outputs
|
||||||
|
*/
|
||||||
|
async findSuitableResources(
|
||||||
|
invitation: XOInvitation,
|
||||||
|
options: { templateIdentifier: string; outputIdentifier: string },
|
||||||
|
) {
|
||||||
|
const engine = this.getEngine();
|
||||||
|
return engine.findSuitableResources(invitation, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Transaction Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes an action (broadcasts transaction).
|
||||||
|
* @param invitation - Invitation with completed transaction
|
||||||
|
* @param options - Execution options
|
||||||
|
* @returns Transaction hash
|
||||||
|
*/
|
||||||
|
async executeAction(
|
||||||
|
invitation: XOInvitation,
|
||||||
|
options: { broadcastTransaction?: boolean } = { broadcastTransaction: true },
|
||||||
|
): Promise<string> {
|
||||||
|
const engine = this.getEngine();
|
||||||
|
const txHash = await engine.executeAction(invitation, {
|
||||||
|
broadcastTransaction: options.broadcastTransaction ?? true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.broadcastTransaction) {
|
||||||
|
this.emit('state-updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
return txHash;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/index.ts
Normal file
32
src/index.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* XO Wallet CLI - Terminal User Interface for XO crypto wallet.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* 1. View wallet state and balance
|
||||||
|
* 2. Create invitations for P2PKH transactions
|
||||||
|
* 3. Import and accept invitations
|
||||||
|
* 4. Sign and broadcast transactions
|
||||||
|
* 5. Real-time updates via SSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { App } from './app.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main entry point.
|
||||||
|
*/
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Create and start the application
|
||||||
|
await App.create({
|
||||||
|
syncServerUrl: process.env['SYNC_SERVER_URL'] ?? 'http://localhost:3000',
|
||||||
|
databasePath: process.env['DB_PATH'] ?? './',
|
||||||
|
databaseFilename: process.env['DB_FILENAME'] ?? 'xo-wallet',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start XO Wallet CLI:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the application
|
||||||
|
main();
|
||||||
435
src/services/invitation-flow.ts
Normal file
435
src/services/invitation-flow.ts
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
/**
|
||||||
|
* Invitation Flow Manager - Manages the collaborative invitation lifecycle.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Coordinates between local Engine and remote sync-server
|
||||||
|
* - Subscribes to SSE for real-time updates
|
||||||
|
* - Tracks invitation state machine
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import type { XOInvitation } from '@xo-cash/types';
|
||||||
|
import { SSESession } from '../utils/sse-client.js';
|
||||||
|
import type { SyncClient } from './sync-client.js';
|
||||||
|
import type { WalletController } from '../controllers/wallet-controller.js';
|
||||||
|
import { decodeExtendedJsonObject } from '../utils/ext-json.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* States an invitation can be in.
|
||||||
|
*/
|
||||||
|
export type InvitationState =
|
||||||
|
| 'created' // Just created locally
|
||||||
|
| 'published' // Published to sync server
|
||||||
|
| 'pending' // Waiting for other party
|
||||||
|
| 'ready' // All requirements met, ready to sign
|
||||||
|
| 'signed' // Signed and ready to broadcast
|
||||||
|
| 'broadcast' // Transaction broadcast
|
||||||
|
| 'completed' // Transaction confirmed
|
||||||
|
| 'expired' // Invitation expired
|
||||||
|
| 'error'; // Error state
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracked invitation with state information.
|
||||||
|
*/
|
||||||
|
export interface TrackedInvitation {
|
||||||
|
/** The invitation data */
|
||||||
|
invitation: XOInvitation;
|
||||||
|
/** Current state */
|
||||||
|
state: InvitationState;
|
||||||
|
/** SSE session for updates (if subscribed) */
|
||||||
|
sseSession?: SSESession;
|
||||||
|
/** Timestamp when tracking started */
|
||||||
|
trackedAt: number;
|
||||||
|
/** Last update timestamp */
|
||||||
|
lastUpdatedAt: number;
|
||||||
|
/** Error message if in error state */
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events emitted by the invitation flow manager.
|
||||||
|
*/
|
||||||
|
export interface InvitationFlowEvents {
|
||||||
|
'invitation-created': (invitation: XOInvitation) => void;
|
||||||
|
'invitation-updated': (invitationId: string, invitation: XOInvitation) => void;
|
||||||
|
'invitation-state-changed': (invitationId: string, state: InvitationState) => void;
|
||||||
|
'error': (invitationId: string, error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the invitation workflow.
|
||||||
|
*/
|
||||||
|
export class InvitationFlowManager extends EventEmitter {
|
||||||
|
/** Map of tracked invitations by ID */
|
||||||
|
private trackedInvitations: Map<string, TrackedInvitation> = new Map();
|
||||||
|
|
||||||
|
/** Wallet controller reference */
|
||||||
|
private walletController: WalletController;
|
||||||
|
|
||||||
|
/** Sync client reference */
|
||||||
|
private syncClient: SyncClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new invitation flow manager.
|
||||||
|
* @param walletController - Wallet controller instance
|
||||||
|
* @param syncClient - Sync client instance
|
||||||
|
*/
|
||||||
|
constructor(walletController: WalletController, syncClient: SyncClient) {
|
||||||
|
super();
|
||||||
|
this.walletController = walletController;
|
||||||
|
this.syncClient = syncClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Invitation Tracking
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts tracking an invitation.
|
||||||
|
* @param invitation - Invitation to track
|
||||||
|
* @param initialState - Initial state (default: 'created')
|
||||||
|
*/
|
||||||
|
track(invitation: XOInvitation, initialState: InvitationState = 'created'): TrackedInvitation {
|
||||||
|
const tracked: TrackedInvitation = {
|
||||||
|
invitation,
|
||||||
|
state: initialState,
|
||||||
|
trackedAt: Date.now(),
|
||||||
|
lastUpdatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.trackedInvitations.set(invitation.invitationIdentifier, tracked);
|
||||||
|
return tracked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a tracked invitation by ID.
|
||||||
|
* @param invitationId - Invitation ID
|
||||||
|
* @returns Tracked invitation or undefined
|
||||||
|
*/
|
||||||
|
get(invitationId: string): TrackedInvitation | undefined {
|
||||||
|
return this.trackedInvitations.get(invitationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all tracked invitations.
|
||||||
|
* @returns Array of tracked invitations
|
||||||
|
*/
|
||||||
|
getAll(): TrackedInvitation[] {
|
||||||
|
return Array.from(this.trackedInvitations.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the state of a tracked invitation.
|
||||||
|
* @param invitationId - Invitation ID
|
||||||
|
* @param state - New state
|
||||||
|
*/
|
||||||
|
private updateState(invitationId: string, state: InvitationState): void {
|
||||||
|
const tracked = this.trackedInvitations.get(invitationId);
|
||||||
|
if (tracked) {
|
||||||
|
tracked.state = state;
|
||||||
|
tracked.lastUpdatedAt = Date.now();
|
||||||
|
this.emit('invitation-state-changed', invitationId, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a tracked invitation with new data.
|
||||||
|
* @param invitation - Updated invitation
|
||||||
|
*/
|
||||||
|
private updateInvitation(invitation: XOInvitation): void {
|
||||||
|
const tracked = this.trackedInvitations.get(invitation.invitationIdentifier);
|
||||||
|
if (tracked) {
|
||||||
|
tracked.invitation = invitation;
|
||||||
|
tracked.lastUpdatedAt = Date.now();
|
||||||
|
this.emit('invitation-updated', invitation.invitationIdentifier, invitation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops tracking an invitation.
|
||||||
|
* @param invitationId - Invitation ID
|
||||||
|
*/
|
||||||
|
untrack(invitationId: string): void {
|
||||||
|
const tracked = this.trackedInvitations.get(invitationId);
|
||||||
|
if (tracked?.sseSession) {
|
||||||
|
tracked.sseSession.close();
|
||||||
|
}
|
||||||
|
this.trackedInvitations.delete(invitationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Flow Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new invitation and starts tracking it.
|
||||||
|
* @param templateIdentifier - Template ID
|
||||||
|
* @param actionIdentifier - Action ID
|
||||||
|
* @returns Created and tracked invitation
|
||||||
|
*/
|
||||||
|
async createInvitation(
|
||||||
|
templateIdentifier: string,
|
||||||
|
actionIdentifier: string,
|
||||||
|
): Promise<TrackedInvitation> {
|
||||||
|
// Create invitation via wallet controller
|
||||||
|
const invitation = await this.walletController.createInvitation(
|
||||||
|
templateIdentifier,
|
||||||
|
actionIdentifier,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Track the invitation
|
||||||
|
const tracked = this.track(invitation, 'created');
|
||||||
|
this.emit('invitation-created', invitation);
|
||||||
|
|
||||||
|
return tracked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishes an invitation to the sync server.
|
||||||
|
* @param invitationId - Invitation ID to publish
|
||||||
|
* @returns Updated tracked invitation
|
||||||
|
*/
|
||||||
|
async publishInvitation(invitationId: string): Promise<TrackedInvitation> {
|
||||||
|
const tracked = this.trackedInvitations.get(invitationId);
|
||||||
|
if (!tracked) {
|
||||||
|
throw new Error(`Invitation not found: ${invitationId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Post to sync server
|
||||||
|
await this.syncClient.postInvitation(tracked.invitation);
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
this.updateState(invitationId, 'published');
|
||||||
|
|
||||||
|
return tracked;
|
||||||
|
} catch (error) {
|
||||||
|
tracked.state = 'error';
|
||||||
|
tracked.error = error instanceof Error ? error.message : String(error);
|
||||||
|
this.emit('error', invitationId, error instanceof Error ? error : new Error(String(error)));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to SSE updates for an invitation.
|
||||||
|
* @param invitationId - Invitation ID to subscribe to
|
||||||
|
*/
|
||||||
|
async subscribeToUpdates(invitationId: string): Promise<void> {
|
||||||
|
const tracked = this.trackedInvitations.get(invitationId);
|
||||||
|
if (!tracked) {
|
||||||
|
throw new Error(`Invitation not found: ${invitationId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close existing SSE session if any
|
||||||
|
if (tracked.sseSession) {
|
||||||
|
tracked.sseSession.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new SSE session
|
||||||
|
const sseUrl = this.syncClient.getSSEUrl(invitationId);
|
||||||
|
|
||||||
|
tracked.sseSession = await SSESession.from(sseUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'text/event-stream',
|
||||||
|
},
|
||||||
|
onMessage: (event) => {
|
||||||
|
this.handleSSEMessage(invitationId, event);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error(`SSE error for ${invitationId}:`, error);
|
||||||
|
this.emit('error', invitationId, error instanceof Error ? error : new Error(String(error)));
|
||||||
|
},
|
||||||
|
onConnected: () => {
|
||||||
|
console.log(`SSE connected for invitation: ${invitationId}`);
|
||||||
|
},
|
||||||
|
onDisconnected: () => {
|
||||||
|
console.log(`SSE disconnected for invitation: ${invitationId}`);
|
||||||
|
},
|
||||||
|
attemptReconnect: true,
|
||||||
|
persistent: true,
|
||||||
|
retryDelay: 3000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update state to pending (waiting for updates)
|
||||||
|
this.updateState(invitationId, 'pending');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles an SSE message for an invitation.
|
||||||
|
* @param invitationId - Invitation ID
|
||||||
|
* @param event - SSE event
|
||||||
|
*/
|
||||||
|
private handleSSEMessage(invitationId: string, event: { data: string; event?: string }): void {
|
||||||
|
try {
|
||||||
|
// Parse the event data
|
||||||
|
const parsed = JSON.parse(event.data) as { topic?: string; data?: unknown };
|
||||||
|
|
||||||
|
if (event.event === 'invitation-updated' || parsed.topic === 'invitation-updated') {
|
||||||
|
// Decode the invitation data (handles ExtJSON)
|
||||||
|
const invitationData = decodeExtendedJsonObject(parsed.data ?? parsed);
|
||||||
|
const invitation = invitationData as XOInvitation;
|
||||||
|
|
||||||
|
// Update tracked invitation
|
||||||
|
this.updateInvitation(invitation);
|
||||||
|
|
||||||
|
// Check if all requirements are met
|
||||||
|
this.checkInvitationState(invitationId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error parsing SSE message for ${invitationId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks and updates the state of an invitation based on its data.
|
||||||
|
* @param invitationId - Invitation ID to check
|
||||||
|
*/
|
||||||
|
private async checkInvitationState(invitationId: string): Promise<void> {
|
||||||
|
const tracked = this.trackedInvitations.get(invitationId);
|
||||||
|
if (!tracked) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check missing requirements
|
||||||
|
const missing = await this.walletController.getMissingRequirements(tracked.invitation);
|
||||||
|
|
||||||
|
// If no missing inputs/outputs, it's ready to sign
|
||||||
|
const hasNoMissingInputs = !missing.inputs || missing.inputs.length === 0;
|
||||||
|
const hasNoMissingOutputs = !missing.outputs || missing.outputs.length === 0;
|
||||||
|
|
||||||
|
if (hasNoMissingInputs && hasNoMissingOutputs) {
|
||||||
|
this.updateState(invitationId, 'ready');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore errors during state check
|
||||||
|
console.error(`Error checking invitation state: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports an invitation from the sync server.
|
||||||
|
* @param invitationId - Invitation ID to import
|
||||||
|
* @returns Tracked invitation
|
||||||
|
*/
|
||||||
|
async importInvitation(invitationId: string): Promise<TrackedInvitation> {
|
||||||
|
// Fetch from sync server
|
||||||
|
const invitation = await this.syncClient.getInvitation(invitationId);
|
||||||
|
if (!invitation) {
|
||||||
|
throw new Error(`Invitation not found on server: ${invitationId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track the invitation
|
||||||
|
const tracked = this.track(invitation, 'pending');
|
||||||
|
|
||||||
|
return tracked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepts an invitation (joins as a participant).
|
||||||
|
* @param invitationId - Invitation ID to accept
|
||||||
|
* @returns Updated tracked invitation
|
||||||
|
*/
|
||||||
|
async acceptInvitation(invitationId: string): Promise<TrackedInvitation> {
|
||||||
|
const tracked = this.trackedInvitations.get(invitationId);
|
||||||
|
if (!tracked) {
|
||||||
|
throw new Error(`Invitation not found: ${invitationId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept via wallet controller
|
||||||
|
const updatedInvitation = await this.walletController.acceptInvitation(tracked.invitation);
|
||||||
|
this.updateInvitation(updatedInvitation);
|
||||||
|
|
||||||
|
return tracked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends data to an invitation.
|
||||||
|
* @param invitationId - Invitation ID
|
||||||
|
* @param params - Data to append
|
||||||
|
* @returns Updated tracked invitation
|
||||||
|
*/
|
||||||
|
async appendToInvitation(
|
||||||
|
invitationId: string,
|
||||||
|
params: Parameters<WalletController['appendInvitation']>[1],
|
||||||
|
): Promise<TrackedInvitation> {
|
||||||
|
const tracked = this.trackedInvitations.get(invitationId);
|
||||||
|
if (!tracked) {
|
||||||
|
throw new Error(`Invitation not found: ${invitationId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append via wallet controller
|
||||||
|
const updatedInvitation = await this.walletController.appendInvitation(
|
||||||
|
tracked.invitation,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
this.updateInvitation(updatedInvitation);
|
||||||
|
|
||||||
|
// Publish update to sync server
|
||||||
|
await this.syncClient.updateInvitation(updatedInvitation);
|
||||||
|
|
||||||
|
return tracked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs an invitation.
|
||||||
|
* @param invitationId - Invitation ID
|
||||||
|
* @returns Updated tracked invitation
|
||||||
|
*/
|
||||||
|
async signInvitation(invitationId: string): Promise<TrackedInvitation> {
|
||||||
|
const tracked = this.trackedInvitations.get(invitationId);
|
||||||
|
if (!tracked) {
|
||||||
|
throw new Error(`Invitation not found: ${invitationId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign via wallet controller
|
||||||
|
const signedInvitation = await this.walletController.signInvitation(tracked.invitation);
|
||||||
|
this.updateInvitation(signedInvitation);
|
||||||
|
this.updateState(invitationId, 'signed');
|
||||||
|
|
||||||
|
// Publish signed invitation to sync server
|
||||||
|
await this.syncClient.updateInvitation(signedInvitation);
|
||||||
|
|
||||||
|
return tracked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcasts the transaction for an invitation.
|
||||||
|
* @param invitationId - Invitation ID
|
||||||
|
* @returns Transaction hash
|
||||||
|
*/
|
||||||
|
async broadcastTransaction(invitationId: string): Promise<string> {
|
||||||
|
const tracked = this.trackedInvitations.get(invitationId);
|
||||||
|
if (!tracked) {
|
||||||
|
throw new Error(`Invitation not found: ${invitationId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute action (broadcast)
|
||||||
|
const txHash = await this.walletController.executeAction(tracked.invitation, {
|
||||||
|
broadcastTransaction: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.updateState(invitationId, 'broadcast');
|
||||||
|
|
||||||
|
// Close SSE session since we're done
|
||||||
|
if (tracked.sseSession) {
|
||||||
|
tracked.sseSession.close();
|
||||||
|
delete tracked.sseSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
return txHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans up all resources.
|
||||||
|
*/
|
||||||
|
destroy(): void {
|
||||||
|
// Close all SSE sessions
|
||||||
|
for (const tracked of this.trackedInvitations.values()) {
|
||||||
|
if (tracked.sseSession) {
|
||||||
|
tracked.sseSession.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.trackedInvitations.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
162
src/services/sync-client.ts
Normal file
162
src/services/sync-client.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* Sync Server Client - HTTP client for sync-server communication.
|
||||||
|
*
|
||||||
|
* Handles:
|
||||||
|
* - Creating/updating invitations on the server
|
||||||
|
* - Fetching invitations by ID
|
||||||
|
* - ExtJSON encoding/decoding for data transfer
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { XOInvitation } from '@xo-cash/types';
|
||||||
|
import { encodeExtendedJson, decodeExtendedJson } from '../utils/ext-json.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response from the sync server.
|
||||||
|
*/
|
||||||
|
export interface SyncServerResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP client for sync-server communication.
|
||||||
|
*/
|
||||||
|
export class SyncClient {
|
||||||
|
/** Base URL of the sync server */
|
||||||
|
private baseUrl: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new sync client.
|
||||||
|
* @param baseUrl - Base URL of the sync server (e.g., http://localhost:3000)
|
||||||
|
*/
|
||||||
|
constructor(baseUrl: string) {
|
||||||
|
// Remove trailing slash if present
|
||||||
|
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes an HTTP request to the sync server.
|
||||||
|
* @param method - HTTP method
|
||||||
|
* @param path - Request path
|
||||||
|
* @param body - Optional request body
|
||||||
|
* @returns Response data
|
||||||
|
*/
|
||||||
|
private async request<T>(
|
||||||
|
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<T> {
|
||||||
|
const url = `${this.baseUrl}${path}`;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
const options: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body !== undefined) {
|
||||||
|
// Encode body using ExtJSON for proper BigInt and Uint8Array serialization
|
||||||
|
options.body = encodeExtendedJson(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseText = await response.text();
|
||||||
|
|
||||||
|
// Return empty object if no response body
|
||||||
|
if (!responseText) {
|
||||||
|
return {} as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode response using ExtJSON
|
||||||
|
return decodeExtendedJson(responseText) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Invitation Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts an invitation to the sync server (create or update).
|
||||||
|
* @param invitation - Invitation to post
|
||||||
|
* @returns The stored invitation
|
||||||
|
*/
|
||||||
|
async postInvitation(invitation: XOInvitation): Promise<XOInvitation> {
|
||||||
|
return this.request<XOInvitation>('POST', '/invitations', invitation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets an invitation from the sync server.
|
||||||
|
* @param invitationIdentifier - Invitation ID to fetch
|
||||||
|
* @returns The invitation or undefined if not found
|
||||||
|
*/
|
||||||
|
async getInvitation(invitationIdentifier: string): Promise<XOInvitation | undefined> {
|
||||||
|
try {
|
||||||
|
// Use query parameter for GET request (can't have body)
|
||||||
|
const response = await this.request<XOInvitation>(
|
||||||
|
'GET',
|
||||||
|
`/invitations?invitationIdentifier=${encodeURIComponent(invitationIdentifier)}`
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
// Return undefined if not found (404)
|
||||||
|
if (error instanceof Error && error.message.includes('404')) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an invitation on the sync server.
|
||||||
|
* @param invitation - Updated invitation
|
||||||
|
* @returns The updated invitation
|
||||||
|
*/
|
||||||
|
async updateInvitation(invitation: XOInvitation): Promise<XOInvitation> {
|
||||||
|
// Uses the same POST endpoint which handles both create and update
|
||||||
|
return this.postInvitation(invitation);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Health Check
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the sync server is healthy.
|
||||||
|
* @returns True if server is healthy
|
||||||
|
*/
|
||||||
|
async isHealthy(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await this.request<{ status: string }>('GET', '/health');
|
||||||
|
return response.status === 'ok';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the base URL of the sync server.
|
||||||
|
*/
|
||||||
|
getBaseUrl(): string {
|
||||||
|
return this.baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the SSE endpoint URL for an invitation.
|
||||||
|
* @param invitationId - Invitation ID to subscribe to
|
||||||
|
* @returns SSE endpoint URL
|
||||||
|
*/
|
||||||
|
getSSEUrl(invitationIdentifier: string): string {
|
||||||
|
return `${this.baseUrl}/invitations?invitationIdentifier=${encodeURIComponent(invitationIdentifier)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
204
src/tui/App.tsx
Normal file
204
src/tui/App.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
/**
|
||||||
|
* Main App component for the XO Wallet CLI.
|
||||||
|
* Uses Ink for terminal rendering with React components.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, Text, useApp, useInput } from 'ink';
|
||||||
|
import { NavigationProvider, useNavigation } from './hooks/useNavigation.js';
|
||||||
|
import { AppProvider, useAppContext, useDialog, useStatus } from './hooks/useAppContext.js';
|
||||||
|
import type { WalletController } from '../controllers/wallet-controller.js';
|
||||||
|
import type { InvitationController } from '../controllers/invitation-controller.js';
|
||||||
|
import { colors, logoSmall } from './theme.js';
|
||||||
|
|
||||||
|
// Screen imports (will be created)
|
||||||
|
import { SeedInputScreen } from './screens/SeedInput.js';
|
||||||
|
import { WalletStateScreen } from './screens/WalletState.js';
|
||||||
|
import { TemplateListScreen } from './screens/TemplateList.js';
|
||||||
|
import { ActionWizardScreen } from './screens/ActionWizard.js';
|
||||||
|
import { InvitationScreen } from './screens/Invitation.js';
|
||||||
|
import { TransactionScreen } from './screens/Transaction.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the App component.
|
||||||
|
*/
|
||||||
|
interface AppProps {
|
||||||
|
walletController: WalletController;
|
||||||
|
invitationController: InvitationController;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Router component that renders the current screen.
|
||||||
|
*/
|
||||||
|
function Router(): React.ReactElement {
|
||||||
|
const { screen } = useNavigation();
|
||||||
|
|
||||||
|
switch (screen) {
|
||||||
|
case 'seed-input':
|
||||||
|
return <SeedInputScreen />;
|
||||||
|
case 'wallet':
|
||||||
|
return <WalletStateScreen />;
|
||||||
|
case 'templates':
|
||||||
|
return <TemplateListScreen />;
|
||||||
|
case 'wizard':
|
||||||
|
return <ActionWizardScreen />;
|
||||||
|
case 'invitations':
|
||||||
|
return <InvitationScreen />;
|
||||||
|
case 'transaction':
|
||||||
|
return <TransactionScreen />;
|
||||||
|
default:
|
||||||
|
return <Text color={colors.error}>Unknown screen: {screen}</Text>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status bar component shown at the bottom of the screen.
|
||||||
|
*/
|
||||||
|
function StatusBar(): React.ReactElement {
|
||||||
|
const { status } = useStatus();
|
||||||
|
const { screen, canGoBack } = useNavigation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
borderStyle="single"
|
||||||
|
borderColor={colors.border}
|
||||||
|
paddingX={1}
|
||||||
|
justifyContent="space-between"
|
||||||
|
>
|
||||||
|
<Text color={colors.primary} bold>{logoSmall}</Text>
|
||||||
|
<Text color={colors.textMuted}>{status}</Text>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{canGoBack ? 'ESC: Back | ' : ''}q: Quit
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog overlay component for modals.
|
||||||
|
*/
|
||||||
|
function DialogOverlay(): React.ReactElement | null {
|
||||||
|
const { dialog, setDialog } = useDialog();
|
||||||
|
|
||||||
|
useInput((input, key) => {
|
||||||
|
if (!dialog?.visible) return;
|
||||||
|
|
||||||
|
if (key.return || input === 'y' || input === 'Y') {
|
||||||
|
if (dialog.type === 'confirm' && dialog.onConfirm) {
|
||||||
|
dialog.onConfirm();
|
||||||
|
} else {
|
||||||
|
dialog.onCancel?.();
|
||||||
|
}
|
||||||
|
} else if (key.escape || input === 'n' || input === 'N') {
|
||||||
|
dialog.onCancel?.();
|
||||||
|
}
|
||||||
|
}, { isActive: dialog?.visible ?? false });
|
||||||
|
|
||||||
|
if (!dialog?.visible) return null;
|
||||||
|
|
||||||
|
const borderColor = dialog.type === 'error' ? colors.error :
|
||||||
|
dialog.type === 'confirm' ? colors.warning :
|
||||||
|
colors.info;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
flexDirection="column"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
flexDirection="column"
|
||||||
|
borderStyle="double"
|
||||||
|
borderColor={borderColor}
|
||||||
|
paddingX={2}
|
||||||
|
paddingY={1}
|
||||||
|
width={60}
|
||||||
|
>
|
||||||
|
<Text color={borderColor} bold>
|
||||||
|
{dialog.type === 'error' ? '✗ Error' :
|
||||||
|
dialog.type === 'confirm' ? '? Confirm' :
|
||||||
|
'ℹ Info'}
|
||||||
|
</Text>
|
||||||
|
<Box marginY={1}>
|
||||||
|
<Text wrap="wrap">{dialog.message}</Text>
|
||||||
|
</Box>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{dialog.type === 'confirm'
|
||||||
|
? 'Press Y to confirm, N or ESC to cancel'
|
||||||
|
: 'Press Enter or ESC to close'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main content wrapper with global keybindings.
|
||||||
|
*/
|
||||||
|
function MainContent(): React.ReactElement {
|
||||||
|
const { exit } = useApp();
|
||||||
|
const { goBack, canGoBack } = useNavigation();
|
||||||
|
const { dialog } = useDialog();
|
||||||
|
const appContext = useAppContext();
|
||||||
|
|
||||||
|
// Global keybindings (disabled when dialog is shown)
|
||||||
|
useInput((input, key) => {
|
||||||
|
// Don't handle global keys when dialog is shown
|
||||||
|
if (dialog?.visible) return;
|
||||||
|
|
||||||
|
// Quit on 'q' or Ctrl+C
|
||||||
|
if (input === 'q' || (key.ctrl && input === 'c')) {
|
||||||
|
appContext.exit();
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go back on Escape
|
||||||
|
if (key.escape && canGoBack) {
|
||||||
|
goBack();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" height="100%">
|
||||||
|
{/* Main content area */}
|
||||||
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
|
<Router />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Status bar */}
|
||||||
|
<StatusBar />
|
||||||
|
|
||||||
|
{/* Dialog overlay */}
|
||||||
|
<DialogOverlay />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main App component.
|
||||||
|
* Sets up providers and renders the main content.
|
||||||
|
*/
|
||||||
|
export function App({ walletController, invitationController }: AppProps): React.ReactElement {
|
||||||
|
const { exit } = useApp();
|
||||||
|
|
||||||
|
const handleExit = () => {
|
||||||
|
// Cleanup controllers if needed
|
||||||
|
walletController.stop();
|
||||||
|
exit();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppProvider
|
||||||
|
walletController={walletController}
|
||||||
|
invitationController={invitationController}
|
||||||
|
onExit={handleExit}
|
||||||
|
>
|
||||||
|
<NavigationProvider initialScreen="seed-input">
|
||||||
|
<MainContent />
|
||||||
|
</NavigationProvider>
|
||||||
|
</AppProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
src/tui/components/Button.tsx
Normal file
75
src/tui/components/Button.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Button component with focus styling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, Text, useFocus } from 'ink';
|
||||||
|
import { colors } from '../theme.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the Button component.
|
||||||
|
*/
|
||||||
|
interface ButtonProps {
|
||||||
|
/** Button label */
|
||||||
|
label: string;
|
||||||
|
/** Whether button is focused */
|
||||||
|
focused?: boolean;
|
||||||
|
/** Whether button is disabled */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Optional keyboard shortcut hint */
|
||||||
|
shortcut?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button component with focus highlighting.
|
||||||
|
*/
|
||||||
|
export function Button({
|
||||||
|
label,
|
||||||
|
focused = false,
|
||||||
|
disabled = false,
|
||||||
|
shortcut,
|
||||||
|
}: ButtonProps): React.ReactElement {
|
||||||
|
const bgColor = disabled
|
||||||
|
? colors.textMuted
|
||||||
|
: focused
|
||||||
|
? colors.focus
|
||||||
|
: colors.secondary;
|
||||||
|
|
||||||
|
const textColor = disabled
|
||||||
|
? colors.bg
|
||||||
|
: focused
|
||||||
|
? colors.bg
|
||||||
|
: colors.text;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box paddingX={1} marginRight={1}>
|
||||||
|
<Text
|
||||||
|
backgroundColor={bgColor}
|
||||||
|
color={textColor}
|
||||||
|
bold={focused}
|
||||||
|
>
|
||||||
|
{` ${label} `}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{shortcut && (
|
||||||
|
<Text color={colors.textMuted} dimColor>({shortcut})</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button row component for multiple buttons.
|
||||||
|
*/
|
||||||
|
interface ButtonRowProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ButtonRow({ children }: ButtonRowProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<Box marginTop={1} gap={2}>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
243
src/tui/components/Dialog.tsx
Normal file
243
src/tui/components/Dialog.tsx
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* Dialog components for modals, confirmations, and input dialogs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Box, Text, useInput } from 'ink';
|
||||||
|
import TextInput from 'ink-text-input';
|
||||||
|
import { colors } from '../theme.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base dialog wrapper props.
|
||||||
|
*/
|
||||||
|
interface DialogWrapperProps {
|
||||||
|
/** Dialog title */
|
||||||
|
title: string;
|
||||||
|
/** Border color */
|
||||||
|
borderColor?: string;
|
||||||
|
/** Dialog content */
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** Dialog width */
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base dialog wrapper component.
|
||||||
|
*/
|
||||||
|
function DialogWrapper({
|
||||||
|
title,
|
||||||
|
borderColor = colors.primary,
|
||||||
|
children,
|
||||||
|
width = 60,
|
||||||
|
}: DialogWrapperProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
flexDirection="column"
|
||||||
|
borderStyle="double"
|
||||||
|
borderColor={borderColor}
|
||||||
|
paddingX={2}
|
||||||
|
paddingY={1}
|
||||||
|
width={width}
|
||||||
|
>
|
||||||
|
<Text color={borderColor} bold>{title}</Text>
|
||||||
|
<Box marginY={1} flexDirection="column">
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for InputDialog component.
|
||||||
|
*/
|
||||||
|
interface InputDialogProps {
|
||||||
|
/** Dialog title */
|
||||||
|
title: string;
|
||||||
|
/** Input prompt/label */
|
||||||
|
prompt: string;
|
||||||
|
/** Initial value */
|
||||||
|
initialValue?: string;
|
||||||
|
/** Placeholder text */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Submit handler */
|
||||||
|
onSubmit: (value: string) => void;
|
||||||
|
/** Cancel handler */
|
||||||
|
onCancel: () => void;
|
||||||
|
/** Whether dialog is visible/active */
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input dialog for getting text input from user.
|
||||||
|
*/
|
||||||
|
export function InputDialog({
|
||||||
|
title,
|
||||||
|
prompt,
|
||||||
|
initialValue = '',
|
||||||
|
placeholder,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
isActive = true,
|
||||||
|
}: InputDialogProps): React.ReactElement {
|
||||||
|
const [value, setValue] = useState(initialValue);
|
||||||
|
|
||||||
|
useInput((input, key) => {
|
||||||
|
if (!isActive) return;
|
||||||
|
|
||||||
|
if (key.escape) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}, { isActive });
|
||||||
|
|
||||||
|
const handleSubmit = (val: string) => {
|
||||||
|
onSubmit(val);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogWrapper title={title} borderColor={colors.primary}>
|
||||||
|
<Text>{prompt}</Text>
|
||||||
|
<Box marginTop={1} borderStyle="single" borderColor={colors.focus} paddingX={1}>
|
||||||
|
<TextInput
|
||||||
|
value={value}
|
||||||
|
onChange={setValue}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
placeholder={placeholder}
|
||||||
|
focus={isActive}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>Enter to submit • Esc to cancel</Text>
|
||||||
|
</Box>
|
||||||
|
</DialogWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for ConfirmDialog component.
|
||||||
|
*/
|
||||||
|
interface ConfirmDialogProps {
|
||||||
|
/** Dialog title */
|
||||||
|
title: string;
|
||||||
|
/** Confirmation message */
|
||||||
|
message: string;
|
||||||
|
/** Confirm handler */
|
||||||
|
onConfirm: () => void;
|
||||||
|
/** Cancel handler */
|
||||||
|
onCancel: () => void;
|
||||||
|
/** Whether dialog is visible/active */
|
||||||
|
isActive?: boolean;
|
||||||
|
/** Confirm button label */
|
||||||
|
confirmLabel?: string;
|
||||||
|
/** Cancel button label */
|
||||||
|
cancelLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirmation dialog with Yes/No options.
|
||||||
|
*/
|
||||||
|
export function ConfirmDialog({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
isActive = true,
|
||||||
|
confirmLabel = 'Yes',
|
||||||
|
cancelLabel = 'No',
|
||||||
|
}: ConfirmDialogProps): React.ReactElement {
|
||||||
|
const [selected, setSelected] = useState<'confirm' | 'cancel'>('confirm');
|
||||||
|
|
||||||
|
useInput((input, key) => {
|
||||||
|
if (!isActive) return;
|
||||||
|
|
||||||
|
if (key.leftArrow || key.rightArrow || key.tab) {
|
||||||
|
setSelected(prev => prev === 'confirm' ? 'cancel' : 'confirm');
|
||||||
|
} else if (key.return) {
|
||||||
|
if (selected === 'confirm') {
|
||||||
|
onConfirm();
|
||||||
|
} else {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
} else if (key.escape || input === 'n' || input === 'N') {
|
||||||
|
onCancel();
|
||||||
|
} else if (input === 'y' || input === 'Y') {
|
||||||
|
onConfirm();
|
||||||
|
}
|
||||||
|
}, { isActive });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogWrapper title={title} borderColor={colors.warning}>
|
||||||
|
<Text wrap="wrap">{message}</Text>
|
||||||
|
<Box marginTop={1} gap={2}>
|
||||||
|
<Text
|
||||||
|
backgroundColor={selected === 'confirm' ? colors.focus : colors.secondary}
|
||||||
|
color={selected === 'confirm' ? colors.bg : colors.text}
|
||||||
|
bold={selected === 'confirm'}
|
||||||
|
>
|
||||||
|
{` ${confirmLabel} `}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
backgroundColor={selected === 'cancel' ? colors.focus : colors.secondary}
|
||||||
|
color={selected === 'cancel' ? colors.bg : colors.text}
|
||||||
|
bold={selected === 'cancel'}
|
||||||
|
>
|
||||||
|
{` ${cancelLabel} `}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>Y/N or Tab to switch • Enter to select</Text>
|
||||||
|
</Box>
|
||||||
|
</DialogWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for MessageDialog component.
|
||||||
|
*/
|
||||||
|
interface MessageDialogProps {
|
||||||
|
/** Dialog title */
|
||||||
|
title: string;
|
||||||
|
/** Message content */
|
||||||
|
message: string;
|
||||||
|
/** Close handler */
|
||||||
|
onClose: () => void;
|
||||||
|
/** Dialog type for styling */
|
||||||
|
type?: 'info' | 'error' | 'success';
|
||||||
|
/** Whether dialog is visible/active */
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple message dialog (info, error, success).
|
||||||
|
*/
|
||||||
|
export function MessageDialog({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
onClose,
|
||||||
|
type = 'info',
|
||||||
|
isActive = true,
|
||||||
|
}: MessageDialogProps): React.ReactElement {
|
||||||
|
useInput((input, key) => {
|
||||||
|
if (!isActive) return;
|
||||||
|
|
||||||
|
if (key.return || key.escape) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, { isActive });
|
||||||
|
|
||||||
|
const borderColor = type === 'error' ? colors.error :
|
||||||
|
type === 'success' ? colors.success :
|
||||||
|
colors.info;
|
||||||
|
|
||||||
|
const icon = type === 'error' ? '✗' :
|
||||||
|
type === 'success' ? '✓' :
|
||||||
|
'ℹ';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogWrapper title={`${icon} ${title}`} borderColor={borderColor}>
|
||||||
|
<Text wrap="wrap">{message}</Text>
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>Press Enter or Esc to close</Text>
|
||||||
|
</Box>
|
||||||
|
</DialogWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
src/tui/components/Input.tsx
Normal file
109
src/tui/components/Input.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Text input component with focus styling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import TextInput from 'ink-text-input';
|
||||||
|
import { colors } from '../theme.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the Input component.
|
||||||
|
*/
|
||||||
|
interface InputProps {
|
||||||
|
/** Current value */
|
||||||
|
value: string;
|
||||||
|
/** Change handler */
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
/** Submit handler (Enter key) */
|
||||||
|
onSubmit?: (value: string) => void;
|
||||||
|
/** Placeholder text */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Label shown above input */
|
||||||
|
label?: string;
|
||||||
|
/** Whether input is focused */
|
||||||
|
focus?: boolean;
|
||||||
|
/** Whether to mask input (for passwords) */
|
||||||
|
mask?: string;
|
||||||
|
/** Whether input is disabled */
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text input component with label and focus styling.
|
||||||
|
*/
|
||||||
|
export function Input({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
placeholder,
|
||||||
|
label,
|
||||||
|
focus = true,
|
||||||
|
mask,
|
||||||
|
disabled = false,
|
||||||
|
}: InputProps): React.ReactElement {
|
||||||
|
const borderColor = focus ? colors.focus : colors.border;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{label && (
|
||||||
|
<Text color={colors.text} bold>{label}</Text>
|
||||||
|
)}
|
||||||
|
<Box
|
||||||
|
borderStyle="single"
|
||||||
|
borderColor={borderColor}
|
||||||
|
paddingX={1}
|
||||||
|
>
|
||||||
|
{disabled ? (
|
||||||
|
<Text color={colors.textMuted}>{value || placeholder || ''}</Text>
|
||||||
|
) : (
|
||||||
|
<TextInput
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
placeholder={placeholder}
|
||||||
|
focus={focus}
|
||||||
|
mask={mask}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi-line text display (read-only, styled like input).
|
||||||
|
*/
|
||||||
|
interface TextDisplayProps {
|
||||||
|
/** Text content */
|
||||||
|
content: string;
|
||||||
|
/** Label shown above */
|
||||||
|
label?: string;
|
||||||
|
/** Whether to show border */
|
||||||
|
border?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextDisplay({
|
||||||
|
content,
|
||||||
|
label,
|
||||||
|
border = true
|
||||||
|
}: TextDisplayProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{label && (
|
||||||
|
<Text color={colors.text} bold>{label}</Text>
|
||||||
|
)}
|
||||||
|
{border ? (
|
||||||
|
<Box
|
||||||
|
borderStyle="single"
|
||||||
|
borderColor={colors.border}
|
||||||
|
paddingX={1}
|
||||||
|
>
|
||||||
|
<Text>{content}</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Text>{content}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
src/tui/components/List.tsx
Normal file
159
src/tui/components/List.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* Selectable list component with keyboard navigation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, Text, useInput } from 'ink';
|
||||||
|
import { colors } from '../theme.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List item type.
|
||||||
|
*/
|
||||||
|
export interface ListItem<T = unknown> {
|
||||||
|
/** Unique key for the item */
|
||||||
|
key: string;
|
||||||
|
/** Display label */
|
||||||
|
label: string;
|
||||||
|
/** Optional secondary text */
|
||||||
|
description?: string;
|
||||||
|
/** Optional value associated with item */
|
||||||
|
value?: T;
|
||||||
|
/** Whether item is disabled */
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the List component.
|
||||||
|
*/
|
||||||
|
interface ListProps<T> {
|
||||||
|
/** List items */
|
||||||
|
items: ListItem<T>[];
|
||||||
|
/** Currently selected index */
|
||||||
|
selectedIndex: number;
|
||||||
|
/** Selection change handler */
|
||||||
|
onSelect: (index: number) => void;
|
||||||
|
/** Item activation handler (Enter key) */
|
||||||
|
onActivate?: (item: ListItem<T>, index: number) => void;
|
||||||
|
/** Whether list is focused */
|
||||||
|
focus?: boolean;
|
||||||
|
/** Maximum visible items (for scrolling) */
|
||||||
|
maxVisible?: number;
|
||||||
|
/** Optional label */
|
||||||
|
label?: string;
|
||||||
|
/** Show border */
|
||||||
|
border?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selectable list with keyboard navigation.
|
||||||
|
*/
|
||||||
|
export function List<T>({
|
||||||
|
items,
|
||||||
|
selectedIndex,
|
||||||
|
onSelect,
|
||||||
|
onActivate,
|
||||||
|
focus = true,
|
||||||
|
maxVisible = 10,
|
||||||
|
label,
|
||||||
|
border = true,
|
||||||
|
}: ListProps<T>): React.ReactElement {
|
||||||
|
// Handle keyboard input
|
||||||
|
useInput((input, key) => {
|
||||||
|
if (!focus) return;
|
||||||
|
|
||||||
|
if (key.upArrow || input === 'k') {
|
||||||
|
const newIndex = selectedIndex > 0 ? selectedIndex - 1 : items.length - 1;
|
||||||
|
onSelect(newIndex);
|
||||||
|
} else if (key.downArrow || input === 'j') {
|
||||||
|
const newIndex = selectedIndex < items.length - 1 ? selectedIndex + 1 : 0;
|
||||||
|
onSelect(newIndex);
|
||||||
|
} else if (key.return && onActivate && items[selectedIndex]) {
|
||||||
|
const item = items[selectedIndex];
|
||||||
|
if (item && !item.disabled) {
|
||||||
|
onActivate(item, selectedIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { isActive: focus });
|
||||||
|
|
||||||
|
// Calculate visible range for scrolling
|
||||||
|
const startIndex = Math.max(0, Math.min(selectedIndex - Math.floor(maxVisible / 2), items.length - maxVisible));
|
||||||
|
const visibleItems = items.slice(startIndex, startIndex + maxVisible);
|
||||||
|
|
||||||
|
const borderColor = focus ? colors.focus : colors.border;
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{visibleItems.map((item, visibleIndex) => {
|
||||||
|
const actualIndex = startIndex + visibleIndex;
|
||||||
|
const isSelected = actualIndex === selectedIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={item.key}>
|
||||||
|
<Text
|
||||||
|
color={item.disabled ? colors.textMuted : isSelected ? colors.bg : colors.text}
|
||||||
|
backgroundColor={isSelected ? colors.focus : undefined}
|
||||||
|
bold={isSelected}
|
||||||
|
dimColor={item.disabled}
|
||||||
|
>
|
||||||
|
{isSelected ? '▸ ' : ' '}
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
{item.description && (
|
||||||
|
<Text color={colors.textMuted} dimColor> - {item.description}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{items.length === 0 && (
|
||||||
|
<Text color={colors.textMuted} dimColor>No items</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{label && <Text color={colors.text} bold>{label}</Text>}
|
||||||
|
{border ? (
|
||||||
|
<Box
|
||||||
|
borderStyle="single"
|
||||||
|
borderColor={borderColor}
|
||||||
|
paddingX={1}
|
||||||
|
flexDirection="column"
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Box>
|
||||||
|
) : content}
|
||||||
|
{items.length > maxVisible && (
|
||||||
|
<Text color={colors.textMuted} dimColor>
|
||||||
|
{startIndex + 1}-{Math.min(startIndex + maxVisible, items.length)} of {items.length}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple inline list for displaying items without selection.
|
||||||
|
*/
|
||||||
|
interface SimpleListProps {
|
||||||
|
items: string[];
|
||||||
|
label?: string;
|
||||||
|
bullet?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SimpleList({
|
||||||
|
items,
|
||||||
|
label,
|
||||||
|
bullet = '•'
|
||||||
|
}: SimpleListProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{label && <Text color={colors.text} bold>{label}</Text>}
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<Text key={index} color={colors.text}>
|
||||||
|
{bullet} {item}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
src/tui/components/ProgressBar.tsx
Normal file
130
src/tui/components/ProgressBar.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* Progress bar and step indicator components.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { colors } from '../theme.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for ProgressBar component.
|
||||||
|
*/
|
||||||
|
interface ProgressBarProps {
|
||||||
|
/** Current progress (0-100) */
|
||||||
|
percent: number;
|
||||||
|
/** Bar width in characters */
|
||||||
|
width?: number;
|
||||||
|
/** Show percentage text */
|
||||||
|
showPercent?: boolean;
|
||||||
|
/** Bar character */
|
||||||
|
character?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple progress bar component.
|
||||||
|
*/
|
||||||
|
export function ProgressBar({
|
||||||
|
percent,
|
||||||
|
width = 40,
|
||||||
|
showPercent = true,
|
||||||
|
character = '█',
|
||||||
|
}: ProgressBarProps): React.ReactElement {
|
||||||
|
const clampedPercent = Math.max(0, Math.min(100, percent));
|
||||||
|
const filled = Math.round((clampedPercent / 100) * width);
|
||||||
|
const empty = width - filled;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Text color={colors.success}>{character.repeat(filled)}</Text>
|
||||||
|
<Text color={colors.textMuted} dimColor>{'░'.repeat(empty)}</Text>
|
||||||
|
{showPercent && (
|
||||||
|
<Text color={colors.text}> {Math.round(clampedPercent)}%</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step definition for StepIndicator.
|
||||||
|
*/
|
||||||
|
export interface Step {
|
||||||
|
/** Step label */
|
||||||
|
label: string;
|
||||||
|
/** Whether step is completed */
|
||||||
|
completed?: boolean;
|
||||||
|
/** Whether step is current */
|
||||||
|
current?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for StepIndicator component.
|
||||||
|
*/
|
||||||
|
interface StepIndicatorProps {
|
||||||
|
/** Steps to display */
|
||||||
|
steps: Step[];
|
||||||
|
/** Current step index (0-based) */
|
||||||
|
currentStep: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step indicator showing progress through a multi-step wizard.
|
||||||
|
*/
|
||||||
|
export function StepIndicator({
|
||||||
|
steps,
|
||||||
|
currentStep,
|
||||||
|
}: StepIndicatorProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Box gap={1}>
|
||||||
|
{steps.map((step, index) => {
|
||||||
|
const isCompleted = index < currentStep;
|
||||||
|
const isCurrent = index === currentStep;
|
||||||
|
|
||||||
|
const color = isCompleted ? colors.success :
|
||||||
|
isCurrent ? colors.focus :
|
||||||
|
colors.textMuted;
|
||||||
|
|
||||||
|
const icon = isCompleted ? '✓' :
|
||||||
|
isCurrent ? '▸' :
|
||||||
|
'○';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={index}>
|
||||||
|
<Text color={color} bold={isCurrent}>
|
||||||
|
{icon} {step.label}
|
||||||
|
</Text>
|
||||||
|
{index < steps.length - 1 && (
|
||||||
|
<Text color={colors.textMuted}> → </Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
Step {currentStep + 1} of {steps.length}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loading spinner with optional message.
|
||||||
|
*/
|
||||||
|
interface LoadingProps {
|
||||||
|
/** Loading message */
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Loading({ message = 'Loading...' }: LoadingProps): React.ReactElement {
|
||||||
|
// Simple spinner using Ink's spinner component
|
||||||
|
const Spinner = require('ink-spinner').default;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Text color={colors.primary}>
|
||||||
|
<Spinner type="dots" />
|
||||||
|
</Text>
|
||||||
|
<Text color={colors.text}> {message}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/tui/components/Screen.tsx
Normal file
71
src/tui/components/Screen.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Screen wrapper component providing consistent layout.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { type ReactNode } from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { colors } from '../theme.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the Screen component.
|
||||||
|
*/
|
||||||
|
interface ScreenProps {
|
||||||
|
/** Screen title displayed in header */
|
||||||
|
title: string;
|
||||||
|
/** Optional subtitle */
|
||||||
|
subtitle?: string;
|
||||||
|
/** Screen content */
|
||||||
|
children: ReactNode;
|
||||||
|
/** Optional footer content */
|
||||||
|
footer?: ReactNode;
|
||||||
|
/** Optional help text shown at bottom */
|
||||||
|
helpText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Screen wrapper component.
|
||||||
|
* Provides consistent header, content area, and footer layout.
|
||||||
|
*/
|
||||||
|
export function Screen({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
helpText
|
||||||
|
}: ScreenProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box
|
||||||
|
borderStyle="single"
|
||||||
|
borderColor={colors.primary}
|
||||||
|
paddingX={1}
|
||||||
|
marginBottom={1}
|
||||||
|
>
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.primary} bold>{title}</Text>
|
||||||
|
{subtitle && <Text color={colors.textMuted}>{subtitle}</Text>}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<Box flexDirection="column" flexGrow={1} paddingX={1}>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Help text */}
|
||||||
|
{helpText && (
|
||||||
|
<Box paddingX={1} marginTop={1}>
|
||||||
|
<Text color={colors.textMuted} dimColor>{helpText}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{footer && (
|
||||||
|
<Box paddingX={1} marginTop={1}>
|
||||||
|
{footer}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
src/tui/components/index.ts
Normal file
10
src/tui/components/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Export all shared components.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { Screen } from './Screen.js';
|
||||||
|
export { Input, TextDisplay } from './Input.js';
|
||||||
|
export { Button, ButtonRow } from './Button.js';
|
||||||
|
export { List, SimpleList, type ListItem } from './List.js';
|
||||||
|
export { InputDialog, ConfirmDialog, MessageDialog } from './Dialog.js';
|
||||||
|
export { ProgressBar, StepIndicator, Loading, type Step } from './ProgressBar.js';
|
||||||
6
src/tui/hooks/index.ts
Normal file
6
src/tui/hooks/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Export all hooks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { NavigationProvider, useNavigation } from './useNavigation.js';
|
||||||
|
export { AppProvider, useAppContext, useDialog, useStatus } from './useAppContext.js';
|
||||||
183
src/tui/hooks/useAppContext.tsx
Normal file
183
src/tui/hooks/useAppContext.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* App context hook for accessing controllers and app-level functions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||||
|
import type { WalletController } from '../../controllers/wallet-controller.js';
|
||||||
|
import type { InvitationController } from '../../controllers/invitation-controller.js';
|
||||||
|
import type { AppContextType, DialogState } from '../types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App context.
|
||||||
|
*/
|
||||||
|
const AppContext = createContext<AppContextType | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog context for managing modal dialogs.
|
||||||
|
*/
|
||||||
|
interface DialogContextType {
|
||||||
|
dialog: DialogState | null;
|
||||||
|
setDialog: (dialog: DialogState | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DialogContext = createContext<DialogContextType | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status context for managing status bar.
|
||||||
|
*/
|
||||||
|
interface StatusContextType {
|
||||||
|
status: string;
|
||||||
|
setStatus: (status: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatusContext = createContext<StatusContextType | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App provider props.
|
||||||
|
*/
|
||||||
|
interface AppProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
walletController: WalletController;
|
||||||
|
invitationController: InvitationController;
|
||||||
|
onExit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App provider component.
|
||||||
|
* Provides controllers, dialog management, and app-level functions to children.
|
||||||
|
*/
|
||||||
|
export function AppProvider({
|
||||||
|
children,
|
||||||
|
walletController,
|
||||||
|
invitationController,
|
||||||
|
onExit,
|
||||||
|
}: AppProviderProps): React.ReactElement {
|
||||||
|
const [dialog, setDialog] = useState<DialogState | null>(null);
|
||||||
|
const [status, setStatusState] = useState<string>('Ready');
|
||||||
|
const [isWalletInitialized, setWalletInitialized] = useState(false);
|
||||||
|
|
||||||
|
// Promise resolver for confirm dialogs
|
||||||
|
const [confirmResolver, setConfirmResolver] = useState<((value: boolean) => void) | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show an error dialog.
|
||||||
|
*/
|
||||||
|
const showError = useCallback((message: string) => {
|
||||||
|
setDialog({
|
||||||
|
visible: true,
|
||||||
|
type: 'error',
|
||||||
|
message,
|
||||||
|
onCancel: () => setDialog(null),
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show an info dialog.
|
||||||
|
*/
|
||||||
|
const showInfo = useCallback((message: string) => {
|
||||||
|
setDialog({
|
||||||
|
visible: true,
|
||||||
|
type: 'info',
|
||||||
|
message,
|
||||||
|
onCancel: () => setDialog(null),
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a confirmation dialog and wait for user response.
|
||||||
|
*/
|
||||||
|
const confirm = useCallback((message: string): Promise<boolean> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setConfirmResolver(() => resolve);
|
||||||
|
setDialog({
|
||||||
|
visible: true,
|
||||||
|
type: 'confirm',
|
||||||
|
message,
|
||||||
|
onConfirm: () => {
|
||||||
|
setDialog(null);
|
||||||
|
resolve(true);
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
setDialog(null);
|
||||||
|
resolve(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update status bar message.
|
||||||
|
*/
|
||||||
|
const setStatus = useCallback((message: string) => {
|
||||||
|
setStatusState(message);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const appValue: AppContextType = {
|
||||||
|
walletController,
|
||||||
|
invitationController,
|
||||||
|
showError,
|
||||||
|
showInfo,
|
||||||
|
confirm,
|
||||||
|
exit: onExit,
|
||||||
|
setStatus,
|
||||||
|
isWalletInitialized,
|
||||||
|
setWalletInitialized,
|
||||||
|
};
|
||||||
|
|
||||||
|
const dialogValue: DialogContextType = {
|
||||||
|
dialog,
|
||||||
|
setDialog,
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusValue: StatusContextType = {
|
||||||
|
status,
|
||||||
|
setStatus,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppContext.Provider value={appValue}>
|
||||||
|
<DialogContext.Provider value={dialogValue}>
|
||||||
|
<StatusContext.Provider value={statusValue}>
|
||||||
|
{children}
|
||||||
|
</StatusContext.Provider>
|
||||||
|
</DialogContext.Provider>
|
||||||
|
</AppContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access app context.
|
||||||
|
* @returns App context
|
||||||
|
* @throws Error if used outside AppProvider
|
||||||
|
*/
|
||||||
|
export function useAppContext(): AppContextType {
|
||||||
|
const context = useContext(AppContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAppContext must be used within an AppProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access dialog context.
|
||||||
|
* @returns Dialog context
|
||||||
|
*/
|
||||||
|
export function useDialog(): DialogContextType {
|
||||||
|
const context = useContext(DialogContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useDialog must be used within an AppProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access status context.
|
||||||
|
* @returns Status context
|
||||||
|
*/
|
||||||
|
export function useStatus(): StatusContextType {
|
||||||
|
const context = useContext(StatusContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useStatus must be used within an AppProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
87
src/tui/hooks/useNavigation.tsx
Normal file
87
src/tui/hooks/useNavigation.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Navigation hook for managing screen navigation with history.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||||
|
import type { ScreenName, NavigationData, NavigationContextType } from '../types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation context.
|
||||||
|
*/
|
||||||
|
const NavigationContext = createContext<NavigationContextType | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation provider props.
|
||||||
|
*/
|
||||||
|
interface NavigationProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
initialScreen?: ScreenName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation provider component.
|
||||||
|
* Manages navigation state and provides navigation functions to children.
|
||||||
|
*/
|
||||||
|
export function NavigationProvider({
|
||||||
|
children,
|
||||||
|
initialScreen = 'seed-input'
|
||||||
|
}: NavigationProviderProps): React.ReactElement {
|
||||||
|
const [screen, setScreen] = useState<ScreenName>(initialScreen);
|
||||||
|
const [data, setData] = useState<NavigationData>({});
|
||||||
|
const [history, setHistory] = useState<ScreenName[]>([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to a new screen, optionally with data.
|
||||||
|
*/
|
||||||
|
const navigate = useCallback((newScreen: ScreenName, newData?: NavigationData) => {
|
||||||
|
// Add current screen to history
|
||||||
|
setHistory(prev => [...prev, screen]);
|
||||||
|
// Set new screen and data
|
||||||
|
setScreen(newScreen);
|
||||||
|
setData(newData ?? {});
|
||||||
|
}, [screen]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Go back to the previous screen.
|
||||||
|
*/
|
||||||
|
const goBack = useCallback(() => {
|
||||||
|
if (history.length === 0) return;
|
||||||
|
|
||||||
|
const newHistory = [...history];
|
||||||
|
const previousScreen = newHistory.pop();
|
||||||
|
|
||||||
|
if (previousScreen) {
|
||||||
|
setHistory(newHistory);
|
||||||
|
setScreen(previousScreen);
|
||||||
|
setData({});
|
||||||
|
}
|
||||||
|
}, [history]);
|
||||||
|
|
||||||
|
const value: NavigationContextType = {
|
||||||
|
screen,
|
||||||
|
data,
|
||||||
|
history,
|
||||||
|
navigate,
|
||||||
|
goBack,
|
||||||
|
canGoBack: history.length > 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavigationContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</NavigationContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access navigation context.
|
||||||
|
* @returns Navigation context
|
||||||
|
* @throws Error if used outside NavigationProvider
|
||||||
|
*/
|
||||||
|
export function useNavigation(): NavigationContextType {
|
||||||
|
const context = useContext(NavigationContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useNavigation must be used within a NavigationProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
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';
|
||||||
113
src/tui/theme.ts
Normal file
113
src/tui/theme.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* Theme configuration for the CLI TUI using Ink.
|
||||||
|
* Defines colors, styles, and visual constants used throughout the application.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TextProps } from 'ink';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Color type - supports Ink color names.
|
||||||
|
*/
|
||||||
|
export type Color = TextProps['color'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Color palette for the application.
|
||||||
|
* All colors are compatible with Ink's Text component.
|
||||||
|
*/
|
||||||
|
export const colors = {
|
||||||
|
// Primary colors
|
||||||
|
primary: 'cyan' as Color,
|
||||||
|
secondary: 'blue' as Color,
|
||||||
|
accent: 'magenta' as Color,
|
||||||
|
|
||||||
|
// Status colors
|
||||||
|
success: 'green' as Color,
|
||||||
|
warning: 'yellow' as Color,
|
||||||
|
error: 'red' as Color,
|
||||||
|
info: 'cyan' as Color,
|
||||||
|
|
||||||
|
// Text colors
|
||||||
|
text: 'white' as Color,
|
||||||
|
textMuted: 'gray' as Color,
|
||||||
|
textHighlight: 'whiteBright' as Color,
|
||||||
|
|
||||||
|
// Background colors
|
||||||
|
bg: 'black' as Color,
|
||||||
|
bgSelected: 'blue' as Color,
|
||||||
|
bgHover: 'gray' as Color,
|
||||||
|
|
||||||
|
// Border colors
|
||||||
|
border: 'cyan' as Color,
|
||||||
|
borderFocused: 'yellowBright' as Color,
|
||||||
|
borderMuted: 'gray' as Color,
|
||||||
|
|
||||||
|
// Focus highlight color (very visible)
|
||||||
|
focus: 'yellowBright' as Color,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout constants for consistent spacing.
|
||||||
|
*/
|
||||||
|
export const layout = {
|
||||||
|
padding: {
|
||||||
|
small: 1,
|
||||||
|
medium: 2,
|
||||||
|
large: 3,
|
||||||
|
},
|
||||||
|
margin: {
|
||||||
|
small: 1,
|
||||||
|
medium: 2,
|
||||||
|
large: 3,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ASCII art logo for the application header.
|
||||||
|
*/
|
||||||
|
export const logo = `
|
||||||
|
██╗ ██╗ ██████╗ ██╗ ██╗ █████╗ ██╗ ██╗ ███████╗████████╗
|
||||||
|
╚██╗██╔╝██╔═══██╗ ██║ ██║██╔══██╗██║ ██║ ██╔════╝╚══██╔══╝
|
||||||
|
╚███╔╝ ██║ ██║ ██║ █╗ ██║███████║██║ ██║ █████╗ ██║
|
||||||
|
██╔██╗ ██║ ██║ ██║███╗██║██╔══██║██║ ██║ ██╔══╝ ██║
|
||||||
|
██╔╝ ██╗╚██████╔╝ ╚███╔███╔╝██║ ██║███████╗███████╗███████╗ ██║
|
||||||
|
╚═╝ ╚═╝ ╚═════╝ ╚══╝╚══╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝ ╚═╝
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small logo for status bar.
|
||||||
|
*/
|
||||||
|
export const logoSmall = 'XO Wallet';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to format satoshis for display.
|
||||||
|
* @param satoshis - Amount in satoshis
|
||||||
|
* @returns Formatted string with BCH amount
|
||||||
|
*/
|
||||||
|
export function formatSatoshis(satoshis: bigint | number): string {
|
||||||
|
const value = typeof satoshis === 'bigint' ? satoshis : BigInt(satoshis);
|
||||||
|
const bch = Number(value) / 100_000_000;
|
||||||
|
return `${bch.toFixed(8)} BCH (${value.toLocaleString()} sats)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to truncate long strings with ellipsis.
|
||||||
|
* @param str - String to truncate
|
||||||
|
* @param maxLength - Maximum length
|
||||||
|
* @returns Truncated string
|
||||||
|
*/
|
||||||
|
export function truncate(str: string, maxLength: number): string {
|
||||||
|
if (str.length <= maxLength) return str;
|
||||||
|
return str.slice(0, maxLength - 3) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to format a hex string for display.
|
||||||
|
* @param hex - Hex string
|
||||||
|
* @param maxLength - Maximum display length
|
||||||
|
* @returns Formatted hex string
|
||||||
|
*/
|
||||||
|
export function formatHex(hex: string, maxLength: number = 16): string {
|
||||||
|
if (hex.length <= maxLength) return hex;
|
||||||
|
const half = Math.floor((maxLength - 3) / 2);
|
||||||
|
return `${hex.slice(0, half)}...${hex.slice(-half)}`;
|
||||||
|
}
|
||||||
91
src/tui/types.ts
Normal file
91
src/tui/types.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Shared types for the CLI TUI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { WalletController } from '../controllers/wallet-controller.js';
|
||||||
|
import type { InvitationController } from '../controllers/invitation-controller.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Screen names for navigation.
|
||||||
|
*/
|
||||||
|
export type ScreenName =
|
||||||
|
| 'seed-input'
|
||||||
|
| 'wallet'
|
||||||
|
| 'templates'
|
||||||
|
| 'wizard'
|
||||||
|
| 'invitations'
|
||||||
|
| 'transaction';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation context data that can be passed between screens.
|
||||||
|
*/
|
||||||
|
export interface NavigationData {
|
||||||
|
/** Template identifier for wizard */
|
||||||
|
templateId?: string;
|
||||||
|
/** Action identifier for wizard */
|
||||||
|
actionId?: string;
|
||||||
|
/** Role identifier for wizard */
|
||||||
|
roleId?: string;
|
||||||
|
/** Invitation ID for transaction screen */
|
||||||
|
invitationId?: string;
|
||||||
|
/** Any additional data */
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation context interface.
|
||||||
|
*/
|
||||||
|
export interface NavigationContextType {
|
||||||
|
/** Current screen name */
|
||||||
|
screen: ScreenName;
|
||||||
|
/** Data passed to the current screen */
|
||||||
|
data: NavigationData;
|
||||||
|
/** Navigation history stack */
|
||||||
|
history: ScreenName[];
|
||||||
|
/** Navigate to a new screen */
|
||||||
|
navigate: (screen: ScreenName, data?: NavigationData) => void;
|
||||||
|
/** Go back to the previous screen */
|
||||||
|
goBack: () => void;
|
||||||
|
/** Check if we can go back */
|
||||||
|
canGoBack: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App context interface - provides access to controllers and app-level functions.
|
||||||
|
*/
|
||||||
|
export interface AppContextType {
|
||||||
|
/** Wallet controller for wallet operations */
|
||||||
|
walletController: WalletController;
|
||||||
|
/** Invitation controller for invitation operations */
|
||||||
|
invitationController: InvitationController;
|
||||||
|
/** Show an error message dialog */
|
||||||
|
showError: (message: string) => void;
|
||||||
|
/** Show an info message dialog */
|
||||||
|
showInfo: (message: string) => void;
|
||||||
|
/** Show a confirmation dialog */
|
||||||
|
confirm: (message: string) => Promise<boolean>;
|
||||||
|
/** Exit the application */
|
||||||
|
exit: () => void;
|
||||||
|
/** Update status bar message */
|
||||||
|
setStatus: (message: string) => void;
|
||||||
|
/** Whether the wallet is initialized */
|
||||||
|
isWalletInitialized: boolean;
|
||||||
|
/** Set wallet initialized state */
|
||||||
|
setWalletInitialized: (initialized: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog state for modals.
|
||||||
|
*/
|
||||||
|
export interface DialogState {
|
||||||
|
/** Whether dialog is visible */
|
||||||
|
visible: boolean;
|
||||||
|
/** Dialog type */
|
||||||
|
type: 'error' | 'info' | 'confirm';
|
||||||
|
/** Dialog message */
|
||||||
|
message: string;
|
||||||
|
/** Callback for confirm dialog */
|
||||||
|
onConfirm?: () => void;
|
||||||
|
/** Callback for cancel/dismiss */
|
||||||
|
onCancel?: () => void;
|
||||||
|
}
|
||||||
62
src/tui/utils/clipboard.ts
Normal file
62
src/tui/utils/clipboard.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Cross-platform clipboard utility with multiple fallback methods.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { exec } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to copy text to clipboard using multiple methods.
|
||||||
|
* Tries native commands first (most reliable), then clipboardy as fallback.
|
||||||
|
*
|
||||||
|
* @param text - The text to copy to clipboard
|
||||||
|
* @returns Promise that resolves on success, rejects with error message on failure
|
||||||
|
*/
|
||||||
|
export async function copyToClipboard(text: string): Promise<void> {
|
||||||
|
const platform = process.platform;
|
||||||
|
|
||||||
|
// Escape the text for shell commands
|
||||||
|
const escapedText = text.replace(/'/g, "'\\''");
|
||||||
|
|
||||||
|
// Try native commands first - they're more reliable
|
||||||
|
try {
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
// macOS - use pbcopy directly
|
||||||
|
await execAsync(`printf '%s' '${escapedText}' | pbcopy`);
|
||||||
|
return;
|
||||||
|
} else if (platform === 'linux') {
|
||||||
|
// Linux - try xclip, then xsel
|
||||||
|
try {
|
||||||
|
await execAsync(`printf '%s' '${escapedText}' | xclip -selection clipboard`);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await execAsync(`printf '%s' '${escapedText}' | xsel --clipboard --input`);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// Fall through to clipboardy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (platform === 'win32') {
|
||||||
|
// Windows - use clip.exe
|
||||||
|
await execAsync(`echo|set /p="${text}" | clip`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Native command failed, try clipboardy
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to clipboardy
|
||||||
|
try {
|
||||||
|
const clipboard = await import('clipboardy');
|
||||||
|
await clipboard.default.write(text);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// clipboardy also failed
|
||||||
|
}
|
||||||
|
|
||||||
|
// All methods failed
|
||||||
|
throw new Error(`Clipboard not available. Install xclip or xsel on Linux.`);
|
||||||
|
}
|
||||||
155
src/utils/exponential-backoff.ts
Normal file
155
src/utils/exponential-backoff.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* Exponential backoff is a technique used to retry a function after a delay.
|
||||||
|
*
|
||||||
|
* The delay increases exponentially with each attempt, up to a maximum delay.
|
||||||
|
*
|
||||||
|
* The jitter is a random amount of time added to the delay to prevent thundering herd problems.
|
||||||
|
*
|
||||||
|
* The growth rate is the factor by which the delay increases with each attempt.
|
||||||
|
*/
|
||||||
|
export class ExponentialBackoff {
|
||||||
|
/**
|
||||||
|
* Create a new ExponentialBackoff instance
|
||||||
|
*
|
||||||
|
* @param config - The configuration for the exponential backoff
|
||||||
|
* @returns The ExponentialBackoff instance
|
||||||
|
*/
|
||||||
|
static from(config?: Partial<ExponentialBackoffOptions>): ExponentialBackoff {
|
||||||
|
const backoff = new ExponentialBackoff(config);
|
||||||
|
return backoff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the function with exponential backoff
|
||||||
|
*
|
||||||
|
* @param fn - The function to run
|
||||||
|
* @param onError - The callback to call when an error occurs
|
||||||
|
* @param options - The configuration for the exponential backoff
|
||||||
|
*
|
||||||
|
* @throws The last error if the function fails and we have hit the max attempts
|
||||||
|
*
|
||||||
|
* @returns The result of the function
|
||||||
|
*/
|
||||||
|
static run<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
onError = (_error: Error) => {},
|
||||||
|
options?: Partial<ExponentialBackoffOptions>,
|
||||||
|
): Promise<T> {
|
||||||
|
const backoff = ExponentialBackoff.from(options);
|
||||||
|
return backoff.run(fn, onError);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly options: ExponentialBackoffOptions;
|
||||||
|
|
||||||
|
constructor(options?: Partial<ExponentialBackoffOptions>) {
|
||||||
|
this.options = {
|
||||||
|
maxDelay: 10000,
|
||||||
|
maxAttempts: 10,
|
||||||
|
baseDelay: 1000,
|
||||||
|
growthRate: 2,
|
||||||
|
jitter: 0.1,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the function with exponential backoff
|
||||||
|
*
|
||||||
|
* If the function fails but we have not hit the max attempts, the error will be passed to the onError callback
|
||||||
|
* and the function will be retried with an exponential delay
|
||||||
|
*
|
||||||
|
* If the function fails and we have hit the max attempts, the last error will be thrown
|
||||||
|
*
|
||||||
|
* @param fn - The function to run
|
||||||
|
* @param onError - The callback to call when an error occurs
|
||||||
|
*
|
||||||
|
* @throws The last error if the function fails and we have hit the max attempts
|
||||||
|
*
|
||||||
|
* @returns The result of the function
|
||||||
|
*/
|
||||||
|
async run<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
onError = (_error: Error) => {},
|
||||||
|
): Promise<T> {
|
||||||
|
let lastError: Error = new Error('Exponential backoff: Max retries hit');
|
||||||
|
|
||||||
|
let attempt = 0;
|
||||||
|
|
||||||
|
while (
|
||||||
|
attempt < this.options.maxAttempts ||
|
||||||
|
this.options.maxAttempts == 0
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (error) {
|
||||||
|
// Store the error in case we fail every attempt
|
||||||
|
lastError = error instanceof Error ? error : new Error(`${error}`);
|
||||||
|
onError(lastError);
|
||||||
|
|
||||||
|
// Wait before going to the next attempt
|
||||||
|
const delay = this.calculateDelay(attempt);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We completed the loop without ever succeeding. Throw the last error we got
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the delay before we should attempt to retry
|
||||||
|
*
|
||||||
|
* NOTE: The maximum delay is (maxDelay * (1 + jitter))
|
||||||
|
*
|
||||||
|
* @param attempt
|
||||||
|
* @returns The time in milliseconds before another attempt should be made
|
||||||
|
*/
|
||||||
|
private calculateDelay(attempt: number): number {
|
||||||
|
// Get the power of the growth rate
|
||||||
|
const power = Math.pow(this.options.growthRate, attempt);
|
||||||
|
|
||||||
|
// Get the delay before jitter or limit
|
||||||
|
const rawDelay = this.options.baseDelay * power;
|
||||||
|
|
||||||
|
// Cap the delay to the maximum. Do this before the jitter so jitter does not become larger than delay
|
||||||
|
const cappedDelay = Math.min(rawDelay, this.options.maxDelay);
|
||||||
|
|
||||||
|
// Get the jitter direction. This will be between -1 and 1
|
||||||
|
const jitterDirection = 2 * Math.random() - 1;
|
||||||
|
|
||||||
|
// Calculate the jitter
|
||||||
|
const jitter = jitterDirection * this.options.jitter * cappedDelay;
|
||||||
|
|
||||||
|
// Add the jitter to the delay
|
||||||
|
return cappedDelay + jitter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExponentialBackoffOptions = {
|
||||||
|
/**
|
||||||
|
* The maximum delay between attempts in milliseconds
|
||||||
|
*/
|
||||||
|
maxDelay: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maximum number of attempts. Passing 0 will result in infinite attempts.
|
||||||
|
*/
|
||||||
|
maxAttempts: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The base delay between attempts in milliseconds
|
||||||
|
*/
|
||||||
|
baseDelay: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The growth rate of the delay
|
||||||
|
*/
|
||||||
|
growthRate: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The jitter of the delay as a percentage of growthRate
|
||||||
|
*/
|
||||||
|
jitter: number;
|
||||||
|
};
|
||||||
158
src/utils/ext-json.ts
Normal file
158
src/utils/ext-json.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Extended JSON encoding/decoding utilities.
|
||||||
|
* Handles BigInt and Uint8Array serialization for communication with sync-server.
|
||||||
|
*
|
||||||
|
* TODO: These are intended as temporary stand-ins until this functionality has been implemented directly in LibAuth.
|
||||||
|
* We are doing this so that we may better standardize with the rest of the BCH eco-system in future.
|
||||||
|
* See: https://github.com/bitauth/libauth/pull/108
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { binToHex, hexToBin } from '@bitauth/libauth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces BigInt and Uint8Array values with their ExtJSON string representations.
|
||||||
|
* @param value - The value to potentially replace
|
||||||
|
* @returns The replaced value as an ExtJSON string, or the original value
|
||||||
|
*/
|
||||||
|
export const extendedJsonReplacer = function (value: unknown): unknown {
|
||||||
|
if (typeof value === 'bigint') {
|
||||||
|
return `<bigint: ${value.toString()}n>`;
|
||||||
|
} else if (value instanceof Uint8Array) {
|
||||||
|
return `<Uint8Array: ${binToHex(value)}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revives ExtJSON string representations back to their original types.
|
||||||
|
* @param value - The value to potentially revive
|
||||||
|
* @returns The revived value (BigInt or Uint8Array), or the original value
|
||||||
|
*/
|
||||||
|
export const extendedJsonReviver = function (value: unknown): unknown {
|
||||||
|
// Define RegEx that matches our Extended JSON fields.
|
||||||
|
const bigIntRegex = /^<bigint: (?<bigint>[+-]?[0-9]*)n>$/;
|
||||||
|
const uint8ArrayRegex = /^<Uint8Array: (?<hex>[a-f0-9]*)>$/;
|
||||||
|
|
||||||
|
// Only perform a check if the value is a string.
|
||||||
|
// NOTE: We can skip all other values as all Extended JSON encoded fields WILL be a string.
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
// Check if this value matches an Extended JSON encoded bigint.
|
||||||
|
const bigintMatch = value.match(bigIntRegex);
|
||||||
|
if (bigintMatch) {
|
||||||
|
// Access the named group directly instead of using array indices
|
||||||
|
const { bigint } = bigintMatch.groups!;
|
||||||
|
|
||||||
|
// Return the value casted to bigint.
|
||||||
|
return BigInt(bigint!);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint8ArrayMatch = value.match(uint8ArrayRegex);
|
||||||
|
if (uint8ArrayMatch) {
|
||||||
|
// Access the named group directly instead of using array indices
|
||||||
|
const { hex } = uint8ArrayMatch.groups!;
|
||||||
|
|
||||||
|
// Return the value casted to Uint8Array.
|
||||||
|
return hexToBin(hex!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the original value.
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively encodes an object to ExtJSON format.
|
||||||
|
* @param value - The value to encode
|
||||||
|
* @returns The ExtJSON encoded value
|
||||||
|
*/
|
||||||
|
export const encodeExtendedJsonObject = function (value: unknown): unknown {
|
||||||
|
// If this is an object type (and it is not null - which is technically an "object")...
|
||||||
|
// ... and it is not an ArrayBuffer (e.g. Uint8Array) which is also technically an "object...
|
||||||
|
if (
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
!ArrayBuffer.isView(value)
|
||||||
|
) {
|
||||||
|
// If this is an array, recursively call this function on each value.
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(encodeExtendedJsonObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declare object to store extended JSON entries.
|
||||||
|
const encodedObject: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
// Iterate through each entry and encode it to extended JSON.
|
||||||
|
for (const [key, valueToEncode] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
encodedObject[key] = encodeExtendedJsonObject(valueToEncode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the extended JSON encoded object.
|
||||||
|
return encodedObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the replaced value.
|
||||||
|
return extendedJsonReplacer(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively decodes an ExtJSON object back to its original types.
|
||||||
|
* @param value - The ExtJSON value to decode
|
||||||
|
* @returns The decoded value with BigInt and Uint8Array restored
|
||||||
|
*/
|
||||||
|
export const decodeExtendedJsonObject = function (value: unknown): unknown {
|
||||||
|
// If this is an object type (and it is not null - which is technically an "object")...
|
||||||
|
// ... and it is not an ArrayBuffer (e.g. Uint8Array) which is also technically an "object...
|
||||||
|
if (
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
!ArrayBuffer.isView(value)
|
||||||
|
) {
|
||||||
|
// If this is an array, recursively call this function on each value.
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(decodeExtendedJsonObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declare object to store decoded JSON entries.
|
||||||
|
const decodedObject: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
// Iterate through each entry and decode it from extended JSON.
|
||||||
|
for (const [key, valueToEncode] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
decodedObject[key] = decodeExtendedJsonObject(valueToEncode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the extended JSON encoded object.
|
||||||
|
return decodedObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the revived value.
|
||||||
|
return extendedJsonReviver(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encodes a value to an ExtJSON string.
|
||||||
|
* @param value - The value to encode
|
||||||
|
* @param space - Optional spacing for pretty printing
|
||||||
|
* @returns The ExtJSON encoded string
|
||||||
|
*/
|
||||||
|
export const encodeExtendedJson = function (
|
||||||
|
value: unknown,
|
||||||
|
space: number | undefined = undefined,
|
||||||
|
): string {
|
||||||
|
const replacedObject = encodeExtendedJsonObject(value);
|
||||||
|
const stringifiedObject = JSON.stringify(replacedObject, null, space);
|
||||||
|
|
||||||
|
return stringifiedObject;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes an ExtJSON string back to its original value.
|
||||||
|
* @param json - The ExtJSON string to decode
|
||||||
|
* @returns The decoded value with BigInt and Uint8Array restored
|
||||||
|
*/
|
||||||
|
export const decodeExtendedJson = function (json: string): unknown {
|
||||||
|
const parsedObject = JSON.parse(json) as unknown;
|
||||||
|
const revivedObject = decodeExtendedJsonObject(parsedObject);
|
||||||
|
|
||||||
|
return revivedObject;
|
||||||
|
};
|
||||||
427
src/utils/sse-client.ts
Normal file
427
src/utils/sse-client.ts
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
import { ExponentialBackoff } from './exponential-backoff.js';
|
||||||
|
|
||||||
|
// Type declarations for browser environment (not available in Node.js)
|
||||||
|
declare const document: {
|
||||||
|
visibilityState: 'visible' | 'hidden';
|
||||||
|
addEventListener: (event: string, handler: (event: Event) => void) => void;
|
||||||
|
removeEventListener: (event: string, handler: (event: Event) => void) => void;
|
||||||
|
} | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Server-Sent Events client implementation using fetch API.
|
||||||
|
* Supports custom headers, POST requests, and is non-blocking.
|
||||||
|
*/
|
||||||
|
export class SSESession {
|
||||||
|
/**
|
||||||
|
* Creates and connects a new SSESession instance.
|
||||||
|
* @param url The URL to connect to
|
||||||
|
* @param options Configuration options
|
||||||
|
* @returns A new connected SSESession instance
|
||||||
|
*/
|
||||||
|
public static async from(
|
||||||
|
url: string,
|
||||||
|
options: Partial<SSESessionOptions> = {},
|
||||||
|
): Promise<SSESession> {
|
||||||
|
const client = new SSESession(url, options);
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
// State.
|
||||||
|
private url: string;
|
||||||
|
private controller: AbortController;
|
||||||
|
private connected: boolean = false;
|
||||||
|
protected options: SSESessionOptions;
|
||||||
|
protected messageBuffer: Uint8Array = new Uint8Array();
|
||||||
|
|
||||||
|
// Listener for when the tab is hidden or shown.
|
||||||
|
private visibilityChangeHandler: ((event: Event) => void) | null = null;
|
||||||
|
|
||||||
|
// Text decoders and encoders for parsing the message buffer.
|
||||||
|
private textDecoder: TextDecoder = new TextDecoder();
|
||||||
|
private textEncoder: TextEncoder = new TextEncoder();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new SSESession instance.
|
||||||
|
* @param url The URL to connect to
|
||||||
|
* @param options Configuration options
|
||||||
|
*/
|
||||||
|
constructor(url: string, options: Partial<SSESessionOptions> = {}) {
|
||||||
|
this.url = url;
|
||||||
|
this.options = {
|
||||||
|
// Use default fetch function.
|
||||||
|
fetch: (...args) => fetch(...args),
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
},
|
||||||
|
onConnected: () => {},
|
||||||
|
onMessage: () => {},
|
||||||
|
onError: (error) => console.error('SSESession error:', error),
|
||||||
|
onDisconnected: () => {},
|
||||||
|
onReconnect: (options) => Promise.resolve(options),
|
||||||
|
|
||||||
|
// Reconnection options
|
||||||
|
attemptReconnect: true,
|
||||||
|
retryDelay: 1000,
|
||||||
|
persistent: false,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
this.controller = new AbortController();
|
||||||
|
|
||||||
|
// Set up visibility change handling if in mobile browser environment
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
this.visibilityChangeHandler = this.handleVisibilityChange.bind(this);
|
||||||
|
document.addEventListener(
|
||||||
|
'visibilitychange',
|
||||||
|
this.visibilityChangeHandler,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles visibility change events in the browser.
|
||||||
|
*/
|
||||||
|
private async handleVisibilityChange(): Promise<void> {
|
||||||
|
// Guard for Node.js environment where document is undefined
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
|
||||||
|
// When going to background, close the current connection cleanly
|
||||||
|
// This allows us to reconnect mobile devices when they come back after leaving the tab or browser app.
|
||||||
|
if (document.visibilityState === 'hidden') {
|
||||||
|
this.controller.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// When coming back to foreground, attempt to reconnect if not connected
|
||||||
|
if (document.visibilityState === 'visible' && !this.connected) {
|
||||||
|
await this.connect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connects to the SSE endpoint.
|
||||||
|
*/
|
||||||
|
public async connect(): Promise<void> {
|
||||||
|
if (this.connected) return;
|
||||||
|
|
||||||
|
this.connected = true;
|
||||||
|
this.controller = new AbortController();
|
||||||
|
|
||||||
|
const { method, headers, body } = this.options;
|
||||||
|
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers: headers || {},
|
||||||
|
body: body || null,
|
||||||
|
signal: this.controller.signal,
|
||||||
|
cache: 'no-store',
|
||||||
|
};
|
||||||
|
|
||||||
|
const exponentialBackoff = ExponentialBackoff.from({
|
||||||
|
baseDelay: this.options.retryDelay,
|
||||||
|
maxDelay: 10000,
|
||||||
|
maxAttempts: 0,
|
||||||
|
growthRate: 1.3,
|
||||||
|
jitter: 0.3,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Establish the connection and get the reader using the exponential backoff
|
||||||
|
const reader = await exponentialBackoff.run(async () => {
|
||||||
|
const reconnectOptions = await this.handleCallback(
|
||||||
|
this.options.onReconnect,
|
||||||
|
fetchOptions,
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedFetchOptions = {
|
||||||
|
...fetchOptions,
|
||||||
|
...reconnectOptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await this.options.fetch(this.url, updatedFetchOptions);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP error! Status: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.body) {
|
||||||
|
throw new Error('Response body is null');
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.body.getReader();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call the onConnected callback
|
||||||
|
this.handleCallback(this.options.onConnected);
|
||||||
|
|
||||||
|
const readStream = async () => {
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
this.connected = false;
|
||||||
|
|
||||||
|
// Call the onDisconnected callback.
|
||||||
|
this.handleCallback(this.options.onDisconnected, undefined);
|
||||||
|
|
||||||
|
// If the connection was closed by the server, we want to attempt a reconnect if the connection should be persistent.
|
||||||
|
if (this.options.persistent) {
|
||||||
|
await this.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = this.parseEvents(value);
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (this.options.onMessage) {
|
||||||
|
this.handleCallback(this.options.onMessage, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.connected = false;
|
||||||
|
|
||||||
|
// Call the onDisconnected callback.
|
||||||
|
this.handleCallback(this.options.onDisconnected, error);
|
||||||
|
|
||||||
|
// If the connection was aborted using the controller, we don't need to call onError.
|
||||||
|
if (this.controller.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the onError callback.
|
||||||
|
// NOTE: we dont use the handleCallback here because it would result in 2 error callbacks.
|
||||||
|
try {
|
||||||
|
this.options.onError(error);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`SSE Session: onError callback error:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to reconnect if enabled
|
||||||
|
if (this.options.attemptReconnect) {
|
||||||
|
await this.connect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
readStream();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseEvents(chunk: Uint8Array): SSEvent[] {
|
||||||
|
// Append new chunk to existing buffer
|
||||||
|
this.messageBuffer = new Uint8Array([...this.messageBuffer, ...chunk]);
|
||||||
|
|
||||||
|
const events: SSEvent[] = [];
|
||||||
|
const lines = this.textDecoder
|
||||||
|
.decode(this.messageBuffer)
|
||||||
|
.split(/\r\n|\r|\n/);
|
||||||
|
|
||||||
|
let currentEvent: Partial<SSEvent> = {};
|
||||||
|
let completeEventCount = 0;
|
||||||
|
|
||||||
|
// Iterate over the lines to find complete events
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
|
||||||
|
// Empty line signals the end of an event
|
||||||
|
if (line === '') {
|
||||||
|
if (currentEvent.data) {
|
||||||
|
// Remove trailing newline if present
|
||||||
|
currentEvent.data = currentEvent.data.replace(/\n$/, '');
|
||||||
|
events.push(currentEvent as SSEvent);
|
||||||
|
currentEvent = {};
|
||||||
|
completeEventCount = i + 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!line) continue;
|
||||||
|
|
||||||
|
// Parse field: value format
|
||||||
|
const colonIndex = line.indexOf(':');
|
||||||
|
if (colonIndex === -1) continue;
|
||||||
|
|
||||||
|
const field = line.slice(0, colonIndex);
|
||||||
|
// Skip initial space after colon if present
|
||||||
|
const valueStartIndex =
|
||||||
|
colonIndex + 1 + (line[colonIndex + 1] === ' ' ? 1 : 0);
|
||||||
|
const value = line.slice(valueStartIndex);
|
||||||
|
|
||||||
|
if (field === 'data') {
|
||||||
|
currentEvent.data = currentEvent.data
|
||||||
|
? currentEvent.data + '\n' + value
|
||||||
|
: value;
|
||||||
|
} else if (field === 'event') {
|
||||||
|
currentEvent.event = value;
|
||||||
|
} else if (field === 'id') {
|
||||||
|
currentEvent.id = value;
|
||||||
|
} else if (field === 'retry') {
|
||||||
|
const retryMs = parseInt(value, 10);
|
||||||
|
if (!isNaN(retryMs)) {
|
||||||
|
currentEvent.retry = retryMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the remainder of the buffer for the next chunk
|
||||||
|
const remainder = lines.slice(completeEventCount).join('\n');
|
||||||
|
this.messageBuffer = this.textEncoder.encode(remainder);
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override the onMessage callback.
|
||||||
|
*
|
||||||
|
* @param onMessage The callback to set.
|
||||||
|
*/
|
||||||
|
public setOnMessage(onMessage: (event: SSEvent) => void): void {
|
||||||
|
this.options.onMessage = onMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the SSE connection and cleans up event listeners.
|
||||||
|
*/
|
||||||
|
public close(): void {
|
||||||
|
// Clean up everything including the visibility handler
|
||||||
|
this.controller.abort();
|
||||||
|
|
||||||
|
// Remove the visibility handler (This is only required on browsers)
|
||||||
|
if (this.visibilityChangeHandler && typeof document !== 'undefined') {
|
||||||
|
document.removeEventListener(
|
||||||
|
'visibilitychange',
|
||||||
|
this.visibilityChangeHandler,
|
||||||
|
);
|
||||||
|
this.visibilityChangeHandler = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the client is currently connected.
|
||||||
|
* @returns Whether the client is connected
|
||||||
|
*/
|
||||||
|
public isConnected(): boolean {
|
||||||
|
return this.connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Will handle thrown errors from the callback and call the onError callback.
|
||||||
|
* This is to avoid the sse-session from disconnecting from errors that are not a result of the sse-session itself.
|
||||||
|
*
|
||||||
|
* @param callback The callback to handle.
|
||||||
|
* @param args The arguments to pass to the callback.
|
||||||
|
*/
|
||||||
|
private handleCallback<T extends (...args: Parameters<T>) => ReturnType<T>>(
|
||||||
|
callback: T,
|
||||||
|
...args: Parameters<T>
|
||||||
|
): ReturnType<T> | undefined {
|
||||||
|
try {
|
||||||
|
return callback(...args);
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
this.options.onError(error);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`SSE Session: onError callback error:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration options for the SSESession.
|
||||||
|
*/
|
||||||
|
export interface SSESessionOptions {
|
||||||
|
/**
|
||||||
|
* The fetch function to use.
|
||||||
|
*
|
||||||
|
* NOTE: This is compatible with Browser/Node's native "fetcH" function.
|
||||||
|
* We use this in place of "typeof fetch" so that we can accept non-standard URLs ("url" is a "string" here).
|
||||||
|
* For example, a LibP2P adapter might not use a standardized URL format (and might only include "path").
|
||||||
|
* This would cause a type error as native fetch expects type "URL".
|
||||||
|
*/
|
||||||
|
fetch: (url: string, options: RequestInit) => Promise<Response>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP method to use (GET or POST).
|
||||||
|
*/
|
||||||
|
method: 'GET' | 'POST';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP headers to send with the request.
|
||||||
|
*/
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body to send with POST requests.
|
||||||
|
*/
|
||||||
|
body?: string | FormData;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the connection is established.
|
||||||
|
*/
|
||||||
|
onConnected: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a message is received.
|
||||||
|
*/
|
||||||
|
onMessage: (event: SSEvent) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when an error occurs.
|
||||||
|
*/
|
||||||
|
onError: (error: unknown) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the connection is closed.
|
||||||
|
*/
|
||||||
|
onDisconnected: (error: unknown) => void;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Called when the connection is going to try to reconnect.
|
||||||
|
*/
|
||||||
|
onReconnect: (options: RequestInit) => Promise<RequestInit>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to attempt to reconnect.
|
||||||
|
*/
|
||||||
|
attemptReconnect: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The delay in milliseconds between reconnection attempts.
|
||||||
|
*/
|
||||||
|
retryDelay: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to reconnect when the session is terminated by the server.
|
||||||
|
*/
|
||||||
|
persistent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a Server-Sent Event.
|
||||||
|
*/
|
||||||
|
export interface SSEvent {
|
||||||
|
/**
|
||||||
|
* Event data.
|
||||||
|
*/
|
||||||
|
data: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event type.
|
||||||
|
*/
|
||||||
|
event?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event ID.
|
||||||
|
*/
|
||||||
|
id?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconnection time in milliseconds.
|
||||||
|
*/
|
||||||
|
retry?: number;
|
||||||
|
}
|
||||||
47
tsconfig.json
Normal file
47
tsconfig.json
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
// Visit https://aka.ms/tsconfig to read more about this file
|
||||||
|
"compilerOptions": {
|
||||||
|
// File Layout
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist",
|
||||||
|
|
||||||
|
// Environment Settings
|
||||||
|
// See also https://aka.ms/tsconfig/module
|
||||||
|
"module": "nodenext",
|
||||||
|
"target": "esnext",
|
||||||
|
// For nodejs:
|
||||||
|
"lib": ["esnext"],
|
||||||
|
"types": ["node"],
|
||||||
|
|
||||||
|
// JSX Support for Ink/React
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
// Other Outputs
|
||||||
|
"sourceMap": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
|
||||||
|
// Stricter Typechecking Options
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
// Note: exactOptionalPropertyTypes disabled for Ink compatibility
|
||||||
|
// "exactOptionalPropertyTypes": true,
|
||||||
|
|
||||||
|
// Style Options
|
||||||
|
// "noImplicitReturns": true,
|
||||||
|
// "noImplicitOverride": true,
|
||||||
|
// "noUnusedLocals": true,
|
||||||
|
// "noUnusedParameters": true,
|
||||||
|
// "noFallthroughCasesInSwitch": true,
|
||||||
|
// "noPropertyAccessFromIndexSignature": true,
|
||||||
|
|
||||||
|
// Recommended Options
|
||||||
|
"strict": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user