Fix dialog focus

This commit is contained in:
2026-03-23 03:51:51 +00:00
parent a28d43a68b
commit 7fd89c5663
18 changed files with 403 additions and 177 deletions

View File

@@ -59,7 +59,7 @@ export class AppService extends EventEmitter<AppEventMap> {
// Import the default P2PKH template // Import the default P2PKH template
await engine.importTemplate(p2pkhTemplate); 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 // Set default locking parameters for P2PKH
// To my knowledge, this doesnt generate any lockscript, so discovery of funds will not work automatically. // To my knowledge, this doesnt generate any lockscript, so discovery of funds will not work automatically.

View File

@@ -144,9 +144,10 @@ export class HistoryService {
}); });
this.indexInvitationOutputsByUtxoOrigin(invitationByOrigin, invitation); this.indexInvitationOutputsByUtxoOrigin(invitationByOrigin, invitation);
const entities = await this.extractEntities(invitation.data); // TODO: Remove or use this. Its a test for extracting the roles to entities.
const entitiesRecord = await this.matchRolesToEntities(invitation.data, entities); // const entities = await this.extractEntities(invitation.data);
console.log(entitiesRecord); // const entitiesRecord = await this.matchRolesToEntities(invitation.data, entities);
// console.log(entitiesRecord);
} }
const usedUtxoIds = new Set<string>(); const usedUtxoIds = new Set<string>();

View File

