opencode-account-manager 0.4.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 (131) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +266 -0
  3. package/dist/cli.d.ts +3 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +183 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/core/accounts.d.ts +19 -0
  8. package/dist/core/accounts.d.ts.map +1 -0
  9. package/dist/core/accounts.js +181 -0
  10. package/dist/core/accounts.js.map +1 -0
  11. package/dist/core/config-store.d.ts +48 -0
  12. package/dist/core/config-store.d.ts.map +1 -0
  13. package/dist/core/config-store.js +206 -0
  14. package/dist/core/config-store.js.map +1 -0
  15. package/dist/core/crypto.d.ts +40 -0
  16. package/dist/core/crypto.d.ts.map +1 -0
  17. package/dist/core/crypto.js +172 -0
  18. package/dist/core/crypto.js.map +1 -0
  19. package/dist/core/importers/amJson.d.ts +17 -0
  20. package/dist/core/importers/amJson.d.ts.map +1 -0
  21. package/dist/core/importers/amJson.js +131 -0
  22. package/dist/core/importers/amJson.js.map +1 -0
  23. package/dist/core/opencode-config.d.ts +92 -0
  24. package/dist/core/opencode-config.d.ts.map +1 -0
  25. package/dist/core/opencode-config.js +148 -0
  26. package/dist/core/opencode-config.js.map +1 -0
  27. package/dist/core/paths.d.ts +5 -0
  28. package/dist/core/paths.d.ts.map +1 -0
  29. package/dist/core/paths.js +38 -0
  30. package/dist/core/paths.js.map +1 -0
  31. package/dist/core/types.d.ts +74 -0
  32. package/dist/core/types.d.ts.map +1 -0
  33. package/dist/core/types.js +30 -0
  34. package/dist/core/types.js.map +1 -0
  35. package/dist/core/utils.d.ts +5 -0
  36. package/dist/core/utils.d.ts.map +1 -0
  37. package/dist/core/utils.js +35 -0
  38. package/dist/core/utils.js.map +1 -0
  39. package/dist/tui/Dashboard.d.ts +7 -0
  40. package/dist/tui/Dashboard.d.ts.map +1 -0
  41. package/dist/tui/Dashboard.js +331 -0
  42. package/dist/tui/Dashboard.js.map +1 -0
  43. package/dist/tui/components/AccountList.d.ts +18 -0
  44. package/dist/tui/components/AccountList.d.ts.map +1 -0
  45. package/dist/tui/components/AccountList.js +92 -0
  46. package/dist/tui/components/AccountList.js.map +1 -0
  47. package/dist/tui/components/Box.d.ts +11 -0
  48. package/dist/tui/components/Box.d.ts.map +1 -0
  49. package/dist/tui/components/Box.js +15 -0
  50. package/dist/tui/components/Box.js.map +1 -0
  51. package/dist/tui/components/ExportModal.d.ts +10 -0
  52. package/dist/tui/components/ExportModal.d.ts.map +1 -0
  53. package/dist/tui/components/ExportModal.js +192 -0
  54. package/dist/tui/components/ExportModal.js.map +1 -0
  55. package/dist/tui/components/FileBrowser.d.ts +12 -0
  56. package/dist/tui/components/FileBrowser.d.ts.map +1 -0
  57. package/dist/tui/components/FileBrowser.js +349 -0
  58. package/dist/tui/components/FileBrowser.js.map +1 -0
  59. package/dist/tui/components/Header.d.ts +8 -0
  60. package/dist/tui/components/Header.d.ts.map +1 -0
  61. package/dist/tui/components/Header.js +20 -0
  62. package/dist/tui/components/Header.js.map +1 -0
  63. package/dist/tui/components/ImportModal.d.ts +10 -0
  64. package/dist/tui/components/ImportModal.d.ts.map +1 -0
  65. package/dist/tui/components/ImportModal.js +215 -0
  66. package/dist/tui/components/ImportModal.js.map +1 -0
  67. package/dist/tui/components/McpServerList.d.ts +8 -0
  68. package/dist/tui/components/McpServerList.d.ts.map +1 -0
  69. package/dist/tui/components/McpServerList.js +35 -0
  70. package/dist/tui/components/McpServerList.js.map +1 -0
  71. package/dist/tui/components/Menu.d.ts +10 -0
  72. package/dist/tui/components/Menu.d.ts.map +1 -0
  73. package/dist/tui/components/Menu.js +83 -0
  74. package/dist/tui/components/Menu.js.map +1 -0
  75. package/dist/tui/components/PasswordInput.d.ts +12 -0
  76. package/dist/tui/components/PasswordInput.d.ts.map +1 -0
  77. package/dist/tui/components/PasswordInput.js +130 -0
  78. package/dist/tui/components/PasswordInput.js.map +1 -0
  79. package/dist/tui/components/ProviderList.d.ts +8 -0
  80. package/dist/tui/components/ProviderList.d.ts.map +1 -0
  81. package/dist/tui/components/ProviderList.js +37 -0
  82. package/dist/tui/components/ProviderList.js.map +1 -0
  83. package/dist/tui/components/SectionBox.d.ts +10 -0
  84. package/dist/tui/components/SectionBox.d.ts.map +1 -0
  85. package/dist/tui/components/SectionBox.js +16 -0
  86. package/dist/tui/components/SectionBox.js.map +1 -0
  87. package/dist/tui/components/StatsRow.d.ts +13 -0
  88. package/dist/tui/components/StatsRow.d.ts.map +1 -0
  89. package/dist/tui/components/StatsRow.js +18 -0
  90. package/dist/tui/components/StatsRow.js.map +1 -0
  91. package/dist/tui/components/StatusBadge.d.ts +8 -0
  92. package/dist/tui/components/StatusBadge.d.ts.map +1 -0
  93. package/dist/tui/components/StatusBadge.js +30 -0
  94. package/dist/tui/components/StatusBadge.js.map +1 -0
  95. package/dist/tui/components/index.d.ts +14 -0
  96. package/dist/tui/components/index.d.ts.map +1 -0
  97. package/dist/tui/components/index.js +32 -0
  98. package/dist/tui/components/index.js.map +1 -0
  99. package/dist/tui/index.d.ts +5 -0
  100. package/dist/tui/index.d.ts.map +1 -0
  101. package/dist/tui/index.js +13 -0
  102. package/dist/tui/index.js.map +1 -0
  103. package/docs/BLUEPRINT.md +476 -0
  104. package/docs/ROADMAP.md +74 -0
  105. package/package.json +38 -0
  106. package/src/cli.ts +207 -0
  107. package/src/core/accounts.ts +215 -0
  108. package/src/core/config-store.ts +212 -0
  109. package/src/core/crypto.ts +162 -0
  110. package/src/core/importers/amJson.ts +185 -0
  111. package/src/core/opencode-config.ts +217 -0
  112. package/src/core/paths.ts +32 -0
  113. package/src/core/types.ts +118 -0
  114. package/src/core/utils.ts +28 -0
  115. package/src/tui/Dashboard.tsx +431 -0
  116. package/src/tui/components/AccountList.tsx +155 -0
  117. package/src/tui/components/Box.tsx +37 -0
  118. package/src/tui/components/ExportModal.tsx +255 -0
  119. package/src/tui/components/FileBrowser.tsx +393 -0
  120. package/src/tui/components/Header.tsx +26 -0
  121. package/src/tui/components/ImportModal.tsx +288 -0
  122. package/src/tui/components/McpServerList.tsx +67 -0
  123. package/src/tui/components/Menu.tsx +103 -0
  124. package/src/tui/components/PasswordInput.tsx +159 -0
  125. package/src/tui/components/ProviderList.tsx +61 -0
  126. package/src/tui/components/SectionBox.tsx +35 -0
  127. package/src/tui/components/StatsRow.tsx +33 -0
  128. package/src/tui/components/StatusBadge.tsx +33 -0
  129. package/src/tui/components/index.ts +13 -0
  130. package/src/tui/index.tsx +11 -0
  131. package/tsconfig.json +20 -0
