@appkit/llamacpp-cli 1.8.0 → 1.10.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.
Files changed (116) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +249 -40
  3. package/dist/cli.js +154 -10
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/completion.d.ts +9 -0
  6. package/dist/commands/completion.d.ts.map +1 -0
  7. package/dist/commands/completion.js +83 -0
  8. package/dist/commands/completion.js.map +1 -0
  9. package/dist/commands/monitor.js +1 -1
  10. package/dist/commands/monitor.js.map +1 -1
  11. package/dist/commands/ps.d.ts +1 -3
  12. package/dist/commands/ps.d.ts.map +1 -1
  13. package/dist/commands/ps.js +36 -115
  14. package/dist/commands/ps.js.map +1 -1
  15. package/dist/commands/router/config.d.ts +11 -0
  16. package/dist/commands/router/config.d.ts.map +1 -0
  17. package/dist/commands/router/config.js +100 -0
  18. package/dist/commands/router/config.js.map +1 -0
  19. package/dist/commands/router/logs.d.ts +12 -0
  20. package/dist/commands/router/logs.d.ts.map +1 -0
  21. package/dist/commands/router/logs.js +238 -0
  22. package/dist/commands/router/logs.js.map +1 -0
  23. package/dist/commands/router/restart.d.ts +2 -0
  24. package/dist/commands/router/restart.d.ts.map +1 -0
  25. package/dist/commands/router/restart.js +39 -0
  26. package/dist/commands/router/restart.js.map +1 -0
  27. package/dist/commands/router/start.d.ts +2 -0
  28. package/dist/commands/router/start.d.ts.map +1 -0
  29. package/dist/commands/router/start.js +60 -0
  30. package/dist/commands/router/start.js.map +1 -0
  31. package/dist/commands/router/status.d.ts +2 -0
  32. package/dist/commands/router/status.d.ts.map +1 -0
  33. package/dist/commands/router/status.js +116 -0
  34. package/dist/commands/router/status.js.map +1 -0
  35. package/dist/commands/router/stop.d.ts +2 -0
  36. package/dist/commands/router/stop.d.ts.map +1 -0
  37. package/dist/commands/router/stop.js +36 -0
  38. package/dist/commands/router/stop.js.map +1 -0
  39. package/dist/commands/tui.d.ts +2 -0
  40. package/dist/commands/tui.d.ts.map +1 -0
  41. package/dist/commands/tui.js +27 -0
  42. package/dist/commands/tui.js.map +1 -0
  43. package/dist/lib/completion.d.ts +5 -0
  44. package/dist/lib/completion.d.ts.map +1 -0
  45. package/dist/lib/completion.js +195 -0
  46. package/dist/lib/completion.js.map +1 -0
  47. package/dist/lib/model-downloader.d.ts +5 -1
  48. package/dist/lib/model-downloader.d.ts.map +1 -1
  49. package/dist/lib/model-downloader.js +53 -20
  50. package/dist/lib/model-downloader.js.map +1 -1
  51. package/dist/lib/router-logger.d.ts +61 -0
  52. package/dist/lib/router-logger.d.ts.map +1 -0
  53. package/dist/lib/router-logger.js +200 -0
  54. package/dist/lib/router-logger.js.map +1 -0
  55. package/dist/lib/router-manager.d.ts +103 -0
  56. package/dist/lib/router-manager.d.ts.map +1 -0
  57. package/dist/lib/router-manager.js +394 -0
  58. package/dist/lib/router-manager.js.map +1 -0
  59. package/dist/lib/router-server.d.ts +61 -0
  60. package/dist/lib/router-server.d.ts.map +1 -0
  61. package/dist/lib/router-server.js +485 -0
  62. package/dist/lib/router-server.js.map +1 -0
  63. package/dist/tui/ConfigApp.d.ts +7 -0
  64. package/dist/tui/ConfigApp.d.ts.map +1 -0
  65. package/dist/tui/ConfigApp.js +1002 -0
  66. package/dist/tui/ConfigApp.js.map +1 -0
  67. package/dist/tui/HistoricalMonitorApp.d.ts.map +1 -1
  68. package/dist/tui/HistoricalMonitorApp.js +85 -49
  69. package/dist/tui/HistoricalMonitorApp.js.map +1 -1
  70. package/dist/tui/ModelsApp.d.ts +7 -0
  71. package/dist/tui/ModelsApp.d.ts.map +1 -0
  72. package/dist/tui/ModelsApp.js +362 -0
  73. package/dist/tui/ModelsApp.js.map +1 -0
  74. package/dist/tui/MultiServerMonitorApp.d.ts +6 -1
  75. package/dist/tui/MultiServerMonitorApp.d.ts.map +1 -1
  76. package/dist/tui/MultiServerMonitorApp.js +1038 -122
  77. package/dist/tui/MultiServerMonitorApp.js.map +1 -1
  78. package/dist/tui/RootNavigator.d.ts +7 -0
  79. package/dist/tui/RootNavigator.d.ts.map +1 -0
  80. package/dist/tui/RootNavigator.js +55 -0
  81. package/dist/tui/RootNavigator.js.map +1 -0
  82. package/dist/tui/SearchApp.d.ts +6 -0
  83. package/dist/tui/SearchApp.d.ts.map +1 -0
  84. package/dist/tui/SearchApp.js +451 -0
  85. package/dist/tui/SearchApp.js.map +1 -0
  86. package/dist/tui/SplashScreen.d.ts +16 -0
  87. package/dist/tui/SplashScreen.d.ts.map +1 -0
  88. package/dist/tui/SplashScreen.js +129 -0
  89. package/dist/tui/SplashScreen.js.map +1 -0
  90. package/dist/types/router-config.d.ts +19 -0
  91. package/dist/types/router-config.d.ts.map +1 -0
  92. package/dist/types/router-config.js +3 -0
  93. package/dist/types/router-config.js.map +1 -0
  94. package/package.json +1 -1
  95. package/src/cli.ts +121 -10
  96. package/src/commands/monitor.ts +1 -1
  97. package/src/commands/ps.ts +44 -133
  98. package/src/commands/router/config.ts +116 -0
  99. package/src/commands/router/logs.ts +256 -0
  100. package/src/commands/router/restart.ts +36 -0
  101. package/src/commands/router/start.ts +60 -0
  102. package/src/commands/router/status.ts +119 -0
  103. package/src/commands/router/stop.ts +33 -0
  104. package/src/commands/tui.ts +25 -0
  105. package/src/lib/model-downloader.ts +57 -20
  106. package/src/lib/router-logger.ts +201 -0
  107. package/src/lib/router-manager.ts +414 -0
  108. package/src/lib/router-server.ts +538 -0
  109. package/src/tui/ConfigApp.ts +1085 -0
  110. package/src/tui/HistoricalMonitorApp.ts +88 -49
  111. package/src/tui/ModelsApp.ts +368 -0
  112. package/src/tui/MultiServerMonitorApp.ts +1163 -122
  113. package/src/tui/RootNavigator.ts +74 -0
  114. package/src/tui/SearchApp.ts +511 -0
  115. package/src/tui/SplashScreen.ts +149 -0
  116. package/src/types/router-config.ts +25 -0
