superoc 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.
- package/LICENSE +21 -0
- package/README.md +37 -0
- package/bin/nimsuper.js +21 -0
- package/bin/nimsuper.ts +10 -0
- package/bin/superoc.js +21 -0
- package/bin/superoc.ts +10 -0
- package/dist/antigravity.d.ts +59 -0
- package/dist/antigravity.d.ts.map +1 -0
- package/dist/antigravity.js +423 -0
- package/dist/antigravity.js.map +1 -0
- package/dist/errors.d.ts +22 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +122 -0
- package/dist/errors.js.map +1 -0
- package/dist/errors.test.d.ts +2 -0
- package/dist/errors.test.d.ts.map +1 -0
- package/dist/errors.test.js +137 -0
- package/dist/errors.test.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +809 -0
- package/dist/index.js.map +1 -0
- package/dist/opencode-sync.d.ts +9 -0
- package/dist/opencode-sync.d.ts.map +1 -0
- package/dist/opencode-sync.js +157 -0
- package/dist/opencode-sync.js.map +1 -0
- package/dist/provider.d.ts +5 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +100 -0
- package/dist/provider.js.map +1 -0
- package/dist/provider.test.d.ts +2 -0
- package/dist/provider.test.d.ts.map +1 -0
- package/dist/provider.test.js +94 -0
- package/dist/provider.test.js.map +1 -0
- package/dist/quota.d.ts +12 -0
- package/dist/quota.d.ts.map +1 -0
- package/dist/quota.js +105 -0
- package/dist/quota.js.map +1 -0
- package/dist/quota.test.d.ts +2 -0
- package/dist/quota.test.d.ts.map +1 -0
- package/dist/quota.test.js +20 -0
- package/dist/quota.test.js.map +1 -0
- package/dist/storage.d.ts +43 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +447 -0
- package/dist/storage.js.map +1 -0
- package/dist/themes.d.ts +64 -0
- package/dist/themes.d.ts.map +1 -0
- package/dist/themes.js +424 -0
- package/dist/themes.js.map +1 -0
- package/dist/tui/actions.d.ts +14 -0
- package/dist/tui/actions.d.ts.map +1 -0
- package/dist/tui/actions.js +464 -0
- package/dist/tui/actions.js.map +1 -0
- package/dist/tui/app.d.ts +2 -0
- package/dist/tui/app.d.ts.map +1 -0
- package/dist/tui/app.js +323 -0
- package/dist/tui/app.js.map +1 -0
- package/dist/tui/benchmark.d.ts +42 -0
- package/dist/tui/benchmark.d.ts.map +1 -0
- package/dist/tui/benchmark.js +379 -0
- package/dist/tui/benchmark.js.map +1 -0
- package/dist/tui/screens.d.ts +23 -0
- package/dist/tui/screens.d.ts.map +1 -0
- package/dist/tui/screens.js +791 -0
- package/dist/tui/screens.js.map +1 -0
- package/dist/tui/state.d.ts +54 -0
- package/dist/tui/state.d.ts.map +1 -0
- package/dist/tui/state.js +85 -0
- package/dist/tui/state.js.map +1 -0
- package/dist/tui/types.d.ts +16 -0
- package/dist/tui/types.d.ts.map +1 -0
- package/dist/tui/types.js +2 -0
- package/dist/tui/types.js.map +1 -0
- package/dist/tui/ui.d.ts +17 -0
- package/dist/tui/ui.d.ts.map +1 -0
- package/dist/tui/ui.js +77 -0
- package/dist/tui/ui.js.map +1 -0
- package/dist/types.d.ts +52 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +68 -0
- package/scripts/postinstall.js +77 -0
- package/scripts/uninstall.js +119 -0
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { Box, Text } from "@opentui/core";
|
|
4
|
+
import { getActiveTheme, getTheme, getResolvedTheme, listThemes, saveThemeOverride, setPreviewTheme, dangerSelectColors, getThemeOverride } from "../themes.js";
|
|
5
|
+
import { addKey, readAndValidateImportFile, validateImportPayload, removeKey, renameKey } from "../storage.js";
|
|
6
|
+
import { state, navigate, callRenderApp, refreshStore, setStatus, clampIndex, safeSaveStore } from "./state.js";
|
|
7
|
+
import { themedSelect, themedInput, events, maskKey, applyThemeToScreen } from "./ui.js";
|
|
8
|
+
import { handleMenuSelect, handleKeyAction, handleExport, handleImportConfirm, handleFallbackMenuSelect, fetchModels, handleStartOAuthLogin, } from "./actions.js";
|
|
9
|
+
import { exchangeAntigravity } from "../antigravity.js";
|
|
10
|
+
function keyStatus(entry) {
|
|
11
|
+
return !entry.enabled ? "OFF" : "OK";
|
|
12
|
+
}
|
|
13
|
+
function getProviderDisplayName(provider) {
|
|
14
|
+
if (provider === "nvidia")
|
|
15
|
+
return "NVIDIA NIM";
|
|
16
|
+
if (provider === "google")
|
|
17
|
+
return "Google Gemini";
|
|
18
|
+
return "Antigravity (Google OAuth)";
|
|
19
|
+
}
|
|
20
|
+
function getProviderShortName(provider) {
|
|
21
|
+
if (provider === "nvidia")
|
|
22
|
+
return "NVIDIA";
|
|
23
|
+
if (provider === "google")
|
|
24
|
+
return "Gemini";
|
|
25
|
+
return "Antigravity";
|
|
26
|
+
}
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Provider Tabs Screen
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
export function buildProviderTabs() {
|
|
31
|
+
const theme = getActiveTheme();
|
|
32
|
+
const options = [
|
|
33
|
+
{
|
|
34
|
+
name: "[1] NVIDIA NIM",
|
|
35
|
+
description: "Manage NVIDIA NIM API keys and fallback models",
|
|
36
|
+
value: "nvidia",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "[2] Google Gemini",
|
|
40
|
+
description: "Manage Google Gemini API keys and fallback models",
|
|
41
|
+
value: "google",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: "[3] Antigravity",
|
|
45
|
+
description: "Manage Google OAuth Antigravity accounts and models",
|
|
46
|
+
value: "antigravity",
|
|
47
|
+
},
|
|
48
|
+
{ name: "Quit", description: "Exit the key manager", value: "quit" },
|
|
49
|
+
];
|
|
50
|
+
state.mainMenuIndex = clampIndex(state.mainMenuIndex, options.length);
|
|
51
|
+
const menu = themedSelect("provider-tabs", 56, 10, options, state.mainMenuIndex, (idx, opt) => {
|
|
52
|
+
state.mainMenuIndex = idx;
|
|
53
|
+
handleProviderTabSelect(opt.value);
|
|
54
|
+
});
|
|
55
|
+
return {
|
|
56
|
+
element: Box({ flexDirection: "column", gap: 1 }, Text({ content: " Select a provider:", fg: theme.primary }), menu),
|
|
57
|
+
helpText: "[Ctrl+C] quit",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function handleProviderTabSelect(value) {
|
|
61
|
+
if (value === "quit") {
|
|
62
|
+
if (state.renderer)
|
|
63
|
+
state.renderer.destroy();
|
|
64
|
+
process.exit(0);
|
|
65
|
+
}
|
|
66
|
+
state.activeProvider = value;
|
|
67
|
+
state.activeTab = "keys";
|
|
68
|
+
navigate("list");
|
|
69
|
+
}
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Main Menu (per provider)
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
export function buildMainMenu() {
|
|
74
|
+
const { store } = state;
|
|
75
|
+
const provider = state.activeProvider;
|
|
76
|
+
const providerKeys = store.keys.filter((k) => k.provider === provider);
|
|
77
|
+
const hasKeys = providerKeys.length > 0;
|
|
78
|
+
const theme = getActiveTheme();
|
|
79
|
+
const override = getThemeOverride();
|
|
80
|
+
const isAntigravity = provider === "antigravity";
|
|
81
|
+
const opts = [
|
|
82
|
+
hasKeys && {
|
|
83
|
+
name: "Manage Accounts / Keys",
|
|
84
|
+
description: "Select an entry to rename, delete, or toggle",
|
|
85
|
+
value: "manage",
|
|
86
|
+
},
|
|
87
|
+
isAntigravity && {
|
|
88
|
+
name: "Login with Google (OAuth)",
|
|
89
|
+
description: "Authenticate via Google OAuth PKCE in browser",
|
|
90
|
+
value: "oauth-start",
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: isAntigravity ? "Add Account Manually (Refresh Token)" : "Add Key",
|
|
94
|
+
description: isAntigravity
|
|
95
|
+
? "Manually add account name and refresh_token"
|
|
96
|
+
: `Add a new ${getProviderDisplayName(provider)} API key`,
|
|
97
|
+
value: "add",
|
|
98
|
+
},
|
|
99
|
+
isAntigravity && {
|
|
100
|
+
name: "Sync Models to opencode.json",
|
|
101
|
+
description: "Auto-register all Antigravity models in opencode.json",
|
|
102
|
+
value: "sync-models",
|
|
103
|
+
},
|
|
104
|
+
hasKeys && {
|
|
105
|
+
name: "Reset Failures",
|
|
106
|
+
description: "Reset all failure counts to zero",
|
|
107
|
+
value: "reset-failures",
|
|
108
|
+
},
|
|
109
|
+
hasKeys && {
|
|
110
|
+
name: "Export Keys",
|
|
111
|
+
description: "Export all keys to a JSON file",
|
|
112
|
+
value: "export",
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: "Import Keys",
|
|
116
|
+
description: "Import keys from a JSON file",
|
|
117
|
+
value: "import",
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: `Strategy: ${store.rotationStrategy}`,
|
|
121
|
+
description: "Toggle between round-robin and least-failures",
|
|
122
|
+
value: "toggle-strategy",
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: `Theme: ${theme.name}${!override ? " (synced with opencode)" : ""}`,
|
|
126
|
+
description: "Change the color theme",
|
|
127
|
+
value: "theme",
|
|
128
|
+
},
|
|
129
|
+
{ name: "Back", description: "Return to provider selection", value: "back" },
|
|
130
|
+
];
|
|
131
|
+
const options = opts.filter(Boolean);
|
|
132
|
+
state.mainMenuIndex = clampIndex(state.mainMenuIndex, options.length);
|
|
133
|
+
const menu = themedSelect("main-menu", 56, 12, options, state.mainMenuIndex, (idx, opt) => {
|
|
134
|
+
state.mainMenuIndex = idx;
|
|
135
|
+
if (opt.value === "oauth-start") {
|
|
136
|
+
handleStartOAuthLogin();
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
handleMenuSelect(opt.value);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
const providerName = getProviderDisplayName(provider);
|
|
143
|
+
return {
|
|
144
|
+
element: Box({ flexDirection: "column", gap: 1 }, Text({ content: ` ${providerName} Key Manager`, fg: theme.primary }), menu),
|
|
145
|
+
helpText: "[Esc] back [Ctrl+C] quit",
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Key Selector
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
export function buildKeySelector() {
|
|
152
|
+
const provider = state.activeProvider;
|
|
153
|
+
const providerKeys = state.store.keys.filter((k) => k.provider === provider);
|
|
154
|
+
const options = providerKeys.map((entry) => ({
|
|
155
|
+
name: `${entry.name} [${keyStatus(entry)}]`,
|
|
156
|
+
description: `${maskKey(entry.key)} rl:${entry.rateLimitCount} ${entry.lastUsedAt ? new Date(entry.lastUsedAt).toLocaleString() : "never used"}`,
|
|
157
|
+
value: entry.id,
|
|
158
|
+
}));
|
|
159
|
+
if (options.length === 0) {
|
|
160
|
+
navigate("list");
|
|
161
|
+
return { element: Text({ content: "", fg: "#000000" }), helpText: "" };
|
|
162
|
+
}
|
|
163
|
+
state.keySelectorIndex = clampIndex(state.keySelectorIndex, options.length);
|
|
164
|
+
const selector = themedSelect("key-selector", 56, 12, options, state.keySelectorIndex, (idx, opt) => {
|
|
165
|
+
state.keySelectorIndex = idx;
|
|
166
|
+
state.selectedKeyId = opt.value;
|
|
167
|
+
state.keyActionsIndex = 0;
|
|
168
|
+
navigate("key-actions");
|
|
169
|
+
});
|
|
170
|
+
return {
|
|
171
|
+
element: Box({ flexDirection: "column", gap: 1 }, Text({ content: " Select a key to manage:", fg: getActiveTheme().primary }), selector),
|
|
172
|
+
helpText: "[Esc] back",
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Key Actions
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
export function buildKeyActions() {
|
|
179
|
+
const { selectedKeyId } = state;
|
|
180
|
+
if (!selectedKeyId) {
|
|
181
|
+
navigate("key-selector");
|
|
182
|
+
return { element: Text({ content: "", fg: "#000000" }), helpText: "" };
|
|
183
|
+
}
|
|
184
|
+
const entry = state.store.keys.find((k) => k.id === selectedKeyId);
|
|
185
|
+
if (!entry) {
|
|
186
|
+
state.selectedKeyId = null;
|
|
187
|
+
navigate("key-selector");
|
|
188
|
+
return { element: Text({ content: "", fg: "#000000" }), helpText: "" };
|
|
189
|
+
}
|
|
190
|
+
const options = [
|
|
191
|
+
{
|
|
192
|
+
name: `Toggle ${entry.enabled ? "OFF" : "ON"}`,
|
|
193
|
+
description: `${entry.enabled ? "Disable" : "Enable"} this key`,
|
|
194
|
+
value: "toggle",
|
|
195
|
+
},
|
|
196
|
+
{ name: "Rename", description: "Change the friendly name", value: "rename" },
|
|
197
|
+
{ name: "Delete", description: "Remove this key permanently", value: "delete" },
|
|
198
|
+
{ name: "Back", description: "Return to key list", value: "back" },
|
|
199
|
+
];
|
|
200
|
+
state.keyActionsIndex = clampIndex(state.keyActionsIndex, options.length);
|
|
201
|
+
const actions = themedSelect("key-actions", 40, 8, options, state.keyActionsIndex, (idx, opt) => {
|
|
202
|
+
state.keyActionsIndex = idx;
|
|
203
|
+
handleKeyAction(opt.value);
|
|
204
|
+
});
|
|
205
|
+
return {
|
|
206
|
+
element: Box({ flexDirection: "column", gap: 1 }, Text({ content: ` Key: ${entry.name} (${maskKey(entry.key)})`, fg: getActiveTheme().primary }), actions),
|
|
207
|
+
helpText: "[Esc] back",
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// Theme Selector
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
export function buildThemeSelector() {
|
|
214
|
+
const theme = getActiveTheme();
|
|
215
|
+
const allThemes = listThemes();
|
|
216
|
+
const currentOverride = getThemeOverride();
|
|
217
|
+
const resolvedId = getResolvedTheme().id;
|
|
218
|
+
const activeId = theme.id;
|
|
219
|
+
const options = [
|
|
220
|
+
{
|
|
221
|
+
name: "sync",
|
|
222
|
+
description: "Sync with opencode theme (default)",
|
|
223
|
+
value: "sync",
|
|
224
|
+
},
|
|
225
|
+
...allThemes.map((th) => ({
|
|
226
|
+
name: th.id,
|
|
227
|
+
description: `${th.name}${th.id === activeId ? " *" : ""}${th.id === resolvedId && !currentOverride ? " (opencode)" : ""}`,
|
|
228
|
+
value: th.id,
|
|
229
|
+
})),
|
|
230
|
+
];
|
|
231
|
+
state.themeSelectorIndex = clampIndex(currentOverride ? options.findIndex((o) => o.value === currentOverride) : 0, options.length);
|
|
232
|
+
const selector = themedSelect("theme-selector", 56, 12, options, state.themeSelectorIndex, (_index, option) => {
|
|
233
|
+
state.themeSelectorIndex = _index;
|
|
234
|
+
setPreviewTheme(null);
|
|
235
|
+
if (option.value === "sync") {
|
|
236
|
+
saveThemeOverride("");
|
|
237
|
+
setStatus("Theme synced with opencode", theme.success);
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
saveThemeOverride(option.value);
|
|
241
|
+
setStatus(`Theme set to ${option.value}`, theme.success);
|
|
242
|
+
}
|
|
243
|
+
refreshStore();
|
|
244
|
+
navigate("list");
|
|
245
|
+
});
|
|
246
|
+
events(selector).on("selectionChanged", (index) => {
|
|
247
|
+
state.themeSelectorIndex = index;
|
|
248
|
+
const option = options[index];
|
|
249
|
+
if (!option)
|
|
250
|
+
return;
|
|
251
|
+
const previewId = option.value === "sync" ? getResolvedTheme().id : option.value;
|
|
252
|
+
setPreviewTheme(previewId);
|
|
253
|
+
applyThemeToScreen(getTheme(previewId));
|
|
254
|
+
});
|
|
255
|
+
return {
|
|
256
|
+
element: Box({ flexDirection: "column", gap: 1 }, Text({ id: "theme-label", content: " Select a theme:", fg: theme.primary }), selector),
|
|
257
|
+
helpText: "[Esc] back [Enter] apply",
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// Confirm Delete
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
export function buildConfirmDelete() {
|
|
264
|
+
const entry = state.store.keys.find((k) => k.id === state.deleteTargetId);
|
|
265
|
+
const name = entry?.name ?? "this key";
|
|
266
|
+
const options = [
|
|
267
|
+
{ name: "Yes, delete", description: `Permanently remove "${name}"`, value: "yes" },
|
|
268
|
+
{ name: "No, cancel", description: "Keep the key", value: "no" },
|
|
269
|
+
];
|
|
270
|
+
const confirm = themedSelect("confirm-delete", 40, 6, options, 0, (_index, option) => {
|
|
271
|
+
if (option.value === "yes" && state.deleteTargetId) {
|
|
272
|
+
const e = state.store.keys.find((k) => k.id === state.deleteTargetId);
|
|
273
|
+
const n = e?.name ?? "key";
|
|
274
|
+
removeKey(state.store, state.deleteTargetId);
|
|
275
|
+
safeSaveStore();
|
|
276
|
+
refreshStore();
|
|
277
|
+
if (state.keySelectorIndex >= state.store.keys.length)
|
|
278
|
+
state.keySelectorIndex = Math.max(0, state.store.keys.length - 1);
|
|
279
|
+
setStatus(`Deleted "${n}"`, getActiveTheme().error);
|
|
280
|
+
}
|
|
281
|
+
state.deleteTargetId = null;
|
|
282
|
+
navigate("key-actions");
|
|
283
|
+
}, dangerSelectColors(getActiveTheme()));
|
|
284
|
+
return {
|
|
285
|
+
element: Box({ flexDirection: "column", gap: 1 }, Text({ content: "Are you sure you want to delete this key?", fg: getActiveTheme().error }), confirm),
|
|
286
|
+
helpText: "[Esc] cancel",
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// Add Name Input
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
export function buildAddNameInput() {
|
|
293
|
+
const theme = getActiveTheme();
|
|
294
|
+
const input = themedInput("add-name-input", "e.g. work-key, personal, team-alpha", 40);
|
|
295
|
+
events(input).on("enter", (value) => {
|
|
296
|
+
state.pendingKeyName = value.trim();
|
|
297
|
+
if (!state.pendingKeyName) {
|
|
298
|
+
setStatus("Name is required", theme.error);
|
|
299
|
+
callRenderApp();
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const provider = state.activeProvider;
|
|
303
|
+
if (state.store.keys.some((k) => k.name === state.pendingKeyName && k.provider === provider)) {
|
|
304
|
+
setStatus("A key with this name already exists", theme.error);
|
|
305
|
+
callRenderApp();
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
navigate("add-key");
|
|
309
|
+
});
|
|
310
|
+
return {
|
|
311
|
+
element: Box({ flexDirection: "column", gap: 1 }, Text({ content: "Enter a friendly name for this key / account:", fg: theme.text }), input),
|
|
312
|
+
helpText: "[Enter] next [Esc] cancel",
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
// Add Key Input
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
export function buildAddKeyInput() {
|
|
319
|
+
const theme = getActiveTheme();
|
|
320
|
+
const provider = state.activeProvider;
|
|
321
|
+
const placeholder = provider === "nvidia"
|
|
322
|
+
? "nvapi-..."
|
|
323
|
+
: provider === "antigravity"
|
|
324
|
+
? "refresh_token (or refresh_token|project_id)"
|
|
325
|
+
: "API key (no prefix required)";
|
|
326
|
+
const input = themedInput("add-key-input", placeholder, 55);
|
|
327
|
+
events(input).on("enter", (value) => {
|
|
328
|
+
const key = value.trim();
|
|
329
|
+
if (!key) {
|
|
330
|
+
setStatus("API key / Token is required", theme.error);
|
|
331
|
+
callRenderApp();
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (provider === "nvidia" && !key.startsWith("nvapi-")) {
|
|
335
|
+
setStatus("NVIDIA key must start with 'nvapi-'", theme.error);
|
|
336
|
+
callRenderApp();
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
addKey(state.store, state.pendingKeyName, key, provider);
|
|
340
|
+
safeSaveStore();
|
|
341
|
+
refreshStore();
|
|
342
|
+
setStatus(`Added "${state.pendingKeyName}"`, theme.success);
|
|
343
|
+
state.pendingKeyName = "";
|
|
344
|
+
navigate("list");
|
|
345
|
+
});
|
|
346
|
+
return {
|
|
347
|
+
element: Box({ flexDirection: "column", gap: 1 }, Text({ content: `Provider: ${getProviderDisplayName(provider)}`, fg: theme.textMuted }), Text({ content: `Name: ${state.pendingKeyName}`, fg: theme.primary }), Text({ content: provider === "antigravity" ? "Enter the refresh token (or token|projectId):" : "Enter the API key:", fg: theme.text }), input),
|
|
348
|
+
helpText: "[Enter] confirm [Esc] cancel",
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
// OAuth Login Screen (for Antigravity)
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
export function buildOAuthLoginScreen() {
|
|
355
|
+
const theme = getActiveTheme();
|
|
356
|
+
const url = state.pendingOAuthUrl;
|
|
357
|
+
const input = themedInput("oauth-code-input", "Paste redirect URL or authorization code here...", 55);
|
|
358
|
+
events(input).on("enter", async (val) => {
|
|
359
|
+
const trimmed = val.trim();
|
|
360
|
+
if (!trimmed)
|
|
361
|
+
return;
|
|
362
|
+
let code = trimmed;
|
|
363
|
+
let stateParam = state.pendingOAuthState;
|
|
364
|
+
if (trimmed.startsWith("http")) {
|
|
365
|
+
try {
|
|
366
|
+
const u = new URL(trimmed);
|
|
367
|
+
code = u.searchParams.get("code") || trimmed;
|
|
368
|
+
stateParam = u.searchParams.get("state") || stateParam;
|
|
369
|
+
}
|
|
370
|
+
catch { }
|
|
371
|
+
}
|
|
372
|
+
const res = await exchangeAntigravity(code, stateParam);
|
|
373
|
+
if (res.type === "success") {
|
|
374
|
+
const name = state.pendingKeyName || res.email || "antigravity-account";
|
|
375
|
+
addKey(state.store, name, res.refresh, "antigravity");
|
|
376
|
+
safeSaveStore();
|
|
377
|
+
refreshStore();
|
|
378
|
+
setStatus(`Added Antigravity account "${name}"`, theme.success);
|
|
379
|
+
if (state.oauthCleanup) {
|
|
380
|
+
state.oauthCleanup();
|
|
381
|
+
state.oauthCleanup = null;
|
|
382
|
+
}
|
|
383
|
+
state.pendingKeyName = "";
|
|
384
|
+
state.pendingOAuthUrl = "";
|
|
385
|
+
state.pendingOAuthState = "";
|
|
386
|
+
navigate("list");
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
setStatus(`OAuth failed: ${res.error}`, theme.error);
|
|
390
|
+
callRenderApp();
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
return {
|
|
394
|
+
element: Box({ flexDirection: "column", gap: 1 }, Text({ content: " Google OAuth Authentication", fg: theme.primary }), Text({ content: " Opening browser for Google login automatically...", fg: theme.success }), Text({ content: " Listening for callback on http://localhost:51121/oauth-callback", fg: theme.textMuted }), Text({ content: " If browser did not open, paste authorization code or callback URL:", fg: theme.text }), input),
|
|
395
|
+
helpText: "[Esc] cancel [Enter] submit code [o] re-open browser",
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
// Rename Input
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
export function buildRenameInput() {
|
|
402
|
+
const theme = getActiveTheme();
|
|
403
|
+
if (!state.renameTargetId) {
|
|
404
|
+
return { element: Text({ content: "Error: no key selected", fg: theme.error }), helpText: "" };
|
|
405
|
+
}
|
|
406
|
+
const entry = state.store.keys.find((k) => k.id === state.renameTargetId);
|
|
407
|
+
const currentName = entry?.name ?? "";
|
|
408
|
+
const input = themedInput("rename-input", "New friendly name", 40, currentName);
|
|
409
|
+
events(input).on("enter", (value) => {
|
|
410
|
+
const newName = value.trim();
|
|
411
|
+
if (!newName) {
|
|
412
|
+
setStatus("Name is required", theme.error);
|
|
413
|
+
callRenderApp();
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const provider = state.activeProvider;
|
|
417
|
+
if (state.store.keys.some((k) => k.name === newName && k.id !== state.renameTargetId && k.provider === provider)) {
|
|
418
|
+
setStatus("A key with this name already exists", theme.error);
|
|
419
|
+
callRenderApp();
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
renameKey(state.store, state.renameTargetId, newName);
|
|
423
|
+
safeSaveStore();
|
|
424
|
+
refreshStore();
|
|
425
|
+
setStatus(`Renamed to "${newName}"`, theme.success);
|
|
426
|
+
state.renameTargetId = null;
|
|
427
|
+
navigate("key-actions");
|
|
428
|
+
});
|
|
429
|
+
return {
|
|
430
|
+
element: Box({ flexDirection: "column", gap: 1 }, Text({ content: "Enter new name:", fg: theme.text }), input),
|
|
431
|
+
helpText: "[Enter] confirm [Esc] cancel",
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
// Export Path Input
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
export function buildExportPathInput() {
|
|
438
|
+
const theme = getActiveTheme();
|
|
439
|
+
const provider = state.activeProvider;
|
|
440
|
+
const providerKeys = state.store.keys.filter((k) => k.provider === provider);
|
|
441
|
+
const defaultPath = `~/nim-${provider}-keys-export.json`;
|
|
442
|
+
const input = themedInput("export-path-input", defaultPath, 55);
|
|
443
|
+
events(input).on("enter", (value) => {
|
|
444
|
+
let filePath = value.trim();
|
|
445
|
+
if (!filePath) {
|
|
446
|
+
setStatus("File path is required", theme.error);
|
|
447
|
+
callRenderApp();
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (filePath.startsWith("~/")) {
|
|
451
|
+
filePath = join(homedir(), filePath.slice(2));
|
|
452
|
+
}
|
|
453
|
+
handleExport(filePath);
|
|
454
|
+
});
|
|
455
|
+
return {
|
|
456
|
+
element: Box({ flexDirection: "column", gap: 1 }, Text({ content: `Export ${providerKeys.length} key(s) to JSON file:`, fg: theme.text }), input),
|
|
457
|
+
helpText: "[Enter] export [Esc] cancel",
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
// ---------------------------------------------------------------------------
|
|
461
|
+
// Import Path Input
|
|
462
|
+
// ---------------------------------------------------------------------------
|
|
463
|
+
export function buildImportPathInput() {
|
|
464
|
+
const theme = getActiveTheme();
|
|
465
|
+
const input = themedInput("import-path-input", "~/nim-keys-export.json", 55);
|
|
466
|
+
events(input).on("enter", (value) => {
|
|
467
|
+
let filePath = value.trim();
|
|
468
|
+
if (!filePath) {
|
|
469
|
+
setStatus("File path is required", theme.error);
|
|
470
|
+
callRenderApp();
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (filePath.startsWith("~/")) {
|
|
474
|
+
filePath = join(homedir(), filePath.slice(2));
|
|
475
|
+
}
|
|
476
|
+
const fileResult = readAndValidateImportFile(filePath);
|
|
477
|
+
if ("error" in fileResult) {
|
|
478
|
+
setStatus(fileResult.error, theme.error);
|
|
479
|
+
callRenderApp();
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const result = validateImportPayload(fileResult.raw);
|
|
483
|
+
if (result.errors.length > 0) {
|
|
484
|
+
setStatus(`Import error: ${result.errors[0]}`, theme.error);
|
|
485
|
+
callRenderApp();
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (result.pendingKeys.length === 0) {
|
|
489
|
+
setStatus("No valid keys found in file", theme.warning);
|
|
490
|
+
navigate("list");
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
state.pendingImportPath = filePath;
|
|
494
|
+
state.pendingImportResult = result;
|
|
495
|
+
navigate("confirm-import");
|
|
496
|
+
});
|
|
497
|
+
return {
|
|
498
|
+
element: Box({ flexDirection: "column", gap: 1 }, Text({ content: "Import keys from JSON file:", fg: theme.text }), input),
|
|
499
|
+
helpText: "[Enter] import [Esc] cancel",
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
// ---------------------------------------------------------------------------
|
|
503
|
+
// Confirm Import
|
|
504
|
+
// ---------------------------------------------------------------------------
|
|
505
|
+
export function buildConfirmImport() {
|
|
506
|
+
const theme = getActiveTheme();
|
|
507
|
+
const result = state.pendingImportResult;
|
|
508
|
+
if (!result) {
|
|
509
|
+
navigate("list");
|
|
510
|
+
return { element: Text({ content: "", fg: "#000000" }), helpText: "" };
|
|
511
|
+
}
|
|
512
|
+
const parts = [];
|
|
513
|
+
if (result.pendingKeys.length > 0)
|
|
514
|
+
parts.push(`${result.pendingKeys.length} key(s) will be imported`);
|
|
515
|
+
if (result.errors.length > 0)
|
|
516
|
+
parts.push(`${result.errors.length} entry/entries invalid`);
|
|
517
|
+
const options = [
|
|
518
|
+
{ name: "Yes, import", description: parts.join(", "), value: "yes" },
|
|
519
|
+
{ name: "No, cancel", description: "Discard the import", value: "no" },
|
|
520
|
+
];
|
|
521
|
+
const confirm = themedSelect("confirm-import", 50, 6, options, 0, (_index, option) => {
|
|
522
|
+
handleImportConfirm(option.value);
|
|
523
|
+
});
|
|
524
|
+
return {
|
|
525
|
+
element: Box({ flexDirection: "column", gap: 1 }, Text({ content: `Confirm import from: ${state.pendingImportPath}`, fg: theme.primary }), confirm),
|
|
526
|
+
helpText: "[Esc] cancel",
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
// ---------------------------------------------------------------------------
|
|
530
|
+
// Fallback Menu
|
|
531
|
+
// ---------------------------------------------------------------------------
|
|
532
|
+
export function buildFallbackMenu() {
|
|
533
|
+
const theme = getActiveTheme();
|
|
534
|
+
const provider = state.activeProvider;
|
|
535
|
+
const chain = state.store.fallbackChains[provider];
|
|
536
|
+
const opts = [
|
|
537
|
+
{
|
|
538
|
+
name: provider === "antigravity" ? "Select / Configure Models" : "Edit Fallback Chain",
|
|
539
|
+
description: `Manage models (${chain.length} configured)`,
|
|
540
|
+
value: "edit-chain",
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
name: `Rate Limit Threshold: ${state.store.maxRateLimitFailures}`,
|
|
544
|
+
description: "Number of rate limits before account failover activates",
|
|
545
|
+
value: "settings",
|
|
546
|
+
},
|
|
547
|
+
];
|
|
548
|
+
const options = opts.filter(Boolean);
|
|
549
|
+
state.fallbackSettingsIndex = clampIndex(state.fallbackSettingsIndex, options.length);
|
|
550
|
+
const menu = themedSelect("fallback-menu", 56, 8, options, state.fallbackSettingsIndex, (idx, opt) => {
|
|
551
|
+
state.fallbackSettingsIndex = idx;
|
|
552
|
+
handleFallbackMenuSelect(opt.value);
|
|
553
|
+
});
|
|
554
|
+
return { element: menu, helpText: "[Tab] switch tabs [Ctrl+C] quit" };
|
|
555
|
+
}
|
|
556
|
+
// ---------------------------------------------------------------------------
|
|
557
|
+
// Fallback Settings
|
|
558
|
+
// ---------------------------------------------------------------------------
|
|
559
|
+
export function buildFallbackSettings() {
|
|
560
|
+
const theme = getActiveTheme();
|
|
561
|
+
const current = state.store.maxRateLimitFailures;
|
|
562
|
+
const options = [
|
|
563
|
+
{ name: "+1", description: `Increase threshold to ${current + 1}`, value: "inc" },
|
|
564
|
+
{ name: "-1", description: `Decrease threshold to ${Math.max(1, current - 1)}`, value: "dec" },
|
|
565
|
+
{ name: "Back", description: "Return to fallback menu", value: "back" },
|
|
566
|
+
];
|
|
567
|
+
const selector = themedSelect("fallback-settings", 56, 8, options, Math.max(0, state.fallbackSettingsIndex - 1), (idx, opt) => {
|
|
568
|
+
if (opt.value === "inc") {
|
|
569
|
+
state.store.maxRateLimitFailures = current + 1;
|
|
570
|
+
safeSaveStore();
|
|
571
|
+
refreshStore();
|
|
572
|
+
state.fallbackSettingsIndex = idx + 1;
|
|
573
|
+
callRenderApp();
|
|
574
|
+
}
|
|
575
|
+
else if (opt.value === "dec") {
|
|
576
|
+
state.store.maxRateLimitFailures = Math.max(1, current - 1);
|
|
577
|
+
safeSaveStore();
|
|
578
|
+
refreshStore();
|
|
579
|
+
state.fallbackSettingsIndex = idx + 1;
|
|
580
|
+
callRenderApp();
|
|
581
|
+
}
|
|
582
|
+
else if (opt.value === "back") {
|
|
583
|
+
navigate("fallback-menu");
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
events(selector).on("selectionChanged", (index) => {
|
|
587
|
+
state.fallbackSettingsIndex = index + 1;
|
|
588
|
+
});
|
|
589
|
+
return {
|
|
590
|
+
element: Box({ flexDirection: "column", gap: 1 }, Box({ flexDirection: "column" }, Text({ content: " Fallback Settings:", fg: theme.primary }), Text({ content: ` Rotate to next account after ${current} consecutive rate limit${current === 1 ? "" : "s"}`, fg: theme.textMuted })), selector),
|
|
591
|
+
helpText: "[Esc] back",
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
// ---------------------------------------------------------------------------
|
|
595
|
+
// Fallback Chain
|
|
596
|
+
// ---------------------------------------------------------------------------
|
|
597
|
+
const BRAILLE_SPINNER_FRAMES = [
|
|
598
|
+
"\u280B", "\u2819", "\u2839", "\u2838",
|
|
599
|
+
"\u283C", "\u2834", "\u2826", "\u2827",
|
|
600
|
+
"\u2807", "\u280F",
|
|
601
|
+
];
|
|
602
|
+
function getBrailleSpinner() {
|
|
603
|
+
const frame = Math.floor(Date.now() / 80) % BRAILLE_SPINNER_FRAMES.length;
|
|
604
|
+
return BRAILLE_SPINNER_FRAMES[frame];
|
|
605
|
+
}
|
|
606
|
+
export function buildFallbackChain() {
|
|
607
|
+
const theme = getActiveTheme();
|
|
608
|
+
const provider = state.activeProvider;
|
|
609
|
+
const chain = state.store.fallbackChains[provider];
|
|
610
|
+
const totalItems = chain.length + 1;
|
|
611
|
+
const currentIndex = state.fallbackChainIndex[provider];
|
|
612
|
+
state.fallbackChainIndex[provider] = clampIndex(currentIndex, totalItems);
|
|
613
|
+
const viewportHeight = 12;
|
|
614
|
+
const listWidth = 56;
|
|
615
|
+
if (state.fallbackChainIndex[provider] < state.fallbackChainScrollOffset[provider]) {
|
|
616
|
+
state.fallbackChainScrollOffset[provider] = state.fallbackChainIndex[provider];
|
|
617
|
+
}
|
|
618
|
+
else if (state.fallbackChainIndex[provider] >=
|
|
619
|
+
state.fallbackChainScrollOffset[provider] + viewportHeight) {
|
|
620
|
+
state.fallbackChainScrollOffset[provider] =
|
|
621
|
+
state.fallbackChainIndex[provider] - viewportHeight + 1;
|
|
622
|
+
}
|
|
623
|
+
const items = [];
|
|
624
|
+
const startIdx = state.fallbackChainScrollOffset[provider];
|
|
625
|
+
const endIdx = Math.min(startIdx + viewportHeight, chain.length);
|
|
626
|
+
for (let i = startIdx; i < endIdx; i++) {
|
|
627
|
+
const model = chain[i];
|
|
628
|
+
const isSelected = i === state.fallbackChainIndex[provider];
|
|
629
|
+
const isActivelyBenchmarking = model.benchmarkStatus === "running" && state.benchmarkRunners.has(model.id);
|
|
630
|
+
let statusText;
|
|
631
|
+
let statusIsError = false;
|
|
632
|
+
if (isActivelyBenchmarking) {
|
|
633
|
+
const runner = state.benchmarkRunners.get(model.id);
|
|
634
|
+
const m = runner.metrics;
|
|
635
|
+
const phase = runner.phase;
|
|
636
|
+
if (phase === "connecting") {
|
|
637
|
+
statusText = `${getBrailleSpinner()} connecting...`;
|
|
638
|
+
}
|
|
639
|
+
else if (phase === "streaming") {
|
|
640
|
+
const ttfb = m.ttfb != null ? `${m.ttfb.toFixed(0)}ms` : "...";
|
|
641
|
+
const tps = m.tps != null ? `${m.tps.toFixed(1)}` : "...";
|
|
642
|
+
statusText = `${getBrailleSpinner()} ${ttfb} TTFB, ${tps} TPS`;
|
|
643
|
+
}
|
|
644
|
+
else {
|
|
645
|
+
statusText = `${getBrailleSpinner()} benchmarking...`;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
else if (model.benchmarkStatus === "running") {
|
|
649
|
+
statusText = `${getBrailleSpinner()} benchmarking...`;
|
|
650
|
+
}
|
|
651
|
+
else if (model.benchmarkStatus === "done") {
|
|
652
|
+
const ttfbStr = model.benchmarkTtfb != null && Number.isFinite(model.benchmarkTtfb)
|
|
653
|
+
? `${model.benchmarkTtfb.toFixed(0)}ms`
|
|
654
|
+
: "?ms";
|
|
655
|
+
const tpsStr = model.benchmarkTps != null && Number.isFinite(model.benchmarkTps) && model.benchmarkTps > 0
|
|
656
|
+
? `${model.benchmarkTps.toFixed(1)}`
|
|
657
|
+
: "?";
|
|
658
|
+
statusText = `\u2713 ${ttfbStr} TTFB, ${tpsStr} TPS`;
|
|
659
|
+
}
|
|
660
|
+
else if (model.benchmarkStatus === "error") {
|
|
661
|
+
statusText = `\u2717 ${model.benchmarkError}`;
|
|
662
|
+
statusIsError = true;
|
|
663
|
+
}
|
|
664
|
+
else {
|
|
665
|
+
statusText = "";
|
|
666
|
+
}
|
|
667
|
+
const prefix = isSelected ? "\u25b6 " : " ";
|
|
668
|
+
const nameWidth = listWidth - 4;
|
|
669
|
+
const displayName = model.name.length > nameWidth
|
|
670
|
+
? model.name.slice(0, nameWidth - 3) + "..."
|
|
671
|
+
: model.name;
|
|
672
|
+
items.push(Box({
|
|
673
|
+
flexDirection: "row",
|
|
674
|
+
paddingX: 1,
|
|
675
|
+
backgroundColor: isSelected ? theme.selectedBg : theme.backgroundPanel,
|
|
676
|
+
width: listWidth,
|
|
677
|
+
}, Text({
|
|
678
|
+
content: `${prefix}${displayName}`,
|
|
679
|
+
fg: isSelected ? theme.selectedText : theme.text,
|
|
680
|
+
width: statusText ? nameWidth - statusText.length : nameWidth,
|
|
681
|
+
}), statusText
|
|
682
|
+
? Text({ content: statusText, fg: statusIsError ? theme.error : theme.textMuted })
|
|
683
|
+
: Text({ content: "", fg: theme.backgroundPanel })));
|
|
684
|
+
}
|
|
685
|
+
const addModelIndex = chain.length;
|
|
686
|
+
const isAddVisible = addModelIndex >= startIdx && addModelIndex < startIdx + viewportHeight;
|
|
687
|
+
if (isAddVisible) {
|
|
688
|
+
const isAddSelected = state.fallbackChainIndex[provider] === addModelIndex;
|
|
689
|
+
items.push(Box({
|
|
690
|
+
flexDirection: "row",
|
|
691
|
+
paddingX: 1,
|
|
692
|
+
backgroundColor: isAddSelected ? theme.selectedBg : theme.backgroundPanel,
|
|
693
|
+
width: listWidth,
|
|
694
|
+
}, Text({
|
|
695
|
+
content: `${isAddSelected ? "\u25b6 " : " "}+ Add model`,
|
|
696
|
+
fg: isAddSelected ? theme.selectedText : theme.textMuted,
|
|
697
|
+
})));
|
|
698
|
+
}
|
|
699
|
+
const providerName = getProviderDisplayName(provider);
|
|
700
|
+
return {
|
|
701
|
+
element: Box({ flexDirection: "column", gap: 0, width: listWidth }, Text({ content: ` ${providerName} Models:`, fg: theme.primary }), ...items),
|
|
702
|
+
helpText: "[Up/Down] move [x] remove [j/k] reorder\n[a] add [b] benchmark [c] cancel",
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
// ---------------------------------------------------------------------------
|
|
706
|
+
// Model Selector
|
|
707
|
+
// ---------------------------------------------------------------------------
|
|
708
|
+
export function getFilteredModelsForSelector() {
|
|
709
|
+
const provider = state.activeProvider;
|
|
710
|
+
const addedIds = new Set(state.store.fallbackChains[provider].map((m) => m.id));
|
|
711
|
+
const filteredModels = state.availableModels[provider].filter((model) => !addedIds.has(model.id));
|
|
712
|
+
const searchQuery = state.modelSearchQuery.toLowerCase();
|
|
713
|
+
return searchQuery.length > 0
|
|
714
|
+
? filteredModels.filter((model) => model.name.toLowerCase().includes(searchQuery) ||
|
|
715
|
+
model.id.toLowerCase().includes(searchQuery))
|
|
716
|
+
: filteredModels;
|
|
717
|
+
}
|
|
718
|
+
export function buildModelSelector() {
|
|
719
|
+
const theme = getActiveTheme();
|
|
720
|
+
const provider = state.activeProvider;
|
|
721
|
+
if (state.availableModels[provider].length === 0 && !state.modelsLoaded[provider]) {
|
|
722
|
+
state.modelsLoaded[provider] = true;
|
|
723
|
+
fetchModels(provider).then(() => {
|
|
724
|
+
if (state.availableModels[provider].length === 0) {
|
|
725
|
+
setStatus("No models available", getActiveTheme().warning);
|
|
726
|
+
}
|
|
727
|
+
callRenderApp();
|
|
728
|
+
});
|
|
729
|
+
return {
|
|
730
|
+
element: Box({ flexDirection: "column", gap: 1 }, Text({ content: `Loading models from ${getProviderDisplayName(provider)}...`, fg: theme.textMuted })),
|
|
731
|
+
helpText: "[Esc] cancel",
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
const searchFilteredModels = getFilteredModelsForSelector();
|
|
735
|
+
if (state.modelSelectorScrollOffset[provider] < 0) {
|
|
736
|
+
state.modelSelectorScrollOffset[provider] = 0;
|
|
737
|
+
}
|
|
738
|
+
state.modelSelectorIndex[provider] = clampIndex(state.modelSelectorIndex[provider], Math.max(1, searchFilteredModels.length));
|
|
739
|
+
const searchDisplay = state.modelSearchQuery.length > 0
|
|
740
|
+
? Text({ content: `Search: ${state.modelSearchQuery}_`, fg: theme.primary })
|
|
741
|
+
: Text({ content: "Type to search or enter custom model ID...", fg: theme.textMuted });
|
|
742
|
+
const listWidth = 56;
|
|
743
|
+
const viewportHeight = 12;
|
|
744
|
+
const totalItems = searchFilteredModels.length;
|
|
745
|
+
if (state.modelSelectorIndex[provider] < state.modelSelectorScrollOffset[provider]) {
|
|
746
|
+
state.modelSelectorScrollOffset[provider] = state.modelSelectorIndex[provider];
|
|
747
|
+
}
|
|
748
|
+
else if (state.modelSelectorIndex[provider] >=
|
|
749
|
+
state.modelSelectorScrollOffset[provider] + viewportHeight) {
|
|
750
|
+
state.modelSelectorScrollOffset[provider] =
|
|
751
|
+
state.modelSelectorIndex[provider] - viewportHeight + 1;
|
|
752
|
+
}
|
|
753
|
+
const items = [];
|
|
754
|
+
const startIdx = state.modelSelectorScrollOffset[provider];
|
|
755
|
+
const endIdx = Math.min(startIdx + viewportHeight, totalItems);
|
|
756
|
+
for (let i = startIdx; i < endIdx; i++) {
|
|
757
|
+
const model = searchFilteredModels[i];
|
|
758
|
+
const isSelected = i === state.modelSelectorIndex[provider];
|
|
759
|
+
const prefix = isSelected ? "\u25b6 " : " ";
|
|
760
|
+
items.push(Box({
|
|
761
|
+
flexDirection: "row",
|
|
762
|
+
paddingX: 1,
|
|
763
|
+
backgroundColor: isSelected ? theme.selectedBg : theme.backgroundPanel,
|
|
764
|
+
width: listWidth,
|
|
765
|
+
}, Text({ content: `${prefix}${model.name}`, fg: isSelected ? theme.selectedText : theme.text })));
|
|
766
|
+
}
|
|
767
|
+
if (totalItems === 0) {
|
|
768
|
+
const query = state.modelSearchQuery.trim();
|
|
769
|
+
if (query.length > 0) {
|
|
770
|
+
items.push(Box({
|
|
771
|
+
flexDirection: "row",
|
|
772
|
+
paddingX: 1,
|
|
773
|
+
backgroundColor: theme.selectedBg,
|
|
774
|
+
width: listWidth,
|
|
775
|
+
}, Text({ content: `▶ + Add custom model ID: "${query}"`, fg: theme.selectedText })));
|
|
776
|
+
}
|
|
777
|
+
else {
|
|
778
|
+
items.push(Box({
|
|
779
|
+
flexDirection: "row",
|
|
780
|
+
paddingX: 1,
|
|
781
|
+
backgroundColor: theme.backgroundPanel,
|
|
782
|
+
width: listWidth,
|
|
783
|
+
}, Text({ content: " No matching models (type to add custom ID)", fg: theme.textMuted })));
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
return {
|
|
787
|
+
element: Box({ flexDirection: "column", gap: 0, width: listWidth }, Text({ content: " Select a model to add:", fg: theme.primary }), searchDisplay, ...items),
|
|
788
|
+
helpText: "[Esc] cancel [Enter] select [Type] search [Backspace] clear [r] refresh live",
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
//# sourceMappingURL=screens.js.map
|