@@ -0,0 +1,255 @@
1
+ import React, { useState } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import * as path from "path";
4
+ import { FileBrowser } from "./FileBrowser";
5
+ import { PasswordInput } from "./PasswordInput";
6
+ import {
7
+ Account,
8
+ ExportFormat,
9
+ EncryptedExportFile,
10
+ PortableExportFile,
11
+ } from "../../core/types";
12
+ import { encrypt } from "../../core/crypto";
13
+ import { writeJsonFile } from "../../core/utils";
14
+ import {
15
+ updateLastExportFolder,
16
+ getLastExportFolder,
17
+ } from "../../core/config-store";
18
+
19
+ interface ExportModalProps {
20
+ accounts: Account[];
21
+ onComplete: (filePath: string) => void;
22
+ onCancel: () => void;
23
+ }
24
+
25
+ type ExportStep = "format" | "folder" | "password" | "exporting" | "success" | "error";
26
+
27
+ export function ExportModal({ accounts, onComplete, onCancel }: ExportModalProps) {
28
+ const [step, setStep] = useState<ExportStep>("format");
29
+ const [format, setFormat] = useState<ExportFormat>("encrypted");
30
+ const [folder, setFolder] = useState<string>("");
31
+ const [exportedPath, setExportedPath] = useState<string>("");
32
+ const [error, setError] = useState<string>("");
33
+
34
+ // Generate filename with timestamp
35
+ const generateFilename = (ext: string): string => {
36
+ const date = new Date();
37
+ const dateStr = date.toISOString().split("T")[0]; // YYYY-MM-DD
38
+ return `opencode-accounts-${dateStr}${ext}`;
39
+ };
40
+
41
+ // Handle format selection
42
+ useInput((input, key) => {
43
+ if (step !== "format") return;
44
+
45
+ if (key.escape) {
46
+ onCancel();
47
+ return;
48
+ }
49
+
50
+ if (input === "1") {
51
+ setFormat("encrypted");
52
+ setStep("folder");
53
+ } else if (input === "2") {
54
+ setFormat("plain");
55
+ setStep("folder");
56
+ }
57
+ });
58
+
59
+ // Handle folder selection
60
+ const handleFolderSelect = (selectedFolder: string) => {
61
+ setFolder(selectedFolder);
62
+ updateLastExportFolder(selectedFolder);
63
+
64
+ if (format === "encrypted") {
65
+ setStep("password");
66
+ } else {
67
+ // Plain export - no password needed
68
+ doExport(selectedFolder, undefined);
69
+ }
70
+ };
71
+
72
+ // Handle password submission
73
+ const handlePasswordSubmit = (password: string) => {
74
+ doExport(folder, password);
75
+ };
76
+
77
+ // Do the actual export
78
+ const doExport = (targetFolder: string, password?: string) => {
79
+ setStep("exporting");
80
+
81
+ try {
82
+ const ext = format === "encrypted" ? ".ocam" : ".json";
83
+ const filename = generateFilename(ext);
84
+ const filePath = path.join(targetFolder, filename);
85
+
86
+ if (format === "encrypted" && password) {
87
+ // Create encrypted export
88
+ const payload = {
89
+ version: 1,
90
+ accounts: accounts,
91
+ exportedAt: Date.now(),
92
+ };
93
+
94
+ const encrypted = encrypt(payload, password);
95
+
96
+ const encryptedFile: EncryptedExportFile = {
97
+ version: 1,
98
+ format: "encrypted",
99
+ algorithm: "aes-256-gcm",
100
+ salt: encrypted.salt,
101
+ iv: encrypted.iv,
102
+ authTag: encrypted.authTag,
103
+ data: encrypted.data,
104
+ exportedAt: Date.now(),
105
+ accountCount: accounts.length,
106
+ exportedFrom: "opencode-account-manager",
107
+ };
108
+
109
+ writeJsonFile(filePath, encryptedFile);
110
+ } else {
111
+ // Plain export
112
+ const plainFile: PortableExportFile = {
113
+ version: 1,
114
+ exportedAt: Date.now(),
115
+ exportedFrom: "opencode-account-manager",
116
+ accounts: accounts,
117
+ };
118
+
119
+ writeJsonFile(filePath, plainFile);
120
+ }
121
+
122
+ setExportedPath(filePath);
123
+ setStep("success");
124
+
125
+ // Auto-complete after 2 seconds
126
+ setTimeout(() => {
127
+ onComplete(filePath);
128
+ }, 2000);
129
+ } catch (err) {
130
+ setError(err instanceof Error ? err.message : "Unknown error");
131
+ setStep("error");
132
+ }
133
+ };
134
+
135
+ // Handle success/error dismissal
136
+ useInput((input, key) => {
137
+ if (step === "success" || step === "error") {
138
+ if (key.return || key.escape) {
139
+ if (step === "success") {
140
+ onComplete(exportedPath);
141
+ } else {
142
+ onCancel();
143
+ }
144
+ }
145
+ }
146
+ });
147
+
148
+ return (
149
+ <Box flexDirection="column">
150
+ {/* Format Selection */}
151
+ {step === "format" && (
152
+ <Box
153
+ flexDirection="column"
154
+ borderStyle="round"
155
+ borderColor="cyan"
156
+ paddingX={2}
157
+ paddingY={1}
158
+ >
159
+ <Text bold color="cyan">EXPORT {accounts.length} ACCOUNTS</Text>
160
+ <Box marginY={1} flexDirection="column">
161
+ <Text>Select export format:</Text>
162
+ <Box marginTop={1}>
163
+ <Text color="green">[1] Encrypted (.ocam)</Text>
164
+ <Text dimColor> - Password protected, recommended</Text>
165
+ </Box>
166
+ <Box>
167
+ <Text color="yellow">[2] Plain JSON</Text>
168
+ <Text dimColor> - No encryption (tokens visible!)</Text>
169
+ </Box>
170
+ </Box>
171
+ <Box marginTop={1}>
172
+ <Text dimColor>[1-2] Select [Esc] Cancel</Text>
173
+ </Box>
174
+ </Box>
175
+ )}
176
+
177
+ {/* Folder Selection */}
178
+ {step === "folder" && (
179
+ <FileBrowser
180
+ mode="folder"
181
+ initialPath={getLastExportFolder()}
182
+ title={`Export to folder (${format === "encrypted" ? ".ocam" : ".json"})`}
183
+ onSelect={handleFolderSelect}
184
+ onCancel={onCancel}
185
+ />
186
+ )}
187
+
188
+ {/* Password Input */}
189
+ {step === "password" && (
190
+ <PasswordInput
191
+ mode="confirm"
192
+ title="Set Export Password"
193
+ subtitle={`${accounts.length} accounts`}
194
+ warning="Remember this password! Without it, you cannot recover your accounts."
195
+ onSubmit={handlePasswordSubmit}
196
+ onCancel={() => setStep("folder")}
197
+ />
198
+ )}
199
+
200
+ {/* Exporting */}
201
+ {step === "exporting" && (
202
+ <Box
203
+ flexDirection="column"
204
+ borderStyle="round"
205
+ borderColor="cyan"
206
+ paddingX={2}
207
+ paddingY={1}
208
+ >
209
+ <Text color="cyan">Exporting...</Text>
210
+ </Box>
211
+ )}
212
+
213
+ {/* Success */}
214
+ {step === "success" && (
215
+ <Box
216
+ flexDirection="column"
217
+ borderStyle="round"
218
+ borderColor="green"
219
+ paddingX={2}
220
+ paddingY={1}
221
+ >
222
+ <Text bold color="green">✓ Export Successful!</Text>
223
+ <Box marginTop={1}>
224
+ <Text>Exported {accounts.length} accounts to:</Text>
225
+ </Box>
226
+ <Box>
227
+ <Text color="cyan">{exportedPath}</Text>
228
+ </Box>
229
+ <Box marginTop={1}>
230
+ <Text dimColor>[Enter] Close</Text>
231
+ </Box>
232
+ </Box>
233
+ )}
234
+
235
+ {/* Error */}
236
+ {step === "error" && (
237
+ <Box
238
+ flexDirection="column"
239
+ borderStyle="round"
240
+ borderColor="red"
241
+ paddingX={2}
242
+ paddingY={1}
243
+ >
244
+ <Text bold color="red">✗ Export Failed</Text>
245
+ <Box marginTop={1}>
246
+ <Text color="red">{error}</Text>
247
+ </Box>
248
+ <Box marginTop={1}>
249
+ <Text dimColor>[Enter] Close</Text>
250
+ </Box>
251
+ </Box>
252
+ )}
253
+ </Box>
254
+ );
255
+ }
@@ -0,0 +1,393 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import * as fs from "fs";
4
+ import * as path from "path";
5
+ import { getQuickLocations, QuickLocation } from "../../core/config-store";
6
+
7
+ interface FileBrowserProps {
8
+ mode: "folder" | "file";
9
+ initialPath?: string;
10
+ extensions?: string[]; // e.g., [".ocam", ".json"]
11
+ title?: string;
12
+ onSelect: (selectedPath: string) => void;
13
+ onCancel: () => void;
14
+ }
15
+
16
+ interface FileItem {
17
+ name: string;
18
+ isDirectory: boolean;
19
+ path: string;
20
+ size?: number;
21
+ mtime?: Date;
22
+ }
23
+
24
+ type ViewMode = "quick" | "browse" | "input";
25
+
26
+ export function FileBrowser({
27
+ mode,
28
+ initialPath,
29
+ extensions = [],
30
+ title,
31
+ onSelect,
32
+ onCancel,
33
+ }: FileBrowserProps) {
34
+ const [currentPath, setCurrentPath] = useState(initialPath || process.cwd());
35
+ const [items, setItems] = useState<FileItem[]>([]);
36
+ const [selectedIndex, setSelectedIndex] = useState(0);
37
+ const [viewMode, setViewMode] = useState<ViewMode>("quick");
38
+ const [inputPath, setInputPath] = useState("");
39
+ const [error, setError] = useState<string | null>(null);
40
+ const [quickLocations, setQuickLocations] = useState<QuickLocation[]>([]);
41
+
42
+ // Load quick locations on mount
43
+ useEffect(() => {
44
+ setQuickLocations(getQuickLocations());
45
+ }, []);
46
+
47
+ // Load directory contents when path changes
48
+ useEffect(() => {
49
+ if (viewMode !== "browse") return;
50
+
51
+ try {
52
+ if (!fs.existsSync(currentPath)) {
53
+ setError(`Path not found: ${currentPath}`);
54
+ setItems([]);
55
+ return;
56
+ }
57
+
58
+ const stat = fs.statSync(currentPath);
59
+ if (!stat.isDirectory()) {
60
+ setError("Not a directory");
61
+ setItems([]);
62
+ return;
63
+ }
64
+
65
+ const entries = fs.readdirSync(currentPath, { withFileTypes: true });
66
+ const fileItems: FileItem[] = [];
67
+
68
+ // Add parent directory
69
+ const parentPath = path.dirname(currentPath);
70
+ if (parentPath !== currentPath) {
71
+ fileItems.push({
72
+ name: "..",
73
+ isDirectory: true,
74
+ path: parentPath,
75
+ });
76
+ }
77
+
78
+ // Add directories and files
79
+ for (const entry of entries) {
80
+ const fullPath = path.join(currentPath, entry.name);
81
+ const isDir = entry.isDirectory();
82
+
83
+ // Skip hidden files/folders (starting with .)
84
+ if (entry.name.startsWith(".")) continue;
85
+
86
+ // In file mode, filter by extension
87
+ if (mode === "file" && !isDir) {
88
+ if (extensions.length > 0) {
89
+ const ext = path.extname(entry.name).toLowerCase();
90
+ if (!extensions.includes(ext)) continue;
91
+ }
92
+ }
93
+
94
+ // In folder mode, only show directories
95
+ if (mode === "folder" && !isDir) continue;
96
+
97
+ try {
98
+ const stat = fs.statSync(fullPath);
99
+ fileItems.push({
100
+ name: entry.name,
101
+ isDirectory: isDir,
102
+ path: fullPath,
103
+ size: stat.size,
104
+ mtime: stat.mtime,
105
+ });
106
+ } catch {
107
+ // Skip files we can't stat
108
+ }
109
+ }
110
+
111
+ // Sort: directories first, then files
112
+ fileItems.sort((a, b) => {
113
+ if (a.name === "..") return -1;
114
+ if (b.name === "..") return 1;
115
+ if (a.isDirectory && !b.isDirectory) return -1;
116
+ if (!a.isDirectory && b.isDirectory) return 1;
117
+ return a.name.localeCompare(b.name);
118
+ });
119
+
120
+ setItems(fileItems);
121
+ setSelectedIndex(0);
122
+ setError(null);
123
+ } catch (err) {
124
+ setError(`Error reading directory: ${err}`);
125
+ setItems([]);
126
+ }
127
+ }, [currentPath, viewMode, mode, extensions]);
128
+
129
+ useInput((input, key) => {
130
+ // Handle escape
131
+ if (key.escape) {
132
+ if (viewMode === "input") {
133
+ setViewMode("quick");
134
+ setInputPath("");
135
+ } else {
136
+ onCancel();
137
+ }
138
+ return;
139
+ }
140
+
141
+ // Handle input mode
142
+ if (viewMode === "input") {
143
+ if (key.return) {
144
+ // Try to navigate to input path
145
+ const trimmed = inputPath.trim();
146
+ if (trimmed) {
147
+ if (fs.existsSync(trimmed)) {
148
+ const stat = fs.statSync(trimmed);
149
+ if (mode === "folder" && stat.isDirectory()) {
150
+ onSelect(trimmed);
151
+ } else if (mode === "file" && stat.isFile()) {
152
+ onSelect(trimmed);
153
+ } else if (stat.isDirectory()) {
154
+ setCurrentPath(trimmed);
155
+ setViewMode("browse");
156
+ } else {
157
+ setError("Invalid path for this mode");
158
+ }
159
+ } else {
160
+ setError("Path does not exist");
161
+ }
162
+ }
163
+ return;
164
+ }
165
+
166
+ if (key.backspace || key.delete) {
167
+ setInputPath(prev => prev.slice(0, -1));
168
+ return;
169
+ }
170
+
171
+ if (input && !key.ctrl && !key.meta) {
172
+ setInputPath(prev => prev + input);
173
+ return;
174
+ }
175
+ return;
176
+ }
177
+
178
+ // Handle quick location mode
179
+ if (viewMode === "quick") {
180
+ // Number keys for quick locations
181
+ const num = parseInt(input, 10);
182
+ if (!isNaN(num) && num >= 1 && num <= quickLocations.length) {
183
+ const loc = quickLocations[num - 1];
184
+ if (mode === "folder") {
185
+ onSelect(loc.path);
186
+ } else {
187
+ setCurrentPath(loc.path);
188
+ setViewMode("browse");
189
+ }
190
+ return;
191
+ }
192
+
193
+ // 'p' for paste path
194
+ if (input.toLowerCase() === "p") {
195
+ setViewMode("input");
196
+ return;
197
+ }
198
+
199
+ // 'b' for browse
200
+ if (input.toLowerCase() === "b") {
201
+ setViewMode("browse");
202
+ return;
203
+ }
204
+
205
+ // Arrow keys to navigate quick locations
206
+ if (key.upArrow) {
207
+ setSelectedIndex(prev => Math.max(0, prev - 1));
208
+ }
209
+ if (key.downArrow) {
210
+ setSelectedIndex(prev => Math.min(quickLocations.length - 1, prev + 1));
211
+ }
212
+ if (key.return) {
213
+ const loc = quickLocations[selectedIndex];
214
+ if (loc) {
215
+ if (mode === "folder") {
216
+ onSelect(loc.path);
217
+ } else {
218
+ setCurrentPath(loc.path);
219
+ setViewMode("browse");
220
+ }
221
+ }
222
+ }
223
+ return;
224
+ }
225
+
226
+ // Handle browse mode
227
+ if (viewMode === "browse") {
228
+ if (key.upArrow) {
229
+ setSelectedIndex(prev => Math.max(0, prev - 1));
230
+ }
231
+ if (key.downArrow) {
232
+ setSelectedIndex(prev => Math.min(items.length - 1, prev + 1));
233
+ }
234
+ if (key.return) {
235
+ const item = items[selectedIndex];
236
+ if (!item) return;
237
+
238
+ if (item.isDirectory) {
239
+ if (mode === "folder") {
240
+ // In folder mode, enter selects current folder
241
+ // Navigate into with right arrow
242
+ onSelect(item.path === path.dirname(currentPath) ? currentPath : item.path);
243
+ } else {
244
+ // In file mode, navigate into directory
245
+ setCurrentPath(item.path);
246
+ }
247
+ } else {
248
+ // Select file
249
+ onSelect(item.path);
250
+ }
251
+ }
252
+ if (key.rightArrow) {
253
+ const item = items[selectedIndex];
254
+ if (item?.isDirectory && item.name !== "..") {
255
+ setCurrentPath(item.path);
256
+ }
257
+ }
258
+ if (key.leftArrow) {
259
+ const parent = path.dirname(currentPath);
260
+ if (parent !== currentPath) {
261
+ setCurrentPath(parent);
262
+ }
263
+ }
264
+
265
+ // 'p' for paste path
266
+ if (input.toLowerCase() === "p") {
267
+ setViewMode("input");
268
+ return;
269
+ }
270
+
271
+ // 'q' for quick locations
272
+ if (input.toLowerCase() === "q") {
273
+ setViewMode("quick");
274
+ return;
275
+ }
276
+ }
277
+ });
278
+
279
+ const formatSize = (bytes?: number): string => {
280
+ if (bytes === undefined) return "";
281
+ if (bytes < 1024) return `${bytes}B`;
282
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K`;
283
+ return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
284
+ };
285
+
286
+ const truncatePath = (p: string, maxLen: number): string => {
287
+ if (p.length <= maxLen) return p;
288
+ return "..." + p.slice(-(maxLen - 3));
289
+ };
290
+
291
+ return (
292
+ <Box
293
+ flexDirection="column"
294
+ borderStyle="round"
295
+ borderColor="cyan"
296
+ paddingX={2}
297
+ paddingY={1}
298
+ >
299
+ {/* Title */}
300
+ <Box marginBottom={1}>
301
+ <Text bold color="cyan">
302
+ {title || (mode === "folder" ? "Select Folder" : "Select File")}
303
+ </Text>
304
+ </Box>
305
+
306
+ {/* Current path */}
307
+ <Box marginBottom={1}>
308
+ <Text dimColor>Path: </Text>
309
+ <Text>{truncatePath(currentPath, 50)}</Text>
310
+ </Box>
311
+
312
+ {/* Error */}
313
+ {error && (
314
+ <Box marginBottom={1}>
315
+ <Text color="red">✗ {error}</Text>
316
+ </Box>
317
+ )}
318
+
319
+ {/* Quick Locations View */}
320
+ {viewMode === "quick" && (
321
+ <Box flexDirection="column">
322
+ <Text dimColor>Quick Locations:</Text>
323
+ {quickLocations.map((loc, index) => (
324
+ <Box key={loc.path}>
325
+ <Text color={index === selectedIndex ? "yellow" : "white"}>
326
+ {index === selectedIndex ? "> " : " "}
327
+ [{index + 1}] {loc.label}
328
+ </Text>
329
+ <Text dimColor> ({truncatePath(loc.path, 30)})</Text>
330
+ </Box>
331
+ ))}
332
+ <Box marginTop={1}>
333
+ <Text dimColor>[1-{quickLocations.length}] Select [B] Browse [P] Paste path [Esc] Cancel</Text>
334
+ </Box>
335
+ </Box>
336
+ )}
337
+
338
+ {/* Browse View */}
339
+ {viewMode === "browse" && (
340
+ <Box flexDirection="column">
341
+ <Box flexDirection="column" height={10}>
342
+ {items.slice(
343
+ Math.max(0, selectedIndex - 4),
344
+ Math.max(10, selectedIndex + 6)
345
+ ).map((item, displayIndex) => {
346
+ const actualIndex = Math.max(0, selectedIndex - 4) + displayIndex;
347
+ const isSelected = actualIndex === selectedIndex;
348
+
349
+ return (
350
+ <Box key={item.path}>
351
+ <Text color={isSelected ? "yellow" : "white"}>
352
+ {isSelected ? "> " : " "}
353
+ {item.isDirectory ? "📁 " : "📄 "}
354
+ {item.name}
355
+ </Text>
356
+ {!item.isDirectory && (
357
+ <Text dimColor> ({formatSize(item.size)})</Text>
358
+ )}
359
+ </Box>
360
+ );
361
+ })}
362
+ </Box>
363
+ <Box marginTop={1}>
364
+ <Text dimColor>
365
+ [↑↓] Navigate [Enter] Select [←→] Navigate dirs [Q] Quick [P] Paste [Esc] Cancel
366
+ </Text>
367
+ </Box>
368
+ </Box>
369
+ )}
370
+
371
+ {/* Input View */}
372
+ {viewMode === "input" && (
373
+ <Box flexDirection="column">
374
+ <Text dimColor>Paste or type path:</Text>
375
+ <Box
376
+ borderStyle="single"
377
+ borderColor="yellow"
378
+ paddingX={1}
379
+ marginY={1}
380
+ >
381
+ <Text>
382
+ {inputPath}
383
+ <Text color="yellow">▌</Text>
384
+ </Text>
385
+ </Box>
386
+ <Box>
387
+ <Text dimColor>[Enter] Confirm [Esc] Back</Text>
388
+ </Box>
389
+ </Box>
390
+ )}
391
+ </Box>
392
+ );
393
+ }
@@ -0,0 +1,26 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+
4
+ interface HeaderProps {
5
+ title?: string;
6
+ subtitle?: string;
7
+ }
8
+
9
+ export function Header({
10
+ title = "OpenCode Account Manager",
11
+ subtitle,
12
+ }: HeaderProps) {
13
+ return (
14
+ <Box flexDirection="column" marginBottom={1}>
15
+ <Box>
16
+ <Text bold color="cyan">
17
+ * {title}
18
+ </Text>
19
+ {subtitle && <Text dimColor> - {subtitle}</Text>}
20
+ </Box>
21
+ <Text dimColor>
22
+ ────────────────────────────────────────────────────────────────
23
+ </Text>
24
+ </Box>
25
+ );
26
+ }