@@ -0,0 +1,74 @@
1
+ import blessed from 'blessed';
2
+ import { ServerConfig } from '../types/server-config.js';
3
+ import { createMultiServerMonitorUI, MonitorUIControls } from './MultiServerMonitorApp.js';
4
+ import { createModelsUI } from './ModelsApp.js';
5
+ import { createSplashScreen } from './SplashScreen.js';
6
+
7
+ type RootView = 'monitor' | 'models';
8
+
9
+ /**
10
+ * Root navigator that manages switching between Monitor and Models views
11
+ */
12
+ export async function createRootNavigator(
13
+ screen: blessed.Widgets.Screen,
14
+ servers: ServerConfig[],
15
+ directJumpToServer?: number
16
+ ): Promise<void> {
17
+ let currentView: RootView = 'monitor';
18
+ let isExiting = false;
19
+
20
+ // Show splash screen with loading sequence
21
+ const cleanupSplash = await createSplashScreen(screen, {
22
+ onLoadConfigs: async () => {
23
+ // Configs are already loaded (passed in), but simulate brief work
24
+ await new Promise(resolve => setTimeout(resolve, 150));
25
+ },
26
+ onCheckServices: async () => {
27
+ // Services already checked by ps command, but simulate brief work
28
+ await new Promise(resolve => setTimeout(resolve, 150));
29
+ },
30
+ onInitMetrics: async () => {
31
+ // Metrics collectors initialize on first poll, but simulate brief work
32
+ await new Promise(resolve => setTimeout(resolve, 150));
33
+ },
34
+ });
35
+
36
+ // Callback to switch to Models view (receives controls from Monitor)
37
+ const switchToModels = async (monitorControls: MonitorUIControls) => {
38
+ if (isExiting) return;
39
+ currentView = 'models';
40
+
41
+ // Create Models view (Monitor is paused, not destroyed)
42
+ await createModelsUI(
43
+ screen,
44
+ async () => {
45
+ // onBack callback - return to Monitor view
46
+ if (isExiting) return;
47
+ currentView = 'monitor';
48
+
49
+ // Resume Monitor view instantly (no reload, just re-attach and resume polling)
50
+ monitorControls.resume();
51
+ },
52
+ async () => {
53
+ // onSearch callback - handled by ModelsApp (opens SearchApp)
54
+ // This is a placeholder, actual implementation in ModelsApp
55
+ }
56
+ );
57
+ };
58
+
59
+ // Start with Monitor view (skip connecting message since splash already showed loading)
60
+ // Pass cleanupSplash as onFirstRender callback - splash stays until monitor data is ready
61
+ await createMultiServerMonitorUI(
62
+ screen,
63
+ servers,
64
+ true, // Skip "Connecting to servers..." since splash screen showed loading
65
+ directJumpToServer,
66
+ switchToModels,
67
+ cleanupSplash
68
+ );
69
+
70
+ // Handle cleanup on exit
71
+ screen.on('destroy', () => {
72
+ isExiting = true;
73
+ });
74
+ }
@@ -0,0 +1,511 @@
1
+ import blessed from 'blessed';
2
+ import { modelSearch, HFModelResult } from '../lib/model-search.js';
3
+ import { modelDownloader, DownloadProgress } from '../lib/model-downloader.js';
4
+ import { stateManager } from '../lib/state-manager.js';
5
+ import { formatBytes } from '../utils/format-utils.js';
6
+
7
+ interface SearchState {
8
+ query: string;
9
+ results: HFModelResult[];
10
+ selectedIndex: number;
11
+ isSearching: boolean;
12
+ expandedModelIndex: number | null;
13
+ modelFiles: string[];
14
+ selectedFileIndex: number;
15
+ isLoadingFiles: boolean;
16
+ error: string | null;
17
+ }
18
+
19
+ /**
20
+ * Search HuggingFace and download models TUI
21
+ */
22
+ export async function createSearchUI(
23
+ screen: blessed.Widgets.Screen,
24
+ onBack: () => void
25
+ ): Promise<void> {
26
+ const state: SearchState = {
27
+ query: '',
28
+ results: [],
29
+ selectedIndex: 0,
30
+ isSearching: false,
31
+ expandedModelIndex: null,
32
+ modelFiles: [],
33
+ selectedFileIndex: 0,
34
+ isLoadingFiles: false,
35
+ error: null,
36
+ };
37
+
38
+ // Create content box for results
39
+ const contentBox = blessed.box({
40
+ parent: screen,
41
+ top: 0,
42
+ left: 0,
43
+ right: 0,
44
+ bottom: 0,
45
+ tags: true,
46
+ scrollable: true,
47
+ alwaysScroll: true,
48
+ keys: true,
49
+ vi: true,
50
+ mouse: true,
51
+ scrollbar: {
52
+ ch: '█',
53
+ style: {
54
+ fg: 'blue',
55
+ },
56
+ },
57
+ });
58
+
59
+ // Render content
60
+ function render() {
61
+ const termWidth = (screen.width as number) || 80;
62
+ const divider = '─'.repeat(termWidth - 2);
63
+ let content = '';
64
+
65
+ // Header
66
+ content += '{bold}{blue-fg}═══ Search Models{/blue-fg}{/bold}\n';
67
+ content += '{gray-fg}Press [/] to search HuggingFace{/gray-fg}\n';
68
+ content += divider + '\n\n';
69
+
70
+ // Show searching state
71
+ if (state.isSearching) {
72
+ content += '{cyan-fg}⏳ Searching...{/cyan-fg}\n';
73
+ contentBox.setContent(content);
74
+ screen.render();
75
+ return;
76
+ }
77
+
78
+ // Show error
79
+ if (state.error) {
80
+ content += `{red-fg}❌ Error: ${state.error}{/red-fg}\n\n`;
81
+ content += '{gray-fg}Press [/] to search again{/gray-fg}\n';
82
+ contentBox.setContent(content);
83
+ screen.render();
84
+ return;
85
+ }
86
+
87
+ // Show results or empty state
88
+ if (state.results.length === 0) {
89
+ if (state.query) {
90
+ content += `{yellow-fg}No results found for: ${state.query}{/yellow-fg}\n`;
91
+ } else {
92
+ content += '{gray-fg}Press [/] to search HuggingFace for models{/gray-fg}\n';
93
+ }
94
+ content += '\n' + divider + '\n';
95
+ content += '{gray-fg}[/] Search [ESC] Back [Q]uit{/gray-fg}';
96
+ contentBox.setContent(content);
97
+ screen.render();
98
+ return;
99
+ }
100
+
101
+ // Show expanded model files
102
+ if (state.expandedModelIndex !== null) {
103
+ const model = state.results[state.expandedModelIndex];
104
+ content += `{bold}Model: ${model.modelId}{/bold}\n`;
105
+ content += `Downloads: ${model.downloads.toLocaleString()} | Likes: ${model.likes}\n`;
106
+ content += divider + '\n\n';
107
+
108
+ if (state.isLoadingFiles) {
109
+ content += '{cyan-fg}⏳ Loading GGUF files...{/cyan-fg}\n';
110
+ } else if (state.modelFiles.length === 0) {
111
+ content += '{yellow-fg}No GGUF files found for this model{/yellow-fg}\n';
112
+ } else {
113
+ content += '{bold}GGUF Files:{/bold}\n\n';
114
+
115
+ for (let i = 0; i < state.modelFiles.length; i++) {
116
+ const file = state.modelFiles[i];
117
+ const isSelected = i === state.selectedFileIndex;
118
+ const indicator = isSelected ? '►' : ' ';
119
+
120
+ let rowContent = '';
121
+ if (isSelected) {
122
+ rowContent = `{cyan-bg}{15-fg}${indicator} ${file}{/15-fg}{/cyan-bg}`;
123
+ } else {
124
+ rowContent = `${indicator} ${file}`;
125
+ }
126
+
127
+ content += rowContent + '\n';
128
+ }
129
+
130
+ content += '\n{gray-fg}Press Enter to download selected file{/gray-fg}\n';
131
+ }
132
+
133
+ content += '\n' + divider + '\n';
134
+ content += '{gray-fg}[↑/↓] Navigate [Enter] Download [ESC] Back{/gray-fg}';
135
+ contentBox.setContent(content);
136
+ screen.render();
137
+ return;
138
+ }
139
+
140
+ // Show results table
141
+ content += `{bold}Results for: "${state.query}"{/bold}\n`;
142
+ content += divider + '\n\n';
143
+
144
+ // Table header
145
+ content += '{bold} │ Model │ Downloads │ Likes{/bold}\n';
146
+ content += divider + '\n';
147
+
148
+ // Result rows
149
+ for (let i = 0; i < state.results.length; i++) {
150
+ const result = state.results[i];
151
+ const isSelected = i === state.selectedIndex;
152
+ const indicator = isSelected ? '►' : ' ';
153
+
154
+ // Model ID (truncate if too long)
155
+ const maxModelLen = 51;
156
+ let modelId = result.modelId;
157
+ if (modelId.length > maxModelLen) {
158
+ modelId = modelId.substring(0, maxModelLen - 3) + '...';
159
+ }
160
+ modelId = modelId.padEnd(maxModelLen);
161
+
162
+ // Downloads
163
+ const downloads = result.downloads.toLocaleString().padStart(10);
164
+
165
+ // Likes
166
+ const likes = result.likes.toString().padStart(6);
167
+
168
+ // Build row content
169
+ let rowContent = '';
170
+ if (isSelected) {
171
+ rowContent = `{cyan-bg}{15-fg}${indicator} │ ${modelId} │ ${downloads} │ ${likes}{/15-fg}{/cyan-bg}`;
172
+ } else {
173
+ rowContent = `${indicator} │ ${modelId} │ ${downloads} │ ${likes}`;
174
+ }
175
+
176
+ content += rowContent + '\n';
177
+ }
178
+
179
+ // Footer
180
+ content += '\n' + divider + '\n';
181
+ content += '{gray-fg}[↑/↓] Navigate [Enter] View files [/] New search [ESC] Back [Q]uit{/gray-fg}';
182
+
183
+ contentBox.setContent(content);
184
+ screen.render();
185
+ }
186
+
187
+ // Show search popup modal
188
+ function showSearchPopup() {
189
+ const searchBox = blessed.box({
190
+ parent: screen,
191
+ top: 'center',
192
+ left: 'center',
193
+ width: '60%',
194
+ height: 7,
195
+ border: { type: 'line' },
196
+ style: {
197
+ border: { fg: 'cyan' },
198
+ },
199
+ tags: true,
200
+ });
201
+
202
+ searchBox.setContent('{bold}Search HuggingFace{/bold}\n\nType your query and press Enter:');
203
+
204
+ const searchInput = blessed.textbox({
205
+ parent: searchBox,
206
+ bottom: 0,
207
+ left: 1,
208
+ right: 1,
209
+ height: 1,
210
+ inputOnFocus: true,
211
+ style: {
212
+ fg: 'white',
213
+ bg: 'black',
214
+ },
215
+ });
216
+
217
+ // Handle submit
218
+ searchInput.on('submit', async (value: string) => {
219
+ screen.remove(searchBox);
220
+ screen.render();
221
+
222
+ if (value && value.trim()) {
223
+ await executeSearch(value.trim());
224
+ }
225
+ });
226
+
227
+ // Handle cancel
228
+ searchInput.on('cancel', () => {
229
+ screen.remove(searchBox);
230
+ render();
231
+ });
232
+
233
+ searchInput.key(['escape'], () => {
234
+ screen.remove(searchBox);
235
+ render();
236
+ });
237
+
238
+ screen.append(searchBox);
239
+ searchInput.focus();
240
+ screen.render();
241
+ }
242
+
243
+ // Execute search
244
+ async function executeSearch(query: string) {
245
+ state.query = query;
246
+ state.isSearching = true;
247
+ state.error = null;
248
+ state.results = [];
249
+ state.selectedIndex = 0;
250
+ render();
251
+
252
+ try {
253
+ const results = await modelSearch.searchModels(query, 20);
254
+ state.results = results;
255
+ state.isSearching = false;
256
+ render();
257
+ } catch (error) {
258
+ state.error = error instanceof Error ? error.message : 'Unknown error';
259
+ state.isSearching = false;
260
+ render();
261
+ }
262
+ }
263
+
264
+ // Load model files
265
+ async function loadModelFiles(modelIndex: number) {
266
+ const model = state.results[modelIndex];
267
+ state.expandedModelIndex = modelIndex;
268
+ state.isLoadingFiles = true;
269
+ state.modelFiles = [];
270
+ state.selectedFileIndex = 0;
271
+ render();
272
+
273
+ try {
274
+ const files = await modelSearch.getModelFiles(model.modelId);
275
+ state.modelFiles = files;
276
+ state.isLoadingFiles = false;
277
+ render();
278
+ } catch (error) {
279
+ state.error = error instanceof Error ? error.message : 'Unknown error';
280
+ state.expandedModelIndex = null;
281
+ state.isLoadingFiles = false;
282
+ render();
283
+ }
284
+ }
285
+
286
+ // Download selected file
287
+ async function downloadFile() {
288
+ if (state.expandedModelIndex === null || state.modelFiles.length === 0) return;
289
+
290
+ const model = state.results[state.expandedModelIndex];
291
+ const filename = state.modelFiles[state.selectedFileIndex];
292
+
293
+ // Get models directory
294
+ const modelsDir = await stateManager.getModelsDirectory();
295
+
296
+ // Create progress modal
297
+ const progressBox = blessed.box({
298
+ parent: screen,
299
+ top: 'center',
300
+ left: 'center',
301
+ width: '70%',
302
+ height: 12,
303
+ border: { type: 'line' },
304
+ style: {
305
+ border: { fg: 'cyan' },
306
+ },
307
+ tags: true,
308
+ });
309
+
310
+ // Create abort controller for cancellation
311
+ const abortController = new AbortController();
312
+ let downloadCancelled = false;
313
+
314
+ // Update progress display
315
+ function updateProgress(progress: DownloadProgress) {
316
+ if (downloadCancelled) return;
317
+ const percentage = progress.percentage.toFixed(1);
318
+ const barLength = 40;
319
+ const filled = Math.round((progress.percentage / 100) * barLength);
320
+ const empty = barLength - filled;
321
+ const bar = '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, empty));
322
+
323
+ let content = `{bold}Downloading: ${progress.filename}{/bold}\n\n`;
324
+ content += `[${bar}] ${percentage}%\n\n`;
325
+ content += `Downloaded: ${formatBytes(progress.downloaded)} / ${formatBytes(progress.total)}\n`;
326
+ content += `Speed: ${progress.speed}\n\n`;
327
+ content += '{gray-fg}Press ESC or Ctrl+C to cancel{/gray-fg}';
328
+
329
+ progressBox.setContent(content);
330
+ screen.render();
331
+ }
332
+
333
+ // Temporarily unregister main handlers during download
334
+ screen.unkey('escape', keyHandlers.escape);
335
+ screen.unkey('C-c', keyHandlers.quit);
336
+
337
+ // Handle cancel
338
+ const cancelHandler = () => {
339
+ downloadCancelled = true;
340
+ abortController.abort(); // Actually abort the download
341
+ };
342
+
343
+ // Cleanup function to restore handlers
344
+ const cleanup = () => {
345
+ screen.unkey('escape', cancelHandler);
346
+ screen.unkey('C-c', cancelHandler);
347
+ screen.remove(progressBox);
348
+ // Re-register main handlers
349
+ screen.key(['escape'], keyHandlers.escape);
350
+ screen.key(['C-c'], keyHandlers.quit);
351
+ };
352
+
353
+ screen.append(progressBox);
354
+ progressBox.focus();
355
+ screen.key(['escape', 'C-c'], cancelHandler);
356
+ screen.render();
357
+
358
+ try {
359
+ // Start download with silent mode and abort signal
360
+ await modelDownloader.downloadModel(
361
+ model.modelId,
362
+ filename,
363
+ (progress) => {
364
+ if (!downloadCancelled) {
365
+ updateProgress(progress);
366
+ }
367
+ },
368
+ modelsDir,
369
+ { silent: true, signal: abortController.signal }
370
+ );
371
+
372
+ if (!downloadCancelled) {
373
+ // Show success message
374
+ cleanup();
375
+
376
+ const successBox = blessed.message({
377
+ parent: screen,
378
+ top: 'center',
379
+ left: 'center',
380
+ width: '60%',
381
+ height: 'shrink',
382
+ border: { type: 'line' },
383
+ style: {
384
+ border: { fg: 'green' },
385
+ fg: 'green',
386
+ },
387
+ tags: true,
388
+ });
389
+
390
+ successBox.display(
391
+ `{bold}Download complete!{/bold}\n\nFile: ${filename}\nLocation: ${modelsDir}\n\nPress any key to continue`,
392
+ () => {
393
+ screen.remove(successBox);
394
+ render();
395
+ }
396
+ );
397
+ }
398
+ } catch (error) {
399
+ cleanup();
400
+
401
+ if (!downloadCancelled) {
402
+ // Show error message (not cancelled by user)
403
+ const errorBox = blessed.message({
404
+ parent: screen,
405
+ top: 'center',
406
+ left: 'center',
407
+ width: '60%',
408
+ height: 'shrink',
409
+ border: { type: 'line' },
410
+ style: {
411
+ border: { fg: 'red' },
412
+ fg: 'red',
413
+ },
414
+ tags: true,
415
+ });
416
+
417
+ const errorMsg = error instanceof Error ? error.message : 'Unknown error';
418
+ errorBox.display(`{bold}Download failed{/bold}\n\n${errorMsg}\n\nPress any key to continue`, () => {
419
+ screen.remove(errorBox);
420
+ render();
421
+ });
422
+ } else {
423
+ // Cancelled by user - just render
424
+ render();
425
+ }
426
+ }
427
+ }
428
+
429
+ // Store key handler references for cleanup
430
+ const keyHandlers = {
431
+ up: () => {
432
+ if (state.expandedModelIndex !== null) {
433
+ // Navigating files
434
+ state.selectedFileIndex = Math.max(0, state.selectedFileIndex - 1);
435
+ } else {
436
+ // Navigating results
437
+ state.selectedIndex = Math.max(0, state.selectedIndex - 1);
438
+ }
439
+ render();
440
+ },
441
+ down: () => {
442
+ if (state.expandedModelIndex !== null) {
443
+ // Navigating files
444
+ state.selectedFileIndex = Math.min(state.modelFiles.length - 1, state.selectedFileIndex + 1);
445
+ } else {
446
+ // Navigating results
447
+ state.selectedIndex = Math.min(state.results.length - 1, state.selectedIndex + 1);
448
+ }
449
+ render();
450
+ },
451
+ enter: () => {
452
+ if (state.expandedModelIndex !== null) {
453
+ // Download selected file
454
+ downloadFile();
455
+ } else if (state.results.length > 0) {
456
+ // Expand selected model to show files
457
+ loadModelFiles(state.selectedIndex);
458
+ }
459
+ },
460
+ search: () => {
461
+ if (state.expandedModelIndex !== null) return; // Don't allow search when viewing files
462
+ showSearchPopup();
463
+ },
464
+ escape: () => {
465
+ if (state.expandedModelIndex !== null) {
466
+ // Go back to results list
467
+ state.expandedModelIndex = null;
468
+ state.modelFiles = [];
469
+ state.selectedFileIndex = 0;
470
+ render();
471
+ } else {
472
+ // Go back to models view
473
+ unregisterHandlers();
474
+ screen.remove(contentBox);
475
+ onBack();
476
+ }
477
+ },
478
+ quit: () => {
479
+ screen.destroy();
480
+ process.exit(0);
481
+ },
482
+ };
483
+
484
+ // Unregister all keyboard handlers
485
+ function unregisterHandlers() {
486
+ screen.unkey('up', keyHandlers.up);
487
+ screen.unkey('k', keyHandlers.up);
488
+ screen.unkey('down', keyHandlers.down);
489
+ screen.unkey('j', keyHandlers.down);
490
+ screen.unkey('enter', keyHandlers.enter);
491
+ screen.unkey('/', keyHandlers.search);
492
+ screen.unkey('escape', keyHandlers.escape);
493
+ screen.unkey('q', keyHandlers.quit);
494
+ screen.unkey('Q', keyHandlers.quit);
495
+ screen.unkey('C-c', keyHandlers.quit);
496
+ }
497
+
498
+ // Register key handlers
499
+ screen.key(['up', 'k'], keyHandlers.up);
500
+ screen.key(['down', 'j'], keyHandlers.down);
501
+ screen.key(['enter'], keyHandlers.enter);
502
+ screen.key(['/'], keyHandlers.search);
503
+ screen.key(['escape'], keyHandlers.escape);
504
+ screen.key(['q', 'Q', 'C-c'], keyHandlers.quit);
505
+
506
+ // Initial render
507
+ render();
508
+
509
+ // Auto-open search popup on load
510
+ showSearchPopup();
511
+ }