From dab745d97a6ec7e93aeadabe38a6ff58edc8be89 Mon Sep 17 00:00:00 2001 From: 0xbe1 <0xbetrue@gmail.com> Date: Mon, 6 Oct 2025 22:41:36 +0800 Subject: [PATCH 1/2] Add multisig address history to TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Store multisig addresses per network in ~/.safely/config.json - Display saved multisig addresses when expanding multisig menu - Allow quick selection from history with arrow keys - Press 'I' to enter a new address when history exists - Automatically save validated multisig addresses to history - History is network-specific and updates when switching networks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/storage.ts | 45 +++++++++++++ src/ui/HomeView.tsx | 161 ++++++++++++++++++++++++++++++-------------- 2 files changed, 157 insertions(+), 49 deletions(-) diff --git a/src/storage.ts b/src/storage.ts index 2080047..1a88046 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -8,11 +8,17 @@ type Address = { address: string; }; +type MultisigHistoryEntry = { + network: NetworkChoice; + address: string; +}; + type SafelyStorage = { addresses: Address[]; multisig?: string; network?: NetworkChoice; profile?: string; + multisigHistory?: MultisigHistoryEntry[]; }; // Config path: ~/.safely/config.json @@ -24,6 +30,7 @@ const defaultData: SafelyStorage = { multisig: undefined, network: undefined, profile: undefined, + multisigHistory: [], }; // Ensure config directory exists @@ -157,3 +164,41 @@ export const ensureMultisigAddressExists = createEnsure( export const ensureNetworkExists = createEnsure(NetworkDefault, 'No network provided'); export const ensureProfileExists = createEnsure(ProfileDefault, 'No profile provided'); + +// **Multisig History Operations** +export const MultisigHistory = { + async getAll(): Promise { + return readStorage((config) => config.multisigHistory || []); + }, + + async getForNetwork(network: NetworkChoice): Promise { + return readStorage((config) => { + const history = config.multisigHistory || []; + return history.filter((entry) => entry.network === network).map((entry) => entry.address); + }); + }, + + async add(network: NetworkChoice, address: string) { + await updateStorage((config) => { + if (!config.multisigHistory) { + config.multisigHistory = []; + } + // Dedupe: check if this network+address combo already exists + const exists = config.multisigHistory.some( + (entry) => entry.network === network && entry.address === address + ); + if (!exists) { + config.multisigHistory.push({ network, address }); + } + }); + }, + + async remove(network: NetworkChoice, address: string) { + await updateStorage((config) => { + if (!config.multisigHistory) return; + config.multisigHistory = config.multisigHistory.filter( + (entry) => !(entry.network === network && entry.address === address) + ); + }); + }, +}; diff --git a/src/ui/HomeView.tsx b/src/ui/HomeView.tsx index 5cfea7e..6ecb71b 100644 --- a/src/ui/HomeView.tsx +++ b/src/ui/HomeView.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { Box, Text, useInput, useApp, render } from 'ink'; import TextInput from 'ink-text-input'; import Spinner from 'ink-spinner'; -import { ProfileDefault, MultisigDefault, NetworkDefault } from '../storage.js'; +import { ProfileDefault, MultisigDefault, NetworkDefault, MultisigHistory } from '../storage.js'; import { getAllProfiles, ProfileInfo } from '../profiles.js'; import { NETWORK_CHOICES, NetworkChoice } from '../constants.js'; import { validateAddress } from '../validators.js'; @@ -23,6 +23,7 @@ interface Config { profiles: ProfileInfo[]; multisigOwners: string[]; profileAddress: string | null; + multisigHistory: string[]; } interface MenuState { @@ -32,6 +33,7 @@ interface MenuState { multisigInput: string; multisigError: string; isValidating: boolean; + multisigInputMode: boolean; } interface MultisigResource { @@ -44,7 +46,8 @@ const HomeView: React.FC = ({ onNavigate }) => { const [config, setConfig] = useState({ profiles: [], multisigOwners: [], - profileAddress: null + profileAddress: null, + multisigHistory: [] }); const [menu, setMenu] = useState({ selectedIndex: 0, @@ -52,7 +55,8 @@ const HomeView: React.FC = ({ onNavigate }) => { subIndex: 0, multisigInput: '', multisigError: '', - isValidating: false + isValidating: false, + multisigInputMode: false }); // Load configuration @@ -64,13 +68,15 @@ const HomeView: React.FC = ({ onNavigate }) => { ProfileDefault.get() ]); const owners = network && multisig ? await fetchMultisigOwners(network, multisig) : []; + const multisigHistory = network ? await MultisigHistory.getForNetwork(network) : []; setConfig({ network, multisig, profile, profiles: getAllProfiles(), multisigOwners: owners, - profileAddress: null + profileAddress: null, + multisigHistory }); }; load(); @@ -153,6 +159,9 @@ const HomeView: React.FC = ({ onNavigate }) => { newConfig.profile = undefined; newConfig.multisigOwners = []; newConfig.profileAddress = null; + // Load multisig history for new network + const multisigHistory = await MultisigHistory.getForNetwork(updates.network); + newConfig.multisigHistory = multisigHistory; } if ('multisig' in updates && updates.multisig) { await MultisigDefault.set(updates.multisig); @@ -160,6 +169,11 @@ const HomeView: React.FC = ({ onNavigate }) => { if (config.network) { const owners = await fetchMultisigOwners(config.network, updates.multisig); newConfig.multisigOwners = owners; + // Add to history + await MultisigHistory.add(config.network, updates.multisig); + // Refresh history + const multisigHistory = await MultisigHistory.getForNetwork(config.network); + newConfig.multisigHistory = multisigHistory; } } if ('profile' in updates && updates.profile) { @@ -174,7 +188,8 @@ const HomeView: React.FC = ({ onNavigate }) => { expandedItem: null, subIndex: 0, multisigInput: '', - multisigError: '' + multisigError: '', + multisigInputMode: false })); const expandMenuItem = (item: string) => { @@ -190,6 +205,14 @@ const HomeView: React.FC = ({ onNavigate }) => { updates.subIndex = Math.max(0, idx); } else if (item === 'multisig') { updates.multisigInput = config.multisig || ''; + updates.multisigInputMode = config.multisigHistory.length === 0; + updates.isValidating = false; + updates.multisigError = ''; + // If there's history, start with first history item selected + if (config.multisigHistory.length > 0) { + const idx = config.multisig ? config.multisigHistory.indexOf(config.multisig) : -1; + updates.subIndex = Math.max(0, idx); + } } setMenu(m => ({ ...m, ...updates })); @@ -233,39 +256,56 @@ const HomeView: React.FC = ({ onNavigate }) => { } else if (expandedItem === 'multisig') { if (key.escape) { collapseMenu(); - } else if (key.return) { - // Validate and save multisig address - (async () => { - try { - validateAddress(menu.multisigInput); - setMenu(m => ({ ...m, isValidating: true, multisigError: '' })); - - // Check if address has MultisigAccount resource - const aptos = initAptos(config.network!); + } else if (input === 'i' && !menu.multisigInputMode) { + // Switch to input mode + setMenu(m => ({ ...m, multisigInputMode: true, multisigInput: '', multisigError: '', isValidating: false })); + } else if (menu.multisigInputMode) { + // In input mode + if (key.return) { + // Validate and save multisig address + (async () => { try { - const resource = await aptos.getAccountResource({ - accountAddress: menu.multisigInput, - resourceType: '0x1::multisig_account::MultisigAccount' - }); - // Successfully got the resource, it's a valid multisig - await updateConfig({ multisig: menu.multisigInput, multisigOwners: resource.owners }); - collapseMenu(); - } catch (resourceError) { - // Resource doesn't exist or error fetching + validateAddress(menu.multisigInput); + setMenu(m => ({ ...m, isValidating: true, multisigError: '' })); + + // Check if address has MultisigAccount resource + const aptos = initAptos(config.network!); + try { + const resource = await aptos.getAccountResource({ + accountAddress: menu.multisigInput, + resourceType: '0x1::multisig_account::MultisigAccount' + }); + // Successfully got the resource, it's a valid multisig + await updateConfig({ multisig: menu.multisigInput, multisigOwners: resource.owners }); + collapseMenu(); + } catch (resourceError) { + // Resource doesn't exist or error fetching + setMenu(m => ({ + ...m, + multisigError: 'Address is not a multisig account', + isValidating: false + })); + } + } catch (error) { setMenu(m => ({ ...m, - multisigError: 'Address is not a multisig account', + multisigError: String(error).replace('Error: ', ''), isValidating: false })); } - } catch (error) { - setMenu(m => ({ - ...m, - multisigError: String(error).replace('Error: ', ''), - isValidating: false - })); - } - })(); + })(); + } + } else { + // In selection mode (navigating history) + if (key.upArrow) { + setMenu(m => ({ ...m, subIndex: Math.max(0, m.subIndex - 1) })); + } else if (key.downArrow) { + setMenu(m => ({ ...m, subIndex: Math.min(config.multisigHistory.length - 1, m.subIndex + 1) })); + } else if (key.return && config.multisigHistory[subIndex]) { + // Select from history + updateConfig({ multisig: config.multisigHistory[subIndex] }); + collapseMenu(); + } } } else { // Main menu navigation @@ -340,6 +380,10 @@ const HomeView: React.FC = ({ onNavigate }) => { isSelected={selectedIndex === 1} isValidating={menu.isValidating} onInputChange={value => setMenu(m => ({ ...m, multisigInput: value }))} + multisigHistory={config.multisigHistory} + currentMultisig={config.multisig} + subIndex={subIndex} + inputMode={menu.multisigInputMode} /> ) : ( void; -}> = ({ input, error, isSelected, isValidating, onInputChange }) => ( + multisigHistory: string[]; + currentMultisig?: string; + subIndex: number; + inputMode: boolean; +}> = ({ input, error, isSelected, isValidating, onInputChange, multisigHistory, currentMultisig, subIndex, inputMode }) => ( <> {isSelected ? '▼' : ' '} Multisig: - - Address: - {isValidating ? ( - - Validating... - - ) : ( - - )} - - {error && ✗ {error}} - {!isValidating && [Enter] Save | [Esc] Cancel} + {multisigHistory.length > 0 && !inputMode && ( + <> + {multisigHistory.map((address, index) => ( + + {' '}{index === subIndex ? '▶' : ' '} {address.slice(0, 10)}...{address.slice(-6)} + {address === currentMultisig && ✓} + + ))} + [I] Enter new address + + )} + {(multisigHistory.length === 0 || inputMode) && ( + <> + + Address: + {isValidating ? ( + + Validating... + + ) : ( + + )} + + {error && ✗ {error}} + {!isValidating && [Enter] Save | [Esc] Cancel} + + )} ); From 49a1f4af59b4255bcdf5c273a9893298afacbb30 Mon Sep 17 00:00:00 2001 From: 0xbe1 <0xbetrue@gmail.com> Date: Mon, 6 Oct 2025 22:51:42 +0800 Subject: [PATCH 2/2] Show multisig input field in-place with history list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove multisigInputMode toggle for unified UX - Always display both history list and input field together - Arrow keys navigate through history items and input field - Input field appears as "New:" entry at bottom of list - Cursor shows which item is selected (history or input) - No need to press 'I' to switch modes anymore 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/ui/HomeView.tsx | 107 ++++++++++++++++++-------------------------- 1 file changed, 43 insertions(+), 64 deletions(-) diff --git a/src/ui/HomeView.tsx b/src/ui/HomeView.tsx index 6ecb71b..0f6b53b 100644 --- a/src/ui/HomeView.tsx +++ b/src/ui/HomeView.tsx @@ -33,7 +33,6 @@ interface MenuState { multisigInput: string; multisigError: string; isValidating: boolean; - multisigInputMode: boolean; } interface MultisigResource { @@ -55,8 +54,7 @@ const HomeView: React.FC = ({ onNavigate }) => { subIndex: 0, multisigInput: '', multisigError: '', - isValidating: false, - multisigInputMode: false + isValidating: false }); // Load configuration @@ -188,8 +186,7 @@ const HomeView: React.FC = ({ onNavigate }) => { expandedItem: null, subIndex: 0, multisigInput: '', - multisigError: '', - multisigInputMode: false + multisigError: '' })); const expandMenuItem = (item: string) => { @@ -204,15 +201,11 @@ const HomeView: React.FC = ({ onNavigate }) => { const idx = filteredProfiles.findIndex(p => p.name === config.profile); updates.subIndex = Math.max(0, idx); } else if (item === 'multisig') { - updates.multisigInput = config.multisig || ''; - updates.multisigInputMode = config.multisigHistory.length === 0; + updates.multisigInput = ''; updates.isValidating = false; updates.multisigError = ''; - // If there's history, start with first history item selected - if (config.multisigHistory.length > 0) { - const idx = config.multisig ? config.multisigHistory.indexOf(config.multisig) : -1; - updates.subIndex = Math.max(0, idx); - } + // Start with input field selected (subIndex = history.length means input field) + updates.subIndex = config.multisigHistory.length; } setMenu(m => ({ ...m, ...updates })); @@ -256,13 +249,13 @@ const HomeView: React.FC = ({ onNavigate }) => { } else if (expandedItem === 'multisig') { if (key.escape) { collapseMenu(); - } else if (input === 'i' && !menu.multisigInputMode) { - // Switch to input mode - setMenu(m => ({ ...m, multisigInputMode: true, multisigInput: '', multisigError: '', isValidating: false })); - } else if (menu.multisigInputMode) { - // In input mode - if (key.return) { - // Validate and save multisig address + } else if (key.upArrow) { + setMenu(m => ({ ...m, subIndex: Math.max(0, m.subIndex - 1) })); + } else if (key.downArrow) { + setMenu(m => ({ ...m, subIndex: Math.min(config.multisigHistory.length, m.subIndex + 1) })); + } else if (key.return) { + // If on input field (subIndex == history.length), validate and save + if (subIndex === config.multisigHistory.length) { (async () => { try { validateAddress(menu.multisigInput); @@ -294,14 +287,7 @@ const HomeView: React.FC = ({ onNavigate }) => { })); } })(); - } - } else { - // In selection mode (navigating history) - if (key.upArrow) { - setMenu(m => ({ ...m, subIndex: Math.max(0, m.subIndex - 1) })); - } else if (key.downArrow) { - setMenu(m => ({ ...m, subIndex: Math.min(config.multisigHistory.length - 1, m.subIndex + 1) })); - } else if (key.return && config.multisigHistory[subIndex]) { + } else if (config.multisigHistory[subIndex]) { // Select from history updateConfig({ multisig: config.multisigHistory[subIndex] }); collapseMenu(); @@ -383,7 +369,6 @@ const HomeView: React.FC = ({ onNavigate }) => { multisigHistory={config.multisigHistory} currentMultisig={config.multisig} subIndex={subIndex} - inputMode={menu.multisigInputMode} /> ) : ( = ({ input, error, isSelected, isValidating, onInputChange, multisigHistory, currentMultisig, subIndex, inputMode }) => ( - <> - {isSelected ? '▼' : ' '} Multisig: - {multisigHistory.length > 0 && !inputMode && ( - <> - {multisigHistory.map((address, index) => ( - - {' '}{index === subIndex ? '▶' : ' '} {address.slice(0, 10)}...{address.slice(-6)} - {address === currentMultisig && ✓} +}> = ({ input, error, isSelected, isValidating, onInputChange, multisigHistory, currentMultisig, subIndex }) => { + const isInputSelected = subIndex === multisigHistory.length; + + return ( + <> + {isSelected ? '▼' : ' '} Multisig: + {multisigHistory.map((address, index) => ( + + {' '}{index === subIndex ? '▶' : ' '} {address.slice(0, 10)}...{address.slice(-6)} + {address === currentMultisig && ✓} + + ))} + + {isInputSelected ? '▶' : ' '} New: + {isValidating ? ( + + Validating... - ))} - [I] Enter new address - - )} - {(multisigHistory.length === 0 || inputMode) && ( - <> - - Address: - {isValidating ? ( - - Validating... - - ) : ( - - )} - - {error && ✗ {error}} - {!isValidating && [Enter] Save | [Esc] Cancel} - - )} - -); + ) : ( + + )} + + {error && ✗ {error}} + + ); +}; // Component: Profile Expanded const ProfileExpanded: React.FC<{