From 7fd89c5663ecbd74e0483d9a6f216ac575c73b98 Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Mon, 23 Mar 2026 03:51:51 +0000 Subject: [PATCH] Fix dialog focus --- src/services/app.ts | 2 +- src/services/history.ts | 7 +- src/tui/App.tsx | 49 ++--- src/tui/components/Dialog.tsx | 34 ++-- src/tui/hooks/index.ts | 7 + src/tui/hooks/useInputLayer.tsx | 169 ++++++++++++++++++ src/tui/screens/SeedInput.tsx | 5 +- src/tui/screens/TemplateList.tsx | 6 +- src/tui/screens/Transaction.tsx | 12 +- src/tui/screens/WalletState.tsx | 76 ++++---- .../action-wizard/hooks/useWizardKeyboard.ts | 4 +- .../screens/invitations/InvitationScreen.tsx | 15 +- .../InvitationImportFlow.tsx | 20 ++- .../steps/InputsSelectStep.tsx | 14 +- .../steps/PreviewInvitationStep.tsx | 140 ++++++++++----- .../invitation-import/steps/ReviewStep.tsx | 9 +- .../steps/RoleSelectStep.tsx | 7 +- src/tui/types.ts | 4 +- 18 files changed, 403 insertions(+), 177 deletions(-) create mode 100644 src/tui/hooks/useInputLayer.tsx diff --git a/src/services/app.ts b/src/services/app.ts index 4c36349..1984bed 100644 --- a/src/services/app.ts +++ b/src/services/app.ts @@ -59,7 +59,7 @@ export class AppService extends EventEmitter { // Import the default P2PKH template await engine.importTemplate(p2pkhTemplate); - console.log('p2pkhTemplate', JSON.stringify(p2pkhTemplate.transactions, null, 2)); + // console.log('p2pkhTemplate', JSON.stringify(p2pkhTemplate.transactions, null, 2)); // Set default locking parameters for P2PKH // To my knowledge, this doesnt generate any lockscript, so discovery of funds will not work automatically. diff --git a/src/services/history.ts b/src/services/history.ts index 5ce12f6..e417751 100644 --- a/src/services/history.ts +++ b/src/services/history.ts @@ -144,9 +144,10 @@ export class HistoryService { }); this.indexInvitationOutputsByUtxoOrigin(invitationByOrigin, invitation); - const entities = await this.extractEntities(invitation.data); - const entitiesRecord = await this.matchRolesToEntities(invitation.data, entities); - console.log(entitiesRecord); + // TODO: Remove or use this. Its a test for extracting the roles to entities. + // const entities = await this.extractEntities(invitation.data); + // const entitiesRecord = await this.matchRolesToEntities(invitation.data, entities); + // console.log(entitiesRecord); } const usedUtxoIds = new Set(); diff --git a/src/tui/App.tsx b/src/tui/App.tsx index f55cbf9..1676398 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -4,9 +4,10 @@ */ import React from 'react'; -import { Box, Text, useApp, useInput } from 'ink'; +import { Box, Text, useApp } from 'ink'; import { NavigationProvider, useNavigation } from './hooks/useNavigation.js'; import { AppProvider, useAppContext, useDialog, useStatus } from './hooks/useAppContext.js'; +import { InputLayerProvider, useBlockableInput } from './hooks/useInputLayer.js'; import type { AppConfig } from '../app.js'; import { colors, logoSmall } from './theme.js'; @@ -78,27 +79,9 @@ function StatusBar(): React.ReactElement { * Dialog overlay component for modals. */ function DialogOverlay(): React.ReactElement | null { - const { dialog, setDialog } = useDialog(); + const { dialog } = useDialog(); - // 'custom' dialogs are rendered and managed by the screen itself; - // we only handle input for the built-in dialog types. - const isBuiltInDialog = dialog?.visible === true && dialog.type !== 'custom'; - - useInput((input, key) => { - if (!isBuiltInDialog) 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: isBuiltInDialog }); - - if (!isBuiltInDialog) return null; + if (!dialog?.visible) return null; const borderColor = dialog.type === 'error' ? colors.error : dialog.type === 'confirm' ? colors.warning : @@ -132,20 +115,12 @@ function MainContent(): React.ReactElement { const { exit } = useApp(); const { goBack, canGoBack } = useNavigation(); const { screen } = 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 ( - // Commenting out 'q'. Its annoying me - It activates in text inputs. - // input === 'q' - (key.ctrl && input === 'c') - ) { + // Global keybindings — auto-blocked when any dialog/overlay is capturing input. + useBlockableInput((input, key) => { + // Quit on Ctrl+C + if (key.ctrl && input === 'c') { appContext.exit(); exit(); } @@ -197,9 +172,11 @@ export function App({ config }: AppProps): React.ReactElement { config={config} onExit={handleExit} > - - - + + + + + ); } diff --git a/src/tui/components/Dialog.tsx b/src/tui/components/Dialog.tsx index acb5409..8a69845 100644 --- a/src/tui/components/Dialog.tsx +++ b/src/tui/components/Dialog.tsx @@ -2,10 +2,11 @@ * Dialog components for modals, confirmations, and input dialogs. */ -import React, { useRef, useState } from 'react'; -import { Box, Text, useInput, measureElement } from 'ink'; +import React, { useId, useRef, useState } from 'react'; +import { Box, Text, measureElement } from 'ink'; import TextInput from './TextInput.js'; import { colors } from '../theme.js'; +import { useInputLayer, useLayeredInput } from '../hooks/useInputLayer.js'; /** * Base dialog wrapper props. @@ -118,15 +119,17 @@ export function InputDialog({ onCancel, isActive = true, }: InputDialogProps): React.ReactElement { + const layerId = useId(); const [value, setValue] = useState(initialValue); - useInput((input, key) => { - if (!isActive) return; - + // Auto-capture input when this dialog is mounted. + useInputLayer(layerId); + + useLayeredInput(layerId, (_input, key) => { if (key.escape) { onCancel(); } - }, { isActive }); + }); const handleSubmit = (val: string) => { onSubmit(val); @@ -183,11 +186,13 @@ export function ConfirmDialog({ confirmLabel = 'Yes', cancelLabel = 'No', }: ConfirmDialogProps): React.ReactElement { + const layerId = useId(); const [selected, setSelected] = useState<'confirm' | 'cancel'>('confirm'); - useInput((input, key) => { - if (!isActive) return; + // Auto-capture input when this dialog is mounted. + useInputLayer(layerId); + useLayeredInput(layerId, (input, key) => { if (key.leftArrow || key.rightArrow || key.tab) { setSelected(prev => prev === 'confirm' ? 'cancel' : 'confirm'); } else if (key.return) { @@ -201,7 +206,7 @@ export function ConfirmDialog({ } else if (input === 'y' || input === 'Y') { onConfirm(); } - }, { isActive }); + }); return ( @@ -255,13 +260,16 @@ export function MessageDialog({ type = 'info', isActive = true, }: MessageDialogProps): React.ReactElement { - useInput((input, key) => { - if (!isActive) return; - + const layerId = useId(); + + // Auto-capture input when this dialog is mounted. + useInputLayer(layerId); + + useLayeredInput(layerId, (_input, key) => { if (key.return || key.escape) { onClose(); } - }, { isActive }); + }); const borderColor = type === 'error' ? colors.error : type === 'success' ? colors.success : diff --git a/src/tui/hooks/index.ts b/src/tui/hooks/index.ts index 8f53882..6c38351 100644 --- a/src/tui/hooks/index.ts +++ b/src/tui/hooks/index.ts @@ -11,3 +11,10 @@ export { useCreateInvitation, useInvitationIds, } from './useInvitations.js'; +export { + InputLayerProvider, + useInputLayer, + useLayeredInput, + useBlockableInput, + useIsInputCaptured, +} from './useInputLayer.js'; diff --git a/src/tui/hooks/useInputLayer.tsx b/src/tui/hooks/useInputLayer.tsx new file mode 100644 index 0000000..3b864a0 --- /dev/null +++ b/src/tui/hooks/useInputLayer.tsx @@ -0,0 +1,169 @@ +/** + * Input Layer System — stack-based keyboard input capture for dialogs and overlays. + * + * Only "capturing" components (dialogs, overlays, import flows) register layers. + * When any layer exists on the stack, all non-capturing input handlers are blocked. + * + * Hooks: + * - `useInputLayer(id)` — push a capturing layer (dialogs/overlays). + * - `useLayeredInput(id, …)` — handle input for a specific capturing layer. + * - `useBlockableInput(…)` — handle input for screens / global keys; auto-blocked + * when any capturing layer is on the stack. + * - `useIsInputCaptured()` — returns true when a capturing layer is present + * (useful for disabling `focus` on child components). + */ + +import React, { + createContext, + useContext, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; +import { useInput } from 'ink'; + +// ── Context ────────────────────────────────────────────────────────────────── + +interface InputLayerContextType { + /** Push a capturing layer. Returns a cleanup that pops it. */ + push: (layerId: string) => () => void; + /** True when `layerId` is the topmost entry in the stack. */ + isTop: (layerId: string) => boolean; + /** True when the stack has no entries (no dialog/overlay is capturing). */ + isStackEmpty: () => boolean; + /** Monotonic counter — bumped on every push/pop so consumers re-render. */ + version: number; +} + +const InputLayerContext = createContext(null); + +// ── Provider ───────────────────────────────────────────────────────────────── + +interface InputLayerProviderProps { + children: ReactNode; +} + +/** + * Wraps the component tree and provides the input-layer stack. + * + * Place this inside your outermost providers but above any component + * that calls the input-layer hooks. + */ +export function InputLayerProvider({ children }: InputLayerProviderProps): React.ReactElement { + const stackRef = useRef([]); + const [version, setVersion] = useState(0); + + const bump = useCallback(() => setVersion((v) => v + 1), []); + + const push = useCallback( + (layerId: string): (() => void) => { + stackRef.current = [...stackRef.current, layerId]; + bump(); + return () => { + stackRef.current = stackRef.current.filter((id) => id !== layerId); + bump(); + }; + }, + [bump], + ); + + const isTop = useCallback( + (layerId: string): boolean => { + const s = stackRef.current; + return s.length > 0 && s[s.length - 1] === layerId; + }, + [], + ); + + const isStackEmpty = useCallback( + (): boolean => stackRef.current.length === 0, + [], + ); + + const value = useMemo( + () => ({ push, isTop, isStackEmpty, version }), + [push, isTop, isStackEmpty, version], + ); + + return ( + + {children} + + ); +} + +// ── Hooks ──────────────────────────────────────────────────────────────────── + +/** + * Register a **capturing** layer (dialog / overlay / import flow). + * + * Pushes on mount, pops on unmount. While this layer is present every + * `useBlockableInput` handler in the tree is automatically disabled. + * + * @returns `{ isActive }` — true only when this layer is the topmost. + */ +export function useInputLayer(layerId: string): { isActive: boolean } { + const ctx = useContext(InputLayerContext); + if (!ctx) { + throw new Error('useInputLayer must be used within an InputLayerProvider'); + } + + const { push } = ctx; + useEffect(() => { + const pop = push(layerId); + return pop; + }, [push, layerId]); + + return { isActive: ctx.isTop(layerId) }; +} + +/** + * Input handler for a **capturing** layer. + * + * Only fires when `layerId` is the topmost entry in the stack. + */ +export function useLayeredInput( + layerId: string, + handler: (input: string, key: any) => void, + options?: { isActive?: boolean }, +): void { + const ctx = useContext(InputLayerContext); + if (!ctx) { + throw new Error('useLayeredInput must be used within an InputLayerProvider'); + } + + const isTopLayer = ctx.isTop(layerId); + const externalActive = options?.isActive !== false; + useInput(handler, { isActive: isTopLayer && externalActive }); +} + +/** + * Input handler for **non-capturing** components (screens, global keys). + * + * Fires only when the capture stack is empty (no dialog/overlay is open). + * This is the hook screens should use instead of raw `useInput`. + */ +export function useBlockableInput( + handler: (input: string, key: any) => void, + options?: { isActive?: boolean }, +): void { + const ctx = useContext(InputLayerContext); + + const nothingCapturing = ctx ? ctx.isStackEmpty() : true; + const externalActive = options?.isActive !== false; + useInput(handler, { isActive: nothingCapturing && externalActive }); +} + +/** + * Returns `true` when any capturing layer is on the stack. + * + * Use this to disable `focus` props on child components (e.g. ScrollableList) + * so their internal `useInput` handlers don't fire while a dialog is open. + */ +export function useIsInputCaptured(): boolean { + const ctx = useContext(InputLayerContext); + return ctx ? !ctx.isStackEmpty() : false; +} diff --git a/src/tui/screens/SeedInput.tsx b/src/tui/screens/SeedInput.tsx index 5b5649d..c2e2150 100644 --- a/src/tui/screens/SeedInput.tsx +++ b/src/tui/screens/SeedInput.tsx @@ -6,11 +6,12 @@ */ import React, { useState, useCallback, useEffect } from 'react'; -import { Box, Text, useInput } from 'ink'; +import { Box, Text } from 'ink'; import TextInput from '../components/TextInput.js'; import { Button } from '../components/Button.js'; import { useNavigation } from '../hooks/useNavigation.js'; import { useAppContext, useStatus } from '../hooks/useAppContext.js'; +import { useBlockableInput } from '../hooks/useInputLayer.js'; import { colors, logo } from '../theme.js'; import fs from 'fs'; @@ -170,7 +171,7 @@ export function SeedInputScreen(): React.ReactElement { }, [mnemonicFiles, doInitialize]); // Keyboard navigation - useInput((input, key) => { + useBlockableInput((_input, key) => { if (isSubmitting) return; // Tab / Shift-Tab to cycle focus sections diff --git a/src/tui/screens/TemplateList.tsx b/src/tui/screens/TemplateList.tsx index f537a6d..ed57ab4 100644 --- a/src/tui/screens/TemplateList.tsx +++ b/src/tui/screens/TemplateList.tsx @@ -7,10 +7,11 @@ */ import React, { useState, useEffect, useCallback, useMemo } from 'react'; -import { Box, Text, useInput } from 'ink'; +import { Box, Text } from 'ink'; import { ScrollableList, type ListItemData } from '../components/List.js'; import { useNavigation } from '../hooks/useNavigation.js'; import { useAppContext, useStatus } from '../hooks/useAppContext.js'; +import { useBlockableInput } from '../hooks/useInputLayer.js'; import { colors, logoSmall } from '../theme.js'; // XO Imports @@ -258,8 +259,7 @@ export function TemplateListScreen(): React.ReactElement { }, [currentTemplate, navigate]); // Handle keyboard navigation - useInput((input, key) => { - // Tab to switch panels + useBlockableInput((_input, key) => { if (key.tab) { setFocusedPanel(prev => prev === 'templates' ? 'actions' : 'templates'); return; diff --git a/src/tui/screens/Transaction.tsx b/src/tui/screens/Transaction.tsx index 4ddad4f..fd72eb3 100644 --- a/src/tui/screens/Transaction.tsx +++ b/src/tui/screens/Transaction.tsx @@ -9,10 +9,11 @@ */ import React, { useState, useEffect, useCallback } from 'react'; -import { Box, Text, useInput } from 'ink'; +import { Box, Text } from 'ink'; import { ConfirmDialog } from '../components/Dialog.js'; import { useNavigation } from '../hooks/useNavigation.js'; import { useAppContext, useStatus } from '../hooks/useAppContext.js'; +import { useBlockableInput } from '../hooks/useInputLayer.js'; import { useInvitation } from '../hooks/useInvitations.js'; import { colors, logoSmall, formatSatoshis, formatHex } from '../theme.js'; import { copyToClipboard } from '../utils/clipboard.js'; @@ -147,10 +148,8 @@ export function TransactionScreen(): React.ReactElement { } }, [signTransaction, copyTransactionHex, goBack]); - // Handle keyboard navigation - useInput((input, key) => { - if (showBroadcastConfirm) return; - + // Handle keyboard navigation — automatically blocked when the confirm dialog is open. + useBlockableInput((input, key) => { // Tab to switch panels if (key.tab) { setFocusedPanel(prev => { @@ -177,7 +176,7 @@ export function TransactionScreen(): React.ReactElement { handleAction(action.value); } } - }, { isActive: !showBroadcastConfirm }); + }); // Extract transaction data from invitation const commits = invitation?.commits ?? []; @@ -407,7 +406,6 @@ export function TransactionScreen(): React.ReactElement { message='Are you sure you want to broadcast this transaction? This action cannot be undone.' onConfirm={broadcastTransaction} onCancel={() => setShowBroadcastConfirm(false)} - isActive={showBroadcastConfirm} /> )} diff --git a/src/tui/screens/WalletState.tsx b/src/tui/screens/WalletState.tsx index 326bb5f..1c662b4 100644 --- a/src/tui/screens/WalletState.tsx +++ b/src/tui/screens/WalletState.tsx @@ -8,11 +8,12 @@ */ import React, { useState, useEffect, useCallback, useMemo } from 'react'; -import { Box, Text, useInput } from 'ink'; +import { Box, Text } from 'ink'; import { ScrollableList, type ListItemData } from '../components/List.js'; import { QRCode } from '../components/QRCode.js'; import { useNavigation } from '../hooks/useNavigation.js'; -import { useAppContext, useStatus, useDialog } from '../hooks/useAppContext.js'; +import { useAppContext, useStatus } from '../hooks/useAppContext.js'; +import { useInputLayer, useLayeredInput, useBlockableInput, useIsInputCaptured } from '../hooks/useInputLayer.js'; import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js'; import type { HistoryItem } from '../../services/history.js'; @@ -65,6 +66,39 @@ const menuItems: ListItemData[] = [ */ type HistoryListItem = ListItemData; +/** + * QR code dialog overlay — auto-captures input via the layer system. + * Rendered only while a QR address is visible; closes on Enter/Esc. + */ +function QRDialogOverlay({ address, onClose }: { address: string; onClose: () => void }): React.ReactElement { + useInputLayer('qr-dialog'); + + useLayeredInput('qr-dialog', (_input, key) => { + if (key.escape || key.return) { + onClose(); + } + }); + + return ( + + + + Press Enter or Esc to close + + + ); +} + /** * Wallet State Screen Component. * Displays wallet balance, history, and action menu. @@ -73,7 +107,6 @@ export function WalletStateScreen(): React.ReactElement { const { navigate } = useNavigation(); const { appService, showError, showInfo } = useAppContext(); const { setStatus } = useStatus(); - const { setDialog } = useDialog(); // State const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null); @@ -169,7 +202,6 @@ export function WalletStateScreen(): React.ReactElement { console.log(result); setQrAddress(result.address); - setDialog({ visible: true, type: 'custom', message: '' }); setStatus('Address generated'); // Refresh to show updated state @@ -227,16 +259,10 @@ export function WalletStateScreen(): React.ReactElement { }); }, [history]); - // Handle keyboard navigation between panels and QR dialog dismissal - useInput((input, key) => { - if (qrAddress) { - if (key.escape || key.return) { - setQrAddress(null); - setDialog(null); - } - return; - } + // Screen input — automatically blocked when any dialog/overlay is capturing. + const isCaptured = useIsInputCaptured(); + useBlockableInput((_input, key) => { if (key.tab) { setFocusedPanel(prev => prev === 'menu' ? 'history' : 'menu'); } @@ -383,7 +409,7 @@ export function WalletStateScreen(): React.ReactElement { selectedIndex={selectedMenuIndex} onSelect={setSelectedMenuIndex} onActivate={handleMenuItemActivate} - focus={focusedPanel === 'menu'} + focus={focusedPanel === 'menu' && !isCaptured} emptyMessage="No actions" /> @@ -411,7 +437,7 @@ export function WalletStateScreen(): React.ReactElement { items={historyListItems} selectedIndex={selectedHistoryIndex} onSelect={setSelectedHistoryIndex} - focus={focusedPanel === 'history'} + focus={focusedPanel === 'history' && !isCaptured} maxVisible={10} emptyMessage="No history found" renderItem={renderHistoryItem} @@ -429,22 +455,10 @@ export function WalletStateScreen(): React.ReactElement { {/* QR Code dialog overlay for generated addresses */} {qrAddress && ( - - - - Press Enter or Esc to close - - + setQrAddress(null)} + /> )} ); diff --git a/src/tui/screens/action-wizard/hooks/useWizardKeyboard.ts b/src/tui/screens/action-wizard/hooks/useWizardKeyboard.ts index 5538835..d327d45 100644 --- a/src/tui/screens/action-wizard/hooks/useWizardKeyboard.ts +++ b/src/tui/screens/action-wizard/hooks/useWizardKeyboard.ts @@ -1,4 +1,4 @@ -import { useInput } from 'ink'; +import { useBlockableInput } from '../../../hooks/useInputLayer.js'; import type { ActionWizardState } from './useActionWizard.js'; /** @@ -9,7 +9,7 @@ import type { ActionWizardState } from './useActionWizard.js'; * component to keep it purely presentational. */ export function useWizardKeyboard(wizard: ActionWizardState): void { - useInput( + useBlockableInput( (input, key) => { // ── Tab: cycle through content and button bar ───────── if (key.tab) { diff --git a/src/tui/screens/invitations/InvitationScreen.tsx b/src/tui/screens/invitations/InvitationScreen.tsx index 50cedbd..0f055d1 100644 --- a/src/tui/screens/invitations/InvitationScreen.tsx +++ b/src/tui/screens/invitations/InvitationScreen.tsx @@ -10,11 +10,12 @@ */ import React, { useState, useEffect, useCallback, useMemo } from 'react'; -import { Box, Text, useInput } from 'ink'; +import { Box, Text } from 'ink'; import { InputDialog } from '../../components/Dialog.js'; import { ScrollableList, type ListItemData, type ListGroup } from '../../components/List.js'; import { useNavigation } from '../../hooks/useNavigation.js'; import { useAppContext, useStatus } from '../../hooks/useAppContext.js'; +import { useBlockableInput, useIsInputCaptured } from '../../hooks/useInputLayer.js'; import { useInvitations } from '../../hooks/useInvitations.js'; import { colors, logoSmall, formatSatoshis } from '../../theme.js'; import { copyToClipboard } from '../../utils/clipboard.js'; @@ -421,10 +422,10 @@ export function InvitationScreen(): React.ReactElement { }, [handleAction]); // ── Keyboard navigation ────────────────────────────────────────────────── - // Disabled when the ID dialog or import flow is open. - const isOverlayOpen = showIdDialog || importingId !== null; + // Automatically blocked when any dialog/overlay is capturing input. + const isCaptured = useIsInputCaptured(); - useInput((input, key) => { + useBlockableInput((input, key) => { if (key.tab) { setFocusedPanel(prev => prev === 'list' ? 'actions' : 'list'); return; @@ -437,7 +438,7 @@ export function InvitationScreen(): React.ReactElement { if (input === 'i') { setShowIdDialog(true); } - }, { isActive: !isOverlayOpen }); + }); // ── Render helpers ─────────────────────────────────────────────────────── @@ -639,7 +640,7 @@ export function InvitationScreen(): React.ReactElement { selectedIndex={selectedIndex} onSelect={setSelectedIndex} onActivate={handleListItemActivate} - focus={focusedPanel === 'list' && !isOverlayOpen} + focus={focusedPanel === 'list' && !isCaptured} maxVisible={6} groups={invitationListGroups} emptyMessage="No invitations yet" @@ -663,7 +664,7 @@ export function InvitationScreen(): React.ReactElement { selectedIndex={selectedActionIndex} onSelect={setSelectedActionIndex} onActivate={handleActionItemActivate} - focus={focusedPanel === 'actions' && !isOverlayOpen} + focus={focusedPanel === 'actions' && !isCaptured} emptyMessage="No actions" /> diff --git a/src/tui/screens/invitations/invitation-import/InvitationImportFlow.tsx b/src/tui/screens/invitations/invitation-import/InvitationImportFlow.tsx index 676e769..ff87687 100644 --- a/src/tui/screens/invitations/invitation-import/InvitationImportFlow.tsx +++ b/src/tui/screens/invitations/invitation-import/InvitationImportFlow.tsx @@ -10,7 +10,7 @@ */ import React, { useState, useCallback } from 'react'; -import { Box, Text, useInput } from 'ink'; +import { Box, Text } from 'ink'; import { colors, logoSmall } from '../../../theme.js'; import { StepIndicator, type Step } from '../../../components/ProgressBar.js'; @@ -24,6 +24,7 @@ import { IMPORT_STEPS, type ImportFlowProps, type SelectableUTXO } from './types import type { Invitation } from '../../../../services/invitation.js'; import type { XOTemplate } from '@xo-cash/types'; import { DialogWrapper } from '../../../components/Dialog.js'; +import { useInputLayer, useLayeredInput } from '../../../hooks/useInputLayer.js'; import { InvitationBuilder } from '@xo-cash/engine'; import { hexToBin } from '@bitauth/libauth'; @@ -140,18 +141,27 @@ export function InvitationImportFlow({ return (raw && typeof raw === 'object' && 'name' in raw) ? String(raw.name) : selectedRole; })(); + showInfo( + `Invitation imported and accepted!\n\n` + + `Role: ${roleName}\n` + + `Template: ${template?.name ?? invitation?.data.templateIdentifier ?? 'Unknown'}\n` + + `Action: ${invitation?.data.actionIdentifier ?? 'Unknown'}` + ); setStatus('Ready'); onClose(); }, [selectedRole, template, invitation, showInfo, setStatus, onClose]); - // ── Keyboard handling for FetchStep error retry ────────────────────────── - // FetchStep auto-advances on success but shows error state with retry on failure. - useInput((_input, key) => { + // ── Keyboard handling ──────────────────────────────────────────────────── + // The import flow registers its own layer so it captures input above the + // parent screen. Individual steps also register sub-layers when needed. + useInputLayer('import-flow'); + + useLayeredInput('import-flow', (_input, key) => { if (currentStep !== 0) return; // Enter retries, Esc cancels — handled within FetchStep rendering, // but we also catch Esc here for safety. if (key.escape) handleCancel(); - }, { isActive: currentStep === 0 }); + }); // ── Step router ────────────────────────────────────────────────────────── const renderStep = (): React.ReactNode => { diff --git a/src/tui/screens/invitations/invitation-import/steps/InputsSelectStep.tsx b/src/tui/screens/invitations/invitation-import/steps/InputsSelectStep.tsx index 43de602..a46c91a 100644 --- a/src/tui/screens/invitations/invitation-import/steps/InputsSelectStep.tsx +++ b/src/tui/screens/invitations/invitation-import/steps/InputsSelectStep.tsx @@ -7,8 +7,9 @@ */ import React, { useState, useEffect, useCallback, useRef } from 'react'; -import { Box, Text, useInput } from 'ink'; +import { Box, Text } from 'ink'; import { colors, formatSatoshis } from '../../../../theme.js'; +import { useLayeredInput } from '../../../../hooks/useInputLayer.js'; import type { InputsSelectStepProps, SelectableUTXO } from '../types.js'; import { autoSelectGreedyUtxos, mapUnspentOutputsToSelectable } from '../../../../../utils/invitation-flow.js'; import type { UnspentOutputData } from '@xo-cash/state'; @@ -98,6 +99,7 @@ export function InputsSelectStep({ const utxoIdToSuitableResource = new Map(); for (const outputIdentifier of outputIdentifiers) { const suitableResources = await invitation.findSuitableResources({ + outputIdentifier, }); console.log('suitableResources', outputIdentifier, JSON.stringify(suitableResources, null, 2)); @@ -141,25 +143,19 @@ export function InputsSelectStep({ }); }, []); - // Keyboard handling - useInput((input, key) => { - if (!isActive) return; - + // Keyboard handling — gated by the import-flow layer so dialogs on top block input. + useLayeredInput('import-flow', (input, key) => { if (key.upArrow || input === 'k') { setFocusedIndex(prev => Math.max(0, prev - 1)); } else if (key.downArrow || input === 'j') { setFocusedIndex(prev => Math.min(utxos.length - 1, prev + 1)); } else if (input === ' ' || (key.return && utxos.length > 0)) { - // Space or Enter toggles the focused UTXO if (utxos.length > 0) toggleSelection(focusedIndex); } else if (input === 'a') { - // Select all setUtxos(prev => prev.map(u => ({ ...u, selected: true }))); } else if (input === 'n') { - // Deselect all setUtxos(prev => prev.map(u => ({ ...u, selected: false }))); } else if (key.tab) { - // Tab confirms selection (moves to next step) if (hasEnough) { onComplete(utxos.filter(u => u.selected)); } diff --git a/src/tui/screens/invitations/invitation-import/steps/PreviewInvitationStep.tsx b/src/tui/screens/invitations/invitation-import/steps/PreviewInvitationStep.tsx index ddfa7af..07949a6 100644 --- a/src/tui/screens/invitations/invitation-import/steps/PreviewInvitationStep.tsx +++ b/src/tui/screens/invitations/invitation-import/steps/PreviewInvitationStep.tsx @@ -7,8 +7,9 @@ */ import React from 'react'; -import { Box, Text, useInput } from 'ink'; +import { Box, Text } from 'ink'; import { colors, formatSatoshis } from '../../../../theme.js'; +import { useLayeredInput } from '../../../../hooks/useInputLayer.js'; import { getInvitationState, getStateColorName, @@ -40,8 +41,7 @@ export function PreviewInvitationStep({ onCancel, isActive, }: PreviewStepProps): React.ReactElement { - useInput((_input, key) => { - if (!isActive) return; + useLayeredInput('import-flow', (_input, key) => { if (key.return) onComplete(); if (key.escape) onCancel(); }, { isActive }); @@ -62,87 +62,129 @@ export function PreviewInvitationStep({ return ( - {/* Template & action info */} + {/* Template info */} - Template: - {template?.name ?? invitation.data.templateIdentifier} + + Template: + + + {template?.name ?? invitation.data.templateIdentifier} + {template?.description && ( - {template.description} + + {template.description} + )} - - - Action: - {action?.name ?? invitation.data.actionIdentifier} - {action?.description && ( - {action.description} - )} + {/* Action info */} + + + Action: - - Status: + + {action?.name ?? invitation.data.actionIdentifier} + + {action?.description && ( + + {action.description} + + )} + + + {/* Status */} + + + Status: + + {state} {/* Roles already filled */} - Roles Filled ({filledRoles.size}): + + Roles Filled ({filledRoles.size}): + + {filledRoles.size === 0 ? ( - None yet + + None yet + ) : ( Array.from(filledRoles).map(role => { const roleInfoRaw = template?.roles?.[role]; const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null; return ( - • {roleInfo?.name ?? role} + + • {roleInfo?.name ?? role} + ); }) )} - {/* Inputs & Outputs side by side */} - - + {/* Inputs */} + + Inputs ({inputs.length}): - {inputs.length === 0 ? ( + + + {inputs.length === 0 ? ( + None yet - ) : ( - inputs.map((input, idx) => { - const inputTemplate = template?.inputs?.[input.inputIdentifier ?? '']; - return ( - + + ) : ( + inputs.map((input, idx) => { + const inputTemplate = template?.inputs?.[input.inputIdentifier ?? '']; + return ( + + {' '}• {inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`} {input.roleIdentifier && ` (${input.roleIdentifier})`} - ); - }) - )} + + ); + }) + )} + + + {/* Outputs */} + + + Outputs ({outputs.length}): - - Outputs ({outputs.length}): - {outputs.length === 0 ? ( + {outputs.length === 0 ? ( + None yet - ) : ( - outputs.map((output, idx) => { - const outputTemplate = template?.outputs?.[output.outputIdentifier ?? '']; - return ( - + + ) : ( + outputs.map((output, idx) => { + const outputTemplate = template?.outputs?.[output.outputIdentifier ?? '']; + return ( + + {' '}• {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`} {output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`} - ); - }) - )} - + + ); + }) + )} {/* Variables */} - Variables ({variables.length}): + + Variables ({variables.length}): + + {variables.length === 0 ? ( - None set + + None set + ) : ( variables.map((variable, idx) => { const varTemplate = template?.variables?.[variable.variableIdentifier]; @@ -150,9 +192,11 @@ export function PreviewInvitationStep({ ? variable.value.toString() : String(variable.value); return ( - - {' '}• {varTemplate?.name ?? variable.variableIdentifier}: {displayValue} - + + + {' '}• {varTemplate?.name ?? variable.variableIdentifier}: {displayValue} + + ); }) )} diff --git a/src/tui/screens/invitations/invitation-import/steps/ReviewStep.tsx b/src/tui/screens/invitations/invitation-import/steps/ReviewStep.tsx index 4382c29..59b6946 100644 --- a/src/tui/screens/invitations/invitation-import/steps/ReviewStep.tsx +++ b/src/tui/screens/invitations/invitation-import/steps/ReviewStep.tsx @@ -8,8 +8,9 @@ */ import React, { useState, useCallback } from 'react'; -import { Box, Text, useInput } from 'ink'; +import { Box, Text } from 'ink'; import { colors, formatSatoshis } from '../../../../theme.js'; +import { useLayeredInput } from '../../../../hooks/useInputLayer.js'; import type { ReviewStepProps, SelectableUTXO } from '../types.js'; /** Default fee estimate in satoshis. */ @@ -55,9 +56,9 @@ export function ReviewStep({ } }, [invitation, selectedRole, selectedInputs, onComplete]); - // Keyboard handling - useInput((_input, key) => { - if (!isActive || isSubmitting) return; + // Keyboard handling — gated by the import-flow layer. + useLayeredInput('import-flow', (_input, key) => { + if (isSubmitting) return; if (key.return) { submit(); diff --git a/src/tui/screens/invitations/invitation-import/steps/RoleSelectStep.tsx b/src/tui/screens/invitations/invitation-import/steps/RoleSelectStep.tsx index 815bfb8..92f7027 100644 --- a/src/tui/screens/invitations/invitation-import/steps/RoleSelectStep.tsx +++ b/src/tui/screens/invitations/invitation-import/steps/RoleSelectStep.tsx @@ -6,8 +6,9 @@ */ import React, { useState } from 'react'; -import { Box, Text, useInput } from 'ink'; +import { Box, Text } from 'ink'; import { colors } from '../../../../theme.js'; +import { useLayeredInput } from '../../../../hooks/useInputLayer.js'; import type { RoleSelectStepProps } from '../types.js'; export function RoleSelectStep({ @@ -20,9 +21,7 @@ export function RoleSelectStep({ }: RoleSelectStepProps): React.ReactElement { const [selectedIndex, setSelectedIndex] = useState(0); - useInput((input, key) => { - if (!isActive) return; - + useLayeredInput('import-flow', (input, key) => { if (key.upArrow || input === 'k') { setSelectedIndex(prev => Math.max(0, prev - 1)); } else if (key.downArrow || input === 'j') { diff --git a/src/tui/types.ts b/src/tui/types.ts index 8e35c21..c056dec 100644 --- a/src/tui/types.ts +++ b/src/tui/types.ts @@ -80,8 +80,8 @@ export interface AppContextType { export interface DialogState { /** Whether dialog is visible */ visible: boolean; - /** Dialog type. Use 'custom' when a screen renders its own dialog overlay. */ - type: 'error' | 'info' | 'confirm' | 'custom'; + /** Dialog type */ + type: 'error' | 'info' | 'confirm'; /** Dialog message */ message: string; /** Callback for confirm dialog */