Skip to content

Commit 14086a2

Browse files
katspaughclaude
andauthored
feat: enhance account list and open commands with better UX (#2)
- Fix account list to update UI independently for each account - Replace Promise.all with independent async fetches - Each account's live data updates as soon as it's available - Fast RPCs no longer blocked by slow ones - Improved perceived performance - Add optional address parameter to account open command - Support EIP-3770 format (shortName:address) for one-step opening - Support bare address with chain prompt - Support fully interactive mode (no arguments) - Better error handling for empty chains configuration - Update README with new account open usage examples 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
1 parent 95e7c44 commit 14086a2

File tree

5 files changed

+151
-71
lines changed

5 files changed

+151
-71
lines changed

AGENTS.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
Run the following commands before committing:
2+
3+
* npm run lint
4+
* npm run format
5+
* npm run typecheck
6+
* npm run test
7+
8+
If any errors pop up, fix them before committing.
9+
10+
If in the course of development or testing you need to clear or modify the local configs, back up the existing ones first, and restore them when finished.

README.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ safe wallet remove # Remove a wallet
7878
```bash
7979
safe account create # Create new Safe account
8080
safe account deploy [account] # Deploy predicted Safe (EIP-3770 format)
81-
safe account open # Open existing Safe
81+
safe account open [address] # Open existing Safe (EIP-3770 format or bare address)
8282
safe account list # List all Safe accounts
8383
safe account info [account] # Show Safe details (EIP-3770 format)
8484
safe account add-owner [account] # Add a new owner to a Safe
@@ -96,6 +96,27 @@ Examples:
9696

9797
This makes it clear which chain a Safe belongs to. Commands will interactively prompt for Safe selection when needed.
9898

99+
**Opening an Existing Safe:**
100+
101+
The `account open` command supports three usage modes:
102+
103+
1. **EIP-3770 format** - Provide both chain and address in one argument:
104+
```bash
105+
safe account open sep:0x742d35Cc6634C0532925a3b844Bc454e4438f44e
106+
```
107+
108+
2. **Bare address** - Provide just the address, CLI will ask for chain:
109+
```bash
110+
safe account open 0x742d35Cc6634C0532925a3b844Bc454e4438f44e
111+
# → Prompts for chain selection
112+
```
113+
114+
3. **Interactive** - No arguments, CLI asks for both chain and address:
115+
```bash
116+
safe account open
117+
# → Prompts for chain and address
118+
```
119+
99120
### Transaction Management
100121

101122
```bash

src/cli.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -178,11 +178,11 @@ account
178178
})
179179

