Fix dialog focus
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 :
|
||||||
|
|||||||
@@ -11,3 +11,10 @@ export {
|
|||||||
useCreateInvitation,
|
useCreateInvitation,
|
||||||
useInvitationIds,
|
useInvitationIds,
|
||||||
} from './useInvitations.js';
|
} from './useInvitations.js';
|
||||||
|
export {
|
||||||
|
InputLayerProvider,
|
||||||
|
useInputLayer,
|
||||||
|
useLayeredInput,
|
||||||
|
useBlockableInput,
|
||||||
|
useIsInputCaptured,
|
||||||
|
} from './useInputLayer.js';
|
||||||
|
|||||||
169
src/tui/hooks/useInputLayer.tsx
Normal file
169
src/tui/hooks/useInputLayer.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
Reference in New Issue
Block a user