@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
@@ -1,10 +1,23 @@
1
1
  import blessed from 'blessed';
2
- import { ServerConfig } from '../types/server-config.js';
2
+ import * as path from 'path';
3
+ import * as fs from 'fs/promises';
4
+ import { ServerConfig, sanitizeModelName } from '../types/server-config.js';
3
5
  import { MetricsAggregator } from '../lib/metrics-aggregator.js';
4
6
  import { SystemCollector } from '../lib/system-collector.js';
5
7
  import { MonitorData, SystemMetrics } from '../types/monitor-types.js';
6
8
  import { HistoryManager } from '../lib/history-manager.js';
7
9
  import { createHistoricalUI, createMultiServerHistoricalUI } from './HistoricalMonitorApp.js';
10
+ import { createConfigUI } from './ConfigApp.js';
11
+ import { stateManager } from '../lib/state-manager.js';
12
+ import { launchctlManager } from '../lib/launchctl-manager.js';
13
+ import { statusChecker } from '../lib/status-checker.js';
14
+ import { modelScanner } from '../lib/model-scanner.js';
15
+ import { portManager } from '../lib/port-manager.js';
16
+ import { configGenerator, ServerOptions } from '../lib/config-generator.js';
17
+ import { ModelInfo } from '../types/model-info.js';
18
+ import { getLogsDir, getLaunchAgentsDir, ensureDir, parseMetalMemoryFromLog } from '../utils/file-utils.js';
19
+ import { formatBytes } from '../utils/format-utils.js';
20
+ import { isPortInUse } from '../utils/process-utils.js';
8
21
 
9
22
  type ViewMode = 'list' | 'detail';
10
23
 
@@ -14,12 +27,20 @@ interface ServerMonitorData {
14
27
  error: string | null;
15
28
  }
16
29
 
