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
@@ -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"
/>
</Box>
@@ -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 => {
@@ -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<string, UnspentOutputData>();
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));
}
@@ -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 (
<Box flexDirection="column">
{/* Template & action info */}
{/* Template info */}
<Box flexDirection="column" marginBottom={1}>
<Text color={colors.primary} bold>Template: </Text>
<Text color={colors.text}>{template?.name ?? invitation.data.templateIdentifier}</Text>
<Box>
<Text color={colors.primary} bold>Template:</Text>
</Box>
<Box>
<Text color={colors.text}>{template?.name ?? invitation.data.templateIdentifier}</Text>
</Box>
{template?.description && (
<Text color={colors.textMuted} dimColor>{template.description}</Text>
<Box>
<Text color={colors.textMuted} dimColor>{template.description}</Text>
</Box>
)}
</Box>
<Box flexDirection="row" marginBottom={1}>
<Box width="50%" flexDirection="column">
<Text color={colors.primary} bold>Action: </Text>
<Text color={colors.text}>{action?.name ?? invitation.data.actionIdentifier}</Text>
{action?.description && (
<Text color={colors.textMuted} dimColor>{action.description}</Text>
)}
{/* Action info */}
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text color={colors.primary} bold>Action:</Text>
</Box>
<Box width="50%" flexDirection="column">
<Text color={colors.primary} bold>Status: </Text>
<Box>
<Text color={colors.text}>{action?.name ?? invitation.data.actionIdentifier}</Text>
</Box>
{action?.description && (
<Box>
<Text color={colors.textMuted} dimColor>{action.description}</Text>
</Box>
)}
</Box>
{/* Status */}
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text color={colors.primary} bold>Status:</Text>
</Box>
<Box>
<Text color={stateColor(state)}>{state}</Text>
</Box>
</Box>
{/* Roles already filled */}
<Box flexDirection="column" marginBottom={1}>
<Text color={colors.primary} bold>Roles Filled ({filledRoles.size}):</Text>
<Box>
<Text color={colors.primary} bold>Roles Filled ({filledRoles.size}):</Text>
</Box>
{filledRoles.size === 0 ? (
<Text color={colors.textMuted}> None yet</Text>
<Box>
<Text color={colors.textMuted}> None yet</Text>
</Box>
) : (
Array.from(filledRoles).map(role => {
const roleInfoRaw = template?.roles?.[role];
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
return (
<Text key={role} color={colors.text}> {roleInfo?.name ?? role}</Text>
<Box key={role}>
<Text color={colors.text}> {roleInfo?.name ?? role}</Text>
</Box>
);
})
)}
</Box>
{/* Inputs & Outputs side by side */}
<Box flexDirection="row" marginBottom={1}>
<Box width="50%" flexDirection="column">
{/* Inputs */}
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text color={colors.primary} bold>Inputs ({inputs.length}):</Text>
{inputs.length === 0 ? (
</Box>
{inputs.length === 0 ? (
<Box>
<Text color={colors.textMuted}> None yet</Text>
) : (
inputs.map((input, idx) => {
const inputTemplate = template?.inputs?.[input.inputIdentifier ?? ''];
return (
<Text key={`input-${idx}`} color={colors.text}>
</Box>
) : (
inputs.map((input, idx) => {
const inputTemplate = template?.inputs?.[input.inputIdentifier ?? ''];
return (
<Box key={`input-${idx}`}>
<Text color={colors.text}>
{' '} {inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
{input.roleIdentifier && ` (${input.roleIdentifier})`}
</Text>
);
})
)}
</Box>
);
})
)}
</Box>
{/* Outputs */}
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text color={colors.primary} bold>Outputs ({outputs.length}):</Text>
</Box>
<Box width="50%" flexDirection="column">
<Text color={colors.primary} bold>Outputs ({outputs.length}):</Text>
{outputs.length === 0 ? (
{outputs.length === 0 ? (
<Box>
<Text color={colors.textMuted}> None yet</Text>
) : (
outputs.map((output, idx) => {
const outputTemplate = template?.outputs?.[output.outputIdentifier ?? ''];
return (
<Text key={`output-${idx}`} color={colors.text}>
</Box>
) : (
outputs.map((output, idx) => {
const outputTemplate = template?.outputs?.[output.outputIdentifier ?? ''];
return (
<Box key={`output-${idx}`}>
<Text color={colors.text}>
{' '} {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
{output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`}
</Text>
);
})
)}
</Box>
</Box>
);
})
)}
</Box>
{/* Variables */}
<Box flexDirection="column" marginBottom={1}>
<Text color={colors.primary} bold>Variables ({variables.length}):</Text>
<Box>
<Text color={colors.primary} bold>Variables ({variables.length}):</Text>
</Box>
{variables.length === 0 ? (
<Text color={colors.textMuted}> None set</Text>
<Box>
<Text color={colors.textMuted}> None set</Text>
</Box>
) : (
variables.map((variable, idx) => {
const varTemplate = template?.variables?.[variable.variableIdentifier];
@@ -150,9 +192,11 @@ export function PreviewInvitationStep({
? variable.value.toString()
: String(variable.value);
return (
<Text key={`var-${idx}`} color={colors.text}>
{' '} {varTemplate?.name ?? variable.variableIdentifier}: {displayValue}
</Text>
<Box key={`var-${idx}`}>
<Text color={colors.text}>
{' '} {varTemplate?.name ?? variable.variableIdentifier}: {displayValue}
</Text>
</Box>
);
})
)}
@@ -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();
@@ -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') {