180180
account
181-
.command('open')
182-
.description('Open an existing Safe')
183-
.action(async () => {
181+
.command('open [address]')
182+
.description('Open an existing Safe (EIP-3770 format: shortName:address)')
183+
.action(async (address?: string) => {
184184
try {
185-
await openSafe()
185+
await openSafe(address)
186186
} catch (error) {
187187
handleError(error)
188188
}

src/commands/account/open.ts

Lines changed: 82 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as p from '@clack/prompts'
22
import pc from 'picocolors'
3-
import { type Address } from 'viem'
3+
import { type Address, isAddress } from 'viem'
44
import { getConfigStore } from '../../storage/config-store.js'
55
import { getSafeStorage } from '../../storage/safe-store.js'
66
import { SafeService } from '../../services/safe-service.js'
@@ -9,47 +9,98 @@ import { checksumAddress, shortenAddress } from '../../utils/ethereum.js'
99
import { logError } from '../../ui/messages.js'
1010
import { renderScreen } from '../../ui/render.js'
1111
import { SafeOpenSuccessScreen } from '../../ui/screens/index.js'
12+
import { parseEIP3770, getChainIdFromShortName } from '../../utils/eip3770.js'
1213

13-
export async function openSafe() {
14+
export async function openSafe(addressArg?: string) {
1415
p.intro(pc.bgCyan(pc.black(' Open Existing Safe ')))
1516

1617
const configStore = getConfigStore()
1718
const safeStorage = getSafeStorage()
19+
const chains = configStore.getAllChains()
1820

19-
// Select chain
20-
const chains = Object.values(configStore.getAllChains())
21-
const chainId = await p.select({
22-
message: 'Select chain:',
23-
options: chains.map((chain) => ({
24-
value: chain.chainId,
25-
label: `${chain.name} (${chain.chainId})`,
26-
})),
27-
})
28-
29-
if (p.isCancel(chainId)) {
30-
p.cancel('Operation cancelled')
21+
if (Object.keys(chains).length === 0) {
22+
logError('No chains configured. Please run "safe config init" first.')
3123
return
3224
}
3325

34-
const chain = configStore.getChain(chainId as string)!
35-
36-
// Get Safe address
37-
const address = await p.text({
38-
message: 'Safe address:',
39-
placeholder: '0x...',
40-
validate: (value) => {
41-
if (!value) return 'Address is required'
42-
if (!isValidAddress(value)) return 'Invalid Ethereum address'
43-
return undefined
44-
},
45-
})
46-
47-
if (p.isCancel(address)) {
48-
p.cancel('Operation cancelled')
49-
return
26+
let chainId: string
27+
let safeAddress: Address
28+
29+
// Scenario 1: EIP-3770 format provided (shortName:address)
30+
if (addressArg && addressArg.includes(':')) {
31+
try {
32+
const { shortName, address } = parseEIP3770(addressArg)
33+
chainId = getChainIdFromShortName(shortName, chains)
34+
safeAddress = address
35+
} catch (error) {
36+
logError(error instanceof Error ? error.message : 'Invalid address format')
37+
return
38+
}
39+
}
40+
// Scenario 2: Bare address provided - ask for chain
41+
else if (addressArg) {
42+
if (!isAddress(addressArg)) {
43+
logError('Invalid Ethereum address')
44+
return
45+
}
46+
safeAddress = checksumAddress(addressArg) as Address
47+
48+
// Ask for chain
49+
const chainsList = Object.values(chains)
50+
const selectedChainId = await p.select({
51+
message: 'Select chain:',
52+
options: chainsList.map((chain) => ({
53+
value: chain.chainId,
54+
label: `${chain.name} (${chain.chainId})`,
55+
})),
56+
})
57+
58+
if (p.isCancel(selectedChainId)) {
59+
p.cancel('Operation cancelled')
60+
return
61+
}
62+
63+
chainId = selectedChainId as string
64+
}
65+
// Scenario 3: No address provided - ask for both chain and address
66+
else {
67+
const chainsList = Object.values(chains)
68+
69+
const selectedChainId = await p.select({
70+
message: 'Select chain:',
71+
options: chainsList.map((chain) => ({
72+
value: chain.chainId,
73+
label: `${chain.name} (${chain.chainId})`,
74+
})),
75+
})
76+
77+
if (p.isCancel(selectedChainId)) {
78+
p.cancel('Operation cancelled')
79+
return
80+
}
81+
82+
chainId = selectedChainId as string
83+
84+
// Get Safe address
85+
const address = await p.text({
86+
message: 'Safe address:',
87+
placeholder: '0x...',
88+
validate: (value) => {
89+
if (!value) return 'Address is required'
90+
if (!isValidAddress(value)) return 'Invalid Ethereum address'
91+
return undefined
92+
},
93+
})
94+
95+
if (p.isCancel(address)) {
96+
p.cancel('Operation cancelled')
97+
return
98+
}
99+
100+
safeAddress = checksumAddress(address as string) as Address
50101
}
51102

52-
const safeAddress = checksumAddress(address as string) as Address
103+
const chain = configStore.getChain(chainId)!
53104

54105
// Check if already exists
55106
if (safeStorage.safeExists(safeAddress, chain.chainId)) {

src/ui/screens/AccountListScreen.tsx

Lines changed: 33 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState, useEffect } from 'react'
1+
import React, { useState, useEffect, useRef } from 'react'
22
import { Box, Text } from 'ink'
33
import type { Address } from 'viem'
44
import { useSafes } from '../hooks/index.js'
@@ -37,6 +37,7 @@ export function AccountListScreen({ onExit }: AccountListScreenProps): React.Rea
3737
const { safes, loading, error } = useSafes()
3838
const [liveData, setLiveData] = useState<Map<string, SafeLiveData>>(new Map())
3939
const [fetchingLive, setFetchingLive] = useState(false)
40+
const completedCountRef = useRef(0)
4041

4142
// Fetch live data for deployed Safes
4243
useEffect(() => {
@@ -46,43 +47,40 @@ export function AccountListScreen({ onExit }: AccountListScreenProps): React.Rea
4647
if (deployedSafes.length === 0) return
4748

4849
setFetchingLive(true)
49-
50-
// Fetch live data in parallel
51-
Promise.all(
52-
deployedSafes.map(async (safe) => {
53-
const configStore = getConfigStore()
54-
const chain = configStore.getChain(safe.chainId)
55-
56-
if (!chain) {
57-
return { address: safe.address, data: { error: true } }
50+
completedCountRef.current = 0
51+
52+
// Fetch live data independently for each safe
53+
deployedSafes.forEach(async (safe) => {
54+
const configStore = getConfigStore()
55+
const chain = configStore.getChain(safe.chainId)
56+
57+
if (!chain) {
58+
setLiveData((prev) => new Map(prev).set(safe.address, { error: true }))
59+
completedCountRef.current++
60+
if (completedCountRef.current === deployedSafes.length) {
61+
setFetchingLive(false)
62+
if (onExit) onExit()
5863
}
64+
return
65+
}
5966

60-
try {
61-
const txService = new TransactionService(chain)
62-
const [owners, threshold] = await Promise.all([
63-
txService.getOwners(safe.address as Address),
64-
txService.getThreshold(safe.address as Address),
65-
])
66-
67-
return {
68-
address: safe.address,
69-
data: { owners, threshold },
70-
}
71-
} catch {
72-
return { address: safe.address, data: { error: true } }
67+
try {
68+
const txService = new TransactionService(chain)
69+
const [owners, threshold] = await Promise.all([
70+
txService.getOwners(safe.address as Address),
71+
txService.getThreshold(safe.address as Address),
72+
])
73+
74+
// Update state independently as soon as data is available
75+
setLiveData((prev) => new Map(prev).set(safe.address, { owners, threshold }))
76+
} catch {
77+
setLiveData((prev) => new Map(prev).set(safe.address, { error: true }))
78+
} finally {
79+
completedCountRef.current++
80+
if (completedCountRef.current === deployedSafes.length) {
81+
setFetchingLive(false)
82+
if (onExit) onExit()
7383
}
74-
})
75-
).then((results) => {
76-
const newLiveData = new Map<string, SafeLiveData>()
77-
results.forEach(({ address, data }) => {
78-
newLiveData.set(address, data)
79-
})
80-
setLiveData(newLiveData)
81-
setFetchingLive(false)
82-
83-
// Auto-exit after live data is loaded
84-
if (onExit) {
85-
onExit()
8684
}
8785
})
8886
}, [loading, safes, onExit])

0 commit comments

Comments
 (0)