@appkit/llamacpp-cli 1.12.0 → 1.13.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 (136) hide show
  1. package/README.md +294 -168
  2. package/dist/cli.js +35 -0
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/launch/claude.d.ts +6 -0
  5. package/dist/commands/launch/claude.d.ts.map +1 -0
  6. package/dist/commands/launch/claude.js +277 -0
  7. package/dist/commands/launch/claude.js.map +1 -0
  8. package/dist/lib/integration-checker.d.ts +26 -0
  9. package/dist/lib/integration-checker.d.ts.map +1 -0
  10. package/dist/lib/integration-checker.js +77 -0
  11. package/dist/lib/integration-checker.js.map +1 -0
  12. package/dist/lib/router-manager.d.ts +4 -0
  13. package/dist/lib/router-manager.d.ts.map +1 -1
  14. package/dist/lib/router-manager.js +10 -0
  15. package/dist/lib/router-manager.js.map +1 -1
  16. package/dist/lib/router-server.d.ts +13 -0
  17. package/dist/lib/router-server.d.ts.map +1 -1
  18. package/dist/lib/router-server.js +267 -7
  19. package/dist/lib/router-server.js.map +1 -1
  20. package/dist/types/integration-config.d.ts +28 -0
  21. package/dist/types/integration-config.d.ts.map +1 -0
  22. package/dist/types/integration-config.js +3 -0
  23. package/dist/types/integration-config.js.map +1 -0
  24. package/package.json +10 -2
  25. package/web/dist/assets/index-Bin89Lwr.css +1 -0
  26. package/web/dist/assets/index-CVmonw3T.js +17 -0
  27. package/web/{index.html → dist/index.html} +2 -1
  28. package/.versionrc.json +0 -16
  29. package/CHANGELOG.md +0 -213
  30. package/docs/images/.gitkeep +0 -1
  31. package/docs/images/web-ui-servers.png +0 -0
  32. package/src/cli.ts +0 -523
  33. package/src/commands/admin/config.ts +0 -121
  34. package/src/commands/admin/logs.ts +0 -91
  35. package/src/commands/admin/restart.ts +0 -26
  36. package/src/commands/admin/start.ts +0 -27
  37. package/src/commands/admin/status.ts +0 -84
  38. package/src/commands/admin/stop.ts +0 -16
  39. package/src/commands/config-global.ts +0 -38
  40. package/src/commands/config.ts +0 -323
  41. package/src/commands/create.ts +0 -183
  42. package/src/commands/delete.ts +0 -74
  43. package/src/commands/list.ts +0 -37
  44. package/src/commands/logs-all.ts +0 -251
  45. package/src/commands/logs.ts +0 -345
  46. package/src/commands/monitor.ts +0 -110
  47. package/src/commands/ps.ts +0 -84
  48. package/src/commands/pull.ts +0 -44
  49. package/src/commands/rm.ts +0 -107
  50. package/src/commands/router/config.ts +0 -116
  51. package/src/commands/router/logs.ts +0 -256
  52. package/src/commands/router/restart.ts +0 -36
  53. package/src/commands/router/start.ts +0 -60
  54. package/src/commands/router/status.ts +0 -119
  55. package/src/commands/router/stop.ts +0 -33
  56. package/src/commands/run.ts +0 -233
  57. package/src/commands/search.ts +0 -107
  58. package/src/commands/server-show.ts +0 -161
  59. package/src/commands/show.ts +0 -207
  60. package/src/commands/start.ts +0 -101
  61. package/src/commands/stop.ts +0 -39
  62. package/src/commands/tui.ts +0 -25
  63. package/src/lib/admin-manager.ts +0 -435
  64. package/src/lib/admin-server.ts +0 -1243
  65. package/src/lib/config-generator.ts +0 -130
  66. package/src/lib/download-job-manager.ts +0 -213
  67. package/src/lib/history-manager.ts +0 -172
  68. package/src/lib/launchctl-manager.ts +0 -225
  69. package/src/lib/metrics-aggregator.ts +0 -257
  70. package/src/lib/model-downloader.ts +0 -328
  71. package/src/lib/model-scanner.ts +0 -157
  72. package/src/lib/model-search.ts +0 -114
  73. package/src/lib/models-dir-setup.ts +0 -46
  74. package/src/lib/port-manager.ts +0 -80
  75. package/src/lib/router-logger.ts +0 -201
  76. package/src/lib/router-manager.ts +0 -414
  77. package/src/lib/router-server.ts +0 -538
  78. package/src/lib/state-manager.ts +0 -206
  79. package/src/lib/status-checker.ts +0 -113
  80. package/src/lib/system-collector.ts +0 -315
  81. package/src/tui/ConfigApp.ts +0 -1085
  82. package/src/tui/HistoricalMonitorApp.ts +0 -587
  83. package/src/tui/ModelsApp.ts +0 -368
  84. package/src/tui/MonitorApp.ts +0 -386
  85. package/src/tui/MultiServerMonitorApp.ts +0 -1833
  86. package/src/tui/RootNavigator.ts +0 -74
  87. package/src/tui/SearchApp.ts +0 -511
  88. package/src/tui/SplashScreen.ts +0 -149
  89. package/src/types/admin-config.ts +0 -25
  90. package/src/types/global-config.ts +0 -26
  91. package/src/types/history-types.ts +0 -39
  92. package/src/types/model-info.ts +0 -8
  93. package/src/types/monitor-types.ts +0 -162
  94. package/src/types/router-config.ts +0 -25
  95. package/src/types/server-config.ts +0 -46
  96. package/src/utils/downsample-utils.ts +0 -128
  97. package/src/utils/file-utils.ts +0 -146
  98. package/src/utils/format-utils.ts +0 -98
  99. package/src/utils/log-parser.ts +0 -284
  100. package/src/utils/log-utils.ts +0 -178
  101. package/src/utils/process-utils.ts +0 -316
  102. package/src/utils/prompt-utils.ts +0 -47
  103. package/test-load.sh +0 -100
  104. package/tsconfig.json +0 -20
  105. package/web/eslint.config.js +0 -23
  106. package/web/llamacpp-web-dist.tar.gz +0 -0
  107. package/web/package-lock.json +0 -4017
  108. package/web/package.json +0 -38
  109. package/web/postcss.config.js +0 -6
  110. package/web/src/App.css +0 -42
  111. package/web/src/App.tsx +0 -86
  112. package/web/src/assets/react.svg +0 -1
  113. package/web/src/components/ApiKeyPrompt.tsx +0 -71
  114. package/web/src/components/CreateServerModal.tsx +0 -372
  115. package/web/src/components/DownloadProgress.tsx +0 -123
  116. package/web/src/components/Nav.tsx +0 -89
  117. package/web/src/components/RouterConfigModal.tsx +0 -240
  118. package/web/src/components/SearchModal.tsx +0 -306
  119. package/web/src/components/ServerConfigModal.tsx +0 -291
  120. package/web/src/hooks/useApi.ts +0 -259
  121. package/web/src/index.css +0 -42
  122. package/web/src/lib/api.ts +0 -226
  123. package/web/src/main.tsx +0 -10
  124. package/web/src/pages/Dashboard.tsx +0 -103
  125. package/web/src/pages/Models.tsx +0 -258
  126. package/web/src/pages/Router.tsx +0 -270
  127. package/web/src/pages/RouterLogs.tsx +0 -201
  128. package/web/src/pages/ServerLogs.tsx +0 -553
  129. package/web/src/pages/Servers.tsx +0 -358
  130. package/web/src/types/api.ts +0 -140
  131. package/web/tailwind.config.js +0 -31
  132. package/web/tsconfig.app.json +0 -28
  133. package/web/tsconfig.json +0 -7
  134. package/web/tsconfig.node.json +0 -26
  135. package/web/vite.config.ts +0 -25
  136. /package/web/{public → dist}/vite.svg +0 -0
