@akta/dao-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,560 @@
1
+ import { getNetworkAppIds } from "@akta/sdk";
2
+ import type { AkitaNetwork } from "@akta/sdk";
3
+ import algosdk from "algosdk";
4
+ import { renderKV, renderColumns, padEndVisible } from "../../output";
5
+ import theme from "../../theme";
6
+ import {
7
+ truncateAddress,
8
+ getAppName,
9
+ resolveAppName,
10
+ camelToLabel,
11
+ isZeroAddress,
12
+ delegationTypeLabel,
13
+ formatDuration,
14
+ formatTimestamp,
15
+ formatBigInt,
16
+ formatCompact,
17
+ colorBool,
18
+ parsePluginKey,
19
+ resolveMethodSelector,
20
+ } from "../../formatting";
21
+ import { renderPanel, splitWidth } from "../panels";
22
+ import type { LoadResult, View, ViewContext } from "../types";
23
+
24
+ // ── Module-level account selection state ────────────────────────
25
+
26
+ let _accountIdx = 0;
27
+ let _accountCount = 1;
28
+ let _selectedLine = 0; // line of selected account in left panel content
29
+
30
+ export function cycleWalletAccount(dir: 1 | -1): void {
31
+ if (_accountCount <= 1) return;
32
+ _accountIdx = (_accountIdx + dir + _accountCount) % _accountCount;
33
+ }
34
+
35
+ export function resetWalletAccount(): void {
36
+ _accountIdx = 0;
37
+ }
38
+
39
+ export function getWalletAccountCount(): number {
40
+ return _accountCount;
41
+ }
42
+
43
+ export function getSelectedAccountLine(): number {
44
+ return _selectedLine;
45
+ }
46
+
47
+ // ── Cached wallet data ──────────────────────────────────────────
48
+
49
+ interface WalletCache {
50
+ ts: number;
51
+ globalState: Record<string, unknown>;
52
+ plugins: [string, PluginData][];
53
+ namedPlugins: [string, { plugin: bigint; caller: string; escrow: string }][];
54
+ escrows: [string, { id: bigint; locked: boolean }][];
55
+ allowances: [string, AllowanceData][];
56
+ executions: [string, { firstValid: bigint; lastValid: bigint }][];
57
+ balances: Map<string, { algo: bigint; akta: bigint; bones: bigint; usdc: bigint }>;
58
+ decimals: { akta: number; bones: number; usdc: number };
59
+ }
60
+
61
+ interface PluginData {
62
+ admin: boolean;
63
+ delegationType: number;
64
+ coverFees: boolean;
65
+ canReclaim: boolean;
66
+ useExecutionKey: boolean;
67
+ useRounds: boolean;
68
+ cooldown: bigint;
69
+ lastCalled: bigint;
70
+ start: bigint;
71
+ lastValid: bigint;
72
+ methods: { name: Uint8Array; cooldown: bigint; lastCalled: bigint }[];
73
+ }
74
+
75
+ interface AllowanceData {
76
+ type: string;
77
+ amount?: bigint;
78
+ spent?: bigint;
79
+ rate?: bigint;
80
+ max?: bigint;
81
+ interval?: bigint;
82
+ }
83
+
84
+ const CACHE_TTL = 30_000;
85
+ let _cache: WalletCache | null = null;
86
+
87
+ function invalidateCache(): void {
88
+ _cache = null;
89
+ }
90
+
91
+ export { invalidateCache as invalidateWalletCache };
92
+
93
+ // ── Hidden fields for wallet info ───────────────────────────────
94
+
95
+ const HIDDEN_WALLET_FIELDS = new Set([
96
+ "spendingAddress",
97
+ "currentPlugin",
98
+ "rekeyIndex",
99
+ ]);
100
+
101
+ // ── Main wallet view ────────────────────────────────────────────
102
+
103
+ export const walletView: View = {
104
+ async load(ctx: ViewContext): Promise<string[] | LoadResult> {
105
+ const { dao, network, width } = ctx;
106
+ const wallet = await dao.getWallet();
107
+ const ids = getNetworkAppIds(network);
108
+
109
+ // Fetch and cache data
110
+ if (!_cache || Date.now() - _cache.ts > CACHE_TTL) {
111
+ const [globalState, plugins, namedPlugins, escrows, allowances, executions] = await Promise.all([
112
+ wallet.getGlobalState(),
113
+ wallet.getPlugins(),
114
+ wallet.getNamedPlugins(),
115
+ wallet.getEscrows(),
116
+ wallet.getAllowances(),
117
+ wallet.getExecutions(),
118
+ ]);
119
+
120
+ const escrowEntries = Array.from(escrows.entries()).sort(([a], [b]) => a.localeCompare(b));
121
+
122
+ // Fetch balances + asset decimals in parallel
123
+ const mainAddr = algosdk.getApplicationAddress(wallet.appId).toString();
124
+ const addresses = [mainAddr, ...escrowEntries.map(([, info]) =>
125
+ algosdk.getApplicationAddress(info.id).toString()
126
+ )];
127
+ const balanceMap = new Map<string, { algo: bigint; akta: bigint; bones: bigint; usdc: bigint }>();
128
+
129
+ const [balanceResults, assetDecimals] = await Promise.all([
130
+ Promise.allSettled(
131
+ addresses.map(async (addr) => {
132
+ const accountInfo = await dao.algorand.account.getInformation(addr);
133
+ const algo = BigInt((accountInfo as any).amount);
134
+ const findAsset = (id: bigint) =>
135
+ accountInfo.assets?.find((a: { assetId: bigint }) => a.assetId === id);
136
+ return {
137
+ addr,
138
+ algo,
139
+ akta: findAsset(ids.akta)?.amount ?? 0n,
140
+ bones: findAsset(ids.bones)?.amount ?? 0n,
141
+ usdc: findAsset(ids.usdc)?.amount ?? 0n,
142
+ };
143
+ })
144
+ ),
145
+ Promise.all([
146
+ dao.algorand.asset.getById(ids.akta).then(a => a.decimals).catch(() => 0),
147
+ dao.algorand.asset.getById(ids.bones).then(a => a.decimals).catch(() => 0),
148
+ dao.algorand.asset.getById(ids.usdc).then(a => a.decimals).catch(() => 6),
149
+ ]),
150
+ ]);
151
+
152
+ for (const result of balanceResults) {
153
+ if (result.status === "fulfilled") {
154
+ const { addr, algo, akta, bones, usdc } = result.value;
155
+ balanceMap.set(addr, { algo, akta, bones, usdc });
156
+ }
157
+ }
158
+
159
+ _cache = {
160
+ ts: Date.now(),
161
+ globalState: globalState as unknown as Record<string, unknown>,
162
+ plugins: Array.from(plugins.entries()) as [string, PluginData][],
163
+ namedPlugins: Array.from(namedPlugins.entries()) as [string, { plugin: bigint; caller: string; escrow: string }][],
164
+ escrows: escrowEntries as [string, { id: bigint; locked: boolean }][],
165
+ allowances: Array.from(allowances.entries()) as [string, AllowanceData][],
166
+ executions: Array.from(executions.entries()).map(([key, info]) => [
167
+ Buffer.from(key as Uint8Array).toString("hex"),
168
+ info as { firstValid: bigint; lastValid: bigint },
169
+ ]),
170
+ balances: balanceMap,
171
+ decimals: { akta: assetDecimals[0], bones: assetDecimals[1], usdc: assetDecimals[2] },
172
+ };
173
+ }
174
+
175
+ const cache = _cache!;
176
+
177
+ // Build account list: main wallet + escrows
178
+ const mainAddr = algosdk.getApplicationAddress(wallet.appId).toString();
179
+ const accounts: { name: string; address: string; escrowName: string }[] = [
180
+ { name: "Main Wallet", address: mainAddr, escrowName: "" },
181
+ ];
182
+ for (const [escrowName, info] of cache.escrows) {
183
+ accounts.push({
184
+ name: escrowName,
185
+ address: algosdk.getApplicationAddress(info.id).toString(),
186
+ escrowName,
187
+ });
188
+ }
189
+
190
+ _accountCount = accounts.length;
191
+ if (_accountIdx >= _accountCount) _accountIdx = 0;
192
+
193
+ const selectedAccount = accounts[_accountIdx];
194
+
195
+ if (width < 80) {
196
+ return renderSingleColumn(cache, network, accounts, selectedAccount, wallet.appId);
197
+ }
198
+
199
+ // Two-panel layout
200
+ const [leftW, rightW] = splitWidth(width, 2);
201
+
202
+ const selectedAppId = selectedAccount.escrowName === ""
203
+ ? wallet.appId
204
+ : cache.escrows.find(([n]) => n === selectedAccount.escrowName)?.[1].id ?? 0n;
205
+
206
+ const leftLines = renderLeftPanel(cache, network, accounts, leftW, wallet.appId);
207
+ const rightLines = renderRightPanel(cache, network, selectedAccount, selectedAppId, rightW);
208
+
209
+ return {
210
+ lines: ["", ...leftLines],
211
+ fixedRight: ["", ...rightLines],
212
+ };
213
+ },
214
+ };
215
+
216
+ // ── Left panel: Wallet Info + Account List ─────────────────────
217
+
218
+ function renderLeftPanel(
219
+ cache: WalletCache,
220
+ network: AkitaNetwork,
221
+ accounts: { name: string; address: string; escrowName: string }[],
222
+ width: number,
223
+ walletAppId: bigint,
224
+ ): string[] {
225
+ const lines: string[] = [];
226
+
227
+ // Wallet Info section
228
+ const infoPairs: [string, string][] = [];
229
+ const gs = cache.globalState;
230
+
231
+ const fields: [string, string][] = [
232
+ ["Version", gs.version as string ?? "-"],
233
+ ["Admin", typeof gs.admin === "string" ? truncateAddress(gs.admin) : "-"],
234
+ ["Domain", typeof gs.domain === "string" && gs.domain ? gs.domain : "-"],
235
+ ["Nickname", typeof gs.nickname === "string" && gs.nickname ? gs.nickname : "-"],
236
+ ["DAO", typeof gs.akitaDao === "bigint" ? resolveAppName(gs.akitaDao as bigint, network) : "-"],
237
+ ["Factory", typeof gs.factoryApp === "bigint" && (gs.factoryApp as bigint) > 0n
238
+ ? resolveAppName(gs.factoryApp as bigint, network)
239
+ : "-"],
240
+ ];
241
+
242
+ if (typeof gs.referrer === "string" && !isZeroAddress(gs.referrer)) {
243
+ fields.push(["Referrer", truncateAddress(gs.referrer)]);
244
+ }
245
+
246
+ for (const [label, value] of fields) {
247
+ infoPairs.push([label, value]);
248
+ }
249
+
250
+ const infoContent = renderKV(infoPairs);
251
+ lines.push(...renderPanel(infoContent, { title: "Wallet Info", width }));
252
+
253
+ // Account list section
254
+ lines.push("");
255
+ const accountsStart = lines.length + 1; // +1 for panel top border
256
+ const accountContent: string[] = [];
257
+
258
+ // Main wallet (full width)
259
+ {
260
+ const acct = accounts[0];
261
+ const selected = _accountIdx === 0;
262
+ if (selected) _selectedLine = accountsStart + accountContent.length;
263
+ const marker = selected ? theme.cursor("▸ ") : " ";
264
+ const bal = cache.balances.get(acct.address);
265
+ const addr = truncateAddress(acct.address, 4);
266
+ const pluginCount = cache.plugins.filter(([key]) => parsePluginKey(key).escrow === "").length;
267
+
268
+ accountContent.push(`${marker}${selected ? theme.selected(acct.name) : acct.name} ${theme.label(addr)}`);
269
+ const details = [
270
+ bal ? `${formatAlgoCompact(bal.algo)} · ${formatCompact(bal.akta, cache.decimals.akta)} AKTA` : null,
271
+ `${pluginCount} plugin${pluginCount !== 1 ? "s" : ""}`,
272
+ ].filter(Boolean).join(" · ");
273
+ accountContent.push(` ${theme.label(details)}`);
274
+ }
275
+
276
+ // Grouped escrows in 2 columns
277
+ if (accounts.length > 1) {
278
+ const groups = buildEscrowGroups(accounts, cache);
279
+ const colWidth = Math.floor((width - 4) / 2);
280
+ const leftGroups = groups.filter((_, i) => i % 2 === 0);
281
+ const rightGroups = groups.filter((_, i) => i % 2 === 1);
282
+ const { lines: leftCol, selectedLine: leftSel } = renderGroupColumn(leftGroups, cache.decimals);
283
+ const { lines: rightCol, selectedLine: rightSel } = renderGroupColumn(rightGroups, cache.decimals);
284
+ const maxLen = Math.max(leftCol.length, rightCol.length);
285
+
286
+ accountContent.push("");
287
+ const mergeStart = accountContent.length;
288
+ for (let i = 0; i < maxLen; i++) {
289
+ const left = leftCol[i] ?? "";
290
+ const right = rightCol[i] ?? "";
291
+ accountContent.push(padEndVisible(left, colWidth) + right);
292
+ }
293
+
294
+ // Track selected line from whichever column has it
295
+ const selInCol = leftSel >= 0 ? leftSel : rightSel;
296
+ if (selInCol >= 0) {
297
+ _selectedLine = accountsStart + mergeStart + selInCol;
298
+ }
299
+ }
300
+
301
+ lines.push(...renderPanel(accountContent, { title: `Accounts (${accounts.length})`, width }));
302
+
303
+ return lines;
304
+ }
305
+
306
+ // ── Right panel: Plugins, Named Plugins, Allowances, Executions ─
307
+
308
+ function renderRightPanel(
309
+ cache: WalletCache,
310
+ network: AkitaNetwork,
311
+ selectedAccount: { name: string; address: string; escrowName: string },
312
+ appId: bigint,
313
+ width: number,
314
+ ): string[] {
315
+ const lines: string[] = [];
316
+ const escrowFilter = selectedAccount.escrowName;
317
+
318
+ // Account info header
319
+ const accountContent = renderKV([
320
+ ["App ID", appId.toString()],
321
+ ["Address", selectedAccount.address],
322
+ ]);
323
+ lines.push(...renderPanel(accountContent, { title: selectedAccount.name, width }));
324
+ lines.push("");
325
+
326
+ // Filter plugins by selected account
327
+ const filteredPlugins = cache.plugins.filter(([key]) => {
328
+ const parsed = parsePluginKey(key);
329
+ return escrowFilter === "" ? parsed.escrow === "" : parsed.escrow === escrowFilter;
330
+ });
331
+
332
+ // Plugins section
333
+ if (filteredPlugins.length > 0) {
334
+ for (let i = 0; i < filteredPlugins.length; i++) {
335
+ const [key, info] = filteredPlugins[i];
336
+ const { pluginId, caller } = parsePluginKey(key);
337
+ const pluginName = pluginId ? getAppName(pluginId, network) : undefined;
338
+ const pluginLabel = pluginName
339
+ ? `${pluginName} (${pluginId})`
340
+ : pluginId?.toString() ?? key;
341
+
342
+ const callerLabel = caller === "" || isZeroAddress(caller) ? theme.globalCaller("Global") : truncateAddress(caller);
343
+ const pairs: [string, string][] = [
344
+ ["Caller", callerLabel],
345
+ ["Admin", colorBool(info.admin)],
346
+ ["Delegation", delegationTypeLabel(info.delegationType)],
347
+ ["Cover Fees", colorBool(info.coverFees)],
348
+ ["Can Reclaim", colorBool(info.canReclaim)],
349
+ ["Use Exec Key", colorBool(info.useExecutionKey)],
350
+ ["Use Rounds", colorBool(info.useRounds)],
351
+ ];
352
+ if (info.cooldown > 0n) pairs.push(["Cooldown", formatDuration(info.cooldown)]);
353
+ if (info.lastCalled > 0n) pairs.push(["Last Called", formatTimestamp(info.lastCalled)]);
354
+ if (info.start > 0n) pairs.push(["Start", formatTimestamp(info.start)]);
355
+ if (info.lastValid < BigInt("18446744073709551615")) {
356
+ pairs.push(["Last Valid", info.useRounds ? info.lastValid.toString() : formatTimestamp(info.lastValid)]);
357
+ }
358
+
359
+ const content = renderKV(pairs);
360
+
361
+ if (info.methods.length > 0) {
362
+ const methods = info.methods.map((m) => resolveMethodSelector(m.name));
363
+ content.push(" " + theme.label("Methods: ") + methods.join(", "));
364
+ }
365
+
366
+ if (i > 0) lines.push("");
367
+ lines.push(...renderPanel(content, { title: pluginLabel, width }));
368
+ }
369
+ } else {
370
+ lines.push(...renderPanel([" No plugins for this account."], { title: "Plugins", width }));
371
+ }
372
+
373
+ // Named Plugins section (not filtered by escrow - show all)
374
+ if (cache.namedPlugins.length > 0) {
375
+ const namedRows = cache.namedPlugins.map(([name, key]) => [
376
+ name,
377
+ key.plugin.toString(),
378
+ truncateAddress(key.caller),
379
+ key.escrow || "(default)",
380
+ ]);
381
+ lines.push("");
382
+ const namedContent = renderColumns(["Name", "Plugin App", "Caller", "Escrow"], namedRows);
383
+ lines.push(...renderPanel(namedContent, { title: "Named Plugins", width }));
384
+ }
385
+
386
+ // Allowances filtered by escrow
387
+ const filteredAllowances = cache.allowances.filter(([key]) => {
388
+ // Allowance keys are stringified AllowanceKey {escrow, asset}
389
+ // The key format from ValueMap is the stringified version
390
+ // For main wallet, escrow is "" or the escrow name
391
+ if (escrowFilter === "") return true; // Show all for main wallet
392
+ return key.includes(escrowFilter);
393
+ });
394
+
395
+ if (filteredAllowances.length > 0) {
396
+ const rows = filteredAllowances.map(([key, info]) => {
397
+ let details: string;
398
+ if (info.type === "flat") {
399
+ details = `amount: ${formatBigInt(info.amount!)}, spent: ${formatBigInt(info.spent!)}`;
400
+ } else if (info.type === "window") {
401
+ details = `amount: ${formatBigInt(info.amount!)}, spent: ${formatBigInt(info.spent!)}, interval: ${formatDuration(info.interval!)}`;
402
+ } else {
403
+ details = `rate: ${formatBigInt(info.rate!)}, max: ${formatBigInt(info.max!)}, interval: ${formatDuration(info.interval!)}`;
404
+ }
405
+ return [key, info.type, details];
406
+ });
407
+ lines.push("");
408
+ const allowContent = renderColumns(["Key", "Type", "Details"], rows);
409
+ lines.push(...renderPanel(allowContent, { title: "Allowances", width }));
410
+ }
411
+
412
+ // Execution keys (show all)
413
+ if (cache.executions.length > 0) {
414
+ const rows = cache.executions.map(([key, info]) => [
415
+ key.slice(0, 16) + "...",
416
+ info.firstValid.toString(),
417
+ info.lastValid.toString(),
418
+ ]);
419
+ lines.push("");
420
+ const execContent = renderColumns(["Lease (hex)", "First Valid", "Last Valid"], rows);
421
+ lines.push(...renderPanel(execContent, { title: "Execution Keys", width }));
422
+ }
423
+
424
+ return lines;
425
+ }
426
+
427
+ // ── Single-column fallback ──────────────────────────────────────
428
+
429
+ function renderSingleColumn(
430
+ cache: WalletCache,
431
+ network: AkitaNetwork,
432
+ accounts: { name: string; address: string; escrowName: string }[],
433
+ selectedAccount: { name: string; address: string; escrowName: string },
434
+ walletAppId: bigint,
435
+ ): string[] {
436
+ const width = 78; // Narrow fallback width
437
+ const allLines: string[] = [""];
438
+
439
+ // Wallet Info
440
+ const gs = cache.globalState;
441
+ const infoPairs: [string, string][] = [
442
+ ["Version", gs.version as string ?? "-"],
443
+ ["Admin", typeof gs.admin === "string" ? truncateAddress(gs.admin) : "-"],
444
+ ["DAO", typeof gs.akitaDao === "bigint" ? resolveAppName(gs.akitaDao as bigint, network) : "-"],
445
+ ];
446
+ allLines.push(...renderPanel(renderKV(infoPairs), { title: "Wallet Info", width }));
447
+
448
+ // Account list with selection (grouped)
449
+ allLines.push("");
450
+ const accountLines: string[] = [];
451
+ {
452
+ const selected = _accountIdx === 0;
453
+ const marker = selected ? theme.cursor("▸ ") : " ";
454
+ accountLines.push(`${marker}${selected ? theme.selected(accounts[0].name) : accounts[0].name}`);
455
+ }
456
+ if (accounts.length > 1) {
457
+ const groups = buildEscrowGroups(accounts, cache);
458
+ accountLines.push("");
459
+ accountLines.push(...renderGroupColumn(groups, cache.decimals).lines);
460
+ }
461
+ allLines.push(...renderPanel(accountLines, { title: `Accounts (${accounts.length})`, width }));
462
+
463
+ // Right-side content for selected account
464
+ const selectedAppId = selectedAccount.escrowName === ""
465
+ ? walletAppId
466
+ : cache.escrows.find(([n]) => n === selectedAccount.escrowName)?.[1].id ?? 0n;
467
+ const right = renderRightPanel(cache, network, selectedAccount, selectedAppId, width);
468
+ allLines.push("");
469
+ allLines.push(...right);
470
+
471
+ return allLines;
472
+ }
473
+
474
+ // ── Helpers ─────────────────────────────────────────────────────
475
+
476
+ function formatAlgoCompact(microAlgos: bigint): string {
477
+ const whole = microAlgos / 1_000_000n;
478
+ return `${formatCompact(whole)} ALGO`;
479
+ }
480
+
481
+ // ── Escrow grouping ─────────────────────────────────────────────
482
+
483
+ interface EscrowGroupItem {
484
+ idx: number;
485
+ acct: { name: string; address: string; escrowName: string };
486
+ locked: boolean;
487
+ pluginCount: number;
488
+ balances?: { algo: bigint; akta: bigint; bones: bigint; usdc: bigint };
489
+ }
490
+
491
+ interface EscrowGroup {
492
+ label: string;
493
+ items: EscrowGroupItem[];
494
+ }
495
+
496
+ function escrowPrefix(name: string): string {
497
+ const underscoreIdx = name.indexOf("_");
498
+ if (underscoreIdx > 0) return name.slice(0, underscoreIdx);
499
+ const match = name.match(/^[a-z]+/);
500
+ return match ? match[0] : name;
501
+ }
502
+
503
+ function buildEscrowGroups(
504
+ accounts: { name: string; address: string; escrowName: string }[],
505
+ cache: WalletCache,
506
+ ): EscrowGroup[] {
507
+ const groupMap = new Map<string, EscrowGroup>();
508
+ const groupOrder: string[] = [];
509
+
510
+ for (let i = 1; i < accounts.length; i++) {
511
+ const acct = accounts[i];
512
+ const prefix = escrowPrefix(acct.escrowName);
513
+ if (!groupMap.has(prefix)) {
514
+ const label = prefix.charAt(0).toUpperCase() + prefix.slice(1);
515
+ groupMap.set(prefix, { label, items: [] });
516
+ groupOrder.push(prefix);
517
+ }
518
+ const escrowInfo = cache.escrows.find(([n]) => n === acct.escrowName)?.[1];
519
+ const pluginCount = cache.plugins.filter(([key]) => {
520
+ const parsed = parsePluginKey(key);
521
+ return parsed.escrow === acct.escrowName;
522
+ }).length;
523
+ groupMap.get(prefix)!.items.push({
524
+ idx: i,
525
+ acct,
526
+ locked: escrowInfo?.locked ?? false,
527
+ pluginCount,
528
+ balances: cache.balances.get(acct.address),
529
+ });
530
+ }
531
+
532
+ return groupOrder.map((p) => groupMap.get(p)!);
533
+ }
534
+
535
+ function renderGroupColumn(groups: EscrowGroup[], decimals: { akta: number; bones: number; usdc: number }): { lines: string[]; selectedLine: number } {
536
+ const lines: string[] = [];
537
+ let selectedLine = -1;
538
+ for (const g of groups) {
539
+ if (lines.length > 0) lines.push("");
540
+ lines.push(theme.label(g.label));
541
+ for (const item of g.items) {
542
+ const selected = item.idx === _accountIdx;
543
+ if (selected) selectedLine = lines.length;
544
+ const marker = selected ? theme.cursor("▸ ") : " ";
545
+ const name = selected ? theme.selected(item.acct.name) : item.acct.name;
546
+ const lockStr = item.locked ? theme.locked(" locked") : theme.unlocked(" unlocked");
547
+ lines.push(`${marker}${name}${lockStr}`);
548
+ if (item.balances) {
549
+ const parts: string[] = [];
550
+ if (item.balances.algo > 0n) parts.push(`${formatAlgoCompact(item.balances.algo)}`);
551
+ if (item.balances.usdc > 0n) parts.push(`${formatCompact(item.balances.usdc, decimals.usdc)} USDC`);
552
+ if (item.balances.akta > 0n) parts.push(`${formatCompact(item.balances.akta, decimals.akta)} AKTA`);
553
+ if (item.balances.bones > 0n) parts.push(`${formatCompact(item.balances.bones, decimals.bones)} BONES`);
554
+ if (parts.length > 0) lines.push(` ${theme.label(parts.join(" · "))}`);
555
+ }
556
+ lines.push(` ${theme.label(`${item.pluginCount} plugin${item.pluginCount !== 1 ? "s" : ""}`)}`);
557
+ }
558
+ }
559
+ return { lines, selectedLine };
560
+ }