@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
@@ -12,6 +12,9 @@ import {
12
12
 
13
13
  type ViewMode = 'recent' | 'hour';
14
14
 
15
+ // Shared view mode across both history screens - persists for the session
16
+ let sharedViewMode: ViewMode = 'recent';
17
+
15
18
  interface ChartStats {
16
19
  avg: number;
17
20
  max: number;
@@ -155,7 +158,6 @@ export async function createHistoricalUI(
155
158
  const REFRESH_INTERVAL = 1000;
156
159
  let lastGoodRender: string | null = null;
157
160
  let consecutiveErrors = 0;
158
- let viewMode: ViewMode = 'recent';
159
161
 
160
162
  const contentBox = createContentBox();
161
163
  screen.append(contentBox);
@@ -163,21 +165,21 @@ export async function createHistoricalUI(
163
165
  // Chart configurations
164
166
  const chartConfigs: Record<string, ChartConfig> = {
165
167
  tokenSpeed: {
166
- title: 'Model Token Generation Speed (tok/s)',
168
+ title: 'Server Token Generation Speed (tok/s)',
167
169
  color: asciichart.cyan,
168
170
  formatValue: (x: number) => Math.round(x).toFixed(0).padStart(6, ' '),
169
171
  isPercentage: false,
170
172
  noDataMessage: 'No generation activity in this time window',
171
173
  },
172
174
  cpu: {
173
- title: 'Model CPU Usage (%)',
175
+ title: 'Server CPU Usage (%)',
174
176
  color: asciichart.blue,
175
177
  formatValue: (x: number) => Math.round(x).toFixed(0).padStart(6, ' '),
176
178
  isPercentage: true,
177
179
  noDataMessage: 'No CPU data in this time window',
178
180
  },
179
181
  memory: {
180
- title: 'Model Memory Usage (GB)',
182
+ title: 'Server Memory Usage (GB)',
181
183
  color: asciichart.magenta,
182
184
  formatValue: (x: number) => x.toFixed(2).padStart(6, ' '),
183
185
  isPercentage: false,
@@ -206,8 +208,8 @@ export async function createHistoricalUI(
206
208
  }
207
209
 
208
210
  // Header
209
- const modeLabel = viewMode === 'recent' ? 'Minute' : 'Hour';
210
- const modeColor = viewMode === 'recent' ? 'cyan' : 'magenta';
211
+ const modeLabel = sharedViewMode === 'recent' ? 'Minute' : 'Hour';
212
+ const modeColor = sharedViewMode === 'recent' ? 'cyan' : 'magenta';
211
213
  let content = `{bold}{blue-fg}\u2550\u2550\u2550 ${server.modelName} (${server.port}) {/blue-fg} `;
212
214
  content += `{${modeColor}-fg}[${modeLabel}]{/${modeColor}-fg}{/bold}\n\n`;
213
215
 
@@ -218,14 +220,14 @@ export async function createHistoricalUI(
218
220
  content += 'Historical data is collected when you run the monitor command.\n';
219
221
  content += 'Start monitoring to begin collecting history.\n\n';
220
222
  content += divider + '\n';
221
- content += '{gray-fg}ESC = Back Q = Quit{/gray-fg}';
223
+ content += '{gray-fg}[ESC] Back [Q]uit{/gray-fg}';
222
224
  contentBox.setContent(content);
223
225
  screen.render();
224
226
  return;
225
227
  }
226
228
 
227
229
  const maxChartPoints = Math.min(chartWidth, 80);
228
- const displaySnapshots = viewMode === 'recent' && snapshots.length > maxChartPoints
230
+ const displaySnapshots = sharedViewMode === 'recent' && snapshots.length > maxChartPoints
229
231
  ? snapshots.slice(-maxChartPoints)
230
232
  : snapshots;
231
233
 
@@ -249,7 +251,7 @@ export async function createHistoricalUI(
249
251
  }
250
252
 
251
253
  // Apply downsampling based on view mode
252
- const useDownsampling = viewMode === 'hour';
254
+ const useDownsampling = sharedViewMode === 'hour';
253
255
  const values = {
254
256
  tokenSpeed: useDownsampling
255
257
  ? downsampleMaxTimeWithFullHour(rawData.tokenSpeed, maxChartPoints)
@@ -273,7 +275,7 @@ export async function createHistoricalUI(
273
275
 
274
276
  // Footer
275
277
  content += divider + '\n';
276
- content += `{gray-fg}Updated: ${new Date().toLocaleTimeString()} | H = Toggle Hour View ESC = Back Q = Quit{/gray-fg}`;
278
+ content += `{gray-fg}[T]oggle Hour View [ESC] Back [Q]uit{/gray-fg}`;
277
279
 
278
280
  contentBox.setContent(content);
279
281
  screen.render();
@@ -290,7 +292,7 @@ export async function createHistoricalUI(
290
292
  '{bold}{red-fg}Render Error{/red-fg}{/bold}\n\n' +
291
293
  `{red-fg}${errorMsg}{/red-fg}\n\n` +
292
294
  `Consecutive errors: ${consecutiveErrors}\n\n` +
293
- '{gray-fg}ESC = Back Q = Quit{/gray-fg}'
295
+ '{gray-fg}[ESC] Back [Q]uit{/gray-fg}'
294
296
  );
295
297
  }
296
298
  screen.render();
@@ -302,24 +304,43 @@ export async function createHistoricalUI(
302
304
  clearInterval(refreshIntervalId);
303
305
  refreshIntervalId = null;
304
306
  }
307
+ unregisterHandlers();
305
308
  }
306
309
 
307
- screen.key(['h', 'H'], () => {
308
- viewMode = viewMode === 'recent' ? 'hour' : 'recent';
309
- render();
310
- });
310
+ // Key handler functions (stored for unregistration)
311
+ const keyHandlers = {
312
+ toggle: () => {
313
+ sharedViewMode = sharedViewMode === 'recent' ? 'hour' : 'recent';
314
+ render();
315
+ },
316
+ escape: () => {
317
+ cleanup();
318
+ screen.remove(contentBox);
319
+ onBack();
320
+ },
321
+ quit: () => {
322
+ cleanup();
323
+ screen.destroy();
324
+ process.exit(0);
325
+ },
326
+ };
311
327
 
312
- screen.key(['escape'], () => {
313
- cleanup();
314
- screen.remove(contentBox);
315
- onBack();
316
- });
328
+ function registerHandlers(): void {
329
+ screen.key(['t', 'T'], keyHandlers.toggle);
330
+ screen.key(['escape'], keyHandlers.escape);
331
+ screen.key(['q', 'Q', 'C-c'], keyHandlers.quit);
332
+ }
317
333
 
318
- screen.key(['q', 'Q', 'C-c'], () => {
319
- cleanup();
320
- screen.destroy();
321
- process.exit(0);
322
- });
334
+ function unregisterHandlers(): void {
335
+ screen.unkey('t', keyHandlers.toggle);
336
+ screen.unkey('T', keyHandlers.toggle);
337
+ screen.unkey('escape', keyHandlers.escape);
338
+ screen.unkey('q', keyHandlers.quit);
339
+ screen.unkey('Q', keyHandlers.quit);
340
+ screen.unkey('C-c', keyHandlers.quit);
341
+ }
342
+
343
+ registerHandlers();
323
344
 
324
345
  contentBox.setContent('{cyan-fg}\u23f3 Loading historical data...{/cyan-fg}');
325
346
  screen.render();
@@ -339,7 +360,6 @@ export async function createMultiServerHistoricalUI(
339
360
  const REFRESH_INTERVAL = 3000;
340
361
  let lastGoodRender: string | null = null;
341
362
  let consecutiveErrors = 0;
342
- let viewMode: ViewMode = 'recent';
343
363
 
344
364
  const contentBox = createContentBox();
345
365
  screen.append(contentBox);
@@ -347,21 +367,21 @@ export async function createMultiServerHistoricalUI(
347
367
  // Chart configurations for multi-server view
348
368
  const chartConfigs: Record<string, ChartConfig> = {
349
369
  tokenSpeed: {
350
- title: 'Total Model Token Generation Speed (tok/s)',
370
+ title: 'Total Server Token Generation Speed (tok/s)',
351
371
  color: asciichart.cyan,
352
372
  formatValue: (x: number) => Math.round(x).toFixed(0).padStart(6, ' '),
353
373
  isPercentage: false,
354
374
  noDataMessage: 'No generation activity in this time window',
355
375
  },
356
376
  cpu: {
357
- title: 'Total Model CPU Usage (%)',
377
+ title: 'Total Server CPU Usage (%)',
358
378
  color: asciichart.blue,
359
379
  formatValue: (x: number) => Math.round(x).toFixed(0).padStart(6, ' '),
360
380
  isPercentage: true,
361
381
  noDataMessage: 'No CPU data in this time window',
362
382
  },
363
383
  memory: {
364
- title: 'Total Model Memory Usage (GB)',
384
+ title: 'Total Server Memory Usage (GB)',
365
385
  color: asciichart.magenta,
366
386
  formatValue: (x: number) => x.toFixed(2).padStart(6, ' '),
367
387
  isPercentage: false,
@@ -384,8 +404,8 @@ export async function createMultiServerHistoricalUI(
384
404
  const chartHeight = 5;
385
405
 
386
406
  // Header
387
- const modeLabel = viewMode === 'recent' ? 'Minute' : 'Hour';
388
- const modeColor = viewMode === 'recent' ? 'cyan' : 'magenta';
407
+ const modeLabel = sharedViewMode === 'recent' ? 'Minute' : 'Hour';
408
+ const modeColor = sharedViewMode === 'recent' ? 'cyan' : 'magenta';
389
409
  let content = `{bold}{blue-fg}\u2550\u2550\u2550 All servers (${servers.length}){/blue-fg} `;
390
410
  content += `{${modeColor}-fg}[${modeLabel}]{/${modeColor}-fg}{/bold}\n\n`;
391
411
 
@@ -453,11 +473,11 @@ export async function createMultiServerHistoricalUI(
453
473
 
454
474
  if (aggregatedData.length > 0) {
455
475
  const maxPoints = Math.min(chartWidth, 80);
456
- const displayData = viewMode === 'recent' && aggregatedData.length > maxPoints
476
+ const displayData = sharedViewMode === 'recent' && aggregatedData.length > maxPoints
457
477
  ? aggregatedData.slice(-maxPoints)
458
478
  : aggregatedData;
459
479
 
460
- const useDownsampling = viewMode === 'hour';
480
+ const useDownsampling = sharedViewMode === 'hour';
461
481
 
462
482
  // Extract time-series data
463
483
  const rawData = {
@@ -492,7 +512,7 @@ export async function createMultiServerHistoricalUI(
492
512
 
493
513
  // Footer
494
514
  content += divider + '\n';
495
- content += `{gray-fg}Updated: ${new Date().toLocaleTimeString()} | H = Toggle Hour View ESC = Back Q = Quit{/gray-fg}`;
515
+ content += `{gray-fg}[T]oggle Hour View [ESC] Back [Q]uit{/gray-fg}`;
496
516
 
497
517
  contentBox.setContent(content);
498
518
  screen.render();
@@ -509,7 +529,7 @@ export async function createMultiServerHistoricalUI(
509
529
  '{bold}{red-fg}Render Error{/red-fg}{/bold}\n\n' +
510
530
  `{red-fg}${errorMsg}{/red-fg}\n\n` +
511
531
  `Consecutive errors: ${consecutiveErrors}\n\n` +
512
- '{gray-fg}ESC = Back Q = Quit{/gray-fg}'
532
+ '{gray-fg}[ESC] Back [Q]uit{/gray-fg}'
513
533
  );
514
534
  }
515
535
  screen.render();
@@ -521,24 +541,43 @@ export async function createMultiServerHistoricalUI(
521
541
  clearInterval(refreshIntervalId);
522
542
  refreshIntervalId = null;
523
543
  }
544
+ unregisterHandlers();
524
545
  }
525
546
 
526
- screen.key(['h', 'H'], () => {
527
- viewMode = viewMode === 'recent' ? 'hour' : 'recent';
528
- render();
529
- });
547
+ // Key handler functions (stored for unregistration)
548
+ const keyHandlers = {
549
+ toggle: () => {
550
+ sharedViewMode = sharedViewMode === 'recent' ? 'hour' : 'recent';
551
+ render();
552
+ },
553
+ escape: () => {
554
+ cleanup();
555
+ screen.remove(contentBox);
556
+ onBack();
557
+ },
558
+ quit: () => {
559
+ cleanup();
560
+ screen.destroy();
561
+ process.exit(0);
562
+ },
563
+ };
530
564
 
531
- screen.key(['escape'], () => {
532
- cleanup();
533
- screen.remove(contentBox);
534
- onBack();
535
- });
565
+ function registerHandlers(): void {
566
+ screen.key(['t', 'T'], keyHandlers.toggle);
567
+ screen.key(['escape'], keyHandlers.escape);
568
+ screen.key(['q', 'Q', 'C-c'], keyHandlers.quit);
569
+ }
536
570
 
537
- screen.key(['q', 'Q', 'C-c'], () => {
538
- cleanup();
539
- screen.destroy();
540
- process.exit(0);
541
- });
571
+ function unregisterHandlers(): void {
572
+ screen.unkey('t', keyHandlers.toggle);
573
+ screen.unkey('T', keyHandlers.toggle);
574
+ screen.unkey('escape', keyHandlers.escape);
575
+ screen.unkey('q', keyHandlers.quit);
576
+ screen.unkey('Q', keyHandlers.quit);
577
+ screen.unkey('C-c', keyHandlers.quit);
578
+ }
579
+
580
+ registerHandlers();
542
581
 
543
582
  contentBox.setContent('{cyan-fg}\u23f3 Loading historical data...{/cyan-fg}');
544
583
  screen.render();
@@ -0,0 +1,368 @@
1
+ import blessed from 'blessed';
2
+ import { modelScanner } from '../lib/model-scanner.js';
3
+ import { stateManager } from '../lib/state-manager.js';
4
+ import { launchctlManager } from '../lib/launchctl-manager.js';
5
+ import { ModelInfo } from '../types/model-info.js';
6
+ import { ServerConfig } from '../types/server-config.js';
7
+ import { formatBytes, formatDateShort } from '../utils/format-utils.js';
8
+ import * as fs from 'fs/promises';
9
+ import { createSearchUI } from './SearchApp.js';
10
+
11
+ /**
12
+ * Models management TUI
13
+ * Display installed models and allow deletion
14
+ */
15
+ export async function createModelsUI(
16
+ screen: blessed.Widgets.Screen,
17
+ onBack: () => void,
18
+ onSearch: () => void
19
+ ): Promise<void> {
20
+ let models: ModelInfo[] = [];
21
+ let selectedIndex = 0;
22
+ let isLoading = false;
23
+
24
+ // Create content box
25
+ const contentBox = blessed.box({
26
+ top: 0,
27
+ left: 0,
28
+ width: '100%',
29
+ height: '100%',
30
+ tags: true,
31
+ scrollable: true,
32
+ alwaysScroll: true,
33
+ keys: true,
34
+ vi: true,
35
+ mouse: true,
36
+ scrollbar: {
37
+ ch: '█',
38
+ style: {
39
+ fg: 'blue',
40
+ },
41
+ },
42
+ });
43
+ screen.append(contentBox);
44
+
45
+ // Render models view
46
+ async function render() {
47
+ const termWidth = (screen.width as number) || 80;
48
+ const divider = '─'.repeat(termWidth - 2);
49
+ let content = '';
50
+
51
+ // Header
52
+ content += '{bold}{blue-fg}═══ Models Management{/blue-fg}{/bold}\n\n';
53
+
54
+ if (isLoading) {
55
+ content += '{cyan-fg}⏳ Loading models...{/cyan-fg}\n';
56
+ contentBox.setContent(content);
57
+ screen.render();
58
+ return;
59
+ }
60
+
61
+ if (models.length === 0) {
62
+ content += '{yellow-fg}No models found{/yellow-fg}\n\n';
63
+ content += '{dim}Models directory: ' + await stateManager.getModelsDirectory() + '{/dim}\n';
64
+ content += '{dim}Download models: Press [S] to search HuggingFace{/dim}\n';
65
+ content += '\n' + divider + '\n';
66
+ content += `{gray-fg}[S]earch [ESC] Back [Q]uit{/gray-fg}`;
67
+ contentBox.setContent(content);
68
+ screen.render();
69
+ return;
70
+ }
71
+
72
+ // System info
73
+ const totalSize = models.reduce((sum, m) => sum + m.size, 0);
74
+ content += `{bold}Total: ${models.length} models{/bold} - ${formatBytes(totalSize)}\n`;
75
+ content += divider + '\n';
76
+
77
+ // Get all servers to check dependencies
78
+ const allServers = await stateManager.getAllServers();
79
+
80
+ // Table header
81
+ content += '{bold} │ Model File │ Size │ Modified │ Servers{/bold}\n';
82
+ content += divider + '\n';
83
+
84
+ // Model rows
85
+ for (let i = 0; i < models.length; i++) {
86
+ const model = models[i];
87
+ const isSelected = i === selectedIndex;
88
+
89
+ // Count servers using this model
90
+ const serversUsingModel = allServers.filter(s => s.modelPath === model.path);
91
+ const serverCount = serversUsingModel.length;
92
+
93
+ // Selection indicator
94
+ const indicator = isSelected ? '►' : ' ';
95
+
96
+ // Model filename (truncate if too long)
97
+ const maxFilenameLen = 46;
98
+ let filename = model.filename;
99
+ if (filename.length > maxFilenameLen) {
100
+ filename = filename.substring(0, maxFilenameLen - 3) + '...';
101
+ }
102
+ filename = filename.padEnd(maxFilenameLen);
103
+
104
+ // Size
105
+ const size = model.sizeFormatted.padStart(11);
106
+
107
+ // Modified date
108
+ const modified = formatDateShort(model.modified).padStart(11);
109
+
110
+ // Servers count with color coding
111
+ let serversText = '';
112
+ let serversTextPlain = '';
113
+ if (serverCount === 0) {
114
+ serversText = '{green-fg}0 servers{/green-fg}';
115
+ serversTextPlain = '0 servers';
116
+ } else {
117
+ const runningCount = serversUsingModel.filter(s => s.status === 'running').length;
118
+ if (runningCount > 0) {
119
+ serversText = `{yellow-fg}${serverCount} (${runningCount} running){/yellow-fg}`;
120
+ serversTextPlain = `${serverCount} (${runningCount} running)`;
121
+ } else {
122
+ serversText = `{gray-fg}${serverCount} stopped{/gray-fg}`;
123
+ serversTextPlain = `${serverCount} stopped`;
124
+ }
125
+ }
126
+
127
+ // Build row content
128
+ let rowContent = '';
129
+ if (isSelected) {
130
+ // Selected row: cyan background with bright white text
131
+ rowContent = `{cyan-bg}{15-fg}${indicator} │ ${filename} │ ${size} │ ${modified} │ ${serversTextPlain}{/15-fg}{/cyan-bg}`;
132
+ } else {
133
+ // Normal row: with colored server text
134
+ rowContent = `${indicator} │ ${filename} │ ${size} │ ${modified} │ ${serversText}`;
135
+ }
136
+
137
+ content += rowContent + '\n';
138
+ }
139
+
140
+ // Footer
141
+ content += '\n' + divider + '\n';
142
+ content += '{gray-fg}[↑/↓] Navigate [D]elete [S]earch [R]efresh [ESC] Back [Q]uit{/gray-fg}';
143
+
144
+ contentBox.setContent(content);
145
+ screen.render();
146
+ }
147
+
148
+ // Load models
149
+ async function loadModels() {
150
+ isLoading = true;
151
+ await render();
152
+
153
+ models = await modelScanner.scanModels();
154
+ selectedIndex = Math.min(selectedIndex, Math.max(0, models.length - 1));
155
+
156
+ isLoading = false;
157
+ await render();
158
+ }
159
+
160
+ // Delete selected model
161
+ async function deleteModel() {
162
+ if (models.length === 0) return;
163
+
164
+ const model = models[selectedIndex];
165
+ const allServers = await stateManager.getAllServers();
166
+ const serversUsingModel = allServers.filter(s => s.modelPath === model.path);
167
+
168
+ // Show confirmation dialog
169
+ const confirmBox = blessed.message({
170
+ parent: screen,
171
+ top: 'center',
172
+ left: 'center',
173
+ width: '60%',
174
+ height: 'shrink',
175
+ border: { type: 'line' },
176
+ style: {
177
+ border: { fg: 'red' },
178
+ fg: 'white',
179
+ },
180
+ tags: true,
181
+ });
182
+
183
+ let confirmText = `{bold}Delete model: ${model.filename}?{/bold}\n\n`;
184
+ confirmText += `Size: ${model.sizeFormatted}\n\n`;
185
+
186
+ if (serversUsingModel.length > 0) {
187
+ confirmText += `{yellow-fg}⚠️ This model has ${serversUsingModel.length} server(s) configured:{/yellow-fg}\n`;
188
+ for (const server of serversUsingModel) {
189
+ const statusColor = server.status === 'running' ? 'green-fg' : 'gray-fg';
190
+ confirmText += ` - ${server.id} ({${statusColor}}${server.status}{/${statusColor}})\n`;
191
+ }
192
+ confirmText += `\n{yellow-fg}These servers will be deleted before removing the model.{/yellow-fg}\n\n`;
193
+ }
194
+
195
+ confirmText += `Type 'yes' to confirm:\n\n\n\n`; // Extra lines for input box space
196
+
197
+ // Create input box for confirmation
198
+ const inputBox = blessed.textbox({
199
+ parent: confirmBox,
200
+ bottom: 1,
201
+ left: 2,
202
+ right: 2,
203
+ height: 3,
204
+ inputOnFocus: true,
205
+ border: { type: 'line' },
206
+ style: {
207
+ border: { fg: 'cyan' },
208
+ focus: { border: { fg: 'green' } },
209
+ },
210
+ });
211
+
212
+ confirmBox.setContent(confirmText);
213
+ screen.append(confirmBox);
214
+ confirmBox.focus();
215
+ inputBox.focus();
216
+ screen.render();
217
+
218
+ inputBox.on('submit', async (value: string) => {
219
+ screen.remove(confirmBox);
220
+
221
+ if (value.toLowerCase() !== 'yes') {
222
+ await render();
223
+ return;
224
+ }
225
+
226
+ // Show deleting message
227
+ isLoading = true;
228
+ contentBox.setContent('{cyan-fg}⏳ Deleting model...{/cyan-fg}');
229
+ screen.render();
230
+
231
+ try {
232
+ // Delete all servers using this model
233
+ for (const server of serversUsingModel) {
234
+ // Unload service (stops and removes from launchd)
235
+ try {
236
+ await launchctlManager.unloadService(server.plistPath);
237
+ if (server.status === 'running') {
238
+ await launchctlManager.waitForServiceStop(server.label, 5000);
239
+ }
240
+ } catch (error) {
241
+ // Continue even if unload fails
242
+ }
243
+
244
+ // Delete plist
245
+ await launchctlManager.deletePlist(server.plistPath);
246
+
247
+ // Delete server config
248
+ await stateManager.deleteServerConfig(server.id);
249
+ }
250
+
251
+ // Delete model file
252
+ await fs.unlink(model.path);
253
+
254
+ // Reload models
255
+ await loadModels();
256
+ } catch (error) {
257
+ // Show error
258
+ const errorBox = blessed.message({
259
+ parent: screen,
260
+ top: 'center',
261
+ left: 'center',
262
+ width: '60%',
263
+ height: 'shrink',
264
+ border: { type: 'line' },
265
+ style: {
266
+ border: { fg: 'red' },
267
+ fg: 'red',
268
+ },
269
+ tags: true,
270
+ });
271
+
272
+ const errorMsg = error instanceof Error ? error.message : 'Unknown error';
273
+ errorBox.display(`{bold}Delete failed{/bold}\n\n${errorMsg}\n\nPress any key to continue`, () => {
274
+ screen.remove(errorBox);
275
+ isLoading = false;
276
+ render();
277
+ });
278
+ }
279
+ });
280
+
281
+ inputBox.on('cancel', () => {
282
+ screen.remove(confirmBox);
283
+ render();
284
+ });
285
+
286
+ inputBox.key(['escape'], () => {
287
+ screen.remove(confirmBox);
288
+ render();
289
+ });
290
+ }
291
+
292
+ // Store key handler references for cleanup
293
+ const keyHandlers = {
294
+ up: () => {
295
+ if (models.length === 0) return;
296
+ selectedIndex = Math.max(0, selectedIndex - 1);
297
+ render();
298
+ },
299
+ down: () => {
300
+ if (models.length === 0) return;
301
+ selectedIndex = Math.min(models.length - 1, selectedIndex + 1);
302
+ render();
303
+ },
304
+ delete: () => {
305
+ deleteModel();
306
+ },
307
+ search: async () => {
308
+ // Cleanup current handlers before switching views
309
+ cleanup();
310
+
311
+ // Open search view
312
+ await createSearchUI(screen, async () => {
313
+ // onBack callback - return to models view
314
+ // Re-register handlers
315
+ registerHandlers();
316
+ screen.append(contentBox);
317
+ await loadModels();
318
+ });
319
+ },
320
+ refresh: () => {
321
+ loadModels();
322
+ },
323
+ escape: async () => {
324
+ cleanup();
325
+ await onBack();
326
+ },
327
+ quit: () => {
328
+ screen.destroy();
329
+ process.exit(0);
330
+ },
331
+ };
332
+
333
+ // Cleanup function to unregister all handlers
334
+ function cleanup() {
335
+ screen.unkey('up', keyHandlers.up);
336
+ screen.unkey('k', keyHandlers.up);
337
+ screen.unkey('down', keyHandlers.down);
338
+ screen.unkey('j', keyHandlers.down);
339
+ screen.unkey('d', keyHandlers.delete);
340
+ screen.unkey('D', keyHandlers.delete);
341
+ screen.unkey('s', keyHandlers.search);
342
+ screen.unkey('S', keyHandlers.search);
343
+ screen.unkey('r', keyHandlers.refresh);
344
+ screen.unkey('R', keyHandlers.refresh);
345
+ screen.unkey('escape', keyHandlers.escape);
346
+ screen.unkey('q', keyHandlers.quit);
347
+ screen.unkey('Q', keyHandlers.quit);
348
+ screen.unkey('C-c', keyHandlers.quit);
349
+ screen.remove(contentBox);
350
+ }
351
+
352
+ // Register key handlers
353
+ function registerHandlers() {
354
+ screen.key(['up', 'k'], keyHandlers.up);
355
+ screen.key(['down', 'j'], keyHandlers.down);
356
+ screen.key(['d', 'D'], keyHandlers.delete);
357
+ screen.key(['s', 'S'], keyHandlers.search);
358
+ screen.key(['r', 'R'], keyHandlers.refresh);
359
+ screen.key(['escape'], keyHandlers.escape);
360
+ screen.key(['q', 'Q', 'C-c'], keyHandlers.quit);
361
+ }
362
+
363
+ // Register initial handlers
364
+ registerHandlers();
365
+
366
+ // Initial load
367
+ await loadModels();
368
+ }