@@ -1,1833 +0,0 @@
1
- import blessed from 'blessed';
2
- import * as path from 'path';
3
- import * as fs from 'fs/promises';
4
- import { ServerConfig, sanitizeModelName } from '../types/server-config.js';
5
- import { MetricsAggregator } from '../lib/metrics-aggregator.js';
6
- import { SystemCollector } from '../lib/system-collector.js';
7
- import { MonitorData, SystemMetrics } from '../types/monitor-types.js';
8
- import { HistoryManager } from '../lib/history-manager.js';
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, formatContextSize } from '../utils/format-utils.js';
20
- import { isPortInUse } from '../utils/process-utils.js';
21
-
22
- type ViewMode = 'list' | 'detail';
23
-
24
- interface ServerMonitorData {
25
- server: ServerConfig;
26
- data: MonitorData | null;
27
- error: string | null;
28
- }
29
-
30
- export interface MonitorUIControls {
31
- pause: () => void;
32
- resume: () => void;
33
- getServers: () => ServerConfig[];
34
- }
35
-
36
- export async function createMultiServerMonitorUI(
37
- screen: blessed.Widgets.Screen,
38
- servers: ServerConfig[],
39
- skipConnectingMessage: boolean = false,
40
- directJumpIndex?: number,
41
- onModels?: (controls: MonitorUIControls) => void,
42
- onFirstRender?: () => void
43
- ): Promise<MonitorUIControls> {
44
- let updateInterval = 5000;
45
- let intervalId: NodeJS.Timeout | null = null;
46
- let viewMode: ViewMode = directJumpIndex !== undefined ? 'detail' : 'list';
47
- let selectedServerIndex = directJumpIndex ?? 0;
48
- let selectedRowIndex = directJumpIndex ?? 0; // Track which row is highlighted in list view
49
- let isLoading = false;
50
- let lastSystemMetrics: SystemMetrics | null = null;
51
- let cameFromDirectJump = directJumpIndex !== undefined; // Track if we entered via ps <id>
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
54
-
55
- // Spinner animation
56
- const spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
57
- let spinnerFrameIndex = 0;
58
- let spinnerIntervalId: NodeJS.Timeout | null = null;
59
-
60
- const systemCollector = new SystemCollector();
61
- const aggregators = new Map<string, MetricsAggregator>();
62
- const historyManagers = new Map<string, HistoryManager>();
63
- const serverDataMap = new Map<string, ServerMonitorData>();
64
-
65
- // Initialize aggregators and history managers for each server
66
- for (const server of servers) {
67
- aggregators.set(server.id, new MetricsAggregator(server));
68
- historyManagers.set(server.id, new HistoryManager(server.id));
69
- serverDataMap.set(server.id, {
70
- server,
71
- data: null,
72
- error: null,
73
- });
74
- }
75
-
76
- // Single scrollable content box
77
- const contentBox = blessed.box({
78
- top: 0,
79
- left: 0,
80
- width: '100%',
81
- height: '100%',
82
- tags: true,
83
- scrollable: true,
84
- alwaysScroll: true,
85
- keys: true,
86
- vi: true,
87
- mouse: true,
88
- scrollbar: {
89
- ch: '█',
90
- style: {
91
- fg: 'blue',
92
- },
93
- },
94
- });
95
- screen.append(contentBox);
96
-
97
- // Helper to create progress bar
98
- function createProgressBar(percentage: number, width: number = 30): string {
99
- const filled = Math.round((percentage / 100) * width);
100
- const empty = width - filled;
101
- return '[' + '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, empty)) + ']';
102
- }
103
-
104
- // Render system resources section (system-wide for list view)
105
- function renderSystemResources(systemMetrics: SystemMetrics | null): string {
106
- let content = '';
107
-
108
- content += '{bold}System Resources{/bold}\n';
109
- const termWidth = (screen.width as number) || 80;
110
- const divider = '─'.repeat(termWidth - 2);
111
- content += divider + '\n';
112
-
113
- if (systemMetrics) {
114
- if (systemMetrics.gpuUsage !== undefined) {
115
- const bar = createProgressBar(systemMetrics.gpuUsage);
116
- content += `GPU: {cyan-fg}${bar}{/cyan-fg} ${Math.round(systemMetrics.gpuUsage)}%`;
117
-
118
- if (systemMetrics.temperature !== undefined) {
119
- content += ` - ${Math.round(systemMetrics.temperature)}°C`;
120
- }
121
-
122
- content += '\n';
123
- }
124
-
125
- if (systemMetrics.cpuUsage !== undefined) {
126
- const bar = createProgressBar(systemMetrics.cpuUsage);
127
- content += `CPU: {cyan-fg}${bar}{/cyan-fg} ${Math.round(systemMetrics.cpuUsage)}%\n`;
128
- }
129
-
130
- if (systemMetrics.aneUsage !== undefined && systemMetrics.aneUsage > 1) {
131
- const bar = createProgressBar(systemMetrics.aneUsage);
132
- content += `ANE: {cyan-fg}${bar}{/cyan-fg} ${Math.round(systemMetrics.aneUsage)}%\n`;
133
- }
134
-
135
- if (systemMetrics.memoryTotal > 0) {
136
- const memoryUsedGB = systemMetrics.memoryUsed / (1024 ** 3);
137
- const memoryTotalGB = systemMetrics.memoryTotal / (1024 ** 3);
138
- const memoryPercentage = (systemMetrics.memoryUsed / systemMetrics.memoryTotal) * 100;
139
- const bar = createProgressBar(memoryPercentage);
140
- content += `Memory: {cyan-fg}${bar}{/cyan-fg} ${Math.round(memoryPercentage)}% `;
141
- content += `(${memoryUsedGB.toFixed(1)} / ${memoryTotalGB.toFixed(1)} GB)\n`;
142
- }
143
-
144
- if (systemMetrics.warnings && systemMetrics.warnings.length > 0) {
145
- content += `\n{yellow-fg}⚠ ${systemMetrics.warnings.join(', ')}{/yellow-fg}\n`;
146
- }
147
- } else {
148
- content += '{gray-fg}Collecting system metrics...{/gray-fg}\n';
149
- }
150
-
151
- return content;
152
- }
153
-
154
- // Render aggregate model resources (all running servers in list view)
155
- function renderAggregateModelResources(): string {
156
- let content = '';
157
-
158
- content += '{bold}Server Resources{/bold}\n';
159
- const termWidth = (screen.width as number) || 80;
160
- const divider = '─'.repeat(termWidth - 2);
161
- content += divider + '\n';
162
-
163
- // Aggregate CPU and memory across all running servers (skip stopped servers)
164
- let totalCpu = 0;
165
- let totalMemoryBytes = 0;
166
- let serverCount = 0;
167
-
168
- for (const serverData of serverDataMap.values()) {
169
- // Only count running servers with valid data
170
- if (serverData.server.status === 'running' && serverData.data?.server && !serverData.data.server.stale) {
171
- if (serverData.data.server.processCpuUsage !== undefined) {
172
- totalCpu += serverData.data.server.processCpuUsage;
173
- serverCount++;
174
- }
175
- if (serverData.data.server.processMemory !== undefined) {
176
- totalMemoryBytes += serverData.data.server.processMemory;
177
- }
178
- }
179
- }
180
-
181
- if (serverCount === 0) {
182
- content += '{gray-fg}No running servers{/gray-fg}\n';
183
- return content;
184
- }
185
-
186
- // CPU: Sum of all process CPU percentages
187
- const cpuBar = createProgressBar(Math.min(totalCpu, 100));
188
- content += `CPU: {cyan-fg}${cpuBar}{/cyan-fg} ${Math.round(totalCpu)}%`;
189
- content += ` {gray-fg}(${serverCount} ${serverCount === 1 ? 'server' : 'servers'}){/gray-fg}\n`;
190
-
191
- // Memory: Sum of all process memory
192
- const totalMemoryGB = totalMemoryBytes / (1024 ** 3);
193
- const estimatedMaxGB = serverCount * 8; // Assume ~8GB per server max
194
- const memoryPercentage = Math.min((totalMemoryGB / estimatedMaxGB) * 100, 100);
195
- const memoryBar = createProgressBar(memoryPercentage);
196
- content += `Memory: {cyan-fg}${memoryBar}{/cyan-fg} ${totalMemoryGB.toFixed(2)} GB`;
197
- content += ` {gray-fg}(${serverCount} ${serverCount === 1 ? 'server' : 'servers'}){/gray-fg}\n`;
198
-
199
- return content;
200
- }
201
-
202
- // Render model resources section (per-process for detail view)
203
- function renderModelResources(data: MonitorData): string {
204
- let content = '';
205
-
206
- content += '{bold}Server Resources{/bold}\n';
207
- const termWidth = (screen.width as number) || 80;
208
- const divider = '─'.repeat(termWidth - 2);
209
- content += divider + '\n';
210
-
211
- // GPU: System-wide (can't get per-process on macOS)
212
- if (data.system && data.system.gpuUsage !== undefined) {
213
- const bar = createProgressBar(data.system.gpuUsage);
214
- content += `GPU: {cyan-fg}${bar}{/cyan-fg} ${Math.round(data.system.gpuUsage)}% {gray-fg}(system){/gray-fg}`;
215
-
216
- if (data.system.temperature !== undefined) {
217
- content += ` - ${Math.round(data.system.temperature)}°C`;
218
- }
219
-
220
- content += '\n';
221
- }
222
-
223
- // CPU: Per-process
224
- if (data.server.processCpuUsage !== undefined) {
225
- const bar = createProgressBar(data.server.processCpuUsage);
226
- content += `CPU: {cyan-fg}${bar}{/cyan-fg} ${Math.round(data.server.processCpuUsage)}%\n`;
227
- }
228
-
229
- // Memory: Per-process
230
- if (data.server.processMemory !== undefined) {
231
- const memoryGB = data.server.processMemory / (1024 ** 3);
232
- const estimatedMax = 8;
233
- const memoryPercentage = Math.min((memoryGB / estimatedMax) * 100, 100);
234
- const bar = createProgressBar(memoryPercentage);
235
- content += `Memory: {cyan-fg}${bar}{/cyan-fg} ${memoryGB.toFixed(2)} GB\n`;
236
- }
237
-
238
- if (data.system && data.system.warnings && data.system.warnings.length > 0) {
239
- content += `\n{yellow-fg}⚠ ${data.system.warnings.join(', ')}{/yellow-fg}\n`;
240
- }
241
-
242
- return content;
243
- }
244
-
245
- // Show loading spinner
246
- function showLoading(): void {
247
- if (isLoading) return; // Already loading
248
-
249
- isLoading = true;
250
- spinnerFrameIndex = 0;
251
-
252
- // Start spinner animation (80ms per frame = smooth rotation)
253
- spinnerIntervalId = setInterval(() => {
254
- spinnerFrameIndex = (spinnerFrameIndex + 1) % spinnerFrames.length;
255
-
256
- // Re-render current view with updated spinner frame
257
- let content = '';
258
- if (viewMode === 'list') {
259
- content = renderListView(lastSystemMetrics);
260
- } else {
261
- content = renderDetailView(lastSystemMetrics);
262
- }
263
- contentBox.setContent(content);
264
- screen.render();
265
- }, 80);
266
-
267
- // Immediate first render
268
- let content = '';
269
- if (viewMode === 'list') {
270
- content = renderListView(lastSystemMetrics);
271
- } else {
272
- content = renderDetailView(lastSystemMetrics);
273
- }
274
- contentBox.setContent(content);
275
- screen.render();
276
- }
277
-
278
- // Hide loading spinner
279
- function hideLoading(): void {
280
- isLoading = false;
281
- if (spinnerIntervalId) {
282
- clearInterval(spinnerIntervalId);
283
- spinnerIntervalId = null;
284
- }
285
- }
286
-
287
- // Render list view
288
- function renderListView(systemMetrics: SystemMetrics | null): string {
289
- const termWidth = (screen.width as number) || 80;
290
- const divider = '─'.repeat(termWidth - 2);
291
- let content = '';
292
-
293
- // Header
294
- content += '{bold}{blue-fg}═══ llama.cpp{/blue-fg}{/bold}\n\n';
295
-
296
- // System resources
297
- content += renderSystemResources(systemMetrics);
298
- content += '\n';
299
-
300
- // Aggregate model resources (CPU + memory for all running servers)
301
- content += renderAggregateModelResources();
302
- content += '\n';
303
-
304
- // Server list header
305
- const runningCount = servers.filter(s => s.status === 'running').length;
306
- const stoppedCount = servers.filter(s => s.status !== 'running').length;
307
- content += `{bold}Servers (${runningCount} running, ${stoppedCount} stopped){/bold}\n`;
308
- content += '{gray-fg}Use arrow keys to navigate, Enter to view details{/gray-fg}\n';
309
- content += divider + '\n';
310
-
311
- // Calculate Server ID column width (variable based on screen width)
312
- // Fixed columns breakdown:
313
- // indicator(1) + " │ "(3) + " │ "(3) + port(4) + " │ "(3) + status(6) + "│ "(2) +
314
- // slots(5) + " │ "(3) + tok/s(6) + " │ "(3) + memory(7) = 46
315
- const fixedColumnsWidth = 48; // Add 2 extra for safety margin
316
- const minServerIdWidth = 20;
317
- const maxServerIdWidth = 60;
318
- const serverIdWidth = Math.max(
319
- minServerIdWidth,
320
- Math.min(maxServerIdWidth, termWidth - fixedColumnsWidth)
321
- );
322
-
323
- // Table header with variable Server ID width
324
- const serverIdHeader = 'Server ID'.padEnd(serverIdWidth);
325
- content += `{bold} │ ${serverIdHeader}│ Port │ Status │ Slots │ tok/s │ Memory{/bold}\n`;
326
- content += divider + '\n';
327
-
328
- // Server rows
329
- servers.forEach((server, index) => {
330
- const serverData = serverDataMap.get(server.id);
331
- const isSelected = index === selectedRowIndex;
332
-
333
- // Selection indicator (arrow for selected row)
334
- // Use plain arrow for selected (will be white), colored for unselected indicator
335
- const indicator = isSelected ? '►' : ' ';
336
-
337
- // Server ID (variable width, truncate if longer than available space)
338
- const serverId = server.id.padEnd(serverIdWidth).substring(0, serverIdWidth);
339
-
340
- // Port
341
- const port = server.port.toString().padStart(4);
342
-
343
- // Status - Check actual server status first, then health
344
- // Build two versions: colored for normal, plain for selected
345
- let status = '';
346
- let statusPlain = '';
347
- if (server.status !== 'running') {
348
- // Server is stopped according to config
349
- status = '{gray-fg}○ OFF{/gray-fg} ';
350
- statusPlain = '○ OFF ';
351
- } else if (serverData?.data) {
352
- // Server is running and we have data
353
- if (serverData.data.server.healthy) {
354
- status = '{green-fg}● RUN{/green-fg} ';
355
- statusPlain = '● RUN ';
356
- } else {
357
- status = '{red-fg}● ERR{/red-fg} ';
358
- statusPlain = '● ERR ';
359
- }
360
- } else {
361
- // Server is running but no data yet (still loading)
362
- status = '{yellow-fg}● ...{/yellow-fg} ';
363
- statusPlain = '● ... ';
364
- }
365
-
366
- // Slots
367
- let slots = '- ';
368
- if (serverData?.data?.server) {
369
- const active = serverData.data.server.activeSlots;
370
- const total = serverData.data.server.totalSlots;
371
- slots = `${active}/${total}`.padStart(5);
372
- }
373
-
374
- // tok/s
375
- let tokensPerSec = '- ';
376
- if (serverData?.data?.server.avgGenerateSpeed !== undefined &&
377
- serverData.data.server.avgGenerateSpeed > 0) {
378
- tokensPerSec = Math.round(serverData.data.server.avgGenerateSpeed).toString().padStart(6);
379
- }
380
-
381
- // Memory (actual process memory from top command)
382
- let memory = '- ';
383
- if (serverData?.data?.server.processMemory) {
384
- const bytes = serverData.data.server.processMemory;
385
- // Format as GB/MB depending on size
386
- if (bytes >= 1024 * 1024 * 1024) {
387
- const gb = (bytes / (1024 * 1024 * 1024)).toFixed(1);
388
- memory = `${gb} GB`.padStart(7);
389
- } else {
390
- const mb = Math.round(bytes / (1024 * 1024));
391
- memory = `${mb} MB`.padStart(7);
392
- }
393
- }
394
-
395
- // Build row content - use plain status for selected rows
396
- let rowContent = '';
397
- if (isSelected) {
398
- // Use color code 15 (bright white) with cyan background
399
- // When white-bg worked, it was probably auto-selecting bright white fg
400
- rowContent = `{cyan-bg}{15-fg}${indicator} │ ${serverId} │ ${port} │ ${statusPlain}│ ${slots} │ ${tokensPerSec} │ ${memory}{/15-fg}{/cyan-bg}`;
401
- } else {
402
- // Use colored status for normal rows
403
- rowContent = `${indicator} │ ${serverId} │ ${port} │ ${status}│ ${slots} │ ${tokensPerSec} │ ${memory}`;
404
- }
405
-
406
- content += rowContent + '\n';
407
- });
408
-
409
- // Footer
410
- content += '\n' + divider + '\n';
411
- content += `{gray-fg}Updated: ${new Date().toLocaleTimeString()} | [N]ew [M]odels [H]istory [Q]uit{/gray-fg}`;
412
-
413
- return content;
414
- }
415
-
416
- // Render detail view for selected server
417
- function renderDetailView(systemMetrics: SystemMetrics | null): string {
418
- const server = servers[selectedServerIndex];
419
- const serverData = serverDataMap.get(server.id);
420
- const termWidth = (screen.width as number) || 80;
421
- const divider = '─'.repeat(termWidth - 2);
422
- let content = '';
423
-
424
- // Header
425
- content += `{bold}{blue-fg}═══ ${server.id} (${server.port}){/blue-fg}{/bold}\n\n`;
426
-
427
- // Check if server is stopped
428
- if (server.status !== 'running') {
429
- // Show minimal stopped server info
430
- content += '{bold}Server Information{/bold}\n';
431
- content += divider + '\n';
432
- content += `Status: {gray-fg}○ STOPPED{/gray-fg}\n`;
433
- content += `Model: ${server.modelName}\n`;
434
- const displayHost = server.host || '127.0.0.1';
435
- content += `Endpoint: http://${displayHost}:${server.port}\n`;
436
-
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}`;
440
-
441
- return content;
442
- }
443
-
444
- if (!serverData?.data) {
445
- content += '{yellow-fg}Loading server data...{/yellow-fg}\n';
446
- return content;
447
- }
448
-
449
- const data = serverData.data;
450
-
451
- // Model resources (per-process)
452
- content += renderModelResources(data);
453
- content += '\n';
454
-
455
- // Server Information
456
- content += '{bold}Server Information{/bold}\n';
457
- content += divider + '\n';
458
-
459
- const statusIcon = data.server.healthy ? '{green-fg}●{/green-fg}' : '{red-fg}●{/red-fg}';
460
- const statusText = data.server.healthy ? 'RUNNING' : 'UNHEALTHY';
461
- content += `Status: ${statusIcon} ${statusText}`;
462
-
463
- if (data.server.uptime) {
464
- content += ` Uptime: ${data.server.uptime}`;
465
- }
466
- content += '\n';
467
-
468
- content += `Model: ${server.modelName}`;
469
- if (data.server.contextSize) {
470
- content += ` Context: ${formatContextSize(data.server.contextSize)}/slot`;
471
- }
472
- content += '\n';
473
-
474
- // Handle null host (legacy configs) by defaulting to 127.0.0.1
475
- const displayHost = server.host || '127.0.0.1';
476
- content += `Endpoint: http://${displayHost}:${server.port}\n`;
477
-
478
- content += `Slots: ${data.server.activeSlots} active / ${data.server.totalSlots} total\n`;
479
- content += '\n';
480
-
481
- // Request Metrics
482
- if (data.server.totalSlots > 0) {
483
- content += '{bold}Request Metrics{/bold}\n';
484
- content += divider + '\n';
485
- content += `Active: ${data.server.activeSlots} / ${data.server.totalSlots}\n`;
486
- content += `Idle: ${data.server.idleSlots} / ${data.server.totalSlots}\n`;
487
-
488
- if (data.server.avgPromptSpeed !== undefined && data.server.avgPromptSpeed > 0) {
489
- content += `Prompt: ${Math.round(data.server.avgPromptSpeed)} tokens/sec\n`;
490
- }
491
-
492
- if (data.server.avgGenerateSpeed !== undefined && data.server.avgGenerateSpeed > 0) {
493
- content += `Generate: ${Math.round(data.server.avgGenerateSpeed)} tokens/sec\n`;
494
- }
495
-
496
- content += '\n';
497
- }
498
-
499
- // Active Slots Detail
500
- if (data.server.slots.length > 0) {
501
- const activeSlots = data.server.slots.filter(s => s.state === 'processing');
502
-
503
- if (activeSlots.length > 0) {
504
- content += '{bold}Active Slots{/bold}\n';
505
- content += divider + '\n';
506
-
507
- activeSlots.forEach((slot) => {
508
- content += `Slot #${slot.id}: {yellow-fg}PROCESSING{/yellow-fg}`;
509
-
510
- if (slot.timings?.predicted_per_second) {
511
- content += ` - ${Math.round(slot.timings.predicted_per_second)} tok/s`;
512
- }
513
-
514
- if (slot.n_decoded !== undefined) {
515
- content += ` - ${slot.n_decoded}`;
516
- if (slot.n_ctx) {
517
- content += ` / ${slot.n_ctx}`;
518
- }
519
- content += ' tokens';
520
- }
521
-
522
- content += '\n';
523
- });
524
-
525
- content += '\n';
526
- }
527
- }
528
-
529
- // Footer - show [S]top for running servers
530
- content += divider + '\n';
531
- content += `{gray-fg}[S]top [C]onfig [R]emove [H]istory [ESC] Back [Q]uit{/gray-fg}`;
532
-
533
- return content;
534
- }
535
-
536
- // Fetch and update display
537
- async function fetchData() {
538
- try {
539
- // Collect system metrics ONCE for all servers (not per-server)
540
- // This prevents spawning multiple macmon processes
541
- const systemMetricsPromise = systemCollector.collectSystemMetrics();
542
-
543
- // Batch collect process memory and CPU for ALL servers in parallel
544
- // This prevents spawning multiple top processes (5x speedup)
545
- const { getBatchProcessMemory, getBatchProcessCpu } = await import('../utils/process-utils.js');
546
- const pids = servers.filter(s => s.pid).map(s => s.pid!);
547
- const memoryMapPromise = pids.length > 0
548
- ? getBatchProcessMemory(pids)
549
- : Promise.resolve(new Map<number, number | null>());
550
- const cpuMapPromise = pids.length > 0
551
- ? getBatchProcessCpu(pids)
552
- : Promise.resolve(new Map<number, number | null>());
553
-
554
- // Wait for both batches to complete
555
- const [memoryMap, cpuMap] = await Promise.all([memoryMapPromise, cpuMapPromise]);
556
-
557
- // Collect server metrics only for RUNNING servers (skip stopped servers)
558
- const promises = servers
559
- .filter(server => server.status === 'running')
560
- .map(async (server) => {
561
- const aggregator = aggregators.get(server.id)!;
562
- try {
563
- // Use collectServerMetrics instead of collectMonitorData
564
- // to avoid spawning macmon per server
565
- // Pass pre-fetched memory and CPU to avoid spawning top per server
566
- const serverMetrics = await aggregator.collectServerMetrics(
567
- server,
568
- server.pid ? memoryMap.get(server.pid) ?? null : null,
569
- server.pid ? cpuMap.get(server.pid) ?? null : null
570
- );
571
-
572
- // Build MonitorData manually with shared system metrics
573
- const data: MonitorData = {
574
- server: serverMetrics,
575
- system: undefined, // Will be set after system metrics resolve
576
- lastUpdated: new Date(),
577
- updateInterval,
578
- consecutiveFailures: 0,
579
- };
580
-
581
- serverDataMap.set(server.id, {
582
- server,
583
- data,
584
- error: null,
585
- });
586
- } catch (err) {
587
- serverDataMap.set(server.id, {
588
- server,
589
- data: null,
590
- error: err instanceof Error ? err.message : 'Unknown error',
591
- });
592
- }
593
- });
594
-
595
- // Set null data for stopped servers (no metrics collection)
596
- servers
597
- .filter(server => server.status !== 'running')
598
- .forEach(server => {
599
- serverDataMap.set(server.id, {
600
- server,
601
- data: null,
602
- error: null,
603
- });
604
- });
605
-
606
- // Wait for both system metrics and server metrics to complete
607
- const systemMetrics = await systemMetricsPromise;
608
- await Promise.all(promises);
609
-
610
- // Store system metrics for loading state
611
- lastSystemMetrics = systemMetrics;
612
-
613
- // Update all server data with shared system metrics
614
- for (const serverData of serverDataMap.values()) {
615
- if (serverData.data) {
616
- serverData.data.system = systemMetrics;
617
- }
618
- }
619
-
620
- // Append to history for each server (silent failure)
621
- // Only save history for servers that are healthy and not stale
622
- for (const [serverId, serverData] of serverDataMap) {
623
- if (serverData.data && !serverData.data.server.stale && serverData.data.server.healthy) {
624
- const manager = historyManagers.get(serverId);
625
- manager?.appendSnapshot(serverData.data.server, serverData.data.system)
626
- .catch(err => {
627
- // Don't interrupt monitoring on history write failure
628
- console.error(`Failed to save history for ${serverId}:`, err);
629
- });
630
- }
631
- }
632
-
633
- // Render once with complete data
634
- let content = '';
635
- if (viewMode === 'list') {
636
- content = renderListView(systemMetrics);
637
- } else {
638
- content = renderDetailView(systemMetrics);
639
- }
640
-
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
-
649
- screen.render();
650
-
651
- // Clear loading state
652
- hideLoading();
653
-
654
- } catch (err) {
655
- const errorMsg = err instanceof Error ? err.message : 'Unknown error';
656
- contentBox.setContent(
657
- '{bold}{red-fg}Error{/red-fg}{/bold}\n\n' +
658
- `{red-fg}${errorMsg}{/red-fg}\n\n` +
659
- '{gray-fg}Press [R] to retry or [Q] to quit{/gray-fg}'
660
- );
661
- screen.render();
662
-
663
- // Clear loading state on error too
664
- isLoading = false;
665
- }
666
- }
667
-
668
- // Polling
669
- function startPolling() {
670
- if (intervalId) clearInterval(intervalId);
671
- fetchData();
672
- intervalId = setInterval(fetchData, updateInterval);
673
- }
674
-
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
909
- const content = renderListView(lastSystemMetrics);
910
- contentBox.setContent(content);
911
- screen.render();
912
- registerHandlers();
913
- startPolling();
914
- return;
915
- }
916
-
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
1004
- const content = renderListView(lastSystemMetrics);
1005
- contentBox.setContent(content);
1006
- screen.render();
1007
- registerHandlers();
1008
- startPolling();
1009
- return;
1010
- }
1011
-
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;
1026
- }
1027
-
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
- }
1037
-
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);
1083
- }
1084
-
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`;
1089
-
1090
- content += ' {bold}Server Configuration:{/bold}\n';
1091
- content += ' ─'.repeat(30) + '\n';
1092
-
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]);
1099
-
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
- }
1106
-
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
- }
1114
-
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
1449
- const content = renderListView(lastSystemMetrics);
1450
- contentBox.setContent(content);
1451
- screen.render();
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);
1459
- contentBox.setContent(content);
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();
1575
- });
1576
- },
1577
- remove: async () => {
1578
- // Only available from detail view and not in historical view
1579
- if (viewMode !== 'detail' || inHistoricalView) return;
1580
-
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
- };
1813
-
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
- }
1822
-
1823
- startPolling();
1824
-
1825
- // Cleanup
1826
- screen.on('destroy', () => {
1827
- if (intervalId) clearInterval(intervalId);
1828
- // Note: macmon child processes will automatically die when parent exits
1829
- // since they're spawned with detached: false
1830
- });
1831
-
1832
- return controls;
1833
- }