@tuskydp/cli 0.1.0 → 0.1.1

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.
Files changed (87) hide show
  1. package/bin/tuskydp.ts +2 -0
  2. package/dist/src/commands/account.d.ts.map +1 -1
  3. package/dist/src/commands/account.js +5 -2
  4. package/dist/src/commands/account.js.map +1 -1
  5. package/dist/src/commands/auth.d.ts.map +1 -1
  6. package/dist/src/commands/auth.js +2 -1
  7. package/dist/src/commands/auth.js.map +1 -1
  8. package/dist/src/commands/files.d.ts.map +1 -1
  9. package/dist/src/commands/files.js +9 -4
  10. package/dist/src/commands/files.js.map +1 -1
  11. package/dist/src/commands/mcp.js +1 -1
  12. package/dist/src/commands/mcp.js.map +1 -1
  13. package/dist/src/commands/rehydrate.d.ts.map +1 -1
  14. package/dist/src/commands/rehydrate.js +5 -2
  15. package/dist/src/commands/rehydrate.js.map +1 -1
  16. package/dist/src/commands/upload.d.ts.map +1 -1
  17. package/dist/src/commands/upload.js +5 -0
  18. package/dist/src/commands/upload.js.map +1 -1
  19. package/dist/src/config.d.ts +0 -2
  20. package/dist/src/config.d.ts.map +1 -1
  21. package/dist/src/config.js +5 -4
  22. package/dist/src/config.js.map +1 -1
  23. package/dist/src/index.js +16 -2
  24. package/dist/src/index.js.map +1 -1
  25. package/dist/src/lib/keyring.d.ts.map +1 -1
  26. package/dist/src/lib/keyring.js +3 -5
  27. package/dist/src/lib/keyring.js.map +1 -1
  28. package/dist/src/lib/output.js +1 -1
  29. package/dist/src/lib/output.js.map +1 -1
  30. package/dist/src/lib/resolve.js +1 -1
  31. package/dist/src/lib/resolve.js.map +1 -1
  32. package/dist/src/mcp/tools/files.d.ts.map +1 -1
  33. package/dist/src/mcp/tools/files.js +20 -0
  34. package/dist/src/mcp/tools/files.js.map +1 -1
  35. package/dist/src/mcp/tools/folders.d.ts.map +1 -1
  36. package/dist/src/mcp/tools/folders.js +15 -0
  37. package/dist/src/mcp/tools/folders.js.map +1 -1
  38. package/dist/src/mcp/tools/trash.d.ts.map +1 -1
  39. package/dist/src/mcp/tools/trash.js +14 -0
  40. package/dist/src/mcp/tools/trash.js.map +1 -1
  41. package/dist/src/sdk.d.ts +1 -1
  42. package/dist/src/sdk.d.ts.map +1 -1
  43. package/dist/src/sdk.js +3 -3
  44. package/dist/src/sdk.js.map +1 -1
  45. package/dist/src/tui/auth-screen.d.ts.map +1 -1
  46. package/dist/src/tui/auth-screen.js +7 -1
  47. package/dist/src/tui/auth-screen.js.map +1 -1
  48. package/package.json +12 -18
  49. package/src/__tests__/crypto.test.ts +315 -0
  50. package/src/commands/account.ts +82 -0
  51. package/src/commands/auth.ts +190 -0
  52. package/src/commands/decrypt.ts +276 -0
  53. package/src/commands/download.ts +82 -0
  54. package/src/commands/encryption.ts +305 -0
  55. package/src/commands/export.ts +251 -0
  56. package/src/commands/files.ts +192 -0
  57. package/src/commands/mcp.ts +220 -0
  58. package/src/commands/rehydrate.ts +37 -0
  59. package/src/commands/tui.ts +11 -0
  60. package/src/commands/upload.ts +143 -0
  61. package/src/commands/vault.ts +132 -0
  62. package/src/config.ts +38 -0
  63. package/src/crypto.ts +130 -0
  64. package/src/index.ts +79 -0
  65. package/src/lib/keyring.ts +50 -0
  66. package/src/lib/output.ts +36 -0
  67. package/src/lib/progress.ts +5 -0
  68. package/src/lib/resolve.ts +26 -0
  69. package/src/mcp/context.ts +22 -0
  70. package/src/mcp/server.ts +140 -0
  71. package/src/mcp/tools/account.ts +40 -0
  72. package/src/mcp/tools/files.ts +428 -0
  73. package/src/mcp/tools/folders.ts +109 -0
  74. package/src/mcp/tools/helpers.ts +28 -0
  75. package/src/mcp/tools/trash.ts +82 -0
  76. package/src/mcp/tools/vaults.ts +114 -0
  77. package/src/sdk.ts +115 -0
  78. package/src/tui/auth-screen.ts +176 -0
  79. package/src/tui/dialogs.ts +339 -0
  80. package/src/tui/files-panel.ts +165 -0
  81. package/src/tui/helpers.ts +206 -0
  82. package/src/tui/index.ts +420 -0
  83. package/src/tui/overview.ts +155 -0
  84. package/src/tui/status-bar.ts +61 -0
  85. package/src/tui/vaults-panel.ts +143 -0
  86. package/tsconfig.json +9 -0
  87. package/vitest.config.ts +7 -0