30
+ export interface MonitorUIControls {
31
+ pause: () => void;
32
+ resume: () => void;
33
+ getServers: () => ServerConfig[];
34
+ }
35
+
17
36
  export async function createMultiServerMonitorUI(
18
37
  screen: blessed.Widgets.Screen,
19
38
  servers: ServerConfig[],
20
- _fromPs: boolean = false,
21
- directJumpIndex?: number
22
- ): Promise<void> {
39
+ skipConnectingMessage: boolean = false,
40
+ directJumpIndex?: number,
41
+ onModels?: (controls: MonitorUIControls) => void,
42
+ onFirstRender?: () => void
43
+ ): Promise<MonitorUIControls> {
23
44
  let updateInterval = 2000;
24
45
  let intervalId: NodeJS.Timeout | null = null;
25
46
  let viewMode: ViewMode = directJumpIndex !== undefined ? 'detail' : 'list';
@@ -29,6 +50,7 @@ export async function createMultiServerMonitorUI(
29
50
  let lastSystemMetrics: SystemMetrics | null = null;
30
51
  let cameFromDirectJump = directJumpIndex !== undefined; // Track if we entered via ps <id>
31
52
  let inHistoricalView = false; // Track whether we're in historical view to prevent key conflicts
53
+ let hasCalledFirstRender = false; // Track if we've called onFirstRender callback
32
54
 
33
55
  // Spinner animation
34
56
  const spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
@@ -133,7 +155,7 @@ export async function createMultiServerMonitorUI(
133
155
  function renderAggregateModelResources(): string {
134
156
  let content = '';
135
157
 
136
- content += '{bold}Model Resources{/bold}\n';
158
+ content += '{bold}Server Resources{/bold}\n';
137
159
  const termWidth = (screen.width as number) || 80;
138
160
  const divider = '─'.repeat(termWidth - 2);
139
161
  content += divider + '\n';
@@ -181,7 +203,7 @@ export async function createMultiServerMonitorUI(
181
203
  function renderModelResources(data: MonitorData): string {
182
204
  let content = '';
183
205
 
184
- content += '{bold}Model Resources{/bold}\n';
206
+ content += '{bold}Server Resources{/bold}\n';
185
207
  const termWidth = (screen.width as number) || 80;
186
208
  const divider = '─'.repeat(termWidth - 2);
187
209
  content += divider + '\n';
@@ -386,7 +408,7 @@ export async function createMultiServerMonitorUI(
386
408
 
387
409
  // Footer
388
410
  content += '\n' + divider + '\n';
389
- content += `{gray-fg}Updated: ${new Date().toLocaleTimeString()} | [H]istory [Q]uit{/gray-fg}`;
411
+ content += `{gray-fg}Updated: ${new Date().toLocaleTimeString()} | [N]ew [M]odels [H]istory [Q]uit{/gray-fg}`;
390
412
 
391
413
  return content;
392
414
  }
@@ -404,43 +426,17 @@ export async function createMultiServerMonitorUI(
404
426
 
405
427
  // Check if server is stopped
406
428
  if (server.status !== 'running') {
407
- // Show stopped server configuration (no metrics)
429
+ // Show minimal stopped server info
408
430
  content += '{bold}Server Information{/bold}\n';
409
431
  content += divider + '\n';
410
432
  content += `Status: {gray-fg}○ STOPPED{/gray-fg}\n`;
411
433
  content += `Model: ${server.modelName}\n`;
412
434
  const displayHost = server.host || '127.0.0.1';
413
435
  content += `Endpoint: http://${displayHost}:${server.port}\n`;
414
- content += '\n';
415
-
416
- content += '{bold}Configuration{/bold}\n';
417
- content += divider + '\n';
418
- content += `Threads: ${server.threads}\n`;
419
- content += `Context: ${server.ctxSize} tokens\n`;
420
- content += `GPU Layers: ${server.gpuLayers}\n`;
421
- if (server.verbose) {
422
- content += `Verbose: Enabled\n`;
423
- }
424
- if (server.customFlags && server.customFlags.length > 0) {
425
- content += `Flags: ${server.customFlags.join(', ')}\n`;
426
- }
427
- content += '\n';
428
436
 
429
- if (server.lastStarted) {
430
- content += '{bold}Last Activity{/bold}\n';
431
- content += divider + '\n';
432
- content += `Started: ${new Date(server.lastStarted).toLocaleString()}\n`;
433
- if (server.lastStopped) {
434
- content += `Stopped: ${new Date(server.lastStopped).toLocaleString()}\n`;
435
- }
436
- content += '\n';
437
- }
438
-
439
- content += '{bold}Quick Actions{/bold}\n';
440
- content += divider + '\n';
441
- content += `{dim}Start server: llamacpp server start ${server.port}{/dim}\n`;
442
- content += `{dim}Update config: llamacpp server config ${server.port} [options]{/dim}\n`;
443
- content += `{dim}View logs: llamacpp server logs ${server.port}{/dim}\n`;
437
+ // Footer - show [S]tart for stopped servers
438
+ content += '\n' + divider + '\n';
439
+ content += `{gray-fg}[S]tart [C]onfig [R]emove [H]istory [ESC] Back [Q]uit{/gray-fg}`;
444
440
 
445
441
  return content;
446
442
  }
@@ -530,9 +526,9 @@ export async function createMultiServerMonitorUI(
530
526
  }
531
527
  }
532
528
 
533
- // Footer
529
+ // Footer - show [S]top for running servers
534
530
  content += divider + '\n';
535
- content += `{gray-fg}Updated: ${data.lastUpdated.toLocaleTimeString()} | [H]istory [ESC] Back [Q]uit{/gray-fg}`;
531
+ content += `{gray-fg}[S]top [C]onfig [R]emove [H]istory [ESC] Back [Q]uit{/gray-fg}`;
536
532
 
537
533
  return content;
538
534
  }
@@ -643,6 +639,13 @@ export async function createMultiServerMonitorUI(
643
639
  }
644
640
 
645
641
  contentBox.setContent(content);
642
+
643
+ // Call onFirstRender callback before first render (to clean up splash screen)
644
+ if (!hasCalledFirstRender && onFirstRender) {
645
+ hasCalledFirstRender = true;
646
+ onFirstRender();
647
+ }
648
+
646
649
  screen.render();
647
650
 
648
651
  // Clear loading state
@@ -669,117 +672,1153 @@ export async function createMultiServerMonitorUI(
669
672
  intervalId = setInterval(fetchData, updateInterval);
670
673
  }
671
674
 
672
- // Keyboard shortcuts - List view navigation with arrow keys
673
- screen.key(['up', 'k'], () => {
674
- if (viewMode === 'list') {
675
- selectedRowIndex = Math.max(0, selectedRowIndex - 1);
676
- // Re-render immediately for responsive feel
675
+ // Parse context size with k/K suffix support (e.g., "4k" -> 4096, "64K" -> 65536)
676
+ function parseContextSize(input: string): number | null {
677
+ const trimmed = input.trim().toLowerCase();
678
+ const match = trimmed.match(/^(\d+(?:\.\d+)?)(k)?$/);
679
+ if (!match) return null;
680
+
681
+ const num = parseFloat(match[1]);
682
+ const hasK = match[2] === 'k';
683
+
684
+ if (isNaN(num) || num <= 0) return null;
685
+
686
+ return hasK ? Math.round(num * 1024) : Math.round(num);
687
+ }
688
+
689
+ // Format context size for display (e.g., 4096 -> "4k", 65536 -> "64k")
690
+ function formatContextSize(value: number): string {
691
+ if (value >= 1024 && value % 1024 === 0) {
692
+ return `${value / 1024}k`;
693
+ }
694
+ return value.toLocaleString();
695
+ }
696
+
697
+ // Helper to create modal boxes
698
+ function createModal(title: string, height: number | string = 'shrink', borderColor: string = 'cyan'): blessed.Widgets.BoxElement {
699
+ return blessed.box({
700
+ parent: screen,
701
+ top: 'center',
702
+ left: 'center',
703
+ width: '70%',
704
+ height,
705
+ border: { type: 'line' },
706
+ style: {
707
+ border: { fg: borderColor },
708
+ fg: 'white',
709
+ },
710
+ tags: true,
711
+ label: ` ${title} `,
712
+ });
713
+ }
714
+
715
+ // Show progress modal
716
+ function showProgressModal(message: string): blessed.Widgets.BoxElement {
717
+ const modal = createModal('Working', 6);
718
+ modal.setContent(`\n {cyan-fg}${message}{/cyan-fg}`);
719
+ screen.render();
720
+ return modal;
721
+ }
722
+
723
+ // Show error modal
724
+ async function showErrorModal(message: string): Promise<void> {
725
+ return new Promise((resolve) => {
726
+ const modal = createModal('Error', 8, 'red');
727
+ modal.setContent(`\n {red-fg}❌ ${message}{/red-fg}\n\n {gray-fg}[Enter] Close{/gray-fg}`);
728
+ screen.render();
729
+ modal.focus();
730
+ modal.key(['enter', 'escape'], () => {
731
+ screen.remove(modal);
732
+ resolve();
733
+ });
734
+ });
735
+ }
736
+
737
+ // Remove server dialog
738
+ async function showRemoveServerDialog(server: ServerConfig): Promise<void> {
739
+ // Pause the monitor
740
+ if (intervalId) clearInterval(intervalId);
741
+ if (spinnerIntervalId) clearInterval(spinnerIntervalId);
742
+ unregisterHandlers();
743
+
744
+ // Check if other servers use the same model
745
+ const allServers = await stateManager.getAllServers();
746
+ const otherServersWithSameModel = allServers.filter(
747
+ s => s.id !== server.id && s.modelPath === server.modelPath
748
+ );
749
+
750
+ let deleteModelOption = false;
751
+ const showDeleteModelOption = otherServersWithSameModel.length === 0;
752
+ // 0 = checkbox (delete model), 1 = confirm button
753
+ let selectedOption = showDeleteModelOption ? 0 : 1;
754
+
755
+ const modal = createModal('Remove Server', showDeleteModelOption ? 18 : 14, 'red');
756
+
757
+ function renderDialog(): void {
758
+ let content = '\n';
759
+ content += ` {bold}Remove server: ${server.id}{/bold}\n\n`;
760
+ content += ` Model: ${server.modelName}\n`;
761
+ content += ` Port: ${server.port}\n`;
762
+ content += ` Status: ${server.status === 'running' ? '{green-fg}running{/green-fg}' : '{gray-fg}stopped{/gray-fg}'}\n\n`;
763
+
764
+ if (server.status === 'running') {
765
+ content += ` {yellow-fg}⚠ Server will be stopped{/yellow-fg}\n\n`;
766
+ }
767
+
768
+ if (showDeleteModelOption) {
769
+ const checkbox = deleteModelOption ? '☑' : '☐';
770
+ const isCheckboxSelected = selectedOption === 0;
771
+ if (isCheckboxSelected) {
772
+ content += ` {cyan-bg}{15-fg}${checkbox} Also delete model file{/15-fg}{/cyan-bg}\n`;
773
+ } else {
774
+ content += ` ${checkbox} Also delete model file\n`;
775
+ }
776
+ content += ` {gray-fg}${server.modelPath}{/gray-fg}\n\n`;
777
+ } else {
778
+ content += ` {gray-fg}Model is used by ${otherServersWithSameModel.length} other server(s) - cannot delete{/gray-fg}\n\n`;
779
+ }
780
+
781
+ const isConfirmSelected = selectedOption === 1 || !showDeleteModelOption;
782
+ if (isConfirmSelected) {
783
+ content += ` {cyan-bg}{15-fg}[ Confirm Remove ]{/15-fg}{/cyan-bg}\n\n`;
784
+ } else {
785
+ content += ` [ Confirm Remove ]\n\n`;
786
+ }
787
+
788
+ content += ` {gray-fg}[↑/↓] Select [Space] Toggle [Enter] Confirm [ESC] Cancel{/gray-fg}`;
789
+ modal.setContent(content);
790
+ screen.render();
791
+ }
792
+
793
+ renderDialog();
794
+ modal.focus();
795
+
796
+ return new Promise((resolve) => {
797
+ modal.key(['up', 'k'], () => {
798
+ if (showDeleteModelOption && selectedOption === 1) {
799
+ selectedOption = 0;
800
+ renderDialog();
801
+ }
802
+ });
803
+
804
+ modal.key(['down', 'j'], () => {
805
+ if (showDeleteModelOption && selectedOption === 0) {
806
+ selectedOption = 1;
807
+ renderDialog();
808
+ }
809
+ });
810
+
811
+ modal.key(['space'], () => {
812
+ if (showDeleteModelOption && selectedOption === 0) {
813
+ deleteModelOption = !deleteModelOption;
814
+ renderDialog();
815
+ }
816
+ });
817
+
818
+ modal.key(['escape'], () => {
819
+ screen.remove(modal);
820
+ registerHandlers();
821
+ startPolling();
822
+ resolve();
823
+ });
824
+
825
+ modal.key(['enter'], async () => {
826
+ screen.remove(modal);
827
+
828
+ // Show progress
829
+ const progressModal = showProgressModal('Removing server...');
830
+
831
+ try {
832
+ // Stop and unload service if running
833
+ if (server.status === 'running') {
834
+ progressModal.setContent('\n {cyan-fg}Stopping server...{/cyan-fg}');
835
+ screen.render();
836
+ try {
837
+ await launchctlManager.unloadService(server.plistPath);
838
+ await launchctlManager.waitForServiceStop(server.label, 5000);
839
+ } catch (err) {
840
+ // Continue even if unload fails
841
+ }
842
+ } else {
843
+ // Still try to unload in case it's in a weird state
844
+ try {
845
+ await launchctlManager.unloadService(server.plistPath);
846
+ } catch (err) {
847
+ // Ignore
848
+ }
849
+ }
850
+
851
+ // Delete plist
852
+ progressModal.setContent('\n {cyan-fg}Removing configuration...{/cyan-fg}');
853
+ screen.render();
854
+ await launchctlManager.deletePlist(server.plistPath);
855
+
856
+ // Delete server config
857
+ await stateManager.deleteServerConfig(server.id);
858
+
859
+ // Delete model if requested
860
+ if (deleteModelOption && showDeleteModelOption) {
861
+ progressModal.setContent('\n {cyan-fg}Deleting model file...{/cyan-fg}');
862
+ screen.render();
863
+ await fs.unlink(server.modelPath);
864
+ }
865
+
866
+ screen.remove(progressModal);
867
+
868
+ // Remove server from our arrays
869
+ const idx = servers.findIndex(s => s.id === server.id);
870
+ if (idx !== -1) {
871
+ servers.splice(idx, 1);
872
+ aggregators.delete(server.id);
873
+ historyManagers.delete(server.id);
874
+ serverDataMap.delete(server.id);
875
+ }
876
+
877
+ // Go back to list view
878
+ viewMode = 'list';
879
+ selectedRowIndex = Math.min(selectedRowIndex, Math.max(0, servers.length - 1));
880
+ selectedServerIndex = selectedRowIndex;
881
+
882
+ registerHandlers();
883
+ startPolling();
884
+ resolve();
885
+
886
+ } catch (err) {
887
+ screen.remove(progressModal);
888
+ await showErrorModal(err instanceof Error ? err.message : 'Unknown error');
889
+ registerHandlers();
890
+ startPolling();
891
+ resolve();
892
+ }
893
+ });
894
+ });
895
+ }
896
+
897
+ // Create server flow
898
+ async function showCreateServerFlow(): Promise<void> {
899
+ // Pause the monitor
900
+ if (intervalId) clearInterval(intervalId);
901
+ if (spinnerIntervalId) clearInterval(spinnerIntervalId);
902
+ unregisterHandlers();
903
+
904
+ // Step 1: Model selection
905
+ const models = await modelScanner.scanModels();
906
+ if (models.length === 0) {
907
+ await showErrorModal('No models found in ~/models directory.\nUse [M]odels → [S]earch to download models.');
908
+ // Immediately render with cached data for instant feedback
677
909
  const content = renderListView(lastSystemMetrics);
678
910
  contentBox.setContent(content);
679
911
  screen.render();
912
+ registerHandlers();
913
+ startPolling();
914
+ return;
680
915
  }
681
- });
682
916
 
683
- screen.key(['down', 'j'], () => {
684
- if (viewMode === 'list') {
685
- selectedRowIndex = Math.min(servers.length - 1, selectedRowIndex + 1);
686
- // Re-render immediately for responsive feel
917
+ // Check which models already have servers
918
+ const allServers = await stateManager.getAllServers();
919
+ const modelsWithServers = new Set(allServers.map(s => s.modelPath));
920
+
921
+ let selectedModelIndex = 0;
922
+ let scrollOffset = 0;
923
+ const maxVisible = 8;
924
+
925
+ const modelModal = createModal('Create Server - Select Model', maxVisible + 8);
926
+
927
+ function renderModelPicker(): void {
928
+ // Adjust scroll offset
929
+ if (selectedModelIndex < scrollOffset) {
930
+ scrollOffset = selectedModelIndex;
931
+ } else if (selectedModelIndex >= scrollOffset + maxVisible) {
932
+ scrollOffset = selectedModelIndex - maxVisible + 1;
933
+ }
934
+
935
+ let content = '\n';
936
+ content += ' {bold}Select a model to create a server for:{/bold}\n\n';
937
+
938
+ const visibleModels = models.slice(scrollOffset, scrollOffset + maxVisible);
939
+
940
+ for (let i = 0; i < visibleModels.length; i++) {
941
+ const model = visibleModels[i];
942
+ const actualIndex = scrollOffset + i;
943
+ const isSelected = actualIndex === selectedModelIndex;
944
+ const hasServer = modelsWithServers.has(model.path);
945
+ const indicator = isSelected ? '►' : ' ';
946
+
947
+ // Truncate filename if too long
948
+ let displayName = model.filename;
949
+ const maxLen = 40;
950
+ if (displayName.length > maxLen) {
951
+ displayName = displayName.substring(0, maxLen - 3) + '...';
952
+ }
953
+ displayName = displayName.padEnd(maxLen);
954
+
955
+ const size = model.sizeFormatted.padStart(8);
956
+ const serverIndicator = hasServer ? ' {yellow-fg}(has server){/yellow-fg}' : '';
957
+ const serverIndicatorPlain = hasServer ? ' (has server)' : '';
958
+
959
+ if (isSelected) {
960
+ content += ` {cyan-bg}{15-fg}${indicator} ${displayName} ${size}${serverIndicatorPlain}{/15-fg}{/cyan-bg}\n`;
961
+ } else {
962
+ content += ` ${indicator} ${displayName} {gray-fg}${size}{/gray-fg}${serverIndicator}\n`;
963
+ }
964
+ }
965
+
966
+ // Scroll indicator
967
+ if (models.length > maxVisible) {
968
+ const scrollInfo = `${selectedModelIndex + 1}/${models.length}`;
969
+ content += `\n {gray-fg}${scrollInfo}{/gray-fg}`;
970
+ }
971
+
972
+ content += '\n\n {gray-fg}[↑/↓] Navigate [Enter] Select [ESC] Cancel{/gray-fg}';
973
+ modelModal.setContent(content);
974
+ screen.render();
975
+ }
976
+
977
+ renderModelPicker();
978
+ modelModal.focus();
979
+
980
+ const selectedModel = await new Promise<ModelInfo | null>((resolve) => {
981
+ modelModal.key(['up', 'k'], () => {
982
+ selectedModelIndex = Math.max(0, selectedModelIndex - 1);
983
+ renderModelPicker();
984
+ });
985
+
986
+ modelModal.key(['down', 'j'], () => {
987
+ selectedModelIndex = Math.min(models.length - 1, selectedModelIndex + 1);
988
+ renderModelPicker();
989
+ });
990
+
991
+ modelModal.key(['escape'], () => {
992
+ screen.remove(modelModal);
993
+ resolve(null);
994
+ });
995
+
996
+ modelModal.key(['enter'], () => {
997
+ screen.remove(modelModal);
998
+ resolve(models[selectedModelIndex]);
999
+ });
1000
+ });
1001
+
1002
+ if (!selectedModel) {
1003
+ // Immediately render with cached data for instant feedback
687
1004
  const content = renderListView(lastSystemMetrics);
688
1005
  contentBox.setContent(content);
689
1006
  screen.render();
1007
+ registerHandlers();
1008
+ startPolling();
1009
+ return;
690
1010
  }
691
- });
692
1011
 
693
- // Enter key to view details for selected server
694
- screen.key(['enter'], () => {
695
- if (viewMode === 'list') {
696
- showLoading();
697
- selectedServerIndex = selectedRowIndex;
698
- viewMode = 'detail';
699
- fetchData();
1012
+ // Create a non-null reference for closures
1013
+ const model = selectedModel;
1014
+
1015
+ // Check if server already exists for this model
1016
+ const existingServer = allServers.find(s => s.modelPath === model.path);
1017
+ if (existingServer) {
1018
+ await showErrorModal(`Server already exists for this model.\nServer ID: ${existingServer.id}\nPort: ${existingServer.port}`);
1019
+ // Immediately render with cached data for instant feedback
1020
+ const content = renderListView(lastSystemMetrics);
1021
+ contentBox.setContent(content);
1022
+ screen.render();
1023
+ registerHandlers();
1024
+ startPolling();
1025
+ return;
700
1026
  }
701
- });
702
1027
 
703
- // Keyboard shortcuts - Detail view
704
- screen.key(['escape'], () => {
705
- // Don't handle ESC if we're in historical view - let historical view handle it
706
- if (inHistoricalView) return;
1028
+ // Step 2: Configuration
1029
+ interface CreateConfig {
1030
+ host: string;
1031
+ port: number;
1032
+ threads: number;
1033
+ ctxSize: number;
1034
+ gpuLayers: number;
1035
+ verbose: boolean;
1036
+ }
707
1037
 
708
- if (viewMode === 'detail') {
709
- showLoading();
710
- viewMode = 'list';
711
- cameFromDirectJump = false; // Clear direct jump flag when returning to list
712
- fetchData();
713
- } else if (viewMode === 'list') {
714
- // ESC in list view - exit
715
- showLoading();
716
- if (intervalId) clearInterval(intervalId);
717
- if (spinnerIntervalId) clearInterval(spinnerIntervalId);
718
- setTimeout(() => {
719
- screen.destroy();
720
- process.exit(0);
721
- }, 100);
1038
+ // Generate smart defaults
1039
+ const defaultPort = await portManager.findAvailablePort();
1040
+ const modelSize = model.size;
1041
+
1042
+ // Smart context size based on model size
1043
+ let defaultCtxSize = 4096;
1044
+ if (modelSize < 1024 * 1024 * 1024) { // < 1GB
1045
+ defaultCtxSize = 2048;
1046
+ } else if (modelSize < 3 * 1024 * 1024 * 1024) { // < 3GB
1047
+ defaultCtxSize = 4096;
1048
+ } else if (modelSize < 6 * 1024 * 1024 * 1024) { // < 6GB
1049
+ defaultCtxSize = 8192;
1050
+ } else {
1051
+ defaultCtxSize = 16384;
1052
+ }
1053
+
1054
+ const os = await import('os');
1055
+ const defaultThreads = Math.max(1, Math.floor(os.cpus().length / 2));
1056
+
1057
+ const config: CreateConfig = {
1058
+ host: '127.0.0.1',
1059
+ port: defaultPort,
1060
+ threads: defaultThreads,
1061
+ ctxSize: defaultCtxSize,
1062
+ gpuLayers: 60,
1063
+ verbose: true,
1064
+ };
1065
+
1066
+ // Configuration fields
1067
+ const fields = [
1068
+ { key: 'host', label: 'Host', type: 'select', options: ['127.0.0.1', '0.0.0.0'] },
1069
+ { key: 'port', label: 'Port', type: 'number' },
1070
+ { key: 'threads', label: 'Threads', type: 'number' },
1071
+ { key: 'ctxSize', label: 'Context Size', type: 'number' },
1072
+ { key: 'gpuLayers', label: 'GPU Layers', type: 'number' },
1073
+ { key: 'verbose', label: 'Verbose Logs', type: 'toggle' },
1074
+ ];
1075
+
1076
+ let selectedFieldIndex = 0;
1077
+ const configModal = createModal('Create Server - Configuration', 20);
1078
+
1079
+ function formatConfigValue(key: string, value: any): string {
1080
+ if (key === 'verbose') return value ? 'Enabled' : 'Disabled';
1081
+ if (key === 'ctxSize') return formatContextSize(value);
1082
+ return String(value);
722
1083
  }
723
- });
724
1084
 
725
- // Keyboard shortcuts - Common
1085
+ function renderConfigScreen(): void {
1086
+ let content = '\n';
1087
+ content += ` {bold}Model:{/bold} ${model.filename}\n`;
1088
+ content += ` {bold}Size:{/bold} ${model.sizeFormatted}\n\n`;
726
1089
 
727
- screen.key(['h', 'H'], async () => {
728
- // Prevent entering historical view if already there
729
- if (inHistoricalView) return;
1090
+ content += ' {bold}Server Configuration:{/bold}\n';
1091
+ content += ' ─'.repeat(30) + '\n';
730
1092
 
731
- // Keep polling in background for live historical updates
732
- // Stop spinner if running
733
- if (spinnerIntervalId) clearInterval(spinnerIntervalId);
1093
+ for (let i = 0; i < fields.length; i++) {
1094
+ const field = fields[i];
1095
+ const isSelected = i === selectedFieldIndex;
1096
+ const indicator = isSelected ? '►' : ' ';
1097
+ const label = field.label.padEnd(14);
1098
+ const value = formatConfigValue(field.key, (config as any)[field.key]);
734
1099
 
735
- // Remove current content box
736
- screen.remove(contentBox);
1100
+ if (isSelected) {
1101
+ content += ` {cyan-bg}{15-fg}${indicator} ${label}${value}{/15-fg}{/cyan-bg}\n`;
1102
+ } else {
1103
+ content += ` ${indicator} ${label}{cyan-fg}${value}{/cyan-fg}\n`;
1104
+ }
1105
+ }
737
1106
 
738
- // Mark that we're in historical view
739
- inHistoricalView = true;
1107
+ content += '\n';
1108
+ const createSelected = selectedFieldIndex === fields.length;
1109
+ if (createSelected) {
1110
+ content += ` {green-bg}{15-fg}[ Create Server ]{/15-fg}{/green-bg}\n`;
1111
+ } else {
1112
+ content += ` {green-fg}[ Create Server ]{/green-fg}\n`;
1113
+ }
740
1114
 
741
- if (viewMode === 'list') {
742
- // Show multi-server historical view
743
- await createMultiServerHistoricalUI(screen, servers, selectedServerIndex, () => {
744
- // Mark that we've left historical view
745
- inHistoricalView = false;
746
- // Re-attach content box when returning from history
747
- screen.append(contentBox);
748
- // Re-render the list view
1115
+ content += '\n {gray-fg}[↑/↓] Navigate [Enter] Edit/Create [ESC] Cancel{/gray-fg}';
1116
+ configModal.setContent(content);
1117
+ screen.render();
1118
+ }
1119
+
1120
+ renderConfigScreen();
1121
+ configModal.focus();
1122
+
1123
+ const shouldCreate = await new Promise<boolean>((resolve) => {
1124
+ configModal.key(['up', 'k'], () => {
1125
+ selectedFieldIndex = Math.max(0, selectedFieldIndex - 1);
1126
+ renderConfigScreen();
1127
+ });
1128
+
1129
+ configModal.key(['down', 'j'], () => {
1130
+ selectedFieldIndex = Math.min(fields.length, selectedFieldIndex + 1);
1131
+ renderConfigScreen();
1132
+ });
1133
+
1134
+ configModal.key(['escape'], () => {
1135
+ screen.remove(configModal);
1136
+ resolve(false);
1137
+ });
1138
+
1139
+ configModal.key(['enter'], async () => {
1140
+ if (selectedFieldIndex === fields.length) {
1141
+ // Create button selected
1142
+ screen.remove(configModal);
1143
+ resolve(true);
1144
+ } else {
1145
+ // Edit field
1146
+ const field = fields[selectedFieldIndex];
1147
+
1148
+ if (field.type === 'select') {
1149
+ // Show select dialog
1150
+ const options = field.options!;
1151
+ let optionIndex = options.indexOf((config as any)[field.key]);
1152
+ if (optionIndex < 0) optionIndex = 0;
1153
+
1154
+ const selectModal = createModal(field.label, options.length + 6);
1155
+
1156
+ function renderSelectOptions(): void {
1157
+ let content = '\n';
1158
+ for (let i = 0; i < options.length; i++) {
1159
+ const isOpt = i === optionIndex;
1160
+ const ind = isOpt ? '●' : '○';
1161
+ if (isOpt) {
1162
+ content += ` {cyan-fg}${ind} ${options[i]}{/cyan-fg}\n`;
1163
+ } else {
1164
+ content += ` {gray-fg}${ind} ${options[i]}{/gray-fg}\n`;
1165
+ }
1166
+ }
1167
+ if (field.key === 'host' && options[optionIndex] === '0.0.0.0') {
1168
+ content += '\n {yellow-fg}⚠ Warning: Exposes server to network{/yellow-fg}';
1169
+ }
1170
+ content += '\n\n {gray-fg}[↑/↓] Select [Enter] Confirm{/gray-fg}';
1171
+ selectModal.setContent(content);
1172
+ screen.render();
1173
+ }
1174
+
1175
+ renderSelectOptions();
1176
+ selectModal.focus();
1177
+
1178
+ await new Promise<void>((resolveSelect) => {
1179
+ selectModal.key(['up', 'k'], () => {
1180
+ optionIndex = Math.max(0, optionIndex - 1);
1181
+ renderSelectOptions();
1182
+ });
1183
+ selectModal.key(['down', 'j'], () => {
1184
+ optionIndex = Math.min(options.length - 1, optionIndex + 1);
1185
+ renderSelectOptions();
1186
+ });
1187
+ selectModal.key(['enter'], () => {
1188
+ (config as any)[field.key] = options[optionIndex];
1189
+ screen.remove(selectModal);
1190
+ resolveSelect();
1191
+ });
1192
+ selectModal.key(['escape'], () => {
1193
+ screen.remove(selectModal);
1194
+ resolveSelect();
1195
+ });
1196
+ });
1197
+
1198
+ renderConfigScreen();
1199
+ configModal.focus();
1200
+
1201
+ } else if (field.type === 'toggle') {
1202
+ (config as any)[field.key] = !(config as any)[field.key];
1203
+ renderConfigScreen();
1204
+
1205
+ } else if (field.type === 'number') {
1206
+ // Number input
1207
+ const isCtxSize = field.key === 'ctxSize';
1208
+ const inputModal = createModal(`Edit ${field.label}`, isCtxSize ? 11 : 10);
1209
+
1210
+ const currentDisplay = isCtxSize
1211
+ ? formatContextSize((config as any)[field.key])
1212
+ : (config as any)[field.key];
1213
+
1214
+ const infoText = blessed.text({
1215
+ parent: inputModal,
1216
+ top: 1,
1217
+ left: 2,
1218
+ content: `Current: ${currentDisplay}`,
1219
+ tags: true,
1220
+ });
1221
+
1222
+ // Add hint for context size
1223
+ if (isCtxSize) {
1224
+ blessed.text({
1225
+ parent: inputModal,
1226
+ top: 2,
1227
+ left: 2,
1228
+ content: '{gray-fg}Accepts: 4096, 4k, 8k, 16k, 32k, 64k, 128k{/gray-fg}',
1229
+ tags: true,
1230
+ });
1231
+ }
1232
+
1233
+ const inputBox = blessed.textbox({
1234
+ parent: inputModal,
1235
+ top: isCtxSize ? 4 : 3,
1236
+ left: 2,
1237
+ right: 2,
1238
+ height: 3,
1239
+ inputOnFocus: true,
1240
+ border: { type: 'line' },
1241
+ style: {
1242
+ border: { fg: 'white' },
1243
+ focus: { border: { fg: 'green' } },
1244
+ },
1245
+ });
1246
+
1247
+ blessed.text({
1248
+ parent: inputModal,
1249
+ bottom: 1,
1250
+ left: 2,
1251
+ content: '{gray-fg}[Enter] Confirm [ESC] Cancel{/gray-fg}',
1252
+ tags: true,
1253
+ });
1254
+
1255
+ // Pre-fill with k notation for context size
1256
+ const initialValue = isCtxSize
1257
+ ? formatContextSize((config as any)[field.key])
1258
+ : String((config as any)[field.key]);
1259
+ inputBox.setValue(initialValue);
1260
+ screen.render();
1261
+ inputBox.focus();
1262
+
1263
+ await new Promise<void>((resolveInput) => {
1264
+ inputBox.on('submit', (value: string) => {
1265
+ let numValue: number | null;
1266
+
1267
+ if (isCtxSize) {
1268
+ numValue = parseContextSize(value);
1269
+ } else {
1270
+ numValue = parseInt(value, 10);
1271
+ if (isNaN(numValue)) numValue = null;
1272
+ }
1273
+
1274
+ if (numValue !== null && numValue > 0) {
1275
+ (config as any)[field.key] = numValue;
1276
+ }
1277
+ screen.remove(inputModal);
1278
+ resolveInput();
1279
+ });
1280
+
1281
+ inputBox.on('cancel', () => {
1282
+ screen.remove(inputModal);
1283
+ resolveInput();
1284
+ });
1285
+
1286
+ inputBox.key(['escape'], () => {
1287
+ screen.remove(inputModal);
1288
+ resolveInput();
1289
+ });
1290
+ });
1291
+
1292
+ renderConfigScreen();
1293
+ configModal.focus();
1294
+ }
1295
+ }
1296
+ });
1297
+ });
1298
+
1299
+ if (!shouldCreate) {
1300
+ // Immediately render with cached data for instant feedback
1301
+ const content = renderListView(lastSystemMetrics);
1302
+ contentBox.setContent(content);
1303
+ screen.render();
1304
+ registerHandlers();
1305
+ startPolling();
1306
+ return;
1307
+ }
1308
+
1309
+ // Step 3: Create the server
1310
+ const progressModal = showProgressModal('Creating server...');
1311
+
1312
+ try {
1313
+ // Generate full server config
1314
+ const serverOptions: ServerOptions = {
1315
+ port: config.port,
1316
+ host: config.host,
1317
+ threads: config.threads,
1318
+ ctxSize: config.ctxSize,
1319
+ gpuLayers: config.gpuLayers,
1320
+ verbose: config.verbose,
1321
+ };
1322
+
1323
+ progressModal.setContent('\n {cyan-fg}Generating configuration...{/cyan-fg}');
1324
+ screen.render();
1325
+
1326
+ const serverConfig = await configGenerator.generateConfig(
1327
+ model.path,
1328
+ model.filename,
1329
+ model.size,
1330
+ config.port,
1331
+ serverOptions
1332
+ );
1333
+
1334
+ // Ensure log directory exists
1335
+ await ensureDir(path.dirname(serverConfig.stdoutPath));
1336
+
1337
+ // Create plist
1338
+ progressModal.setContent('\n {cyan-fg}Creating launchctl service...{/cyan-fg}');
1339
+ screen.render();
1340
+ await launchctlManager.createPlist(serverConfig);
1341
+
1342
+ // Load service
1343
+ try {
1344
+ await launchctlManager.loadService(serverConfig.plistPath);
1345
+ } catch (error) {
1346
+ await launchctlManager.deletePlist(serverConfig.plistPath);
1347
+ throw new Error(`Failed to load service: ${(error as Error).message}`);
1348
+ }
1349
+
1350
+ // Start service
1351
+ progressModal.setContent('\n {cyan-fg}Starting server...{/cyan-fg}');
1352
+ screen.render();
1353
+ try {
1354
+ await launchctlManager.startService(serverConfig.label);
1355
+ } catch (error) {
1356
+ await launchctlManager.unloadService(serverConfig.plistPath);
1357
+ await launchctlManager.deletePlist(serverConfig.plistPath);
1358
+ throw new Error(`Failed to start service: ${(error as Error).message}`);
1359
+ }
1360
+
1361
+ // Wait for startup
1362
+ progressModal.setContent('\n {cyan-fg}Waiting for server to start...{/cyan-fg}');
1363
+ screen.render();
1364
+ const started = await launchctlManager.waitForServiceStart(serverConfig.label, 5000);
1365
+
1366
+ if (!started) {
1367
+ await launchctlManager.unloadService(serverConfig.plistPath);
1368
+ await launchctlManager.deletePlist(serverConfig.plistPath);
1369
+ throw new Error('Server failed to start. Check logs.');
1370
+ }
1371
+
1372
+ // Wait for port to be ready (server may take a moment to bind)
1373
+ progressModal.setContent('\n {cyan-fg}Waiting for server to be ready...{/cyan-fg}');
1374
+ screen.render();
1375
+ const portTimeout = 10000; // 10 seconds
1376
+ const portStartTime = Date.now();
1377
+ let portReady = false;
1378
+ while (Date.now() - portStartTime < portTimeout) {
1379
+ if (await isPortInUse(serverConfig.port)) {
1380
+ portReady = true;
1381
+ break;
1382
+ }
1383
+ await new Promise(resolve => setTimeout(resolve, 500));
1384
+ }
1385
+
1386
+ if (!portReady) {
1387
+ await launchctlManager.unloadService(serverConfig.plistPath);
1388
+ await launchctlManager.deletePlist(serverConfig.plistPath);
1389
+ throw new Error('Server started but port not responding. Check logs.');
1390
+ }
1391
+
1392
+ // Update config with running status
1393
+ let updatedConfig = await statusChecker.updateServerStatus(serverConfig);
1394
+
1395
+ // Parse Metal memory allocation (wait a bit for model to load)
1396
+ progressModal.setContent('\n {cyan-fg}Detecting GPU memory allocation...{/cyan-fg}');
1397
+ screen.render();
1398
+ await new Promise(resolve => setTimeout(resolve, 3000));
1399
+ const metalMemoryMB = await parseMetalMemoryFromLog(updatedConfig.stderrPath);
1400
+ if (metalMemoryMB) {
1401
+ updatedConfig = { ...updatedConfig, metalMemoryMB };
1402
+ }
1403
+
1404
+ // Save server config
1405
+ await stateManager.saveServerConfig(updatedConfig);
1406
+
1407
+ // Show success message briefly
1408
+ progressModal.setContent('\n {green-fg}✓ Server created successfully!{/green-fg}');
1409
+ screen.render();
1410
+ await new Promise(resolve => setTimeout(resolve, 1000));
1411
+
1412
+ screen.remove(progressModal);
1413
+
1414
+ // Add to our arrays
1415
+ servers.push(updatedConfig);
1416
+ aggregators.set(updatedConfig.id, new MetricsAggregator(updatedConfig));
1417
+ historyManagers.set(updatedConfig.id, new HistoryManager(updatedConfig.id));
1418
+ serverDataMap.set(updatedConfig.id, {
1419
+ server: updatedConfig,
1420
+ data: null,
1421
+ error: null,
1422
+ });
1423
+
1424
+ // Select the new server in list view
1425
+ selectedRowIndex = servers.length - 1;
1426
+ selectedServerIndex = selectedRowIndex;
1427
+
1428
+ registerHandlers();
1429
+ startPolling();
1430
+
1431
+ } catch (err) {
1432
+ screen.remove(progressModal);
1433
+ await showErrorModal(err instanceof Error ? err.message : 'Unknown error');
1434
+ // Immediately render with cached data for instant feedback
1435
+ const content = renderListView(lastSystemMetrics);
1436
+ contentBox.setContent(content);
1437
+ screen.render();
1438
+ registerHandlers();
1439
+ startPolling();
1440
+ }
1441
+ }
1442
+
1443
+ // Store key handler references for cleanup when switching views
1444
+ const keyHandlers = {
1445
+ up: () => {
1446
+ if (viewMode === 'list') {
1447
+ selectedRowIndex = Math.max(0, selectedRowIndex - 1);
1448
+ // Re-render immediately for responsive feel
749
1449
  const content = renderListView(lastSystemMetrics);
750
1450
  contentBox.setContent(content);
751
1451
  screen.render();
752
- });
753
- } else {
754
- // Show single-server historical view for selected server
755
- const selectedServer = servers[selectedServerIndex];
756
- await createHistoricalUI(screen, selectedServer, () => {
757
- // Mark that we've left historical view
758
- inHistoricalView = false;
759
- // Re-attach content box when returning from history
760
- screen.append(contentBox);
761
- // Re-render the detail view
762
- const content = renderDetailView(lastSystemMetrics);
1452
+ }
1453
+ },
1454
+ down: () => {
1455
+ if (viewMode === 'list') {
1456
+ selectedRowIndex = Math.min(servers.length - 1, selectedRowIndex + 1);
1457
+ // Re-render immediately for responsive feel
1458
+ const content = renderListView(lastSystemMetrics);
763
1459
  contentBox.setContent(content);
764
1460
  screen.render();
1461
+ }
1462
+ },
1463
+ enter: () => {
1464
+ if (viewMode === 'list') {
1465
+ showLoading();
1466
+ selectedServerIndex = selectedRowIndex;
1467
+ viewMode = 'detail';
1468
+ fetchData();
1469
+ }
1470
+ },
1471
+ escape: () => {
1472
+ // Don't handle ESC if we're in historical view - let historical view handle it
1473
+ if (inHistoricalView) return;
1474
+
1475
+ if (viewMode === 'detail') {
1476
+ showLoading();
1477
+ viewMode = 'list';
1478
+ cameFromDirectJump = false; // Clear direct jump flag when returning to list
1479
+ fetchData();
1480
+ } else if (viewMode === 'list') {
1481
+ // ESC in list view - exit
1482
+ showLoading();
1483
+ if (intervalId) clearInterval(intervalId);
1484
+ if (spinnerIntervalId) clearInterval(spinnerIntervalId);
1485
+ setTimeout(() => {
1486
+ screen.destroy();
1487
+ process.exit(0);
1488
+ }, 100);
1489
+ }
1490
+ },
1491
+ models: async () => {
1492
+ if (onModels && viewMode === 'list' && !inHistoricalView) {
1493
+ // Pause monitor (don't destroy - we'll resume when returning)
1494
+ controls.pause();
1495
+ await onModels(controls);
1496
+ }
1497
+ },
1498
+ history: async () => {
1499
+ // Prevent entering historical view if already there
1500
+ if (inHistoricalView) return;
1501
+
1502
+ // Keep polling in background for live historical updates
1503
+ // Stop spinner if running
1504
+ if (spinnerIntervalId) clearInterval(spinnerIntervalId);
1505
+
1506
+ // Remove current content box
1507
+ screen.remove(contentBox);
1508
+
1509
+ // Mark that we're in historical view
1510
+ inHistoricalView = true;
1511
+
1512
+ if (viewMode === 'list') {
1513
+ // Show multi-server historical view
1514
+ await createMultiServerHistoricalUI(screen, servers, selectedServerIndex, () => {
1515
+ // Mark that we've left historical view
1516
+ inHistoricalView = false;
1517
+ // Re-attach content box when returning from history
1518
+ screen.append(contentBox);
1519
+ // Re-render the list view
1520
+ const content = renderListView(lastSystemMetrics);
1521
+ contentBox.setContent(content);
1522
+ screen.render();
1523
+ });
1524
+ } else {
1525
+ // Show single-server historical view for selected server
1526
+ const selectedServer = servers[selectedServerIndex];
1527
+ await createHistoricalUI(screen, selectedServer, () => {
1528
+ // Mark that we've left historical view
1529
+ inHistoricalView = false;
1530
+ // Re-attach content box when returning from history
1531
+ screen.append(contentBox);
1532
+ // Re-render the detail view
1533
+ const content = renderDetailView(lastSystemMetrics);
1534
+ contentBox.setContent(content);
1535
+ screen.render();
1536
+ });
1537
+ }
1538
+ },
1539
+ config: async () => {
1540
+ // Only available from detail view and not in historical view
1541
+ if (viewMode !== 'detail' || inHistoricalView) return;
1542
+
1543
+ // Pause monitor
1544
+ controls.pause();
1545
+
1546
+ const selectedServer = servers[selectedServerIndex];
1547
+ await createConfigUI(screen, selectedServer, (updatedServer) => {
1548
+ if (updatedServer) {
1549
+ // Check if server ID changed (model migration)
1550
+ if (updatedServer.id !== selectedServer.id) {
1551
+ // Replace server in array and update aggregator/history manager
1552
+ servers[selectedServerIndex] = updatedServer;
1553
+ aggregators.delete(selectedServer.id);
1554
+ historyManagers.delete(selectedServer.id);
1555
+ serverDataMap.delete(selectedServer.id);
1556
+ aggregators.set(updatedServer.id, new MetricsAggregator(updatedServer));
1557
+ historyManagers.set(updatedServer.id, new HistoryManager(updatedServer.id));
1558
+ serverDataMap.set(updatedServer.id, {
1559
+ server: updatedServer,
1560
+ data: null,
1561
+ error: null,
1562
+ });
1563
+ } else {
1564
+ // Update server in place
1565
+ servers[selectedServerIndex] = updatedServer;
1566
+ serverDataMap.set(updatedServer.id, {
1567
+ server: updatedServer,
1568
+ data: null,
1569
+ error: null,
1570
+ });
1571
+ }
1572
+ }
1573
+ // Resume monitor
1574
+ controls.resume();
765
1575
  });
766
- }
767
- });
1576
+ },
1577
+ remove: async () => {
1578
+ // Only available from detail view and not in historical view
1579
+ if (viewMode !== 'detail' || inHistoricalView) return;
768
1580
 
769
- screen.key(['q', 'Q', 'C-c'], () => {
770
- showLoading();
771
- if (intervalId) clearInterval(intervalId);
772
- if (spinnerIntervalId) clearInterval(spinnerIntervalId);
773
- // Small delay to show the loading state before exit
774
- setTimeout(() => {
775
- screen.destroy();
776
- process.exit(0);
777
- }, 100);
778
- });
1581
+ const selectedServer = servers[selectedServerIndex];
1582
+
1583
+ // Show remove server dialog
1584
+ await showRemoveServerDialog(selectedServer);
1585
+ },
1586
+ startStop: async () => {
1587
+ // Only available from detail view and not in historical view
1588
+ if (viewMode !== 'detail' || inHistoricalView) return;
1589
+
1590
+ const selectedServer = servers[selectedServerIndex];
1591
+
1592
+ // If running, stop it. If stopped, start it.
1593
+ if (selectedServer.status === 'running') {
1594
+ // Stop the server
1595
+ if (intervalId) clearInterval(intervalId);
1596
+ if (spinnerIntervalId) clearInterval(spinnerIntervalId);
1597
+ unregisterHandlers();
1598
+
1599
+ const progressModal = showProgressModal('Stopping server...');
1600
+
1601
+ try {
1602
+ // Unload service (this stops and unregisters it)
1603
+ progressModal.setContent('\n {cyan-fg}Stopping server...{/cyan-fg}');
1604
+ screen.render();
1605
+ await launchctlManager.unloadService(selectedServer.plistPath);
1606
+
1607
+ // Wait for shutdown
1608
+ progressModal.setContent('\n {cyan-fg}Waiting for server to stop...{/cyan-fg}');
1609
+ screen.render();
1610
+ await launchctlManager.waitForServiceStop(selectedServer.label, 5000);
1611
+
1612
+ // Update server status
1613
+ const updatedServer = await statusChecker.updateServerStatus(selectedServer);
1614
+ servers[selectedServerIndex] = updatedServer;
1615
+ serverDataMap.set(updatedServer.id, {
1616
+ server: updatedServer,
1617
+ data: null,
1618
+ error: null,
1619
+ });
1620
+
1621
+ // Save updated config
1622
+ await stateManager.saveServerConfig(updatedServer);
1623
+
1624
+ // Show success briefly
1625
+ progressModal.setContent('\n {green-fg}✓ Server stopped successfully!{/green-fg}');
1626
+ screen.render();
1627
+ await new Promise(resolve => setTimeout(resolve, 800));
1628
+
1629
+ screen.remove(progressModal);
1630
+ registerHandlers();
1631
+ startPolling();
1632
+
1633
+ } catch (err) {
1634
+ screen.remove(progressModal);
1635
+ await showErrorModal(err instanceof Error ? err.message : 'Unknown error');
1636
+ registerHandlers();
1637
+ startPolling();
1638
+ }
1639
+ return;
1640
+ }
1641
+
1642
+ // Start the server
1643
+
1644
+ // Pause the monitor
1645
+ if (intervalId) clearInterval(intervalId);
1646
+ if (spinnerIntervalId) clearInterval(spinnerIntervalId);
1647
+ unregisterHandlers();
1648
+
1649
+ const progressModal = showProgressModal('Starting server...');
1650
+
1651
+ try {
1652
+ // Recreate plist if needed
1653
+ const plistExists = await fs.access(selectedServer.plistPath).then(() => true).catch(() => false);
1654
+ if (!plistExists) {
1655
+ progressModal.setContent('\n {cyan-fg}Recreating plist...{/cyan-fg}');
1656
+ screen.render();
1657
+ await launchctlManager.createPlist(selectedServer);
1658
+ }
1659
+
1660
+ // Load service
1661
+ progressModal.setContent('\n {cyan-fg}Loading service...{/cyan-fg}');
1662
+ screen.render();
1663
+ try {
1664
+ await launchctlManager.loadService(selectedServer.plistPath);
1665
+ } catch (err) {
1666
+ // May already be loaded, continue
1667
+ }
1668
+
1669
+ // Start service
1670
+ progressModal.setContent('\n {cyan-fg}Starting server...{/cyan-fg}');
1671
+ screen.render();
1672
+ await launchctlManager.startService(selectedServer.label);
1673
+
1674
+ // Wait for startup
1675
+ progressModal.setContent('\n {cyan-fg}Waiting for server to start...{/cyan-fg}');
1676
+ screen.render();
1677
+ const started = await launchctlManager.waitForServiceStart(selectedServer.label, 5000);
1678
+
1679
+ if (!started) {
1680
+ throw new Error('Server failed to start. Check logs.');
1681
+ }
1682
+
1683
+ // Wait for port to be ready
1684
+ progressModal.setContent('\n {cyan-fg}Waiting for server to be ready...{/cyan-fg}');
1685
+ screen.render();
1686
+ const portTimeout = 10000;
1687
+ const portStartTime = Date.now();
1688
+ let portReady = false;
1689
+ while (Date.now() - portStartTime < portTimeout) {
1690
+ if (await isPortInUse(selectedServer.port)) {
1691
+ portReady = true;
1692
+ break;
1693
+ }
1694
+ await new Promise(resolve => setTimeout(resolve, 500));
1695
+ }
1696
+
1697
+ if (!portReady) {
1698
+ throw new Error('Server started but port not responding. Check logs.');
1699
+ }
1700
+
1701
+ // Update server status
1702
+ const updatedServer = await statusChecker.updateServerStatus(selectedServer);
1703
+ servers[selectedServerIndex] = updatedServer;
1704
+ serverDataMap.set(updatedServer.id, {
1705
+ server: updatedServer,
1706
+ data: null,
1707
+ error: null,
1708
+ });
1709
+
1710
+ // Save updated config
1711
+ await stateManager.saveServerConfig(updatedServer);
1712
+
1713
+ // Show success briefly
1714
+ progressModal.setContent('\n {green-fg}✓ Server started successfully!{/green-fg}');
1715
+ screen.render();
1716
+ await new Promise(resolve => setTimeout(resolve, 800));
1717
+
1718
+ screen.remove(progressModal);
1719
+ registerHandlers();
1720
+ startPolling();
1721
+
1722
+ } catch (err) {
1723
+ screen.remove(progressModal);
1724
+ await showErrorModal(err instanceof Error ? err.message : 'Unknown error');
1725
+ registerHandlers();
1726
+ startPolling();
1727
+ }
1728
+ },
1729
+ create: async () => {
1730
+ // Only available from list view and not in historical view
1731
+ if (viewMode !== 'list' || inHistoricalView) return;
1732
+
1733
+ // Show create server flow
1734
+ await showCreateServerFlow();
1735
+ },
1736
+ quit: () => {
1737
+ showLoading();
1738
+ if (intervalId) clearInterval(intervalId);
1739
+ if (spinnerIntervalId) clearInterval(spinnerIntervalId);
1740
+ // Small delay to show the loading state before exit
1741
+ setTimeout(() => {
1742
+ screen.destroy();
1743
+ process.exit(0);
1744
+ }, 100);
1745
+ },
1746
+ };
1747
+
1748
+ // Unregister all keyboard handlers
1749
+ function unregisterHandlers() {
1750
+ screen.unkey('up', keyHandlers.up);
1751
+ screen.unkey('k', keyHandlers.up);
1752
+ screen.unkey('down', keyHandlers.down);
1753
+ screen.unkey('j', keyHandlers.down);
1754
+ screen.unkey('enter', keyHandlers.enter);
1755
+ screen.unkey('escape', keyHandlers.escape);
1756
+ screen.unkey('m', keyHandlers.models);
1757
+ screen.unkey('M', keyHandlers.models);
1758
+ screen.unkey('h', keyHandlers.history);
1759
+ screen.unkey('H', keyHandlers.history);
1760
+ screen.unkey('c', keyHandlers.config);
1761
+ screen.unkey('C', keyHandlers.config);
1762
+ screen.unkey('r', keyHandlers.remove);
1763
+ screen.unkey('R', keyHandlers.remove);
1764
+ screen.unkey('s', keyHandlers.startStop);
1765
+ screen.unkey('S', keyHandlers.startStop);
1766
+ screen.unkey('n', keyHandlers.create);
1767
+ screen.unkey('N', keyHandlers.create);
1768
+ screen.unkey('q', keyHandlers.quit);
1769
+ screen.unkey('Q', keyHandlers.quit);
1770
+ screen.unkey('C-c', keyHandlers.quit);
1771
+ }
1772
+
1773
+ // Register keyboard handlers
1774
+ function registerHandlers() {
1775
+ screen.key(['up', 'k'], keyHandlers.up);
1776
+ screen.key(['down', 'j'], keyHandlers.down);
1777
+ screen.key(['enter'], keyHandlers.enter);
1778
+ screen.key(['escape'], keyHandlers.escape);
1779
+ screen.key(['m', 'M'], keyHandlers.models);
1780
+ screen.key(['h', 'H'], keyHandlers.history);
1781
+ screen.key(['c', 'C'], keyHandlers.config);
1782
+ screen.key(['r', 'R'], keyHandlers.remove);
1783
+ screen.key(['s', 'S'], keyHandlers.startStop);
1784
+ screen.key(['n', 'N'], keyHandlers.create);
1785
+ screen.key(['q', 'Q', 'C-c'], keyHandlers.quit);
1786
+ }
1787
+
1788
+ // Controls object for pause/resume from other views
1789
+ const controls: MonitorUIControls = {
1790
+ pause: () => {
1791
+ unregisterHandlers();
1792
+ if (intervalId) clearInterval(intervalId);
1793
+ if (spinnerIntervalId) clearInterval(spinnerIntervalId);
1794
+ screen.remove(contentBox);
1795
+ },
1796
+ resume: () => {
1797
+ screen.append(contentBox);
1798
+ registerHandlers();
1799
+ // Re-render with last known data (instant, no loading)
1800
+ let content = '';
1801
+ if (viewMode === 'list') {
1802
+ content = renderListView(lastSystemMetrics);
1803
+ } else {
1804
+ content = renderDetailView(lastSystemMetrics);
1805
+ }
1806
+ contentBox.setContent(content);
1807
+ screen.render();
1808
+ // Resume polling
1809
+ startPolling();
1810
+ },
1811
+ getServers: () => servers,
1812
+ };
779
1813
 
780
- // Initial display
781
- contentBox.setContent('{cyan-fg}⏳ Connecting to servers...{/cyan-fg}');
782
- screen.render();
1814
+ // Initial registration
1815
+ registerHandlers();
1816
+
1817
+ // Initial display - skip "Connecting" message when returning from another view
1818
+ if (!skipConnectingMessage) {
1819
+ contentBox.setContent('{cyan-fg}⏳ Connecting to servers...{/cyan-fg}');
1820
+ screen.render();
1821
+ }
783
1822
 
784
1823
  startPolling();
785
1824
 
@@ -789,4 +1828,6 @@ export async function createMultiServerMonitorUI(
789
1828
  // Note: macmon child processes will automatically die when parent exits
790
1829
  // since they're spawned with detached: false
791
1830
  });
1831
+
1832
+ return controls;
792
1833
  }