@@ -4,9 +4,10 @@
*/ */
import React from 'react'; 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 { NavigationProvider, useNavigation } from './hooks/useNavigation.js';
import { AppProvider, useAppContext, useDialog, useStatus } from './hooks/useAppContext.js'; import { AppProvider, useAppContext, useDialog, useStatus } from './hooks/useAppContext.js';
import { InputLayerProvider, useBlockableInput } from './hooks/useInputLayer.js';
import type { AppConfig } from '../app.js'; import type { AppConfig } from '../app.js';
import { colors, logoSmall } from './theme.js'; import { colors, logoSmall } from './theme.js';
@@ -78,27 +79,9 @@ function StatusBar(): React.ReactElement {
* Dialog overlay component for modals. * Dialog overlay component for modals.
*/ */
function DialogOverlay(): React.ReactElement | null { function DialogOverlay(): React.ReactElement | null {
const { dialog, setDialog } = useDialog(); const { dialog } = useDialog();
// 'custom' dialogs are rendered and managed by the screen itself; if (!dialog?.visible) return null;
// 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;
const borderColor = dialog.type === 'error' ? colors.error : const borderColor = dialog.type === 'error' ? colors.error :
dialog.type === 'confirm' ? colors.warning : dialog.type === 'confirm' ? colors.warning :
@@ -132,20 +115,12 @@ function MainContent(): React.ReactElement {
const { exit } = useApp(); const { exit } = useApp();
const { goBack, canGoBack } = useNavigation(); const { goBack, canGoBack } = useNavigation();
const { screen } = useNavigation(); const { screen } = useNavigation();
const { dialog } = useDialog();
const appContext = useAppContext(); const appContext = useAppContext();
// Global keybindings (disabled when dialog is shown) // Global keybindings — auto-blocked when any dialog/overlay is capturing input.
useInput((input, key) => { useBlockableInput((input, key) => {
// Don't handle global keys when dialog is shown // Quit on Ctrl+C
if (dialog?.visible) return; if (key.ctrl && input === 'c') {
// Quit on 'q' or Ctrl+C
if (
// Commenting out 'q'. Its annoying me - It activates in text inputs.
// input === 'q'
(key.ctrl && input === 'c')
) {
appContext.exit(); appContext.exit();
exit(); exit();
} }
@@ -197,9 +172,11 @@ export function App({ config }: AppProps): React.ReactElement {
config={config} config={config}
onExit={handleExit} onExit={handleExit}
> >
<InputLayerProvider>
<NavigationProvider initialScreen="seed-input"> <NavigationProvider initialScreen="seed-input">
<MainContent /> <MainContent />
</NavigationProvider> </NavigationProvider>
</InputLayerProvider>
</AppProvider> </AppProvider>
); );
} }

View File

@@ -2,10 +2,11 @@
* Dialog components for modals, confirmations, and input dialogs. * Dialog components for modals, confirmations, and input dialogs.
*/ */
import React, { useRef, useState } from 'react'; import React, { useId, useRef, useState } from 'react';
import { Box, Text, useInput, measureElement } from 'ink'; import { Box, Text, measureElement } from 'ink';
import TextInput from './TextInput.js'; import TextInput from './TextInput.js';
import { colors } from '../theme.js'; import { colors } from '../theme.js';
import { useInputLayer, useLayeredInput } from '../hooks/useInputLayer.js';
/** /**
* Base dialog wrapper props. * Base dialog wrapper props.
@@ -118,15 +119,17 @@ export function InputDialog({
onCancel, onCancel,
isActive = true, isActive = true,
}: InputDialogProps): React.ReactElement { }: InputDialogProps): React.ReactElement {
const layerId = useId();
const [value, setValue] = useState(initialValue); const [value, setValue] = useState(initialValue);
useInput((input, key) => { // Auto-capture input when this dialog is mounted.
if (!isActive) return; useInputLayer(layerId);
useLayeredInput(layerId, (_input, key) => {
if (key.escape) { if (key.escape) {
onCancel(); onCancel();
} }
}, { isActive }); });
const handleSubmit = (val: string) => { const handleSubmit = (val: string) => {
onSubmit(val); onSubmit(val);
@@ -183,11 +186,13 @@ export function ConfirmDialog({
confirmLabel = 'Yes', confirmLabel = 'Yes',
cancelLabel = 'No', cancelLabel = 'No',
}: ConfirmDialogProps): React.ReactElement { }: ConfirmDialogProps): React.ReactElement {
const layerId = useId();
const [selected, setSelected] = useState<'confirm' | 'cancel'>('confirm'); const [selected, setSelected] = useState<'confirm' | 'cancel'>('confirm');
useInput((input, key) => { // Auto-capture input when this dialog is mounted.
if (!isActive) return; useInputLayer(layerId);
useLayeredInput(layerId, (input, key) => {
if (key.leftArrow || key.rightArrow || key.tab) { if (key.leftArrow || key.rightArrow || key.tab) {
setSelected(prev => prev === 'confirm' ? 'cancel' : 'confirm'); setSelected(prev => prev === 'confirm' ? 'cancel' : 'confirm');
} else if (key.return) { } else if (key.return) {
@@ -201,7 +206,7 @@ export function ConfirmDialog({
} else if (input === 'y' || input === 'Y') { } else if (input === 'y' || input === 'Y') {
onConfirm(); onConfirm();
} }
}, { isActive }); });
return ( return (
<DialogWrapper title={title} borderColor={colors.warning}> <DialogWrapper title={title} borderColor={colors.warning}>
@@ -255,13 +260,16 @@ export function MessageDialog({
type = 'info', type = 'info',
isActive = true, isActive = true,
}: MessageDialogProps): React.ReactElement { }: MessageDialogProps): React.ReactElement {
useInput((input, key) => { const layerId = useId();
if (!isActive) return;
// Auto-capture input when this dialog is mounted.
useInputLayer(layerId);
useLayeredInput(layerId, (_input, key) => {
if (key.return || key.escape) { if (key.return || key.escape) {
onClose(); onClose();
} }
}, { isActive }); });
const borderColor = type === 'error' ? colors.error : const borderColor = type === 'error' ? colors.error :
type === 'success' ? colors.success : type === 'success' ? colors.success :

View File

@@ -11,3 +11,10 @@ export {
useCreateInvitation, useCreateInvitation,
useInvitationIds, useInvitationIds,
} from './useInvitations.js'; } from './useInvitations.js';
export {
InputLayerProvider,
useInputLayer,
useLayeredInput,
useBlockableInput,
useIsInputCaptured,
} from './useInputLayer.js';

View File

@@ -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<InputLayerContextType | null>(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<string[]>([]);
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<InputLayerContextType>(
() => ({ push, isTop, isStackEmpty, version }),
[push, isTop, isStackEmpty, version],
);
return (
<InputLayerContext.Provider value={value}>
{children}
</InputLayerContext.Provider>
);
}
// ── 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;
}

View File

@@ -6,11 +6,12 @@
*/ */
import React, { useState, useCallback, useEffect } from 'react'; 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 TextInput from '../components/TextInput.js';
import { Button } from '../components/Button.js'; import { Button } from '../components/Button.js';
import { useNavigation } from '../hooks/useNavigation.js'; import { useNavigation } from '../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../hooks/useAppContext.js'; import { useAppContext, useStatus } from '../hooks/useAppContext.js';
import { useBlockableInput } from '../hooks/useInputLayer.js';
import { colors, logo } from '../theme.js'; import { colors, logo } from '../theme.js';
import fs from 'fs'; import fs from 'fs';
@@ -170,7 +171,7 @@ export function SeedInputScreen(): React.ReactElement {
}, [mnemonicFiles, doInitialize]); }, [mnemonicFiles, doInitialize]);
// Keyboard navigation // Keyboard navigation
useInput((input, key) => { useBlockableInput((_input, key) => {
if (isSubmitting) return; if (isSubmitting) return;
// Tab / Shift-Tab to cycle focus sections // Tab / Shift-Tab to cycle focus sections

View File

@@ -7,10 +7,11 @@
*/ */
import React, { useState, useEffect, useCallback, useMemo } from 'react'; 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 { ScrollableList, type ListItemData } from '../components/List.js';
import { useNavigation } from '../hooks/useNavigation.js'; import { useNavigation } from '../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../hooks/useAppContext.js'; import { useAppContext, useStatus } from '../hooks/useAppContext.js';
import { useBlockableInput } from '../hooks/useInputLayer.js';
import { colors, logoSmall } from '../theme.js'; import { colors, logoSmall } from '../theme.js';
// XO Imports // XO Imports
@@ -258,8 +259,7 @@ export function TemplateListScreen(): React.ReactElement {
}, [currentTemplate, navigate]); }, [currentTemplate, navigate]);
// Handle keyboard navigation // Handle keyboard navigation
useInput((input, key) => { useBlockableInput((_input, key) => {
// Tab to switch panels
if (key.tab) { if (key.tab) {
setFocusedPanel(prev => prev === 'templates' ? 'actions' : 'templates'); setFocusedPanel(prev => prev === 'templates' ? 'actions' : 'templates');
return; return;

View File

@@ -9,10 +9,11 @@
*/ */
import React, { useState, useEffect, useCallback } from 'react'; 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 { ConfirmDialog } from '../components/Dialog.js';
import { useNavigation } from '../hooks/useNavigation.js'; import { useNavigation } from '../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../hooks/useAppContext.js'; import { useAppContext, useStatus } from '../hooks/useAppContext.js';
import { useBlockableInput } from '../hooks/useInputLayer.js';
import { useInvitation } from '../hooks/useInvitations.js'; import { useInvitation } from '../hooks/useInvitations.js';
import { colors, logoSmall, formatSatoshis, formatHex } from '../theme.js'; import { colors, logoSmall, formatSatoshis, formatHex } from '../theme.js';
import { copyToClipboard } from '../utils/clipboard.js'; import { copyToClipboard } from '../utils/clipboard.js';
@@ -147,10 +148,8 @@ export function TransactionScreen(): React.ReactElement {
} }
}, [signTransaction, copyTransactionHex, goBack]); }, [signTransaction, copyTransactionHex, goBack]);
// Handle keyboard navigation // Handle keyboard navigation — automatically blocked when the confirm dialog is open.
useInput((input, key) => { useBlockableInput((input, key) => {
if (showBroadcastConfirm) return;
// Tab to switch panels // Tab to switch panels
if (key.tab) { if (key.tab) {
setFocusedPanel(prev => { setFocusedPanel(prev => {
@@ -177,7 +176,7 @@ export function TransactionScreen(): React.ReactElement {
handleAction(action.value); handleAction(action.value);
} }
} }
}, { isActive: !showBroadcastConfirm }); });
// Extract transaction data from invitation // Extract transaction data from invitation
const commits = invitation?.commits ?? []; 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.' message='Are you sure you want to broadcast this transaction? This action cannot be undone.'
onConfirm={broadcastTransaction} onConfirm={broadcastTransaction}
onCancel={() => setShowBroadcastConfirm(false)} onCancel={() => setShowBroadcastConfirm(false)}
isActive={showBroadcastConfirm}
/> />
</Box> </Box>
)} )}

View File

@@ -8,11 +8,12 @@
*/ */
import React, { useState, useEffect, useCallback, useMemo } from 'react'; 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 { ScrollableList, type ListItemData } from '../components/List.js';
import { QRCode } from '../components/QRCode.js'; import { QRCode } from '../components/QRCode.js';
import { useNavigation } from '../hooks/useNavigation.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 { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js';
import type { HistoryItem } from '../../services/history.js'; import type { HistoryItem } from '../../services/history.js';
@@ -65,6 +66,39 @@ const menuItems: ListItemData<string>[] = [
*/ */
type HistoryListItem = ListItemData<HistoryDisplayRow>; type HistoryListItem = ListItemData<HistoryDisplayRow>;
/**
* 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 (
<Box
position="absolute"
flexDirection="column"
alignItems="center"
width="100%"
>
<QRCode
value={address}
dialog
dialogTitle="Receive Address"
showValue
/>
<Box justifyContent="center" marginTop={1}>
<Text color={colors.textMuted}>Press Enter or Esc to close</Text>
</Box>
</Box>
);
}
/** /**
* Wallet State Screen Component. * Wallet State Screen Component.
* Displays wallet balance, history, and action menu. * Displays wallet balance, history, and action menu.
@@ -73,7 +107,6 @@ export function WalletStateScreen(): React.ReactElement {
const { navigate } = useNavigation(); const { navigate } = useNavigation();
const { appService, showError, showInfo } = useAppContext(); const { appService, showError, showInfo } = useAppContext();
const { setStatus } = useStatus(); const { setStatus } = useStatus();
const { setDialog } = useDialog();
// State // State
const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null); const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null);
@@ -169,7 +202,6 @@ export function WalletStateScreen(): React.ReactElement {
console.log(result); console.log(result);
setQrAddress(result.address); setQrAddress(result.address);
setDialog({ visible: true, type: 'custom', message: '' });
setStatus('Address generated'); setStatus('Address generated');
// Refresh to show updated state // Refresh to show updated state
@@ -227,16 +259,10 @@ export function WalletStateScreen(): React.ReactElement {
}); });
}, [history]); }, [history]);
// Handle keyboard navigation between panels and QR dialog dismissal // Screen input — automatically blocked when any dialog/overlay is capturing.
useInput((input, key) => { const isCaptured = useIsInputCaptured();
if (qrAddress) {
if (key.escape || key.return) {
setQrAddress(null);
setDialog(null);
}
return;
}
useBlockableInput((_input, key) => {
if (key.tab) { if (key.tab) {
setFocusedPanel(prev => prev === 'menu' ? 'history' : 'menu'); setFocusedPanel(prev => prev === 'menu' ? 'history' : 'menu');
} }
@@ -383,7 +409,7 @@ export function WalletStateScreen(): React.ReactElement {
selectedIndex={selectedMenuIndex} selectedIndex={selectedMenuIndex}
onSelect={setSelectedMenuIndex} onSelect={setSelectedMenuIndex}
onActivate={handleMenuItemActivate} onActivate={handleMenuItemActivate}
focus={focusedPanel === 'menu'} focus={focusedPanel === 'menu' && !isCaptured}
emptyMessage="No actions" emptyMessage="No actions"
/> />
</Box> </Box>
@@ -411,7 +437,7 @@ export function WalletStateScreen(): React.ReactElement {
items={historyListItems} items={historyListItems}
selectedIndex={selectedHistoryIndex} selectedIndex={selectedHistoryIndex}
onSelect={setSelectedHistoryIndex} onSelect={setSelectedHistoryIndex}
focus={focusedPanel === 'history'} focus={focusedPanel === 'history' && !isCaptured}
maxVisible={10} maxVisible={10}
emptyMessage="No history found" emptyMessage="No history found"
renderItem={renderHistoryItem} renderItem={renderHistoryItem}
@@ -429,22 +455,10 @@ export function WalletStateScreen(): React.ReactElement {
{/* QR Code dialog overlay for generated addresses */} {/* QR Code dialog overlay for generated addresses */}
{qrAddress && ( {qrAddress && (
<Box <QRDialogOverlay
position="absolute" address={qrAddress}
flexDirection="column" onClose={() => setQrAddress(null)}
alignItems="center"
width="100%"
>
<QRCode
value={qrAddress}
dialog
dialogTitle="Receive Address"
showValue
/> />
<Box justifyContent="center" marginTop={1}>
<Text color={colors.textMuted}>Press Enter or Esc to close</Text>
</Box>
</Box>
)} )}
</Box> </Box>
); );

View File

@@ -1,4 +1,4 @@
import { useInput } from 'ink'; import { useBlockableInput } from '../../../hooks/useInputLayer.js';
import type { ActionWizardState } from './useActionWizard.js'; import type { ActionWizardState } from './useActionWizard.js';
/** /**
@@ -9,7 +9,7 @@ import type { ActionWizardState } from './useActionWizard.js';
* component to keep it purely presentational. * component to keep it purely presentational.
*/ */
export function useWizardKeyboard(wizard: ActionWizardState): void { export function useWizardKeyboard(wizard: ActionWizardState): void {
useInput( useBlockableInput(
(input, key) => { (input, key) => {
// ── Tab: cycle through content and button bar ───────── // ── Tab: cycle through content and button bar ─────────
if (key.tab) { if (key.tab) {

View File

@@ -10,11 +10,12 @@
*/ */
import React, { useState, useEffect, useCallback, useMemo } from 'react'; 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 { InputDialog } from '../../components/Dialog.js';
import { ScrollableList, type ListItemData, type ListGroup } from '../../components/List.js'; import { ScrollableList, type ListItemData, type ListGroup } from '../../components/List.js';
import { useNavigation } from '../../hooks/useNavigation.js'; import { useNavigation } from '../../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../../hooks/useAppContext.js'; import { useAppContext, useStatus } from '../../hooks/useAppContext.js';
import { useBlockableInput, useIsInputCaptured } from '../../hooks/useInputLayer.js';
import { useInvitations } from '../../hooks/useInvitations.js'; import { useInvitations } from '../../hooks/useInvitations.js';
import { colors, logoSmall, formatSatoshis } from '../../theme.js'; import { colors, logoSmall, formatSatoshis } from '../../theme.js';
import { copyToClipboard } from '../../utils/clipboard.js'; import { copyToClipboard } from '../../utils/clipboard.js';
@@ -421,10 +422,10 @@ export function InvitationScreen(): React.ReactElement {
}, [handleAction]); }, [handleAction]);
// ── Keyboard navigation ────────────────────────────────────────────────── // ── Keyboard navigation ──────────────────────────────────────────────────
// Disabled when the ID dialog or import flow is open. // Automatically blocked when any dialog/overlay is capturing input.
const isOverlayOpen = showIdDialog || importingId !== null; const isCaptured = useIsInputCaptured();
useInput((input, key) => { useBlockableInput((input, key) => {
if (key.tab) { if (key.tab) {
setFocusedPanel(prev => prev === 'list' ? 'actions' : 'list'); setFocusedPanel(prev => prev === 'list' ? 'actions' : 'list');
return; return;
@@ -437,7 +438,7 @@ export function InvitationScreen(): React.ReactElement {
if (input === 'i') { if (input === 'i') {
setShowIdDialog(true); setShowIdDialog(true);
} }
}, { isActive: !isOverlayOpen }); });
// ── Render helpers ─────────────────────────────────────────────────────── // ── Render helpers ───────────────────────────────────────────────────────
@@ -639,7 +640,7 @@ export function InvitationScreen(): React.ReactElement {
selectedIndex={selectedIndex} selectedIndex={selectedIndex}
onSelect={setSelectedIndex} onSelect={setSelectedIndex}
onActivate={handleListItemActivate} onActivate={handleListItemActivate}
focus={focusedPanel === 'list' && !isOverlayOpen} focus={focusedPanel === 'list' && !isCaptured}
maxVisible={6} maxVisible={6}
groups={invitationListGroups} groups={invitationListGroups}
emptyMessage="No invitations yet" emptyMessage="No invitations yet"
@@ -663,7 +664,7 @@ export function InvitationScreen(): React.ReactElement {
selectedIndex={selectedActionIndex} selectedIndex={selectedActionIndex}
onSelect={setSelectedActionIndex} onSelect={setSelectedActionIndex}
onActivate={handleActionItemActivate} onActivate={handleActionItemActivate}
focus={focusedPanel === 'actions' && !isOverlayOpen} focus={focusedPanel === 'actions' && !isCaptured}
emptyMessage="No actions" emptyMessage="No actions"
/> />
</Box> </Box>

View File

@@ -10,7 +10,7 @@
*/ */
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { Box, Text, useInput } from 'ink'; import { Box, Text } from 'ink';
import { colors, logoSmall } from '../../../theme.js'; import { colors, logoSmall } from '../../../theme.js';
import { StepIndicator, type Step } from '../../../components/ProgressBar.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 { Invitation } from '../../../../services/invitation.js';
import type { XOTemplate } from '@xo-cash/types'; import type { XOTemplate } from '@xo-cash/types';
import { DialogWrapper } from '../../../components/Dialog.js'; import { DialogWrapper } from '../../../components/Dialog.js';
import { useInputLayer, useLayeredInput } from '../../../hooks/useInputLayer.js';
import { InvitationBuilder } from '@xo-cash/engine'; import { InvitationBuilder } from '@xo-cash/engine';
import { hexToBin } from '@bitauth/libauth'; import { hexToBin } from '@bitauth/libauth';
@@ -140,18 +141,27 @@ export function InvitationImportFlow({
return (raw && typeof raw === 'object' && 'name' in raw) ? String(raw.name) : selectedRole; 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'); setStatus('Ready');
onClose(); onClose();
}, [selectedRole, template, invitation, showInfo, setStatus, onClose]); }, [selectedRole, template, invitation, showInfo, setStatus, onClose]);
// ── Keyboard handling for FetchStep error retry ────────────────────────── // ── Keyboard handling ────────────────────────────────────────────────────
// FetchStep auto-advances on success but shows error state with retry on failure. // The import flow registers its own layer so it captures input above the
useInput((_input, key) => { // parent screen. Individual steps also register sub-layers when needed.
useInputLayer('import-flow');
useLayeredInput('import-flow', (_input, key) => {
if (currentStep !== 0) return; if (currentStep !== 0) return;
// Enter retries, Esc cancels — handled within FetchStep rendering, // Enter retries, Esc cancels — handled within FetchStep rendering,
// but we also catch Esc here for safety. // but we also catch Esc here for safety.
if (key.escape) handleCancel(); if (key.escape) handleCancel();
}, { isActive: currentStep === 0 }); });
// ── Step router ────────────────────────────────────────────────────────── // ── Step router ──────────────────────────────────────────────────────────
const renderStep = (): React.ReactNode => { const renderStep = (): React.ReactNode => {

View File

@@ -7,8 +7,9 @@
*/ */
import React, { useState, useEffect, useCallback, useRef } from 'react'; 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 { colors, formatSatoshis } from '../../../../theme.js';
import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
import type { InputsSelectStepProps, SelectableUTXO } from '../types.js'; import type { InputsSelectStepProps, SelectableUTXO } from '../types.js';
import { autoSelectGreedyUtxos, mapUnspentOutputsToSelectable } from '../../../../../utils/invitation-flow.js'; import { autoSelectGreedyUtxos, mapUnspentOutputsToSelectable } from '../../../../../utils/invitation-flow.js';
import type { UnspentOutputData } from '@xo-cash/state'; import type { UnspentOutputData } from '@xo-cash/state';
@@ -98,6 +99,7 @@ export function InputsSelectStep({
const utxoIdToSuitableResource = new Map<string, UnspentOutputData>(); const utxoIdToSuitableResource = new Map<string, UnspentOutputData>();
for (const outputIdentifier of outputIdentifiers) { for (const outputIdentifier of outputIdentifiers) {
const suitableResources = await invitation.findSuitableResources({ const suitableResources = await invitation.findSuitableResources({
outputIdentifier, outputIdentifier,
}); });
console.log('suitableResources', outputIdentifier, JSON.stringify(suitableResources, null, 2)); console.log('suitableResources', outputIdentifier, JSON.stringify(suitableResources, null, 2));
@@ -141,25 +143,19 @@ export function InputsSelectStep({
}); });
}, []); }, []);
// Keyboard handling // Keyboard handling — gated by the import-flow layer so dialogs on top block input.
useInput((input, key) => { useLayeredInput('import-flow', (input, key) => {
if (!isActive) return;
if (key.upArrow || input === 'k') { if (key.upArrow || input === 'k') {
setFocusedIndex(prev => Math.max(0, prev - 1)); setFocusedIndex(prev => Math.max(0, prev - 1));
} else if (key.downArrow || input === 'j') { } else if (key.downArrow || input === 'j') {
setFocusedIndex(prev => Math.min(utxos.length - 1, prev + 1)); setFocusedIndex(prev => Math.min(utxos.length - 1, prev + 1));
} else if (input === ' ' || (key.return && utxos.length > 0)) { } else if (input === ' ' || (key.return && utxos.length > 0)) {
// Space or Enter toggles the focused UTXO
if (utxos.length > 0) toggleSelection(focusedIndex); if (utxos.length > 0) toggleSelection(focusedIndex);
} else if (input === 'a') { } else if (input === 'a') {
// Select all
setUtxos(prev => prev.map(u => ({ ...u, selected: true }))); setUtxos(prev => prev.map(u => ({ ...u, selected: true })));
} else if (input === 'n') { } else if (input === 'n') {
// Deselect all
setUtxos(prev => prev.map(u => ({ ...u, selected: false }))); setUtxos(prev => prev.map(u => ({ ...u, selected: false })));
} else if (key.tab) { } else if (key.tab) {
// Tab confirms selection (moves to next step)
if (hasEnough) { if (hasEnough) {
onComplete(utxos.filter(u => u.selected)); onComplete(utxos.filter(u => u.selected));
} }

View File

@@ -7,8 +7,9 @@
*/ */
import React from 'react'; import React from 'react';
import { Box, Text, useInput } from 'ink'; import { Box, Text } from 'ink';
import { colors, formatSatoshis } from '../../../../theme.js'; import { colors, formatSatoshis } from '../../../../theme.js';
import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
import { import {
getInvitationState, getInvitationState,
getStateColorName, getStateColorName,
@@ -40,8 +41,7 @@ export function PreviewInvitationStep({
onCancel, onCancel,
isActive, isActive,
}: PreviewStepProps): React.ReactElement { }: PreviewStepProps): React.ReactElement {
useInput((_input, key) => { useLayeredInput('import-flow', (_input, key) => {
if (!isActive) return;
if (key.return) onComplete(); if (key.return) onComplete();
if (key.escape) onCancel(); if (key.escape) onCancel();
}, { isActive }); }, { isActive });
@@ -62,87 +62,129 @@ export function PreviewInvitationStep({
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">
{/* Template & action info */} {/* Template info */}
<Box flexDirection="column" marginBottom={1}> <Box flexDirection="column" marginBottom={1}>
<Text color={colors.primary} bold>Template: </Text> <Box>
<Text color={colors.primary} bold>Template:</Text>
</Box>
<Box>
<Text color={colors.text}>{template?.name ?? invitation.data.templateIdentifier}</Text> <Text color={colors.text}>{template?.name ?? invitation.data.templateIdentifier}</Text>
</Box>
{template?.description && ( {template?.description && (
<Box>
<Text color={colors.textMuted} dimColor>{template.description}</Text> <Text color={colors.textMuted} dimColor>{template.description}</Text>
</Box>
)} )}
</Box> </Box>
<Box flexDirection="row" marginBottom={1}> {/* Action info */}
<Box width="50%" flexDirection="column"> <Box flexDirection="column" marginBottom={1}>
<Text color={colors.primary} bold>Action: </Text> <Box>
<Text color={colors.primary} bold>Action:</Text>
</Box>
<Box>
<Text color={colors.text}>{action?.name ?? invitation.data.actionIdentifier}</Text> <Text color={colors.text}>{action?.name ?? invitation.data.actionIdentifier}</Text>
</Box>
{action?.description && ( {action?.description && (
<Box>
<Text color={colors.textMuted} dimColor>{action.description}</Text> <Text color={colors.textMuted} dimColor>{action.description}</Text>
</Box>
)} )}
</Box> </Box>
<Box width="50%" flexDirection="column">
<Text color={colors.primary} bold>Status: </Text> {/* Status */}
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text color={colors.primary} bold>Status:</Text>
</Box>
<Box>
<Text color={stateColor(state)}>{state}</Text> <Text color={stateColor(state)}>{state}</Text>
</Box> </Box>
</Box> </Box>
{/* Roles already filled */} {/* Roles already filled */}
<Box flexDirection="column" marginBottom={1}> <Box flexDirection="column" marginBottom={1}>
<Box>
<Text color={colors.primary} bold>Roles Filled ({filledRoles.size}):</Text> <Text color={colors.primary} bold>Roles Filled ({filledRoles.size}):</Text>
</Box>
{filledRoles.size === 0 ? ( {filledRoles.size === 0 ? (
<Box>
<Text color={colors.textMuted}> None yet</Text> <Text color={colors.textMuted}> None yet</Text>
</Box>
) : ( ) : (
Array.from(filledRoles).map(role => { Array.from(filledRoles).map(role => {
const roleInfoRaw = template?.roles?.[role]; const roleInfoRaw = template?.roles?.[role];
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null; const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
return ( return (
<Text key={role} color={colors.text}> {roleInfo?.name ?? role}</Text> <Box key={role}>
<Text color={colors.text}> {roleInfo?.name ?? role}</Text>
</Box>
); );
}) })
)} )}
</Box> </Box>
{/* Inputs & Outputs side by side */} {/* Inputs */}
<Box flexDirection="row" marginBottom={1}> <Box flexDirection="column" marginBottom={1}>
<Box width="50%" flexDirection="column"> <Box>
<Text color={colors.primary} bold>Inputs ({inputs.length}):</Text> <Text color={colors.primary} bold>Inputs ({inputs.length}):</Text>
</Box>
{inputs.length === 0 ? ( {inputs.length === 0 ? (
<Box>
<Text color={colors.textMuted}> None yet</Text> <Text color={colors.textMuted}> None yet</Text>
</Box>
) : ( ) : (
inputs.map((input, idx) => { inputs.map((input, idx) => {
const inputTemplate = template?.inputs?.[input.inputIdentifier ?? '']; const inputTemplate = template?.inputs?.[input.inputIdentifier ?? ''];
return ( return (
<Text key={`input-${idx}`} color={colors.text}> <Box key={`input-${idx}`}>
<Text color={colors.text}>
{' '} {inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`} {' '} {inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
{input.roleIdentifier && ` (${input.roleIdentifier})`} {input.roleIdentifier && ` (${input.roleIdentifier})`}
</Text> </Text>
</Box>
); );
}) })
)} )}
</Box> </Box>
<Box width="50%" flexDirection="column"> {/* Outputs */}
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text color={colors.primary} bold>Outputs ({outputs.length}):</Text> <Text color={colors.primary} bold>Outputs ({outputs.length}):</Text>
</Box>
{outputs.length === 0 ? ( {outputs.length === 0 ? (
<Box>
<Text color={colors.textMuted}> None yet</Text> <Text color={colors.textMuted}> None yet</Text>
</Box>
) : ( ) : (
outputs.map((output, idx) => { outputs.map((output, idx) => {
const outputTemplate = template?.outputs?.[output.outputIdentifier ?? '']; const outputTemplate = template?.outputs?.[output.outputIdentifier ?? ''];
return ( return (
<Text key={`output-${idx}`} color={colors.text}> <Box key={`output-${idx}`}>
<Text color={colors.text}>
{' '} {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`} {' '} {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
{output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`} {output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`}
</Text> </Text>
</Box>
); );
}) })
)} )}
</Box> </Box>
</Box>
{/* Variables */} {/* Variables */}
<Box flexDirection="column" marginBottom={1}> <Box flexDirection="column" marginBottom={1}>
<Box>
<Text color={colors.primary} bold>Variables ({variables.length}):</Text> <Text color={colors.primary} bold>Variables ({variables.length}):</Text>
</Box>
{variables.length === 0 ? ( {variables.length === 0 ? (
<Box>
<Text color={colors.textMuted}> None set</Text> <Text color={colors.textMuted}> None set</Text>
</Box>
) : ( ) : (
variables.map((variable, idx) => { variables.map((variable, idx) => {
const varTemplate = template?.variables?.[variable.variableIdentifier]; const varTemplate = template?.variables?.[variable.variableIdentifier];
@@ -150,9 +192,11 @@ export function PreviewInvitationStep({
? variable.value.toString() ? variable.value.toString()
: String(variable.value); : String(variable.value);
return ( return (
<Text key={`var-${idx}`} color={colors.text}> <Box key={`var-${idx}`}>
<Text color={colors.text}>
{' '} {varTemplate?.name ?? variable.variableIdentifier}: {displayValue} {' '} {varTemplate?.name ?? variable.variableIdentifier}: {displayValue}
</Text> </Text>
</Box>
); );
}) })
)} )}

View File

@@ -8,8 +8,9 @@
*/ */
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { Box, Text, useInput } from 'ink'; import { Box, Text } from 'ink';
import { colors, formatSatoshis } from '../../../../theme.js'; import { colors, formatSatoshis } from '../../../../theme.js';
import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
import type { ReviewStepProps, SelectableUTXO } from '../types.js'; import type { ReviewStepProps, SelectableUTXO } from '../types.js';
/** Default fee estimate in satoshis. */ /** Default fee estimate in satoshis. */
@@ -55,9 +56,9 @@ export function ReviewStep({
} }
}, [invitation, selectedRole, selectedInputs, onComplete]); }, [invitation, selectedRole, selectedInputs, onComplete]);
// Keyboard handling // Keyboard handling — gated by the import-flow layer.
useInput((_input, key) => { useLayeredInput('import-flow', (_input, key) => {
if (!isActive || isSubmitting) return; if (isSubmitting) return;
if (key.return) { if (key.return) {
submit(); submit();

View File

@@ -6,8 +6,9 @@
*/ */
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Box, Text, useInput } from 'ink'; import { Box, Text } from 'ink';
import { colors } from '../../../../theme.js'; import { colors } from '../../../../theme.js';
import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
import type { RoleSelectStepProps } from '../types.js'; import type { RoleSelectStepProps } from '../types.js';
export function RoleSelectStep({ export function RoleSelectStep({
@@ -20,9 +21,7 @@ export function RoleSelectStep({
}: RoleSelectStepProps): React.ReactElement { }: RoleSelectStepProps): React.ReactElement {
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
useInput((input, key) => { useLayeredInput('import-flow', (input, key) => {
if (!isActive) return;
if (key.upArrow || input === 'k') { if (key.upArrow || input === 'k') {
setSelectedIndex(prev => Math.max(0, prev - 1)); setSelectedIndex(prev => Math.max(0, prev - 1));
} else if (key.downArrow || input === 'j') { } else if (key.downArrow || input === 'j') {

View File

@@ -80,8 +80,8 @@ export interface AppContextType {
export interface DialogState { export interface DialogState {
/** Whether dialog is visible */ /** Whether dialog is visible */
visible: boolean; visible: boolean;
/** Dialog type. Use 'custom' when a screen renders its own dialog overlay. */ /** Dialog type */
type: 'error' | 'info' | 'confirm' | 'custom'; type: 'error' | 'info' | 'confirm';
/** Dialog message */ /** Dialog message */
message: string; message: string;
/** Callback for confirm dialog */ /** Callback for confirm dialog */