@@ -0,0 +1,420 @@
1
+ import blessed from 'blessed';
2
+ import { readFileSync, statSync, readdirSync } from 'fs';
3
+ import { basename, join, resolve } from 'path';
4
+
5
+ function collectFiles(dir: string): string[] {
6
+ const results: string[] = [];
7
+ const entries = readdirSync(dir, { withFileTypes: true });
8
+ for (const entry of entries) {
9
+ const full = join(dir, entry.name);
10
+ if (entry.isFile()) results.push(full);
11
+ else if (entry.isDirectory()) results.push(...collectFiles(full));
12
+ }
13
+ return results;
14
+ }
15
+ import { lookup } from 'mime-types';
16
+ import { createSDKClient } from '../sdk.js';
17
+ import { cliConfig, getApiUrl } from '../config.js';
18
+ import { encryptBuffer } from '../crypto.js';
19
+ import { loadMasterKey } from '../lib/keyring.js';
20
+ import { showAuthScreen } from './auth-screen.js';
21
+ import { VaultsPanel, VaultItem } from './vaults-panel.js';
22
+ import { FilesPanel } from './files-panel.js';
23
+ import { StatusBar } from './status-bar.js';
24
+ import { showOverview } from './overview.js';
25
+ import { confirmDialog, textInputDialog, selectDialog, detailPopup, fileBrowserDialog } from './dialogs.js';
26
+ import { formatBytes, formatDate, formatRow, showError, showMessage, statusColor, statusColorClose, getCurrentTheme, cycleTheme, Theme } from './helpers.js';
27
+
28
+ function formatDetailTable(rows: [string, string][]): string {
29
+ const labelW = Math.max(...rows.map(([l]) => l.length));
30
+ return rows.map(([label, value]) => ` {bold}${label.padEnd(labelW)}{/bold} │ ${value}`).join('\n');
31
+ }
32
+
33
+ type FocusTarget = 'vaults' | 'files';
34
+
35
+ export async function launchTui() {
36
+ const screen = blessed.screen({
37
+ smartCSR: true,
38
+ title: 'Tusky TUI',
39
+ fullUnicode: true,
40
+ });
41
+
42
+ // Check auth
43
+ let apiKey = cliConfig.get('apiKey');
44
+ let apiUrl = getApiUrl();
45
+
46
+ if (!apiKey) {
47
+ const authResult = await showAuthScreen(screen);
48
+ if (!authResult) {
49
+ screen.destroy();
50
+ process.exit(0);
51
+ }
52
+ apiKey = authResult.apiKey;
53
+ apiUrl = authResult.apiUrl;
54
+ }
55
+
56
+ const sdk = createSDKClient(apiUrl, apiKey);
57
+
58
+ // Create main layout
59
+ const leftBox = blessed.box({
60
+ parent: screen,
61
+ top: 0,
62
+ left: 0,
63
+ width: '40%',
64
+ height: '100%-1',
65
+ });
66
+
67
+ const rightBox = blessed.box({
68
+ parent: screen,
69
+ top: 0,
70
+ left: '40%',
71
+ width: '60%',
72
+ height: '100%-1',
73
+ });
74
+
75
+ const statusBar = new StatusBar(screen);
76
+ const vaultsPanel = new VaultsPanel(screen, sdk, leftBox, (vault) => {
77
+ filesPanel.loadForVault(vault.id, vault.name);
78
+ });
79
+ const filesPanel = new FilesPanel(screen, sdk, rightBox);
80
+
81
+ let currentFocus: FocusTarget = 'vaults';
82
+
83
+ function setFocus(target: FocusTarget) {
84
+ currentFocus = target;
85
+ if (target === 'vaults') {
86
+ vaultsPanel.focus();
87
+ filesPanel.blur();
88
+ statusBar.setHints('n:new ←→:switch u:upload o:overview ?:help');
89
+ } else {
90
+ filesPanel.focus();
91
+ vaultsPanel.blur();
92
+ statusBar.setHints('u:upload d:delete r:retry ←→:switch Enter:detail ?:help');
93
+ }
94
+ }
95
+
96
+ // Load account info for status bar
97
+ async function loadStatusBar() {
98
+ try {
99
+ const account = await sdk.account.get();
100
+ statusBar.setUser(
101
+ account.email || '',
102
+ account.planName || account.planTier || 'ppu',
103
+ formatBytes(account.storageUsedBytes || 0),
104
+ formatBytes(account.storageLimitBytes || 0),
105
+ );
106
+ } catch {
107
+ // Silently fail — status bar will show defaults
108
+ }
109
+ }
110
+
111
+ // Key bindings
112
+ screen.key(['q', 'C-c'], () => {
113
+ screen.destroy();
114
+ process.exit(0);
115
+ });
116
+
117
+ screen.key(['left'], () => {
118
+ setFocus('vaults');
119
+ });
120
+
121
+ screen.key(['right', 'tab'], () => {
122
+ if (filesPanel.getCurrentVaultId()) {
123
+ setFocus('files');
124
+ }
125
+ });
126
+
127
+ screen.key(['n'], async () => {
128
+ const name = await textInputDialog(screen, 'Vault name');
129
+ if (!name) {
130
+ setFocus(currentFocus);
131
+ return;
132
+ }
133
+ const visIdx = await selectDialog(screen, 'Visibility', ['private', 'public']);
134
+ if (visIdx < 0) {
135
+ setFocus(currentFocus);
136
+ return;
137
+ }
138
+ const visibility = visIdx === 0 ? 'private' as const : 'public' as const;
139
+ try {
140
+ await sdk.vaults.create({ name, visibility });
141
+ await vaultsPanel.load();
142
+ showMessage(screen, `Vault "${name}" created`);
143
+ } catch (err: any) {
144
+ showError(screen, err.message);
145
+ }
146
+ setFocus(currentFocus);
147
+ });
148
+
149
+ screen.key(['u'], async () => {
150
+ const vaultId = filesPanel.getCurrentVaultId();
151
+ if (!vaultId) {
152
+ showError(screen, 'Select a vault first');
153
+ setFocus(currentFocus);
154
+ return;
155
+ }
156
+
157
+ const selection = await fileBrowserDialog(screen);
158
+ if (!selection) {
159
+ setFocus(currentFocus);
160
+ return;
161
+ }
162
+
163
+ // Collect all file paths
164
+ let filePaths: string[] = [];
165
+ if (selection.isDirectory) {
166
+ filePaths = collectFiles(selection.path);
167
+ if (filePaths.length === 0) {
168
+ showError(screen, 'No files found in directory');
169
+ setFocus(currentFocus);
170
+ return;
171
+ }
172
+ } else {
173
+ filePaths = [selection.path];
174
+ }
175
+
176
+ try {
177
+ const vault = await sdk.vaults.get(vaultId);
178
+ const isPrivate = vault.visibility === 'private';
179
+ let masterKey: Buffer | null = null;
180
+
181
+ if (isPrivate) {
182
+ masterKey = loadMasterKey();
183
+ if (!masterKey) {
184
+ showError(screen, 'Encryption session not unlocked. Run: tusky encryption unlock');
185
+ setFocus(currentFocus);
186
+ return;
187
+ }
188
+ }
189
+
190
+ let successCount = 0;
191
+ const total = filePaths.length;
192
+
193
+ for (const filePath of filePaths) {
194
+ const fileName = basename(filePath);
195
+ statusBar.setHints(`Uploading ${successCount + 1}/${total}: ${fileName}...`);
196
+ screen.render();
197
+
198
+ const stat = statSync(filePath);
199
+ const fileBuffer = readFileSync(filePath);
200
+ const mimeType = lookup(filePath) || 'application/octet-stream';
201
+
202
+ let uploadBody: Buffer;
203
+ let encryptionMeta: {
204
+ wrappedKey?: string;
205
+ encryptionIv?: string;
206
+ plaintextSizeBytes?: number;
207
+ plaintextChecksumSha256?: string;
208
+ } = {};
209
+
210
+ if (isPrivate && masterKey) {
211
+ const { ciphertext, wrappedKey, iv, plaintextChecksum } = encryptBuffer(fileBuffer, masterKey);
212
+ uploadBody = ciphertext;
213
+ encryptionMeta = {
214
+ wrappedKey,
215
+ encryptionIv: iv,
216
+ plaintextSizeBytes: stat.size,
217
+ plaintextChecksumSha256: plaintextChecksum,
218
+ };
219
+ } else {
220
+ uploadBody = fileBuffer;
221
+ }
222
+
223
+ const { fileId, uploadUrl } = await sdk.files.requestUpload({
224
+ name: fileName,
225
+ mimeType,
226
+ sizeBytes: uploadBody.length,
227
+ vaultId,
228
+ ...encryptionMeta,
229
+ });
230
+
231
+ const uploadResponse = await fetch(uploadUrl, {
232
+ method: 'PUT',
233
+ headers: { 'Content-Type': 'application/octet-stream' },
234
+ body: new Uint8Array(uploadBody),
235
+ });
236
+
237
+ if (!uploadResponse.ok) throw new Error(`Upload failed for ${fileName}: ${uploadResponse.status}`);
238
+
239
+ await sdk.files.confirmUpload(fileId);
240
+ successCount++;
241
+ }
242
+
243
+ showMessage(screen, `Uploaded ${successCount} file(s)`);
244
+ await filesPanel.loadForVault(vaultId, filesPanel.getCurrentVaultName());
245
+ await vaultsPanel.load();
246
+ } catch (err: any) {
247
+ showError(screen, err.message);
248
+ }
249
+ setFocus(currentFocus);
250
+ });
251
+
252
+ screen.key(['d', 'delete'], async () => {
253
+ if (currentFocus === 'vaults') {
254
+ const vault = vaultsPanel.getSelected();
255
+ if (!vault) return;
256
+ const confirmed = await confirmDialog(screen, `Delete vault "${vault.name}" and ALL its files?`);
257
+ if (confirmed) {
258
+ try {
259
+ await sdk.vaults.delete(vault.id, { force: true });
260
+ filesPanel.clear();
261
+ await vaultsPanel.load();
262
+ showMessage(screen, `Vault "${vault.name}" deleted`);
263
+ } catch (err: any) {
264
+ showError(screen, err.message);
265
+ }
266
+ }
267
+ setFocus(currentFocus);
268
+ } else {
269
+ const file = filesPanel.getSelected();
270
+ if (!file) return;
271
+ const confirmed = await confirmDialog(screen, `Delete file "${file.name}"?`);
272
+ if (confirmed) {
273
+ try {
274
+ await sdk.files.delete(file.id);
275
+ const vaultId = filesPanel.getCurrentVaultId();
276
+ if (vaultId) {
277
+ await filesPanel.loadForVault(vaultId, filesPanel.getCurrentVaultName());
278
+ await vaultsPanel.load();
279
+ }
280
+ showMessage(screen, `File "${file.name}" deleted`);
281
+ } catch (err: any) {
282
+ showError(screen, err.message);
283
+ }
284
+ }
285
+ setFocus(currentFocus);
286
+ }
287
+ });
288
+
289
+ screen.key(['enter'], () => {
290
+ if (currentFocus === 'vaults') {
291
+ const vault = vaultsPanel.getSelected();
292
+ if (vault) {
293
+ const rows: [string, string][] = [
294
+ ['Name', vault.name],
295
+ ['Slug', vault.slug],
296
+ ['ID', vault.id],
297
+ ['Visibility', vault.visibility],
298
+ ['Files', String(vault.fileCount)],
299
+ ['Size', formatBytes(vault.totalSizeBytes)],
300
+ ];
301
+ detailPopup(screen, vault.name, formatDetailTable(rows));
302
+ }
303
+ } else {
304
+ const file = filesPanel.getSelected();
305
+ if (file) {
306
+ const open = statusColor(file.status);
307
+ const close = statusColorClose(file.status);
308
+ const rows: [string, string][] = [
309
+ ['Name', file.name],
310
+ ['ID', file.id],
311
+ ['MIME', file.mimeType],
312
+ ['Size', formatBytes(file.plaintextSizeBytes ?? file.sizeBytes)],
313
+ ['Status', `${open}${file.status}${close}`],
314
+ ['Encrypted', file.encrypted ? 'Yes' : 'No'],
315
+ ['Uploaded', file.createdAt ? formatDate(file.createdAt) : 'N/A'],
316
+ ['Blob ID', file.walrusBlobId || 'N/A'],
317
+ ['Blob Object', file.walrusBlobObjectId || 'N/A'],
318
+ ];
319
+ detailPopup(screen, file.name, formatDetailTable(rows));
320
+ }
321
+ }
322
+ });
323
+
324
+ screen.key(['r'], async () => {
325
+ if (currentFocus === 'files') {
326
+ const file = filesPanel.getSelected();
327
+ if (!file) return;
328
+ if (file.status !== 'error') {
329
+ showMessage(screen, 'Only error files can be retried');
330
+ return;
331
+ }
332
+ try {
333
+ await sdk.files.retry(file.id);
334
+ const vaultId = filesPanel.getCurrentVaultId();
335
+ if (vaultId) {
336
+ await filesPanel.loadForVault(vaultId, filesPanel.getCurrentVaultName());
337
+ }
338
+ showMessage(screen, `Retry queued for "${file.name}"`);
339
+ } catch (err: any) {
340
+ showError(screen, err.message);
341
+ }
342
+ setFocus(currentFocus);
343
+ }
344
+ });
345
+
346
+ screen.key(['o'], async () => {
347
+ await showOverview(screen, sdk);
348
+ setFocus(currentFocus);
349
+ });
350
+
351
+ screen.key(['S-l'], async () => {
352
+ const confirmed = await confirmDialog(screen, 'Log out?');
353
+ if (confirmed) {
354
+ cliConfig.delete('apiKey' as any);
355
+ screen.destroy();
356
+ process.exit(0);
357
+ }
358
+ setFocus(currentFocus);
359
+ });
360
+
361
+ function applyTheme(theme: Theme) {
362
+ // Vaults panel
363
+ vaultsPanel.list.style.border = { fg: currentFocus === 'vaults' ? theme.borderFocus : theme.border } as any;
364
+ vaultsPanel.list.style.fg = theme.fg;
365
+ vaultsPanel.list.style.selected = theme.selected as any;
366
+ (vaultsPanel.list.style as any).focus = { border: { fg: theme.borderFocus } };
367
+
368
+ // Files panel
369
+ filesPanel.list.style.border = { fg: currentFocus === 'files' ? theme.borderFocus : theme.border } as any;
370
+ filesPanel.list.style.fg = theme.fg;
371
+ filesPanel.list.style.selected = theme.selected as any;
372
+ (filesPanel.list.style as any).focus = { border: { fg: theme.borderFocus } };
373
+
374
+ // Status bar
375
+ statusBar.applyTheme(theme);
376
+
377
+ screen.render();
378
+ }
379
+
380
+ screen.key(['t'], () => {
381
+ const theme = cycleTheme();
382
+ applyTheme(theme);
383
+ showMessage(screen, `Theme: ${theme.name}`);
384
+ });
385
+
386
+ screen.key(['?'], () => {
387
+ const helpContent = [
388
+ '{bold}{cyan-fg}Tusky TUI — Keyboard Shortcuts{/cyan-fg}{/bold}',
389
+ '',
390
+ '{bold}Navigation{/bold}',
391
+ ' ←/→ Switch between vaults/files',
392
+ ' Tab Switch between vaults/files',
393
+ ' Up/Down Navigate list',
394
+ '',
395
+ '{bold}Actions{/bold}',
396
+ ' n Create new vault',
397
+ ' u Upload file to current vault',
398
+ ' d/Delete Delete selected item',
399
+ ' r Retry failed file',
400
+ ' Space/Enter View details',
401
+ '',
402
+ '{bold}Screens{/bold}',
403
+ ' o Overview (account, storage, files)',
404
+ ' t Cycle theme',
405
+ ' L Logout',
406
+ ' ? This help',
407
+ ' q/Ctrl-C Quit',
408
+ ].join('\n');
409
+ detailPopup(screen, 'Help', helpContent);
410
+ });
411
+
412
+ // Initial load
413
+ await Promise.all([
414
+ vaultsPanel.load(),
415
+ loadStatusBar(),
416
+ ]);
417
+
418
+ setFocus('vaults');
419
+ screen.render();
420
+ }
@@ -0,0 +1,155 @@
1
+ import blessed from 'blessed';
2
+ import type { TuskyClient } from '@tuskydp/sdk';
3
+ import { formatBytes, formatDate, statusColor, statusColorClose } from './helpers.js';
4
+
5
+ export async function showOverview(
6
+ screen: blessed.Widgets.Screen,
7
+ sdk: TuskyClient,
8
+ ): Promise<void> {
9
+ return new Promise((resolve) => {
10
+ const container = blessed.box({
11
+ parent: screen,
12
+ top: 0,
13
+ left: 0,
14
+ width: '100%',
15
+ height: '100%',
16
+ tags: true,
17
+ scrollable: true,
18
+ alwaysScroll: true,
19
+ keys: true,
20
+ vi: true,
21
+ scrollbar: {
22
+ style: { bg: 'cyan' },
23
+ },
24
+ border: { type: 'line' },
25
+ style: {
26
+ border: { fg: 'cyan' },
27
+ fg: 'white',
28
+ },
29
+ label: ' Overview ',
30
+ });
31
+
32
+ const loading = blessed.box({
33
+ parent: container,
34
+ top: 'center',
35
+ left: 'center',
36
+ width: 'shrink',
37
+ height: 1,
38
+ content: 'Loading...',
39
+ tags: true,
40
+ });
41
+
42
+ screen.render();
43
+
44
+ (async () => {
45
+ try {
46
+ const [account, vaults, filesData] = await Promise.all([
47
+ sdk.account.get(),
48
+ sdk.vaults.list(),
49
+ sdk.files.list({ limit: 20, sortBy: 'createdAt', order: 'desc' }),
50
+ ]);
51
+
52
+ loading.destroy();
53
+
54
+ const files = filesData.files;
55
+
56
+ const storageUsed = account.storageUsedBytes || 0;
57
+ const storageLimit = account.storageLimitBytes || 0;
58
+ const pct = storageLimit > 0 ? Math.round((storageUsed / storageLimit) * 100) : 0;
59
+ const barWidth = 30;
60
+ const filled = Math.round((pct / 100) * barWidth);
61
+ const barFill = '█'.repeat(filled);
62
+ const barEmpty = '░'.repeat(barWidth - filled);
63
+ const barColor = pct > 90 ? '{red-fg}' : pct > 70 ? '{yellow-fg}' : '{green-fg}';
64
+ const barColorClose = pct > 90 ? '{/red-fg}' : pct > 70 ? '{/yellow-fg}' : '{/green-fg}';
65
+
66
+ let content = '';
67
+ content += '{bold}{cyan-fg}Account{/cyan-fg}{/bold}\n';
68
+ content += ` Email: ${account.email || 'N/A'}\n`;
69
+ content += ` Plan: ${account.planName || account.planTier || 'ppu'}\n`;
70
+ content += ` Storage: ${formatBytes(storageUsed)} / ${formatBytes(storageLimit)}\n`;
71
+ content += ` ${barColor}${barFill}${barEmpty}${barColorClose} ${pct}%\n`;
72
+ content += '\n';
73
+ content += `{bold}{cyan-fg}Vaults (${vaults.length}){/cyan-fg}{/bold}\n`;
74
+
75
+ if (vaults.length > 0) {
76
+ // Calculate column widths for vaults table
77
+ const vaultRows = vaults.map((v) => ({
78
+ name: v.name || '',
79
+ vis: v.visibility === 'private' ? 'private' : 'public',
80
+ visTag: v.visibility === 'private' ? '{yellow-fg}private{/yellow-fg}' : '{green-fg}public{/green-fg}',
81
+ files: String(v.fileCount || 0),
82
+ size: formatBytes(v.totalSizeBytes || 0),
83
+ }));
84
+ const vNameW = Math.max(4, ...vaultRows.map((r) => r.name.length));
85
+ const vVisW = 7; // "private" is longest
86
+ const vFilesW = Math.max(5, ...vaultRows.map((r) => r.files.length));
87
+ const vSizeW = Math.max(4, ...vaultRows.map((r) => r.size.length));
88
+
89
+ // Header
90
+ content += ` ${'Name'.padEnd(vNameW)} ${'Vis'.padEnd(vVisW)} ${'Files'.padStart(vFilesW)} ${'Size'.padStart(vSizeW)}\n`;
91
+ content += ` ${'─'.repeat(vNameW)} ${'─'.repeat(vVisW)} ${'─'.repeat(vFilesW)} ${'─'.repeat(vSizeW)}\n`;
92
+ for (const r of vaultRows) {
93
+ // Pad the plain text width, but use the tagged version for display
94
+ const visPad = ' '.repeat(Math.max(0, vVisW - r.vis.length));
95
+ content += ` ${r.name.padEnd(vNameW)} ${r.visTag}${visPad} ${r.files.padStart(vFilesW)} ${r.size.padStart(vSizeW)}\n`;
96
+ }
97
+ }
98
+
99
+ content += '\n';
100
+ content += '{bold}{cyan-fg}Recent Files{/cyan-fg}{/bold}\n';
101
+
102
+ if (files.length === 0) {
103
+ content += ' (no files yet)\n';
104
+ } else {
105
+ // Calculate column widths for files table
106
+ const fileRows = files.map((f) => ({
107
+ name: f.name || '',
108
+ size: formatBytes(f.plaintextSizeBytes ?? f.sizeBytes ?? 0),
109
+ status: f.status || '',
110
+ statusTag: `${statusColor(f.status)}${f.status}${statusColorClose(f.status)}`,
111
+ date: f.createdAt ? formatDate(f.createdAt) : 'N/A',
112
+ }));
113
+ const fNameW = Math.max(4, ...fileRows.map((r) => r.name.length));
114
+ const fSizeW = Math.max(4, ...fileRows.map((r) => r.size.length));
115
+ const fStatW = Math.max(6, ...fileRows.map((r) => r.status.length));
116
+ const fDateW = Math.max(4, ...fileRows.map((r) => r.date.length));
117
+
118
+ // Clamp name width to something reasonable
119
+ const maxNameW = Math.min(fNameW, 40);
120
+
121
+ // Header
122
+ content += ` ${'Name'.padEnd(maxNameW)} ${'Size'.padStart(fSizeW)} ${'Status'.padEnd(fStatW)} ${'Date'.padEnd(fDateW)}\n`;
123
+ content += ` ${'─'.repeat(maxNameW)} ${'─'.repeat(fSizeW)} ${'─'.repeat(fStatW)} ${'─'.repeat(fDateW)}\n`;
124
+ for (const r of fileRows) {
125
+ const truncName = r.name.length > maxNameW ? r.name.slice(0, maxNameW - 1) + '…' : r.name;
126
+ const statusPad = ' '.repeat(Math.max(0, fStatW - r.status.length));
127
+ content += ` ${truncName.padEnd(maxNameW)} ${r.size.padStart(fSizeW)} ${r.statusTag}${statusPad} ${r.date}\n`;
128
+ }
129
+ }
130
+
131
+ content += '\n{gray-fg}Press Escape to return{/gray-fg}';
132
+
133
+ container.setContent(content);
134
+ container.focus();
135
+ screen.render();
136
+ } catch (err: any) {
137
+ loading.setContent(`{red-fg}Error: ${err.message}{/red-fg}`);
138
+ screen.render();
139
+ }
140
+ })();
141
+
142
+ container.key(['escape', 'q'], () => {
143
+ container.destroy();
144
+ screen.render();
145
+ resolve();
146
+ });
147
+
148
+ screen.key(['escape'], function escHandler() {
149
+ screen.unkey('escape', escHandler);
150
+ container.destroy();
151
+ screen.render();
152
+ resolve();
153
+ });
154
+ });
155
+ }
@@ -0,0 +1,61 @@
1
+ import blessed from 'blessed';
2
+ import type { Theme } from './helpers.js';
3
+
4
+ export class StatusBar {
5
+ box: blessed.Widgets.BoxElement;
6
+ private screen: blessed.Widgets.Screen;
7
+ private email = '';
8
+ private plan = '';
9
+ private storageUsed = '';
10
+ private storageLimit = '';
11
+ private hints = '';
12
+
13
+ constructor(screen: blessed.Widgets.Screen) {
14
+ this.screen = screen;
15
+ this.box = blessed.box({
16
+ parent: screen,
17
+ bottom: 0,
18
+ left: 0,
19
+ width: '100%',
20
+ height: 1,
21
+ tags: true,
22
+ style: {
23
+ fg: 'white',
24
+ bg: 'blue',
25
+ },
26
+ });
27
+ this.render();
28
+ }
29
+
30
+ setUser(email: string, plan: string, storageUsed: string, storageLimit: string) {
31
+ this.email = email;
32
+ this.plan = plan;
33
+ this.storageUsed = storageUsed;
34
+ this.storageLimit = storageLimit;
35
+ this.render();
36
+ }
37
+
38
+ setHints(hints: string) {
39
+ this.hints = hints;
40
+ this.render();
41
+ }
42
+
43
+ private render() {
44
+ const left = this.email
45
+ ? `{bold}${this.email}{/bold} | ${this.plan} | ${this.storageUsed} / ${this.storageLimit}`
46
+ : '{bold}Tusky{/bold} — Decentralized Storage';
47
+ const right = this.hints ? `${this.hints} ` : '';
48
+ this.box.setContent(` ${left}${right ? ' | ' + right : ''}`);
49
+ this.screen.render();
50
+ }
51
+
52
+ applyTheme(theme: Theme) {
53
+ this.box.style.bg = theme.statusBar.bg;
54
+ this.box.style.fg = theme.statusBar.fg;
55
+ this.render();
56
+ }
57
+
58
+ destroy() {
59
+ this.box.destroy();
60
+ }
61
+ }