agent-tool-forge 0.3.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 (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +209 -0
  3. package/lib/agent-registry.js +170 -0
  4. package/lib/api-client.js +792 -0
  5. package/lib/api-loader.js +260 -0
  6. package/lib/auth.d.ts +25 -0
  7. package/lib/auth.js +158 -0
  8. package/lib/checks/check-adapter.js +172 -0
  9. package/lib/checks/compose.js +42 -0
  10. package/lib/checks/content-match.js +14 -0
  11. package/lib/checks/cost-budget.js +11 -0
  12. package/lib/checks/index.js +18 -0
  13. package/lib/checks/json-valid.js +15 -0
  14. package/lib/checks/latency.js +11 -0
  15. package/lib/checks/length-bounds.js +17 -0
  16. package/lib/checks/negative-match.js +14 -0
  17. package/lib/checks/no-hallucinated-numbers.js +63 -0
  18. package/lib/checks/non-empty.js +34 -0
  19. package/lib/checks/regex-match.js +12 -0
  20. package/lib/checks/run-checks.js +84 -0
  21. package/lib/checks/schema-match.js +26 -0
  22. package/lib/checks/tool-call-count.js +16 -0
  23. package/lib/checks/tool-selection.js +34 -0
  24. package/lib/checks/types.js +45 -0
  25. package/lib/comparison/compare.js +86 -0
  26. package/lib/comparison/format.js +104 -0
  27. package/lib/comparison/index.js +6 -0
  28. package/lib/comparison/statistics.js +59 -0
  29. package/lib/comparison/types.js +41 -0
  30. package/lib/config-schema.js +200 -0
  31. package/lib/config.d.ts +66 -0
  32. package/lib/conversation-store.d.ts +77 -0
  33. package/lib/conversation-store.js +443 -0
  34. package/lib/db.d.ts +6 -0
  35. package/lib/db.js +1112 -0
  36. package/lib/dep-check.js +99 -0
  37. package/lib/drift-background.js +61 -0
  38. package/lib/drift-monitor.js +187 -0
  39. package/lib/eval-runner.js +566 -0
  40. package/lib/fixtures/fixture-store.js +161 -0
  41. package/lib/fixtures/index.js +11 -0
  42. package/lib/forge-engine.js +982 -0
  43. package/lib/forge-eval-generator.js +417 -0
  44. package/lib/forge-file-writer.js +386 -0
  45. package/lib/forge-service-client.js +190 -0
  46. package/lib/forge-service.d.ts +4 -0
  47. package/lib/forge-service.js +655 -0
  48. package/lib/forge-verifier-generator.js +271 -0
  49. package/lib/handlers/admin.js +151 -0
  50. package/lib/handlers/agents.js +229 -0
  51. package/lib/handlers/chat-resume.js +334 -0
  52. package/lib/handlers/chat-sync.js +320 -0
  53. package/lib/handlers/chat.js +320 -0
  54. package/lib/handlers/conversations.js +92 -0
  55. package/lib/handlers/preferences.js +88 -0
  56. package/lib/handlers/tools-list.js +58 -0
  57. package/lib/hitl-engine.d.ts +60 -0
  58. package/lib/hitl-engine.js +261 -0
  59. package/lib/http-utils.js +92 -0
  60. package/lib/index.d.ts +20 -0
  61. package/lib/index.js +141 -0
  62. package/lib/init.js +636 -0
  63. package/lib/manual-entry.js +59 -0
  64. package/lib/mcp-server.js +252 -0
  65. package/lib/output-groups.js +54 -0
  66. package/lib/postgres-store.d.ts +31 -0
  67. package/lib/postgres-store.js +465 -0
  68. package/lib/preference-store.d.ts +47 -0
  69. package/lib/preference-store.js +79 -0
  70. package/lib/prompt-store.d.ts +42 -0
  71. package/lib/prompt-store.js +60 -0
  72. package/lib/rate-limiter.d.ts +30 -0
  73. package/lib/rate-limiter.js +104 -0
  74. package/lib/react-engine.d.ts +110 -0
  75. package/lib/react-engine.js +337 -0
  76. package/lib/runner/cli.js +156 -0
  77. package/lib/runner/cost-estimator.js +71 -0
  78. package/lib/runner/gate.js +46 -0
  79. package/lib/runner/index.js +165 -0
  80. package/lib/sidecar.d.ts +83 -0
  81. package/lib/sidecar.js +161 -0
  82. package/lib/sse.d.ts +15 -0
  83. package/lib/sse.js +30 -0
  84. package/lib/tools-scanner.js +91 -0
  85. package/lib/tui.js +253 -0
  86. package/lib/verifier-report.js +78 -0
  87. package/lib/verifier-runner.js +338 -0
  88. package/lib/verifier-scanner.js +70 -0
  89. package/lib/verifier-worker-pool.js +196 -0
  90. package/lib/views/chat.js +340 -0
  91. package/lib/views/endpoints.js +203 -0
  92. package/lib/views/eval-run.js +206 -0
  93. package/lib/views/forge-agent.js +538 -0
  94. package/lib/views/forge.js +410 -0
  95. package/lib/views/main-menu.js +275 -0
  96. package/lib/views/mediation.js +381 -0
  97. package/lib/views/model-compare.js +430 -0
  98. package/lib/views/model-comparison.js +333 -0
  99. package/lib/views/onboarding.js +470 -0
  100. package/lib/views/performance.js +237 -0
  101. package/lib/views/run-evals.js +205 -0
  102. package/lib/views/settings.js +829 -0
  103. package/lib/views/tools-evals.js +514 -0
  104. package/lib/views/verifier-coverage.js +617 -0
  105. package/lib/workers/verifier-worker.js +52 -0
  106. package/package.json +123 -0
  107. package/widget/forge-chat.js +789 -0
@@ -0,0 +1,829 @@
1
+ /**
2
+ * Settings View — Model selector, system prompt editor, forge skill prompt, .env manager.
3
+ */
4
+
5
+ import blessed from 'blessed';
6
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
7
+ import { resolve, dirname } from 'path';
8
+ import { fileURLToPath } from 'url';
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const PROJECT_ROOT = resolve(__dirname, '../..');
12
+ const CONFIG_FILE = resolve(PROJECT_ROOT, 'forge.config.json');
13
+ const ENV_FILE = resolve(PROJECT_ROOT, '.env');
14
+ const SKILL_FILE = resolve(PROJECT_ROOT, 'skills/forge-tool/SKILL.md');
15
+
16
+ const CLAUDE_MODELS = [
17
+ 'claude-opus-4-6',
18
+ 'claude-sonnet-4-6',
19
+ 'claude-haiku-4-5-20251001'
20
+ ];
21
+
22
+ const OPENAI_MODELS = [
23
+ 'gpt-4o',
24
+ 'gpt-4o-mini',
25
+ 'o1',
26
+ 'o3-mini'
27
+ ];
28
+
29
+ const GOOGLE_MODELS = [
30
+ 'gemini-2.0-flash',
31
+ 'gemini-2.5-pro-exp'
32
+ ];
33
+
34
+ const DEEPSEEK_MODELS = [
35
+ 'deepseek-chat',
36
+ 'deepseek-reasoner'
37
+ ];
38
+
39
+ /**
40
+ * Build the model list based on which API keys are present in .env.
41
+ * Always includes Claude models (forge-tool always uses Claude).
42
+ * Adds provider sections when matching keys are detected.
43
+ * Always ends with a "Custom..." entry.
44
+ */
45
+ function buildModelList(envMap) {
46
+ const sections = [
47
+ { header: '── Anthropic (forge-tool) ──────────────', models: CLAUDE_MODELS }
48
+ ];
49
+
50
+ const hasOpenAI = Object.keys(envMap).some((k) => /OPENAI/i.test(k));
51
+ const hasGoogle = Object.keys(envMap).some((k) => /GOOGLE|GEMINI/i.test(k));
52
+ const hasDeepSeek = Object.keys(envMap).some((k) => /DEEPSEEK/i.test(k));
53
+
54
+ if (hasOpenAI) sections.push({ header: '── OpenAI ──────────────────────────────', models: OPENAI_MODELS });
55
+ if (hasGoogle) sections.push({ header: '── Google ──────────────────────────────', models: GOOGLE_MODELS });
56
+ if (hasDeepSeek) sections.push({ header: '── DeepSeek ────────────────────────────', models: DEEPSEEK_MODELS });
57
+
58
+ // Flat list with divider labels (not selectable) interleaved
59
+ const items = []; // display strings
60
+ const values = []; // corresponding model strings (null for divider rows)
61
+
62
+ for (const section of sections) {
63
+ items.push(`{#555555-fg}${section.header}{/#555555-fg}`);
64
+ values.push(null); // divider — not selectable
65
+ for (const m of section.models) {
66
+ items.push(m);
67
+ values.push(m);
68
+ }
69
+ }
70
+
71
+ items.push('{#555555-fg}────────────────────────────────────────{/#555555-fg}');
72
+ values.push(null);
73
+ items.push(' Custom model...');
74
+ values.push('__custom__');
75
+
76
+ return { items, values };
77
+ }
78
+
79
+ function loadConfig() {
80
+ try { return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')); } catch (_) { return {}; }
81
+ }
82
+
83
+ function saveConfig(cfg) {
84
+ writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), 'utf-8');
85
+ }
86
+
87
+ function loadEnv() {
88
+ if (!existsSync(ENV_FILE)) return {};
89
+ const lines = readFileSync(ENV_FILE, 'utf-8').split('\n');
90
+ const out = {};
91
+ for (const line of lines) {
92
+ const trimmed = line.trim();
93
+ if (!trimmed || trimmed.startsWith('#')) continue;
94
+ const eqIdx = trimmed.indexOf('=');
95
+ if (eqIdx === -1) continue;
96
+ const key = trimmed.slice(0, eqIdx).trim();
97
+ const val = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, '');
98
+ out[key] = val;
99
+ }
100
+ return out;
101
+ }
102
+
103
+ function saveEnv(envMap) {
104
+ const lines = Object.entries(envMap).map(([k, v]) => `${k}=${v}`);
105
+ writeFileSync(ENV_FILE, lines.join('\n') + '\n', 'utf-8');
106
+ }
107
+
108
+ function maskValue(val) {
109
+ if (!val || val.length <= 4) return '****';
110
+ return val.slice(0, 3) + '****';
111
+ }
112
+
113
+ export function createView({ screen, content, config, navigate, setFooter, screenKey, openPopup, closePopup, startService }) {
114
+ const list = blessed.list({
115
+ top: 0,
116
+ left: 0,
117
+ width: '100%',
118
+ height: '100%',
119
+ tags: true,
120
+ keys: true,
121
+ vi: true,
122
+ mouse: true,
123
+ style: {
124
+ selected: { bg: 'blue', fg: 'white', bold: true },
125
+ item: { fg: 'white' }
126
+ },
127
+ padding: { top: 1, left: 2 }
128
+ });
129
+
130
+ setFooter(' {bold}↑↓{/bold} navigate {bold}Enter{/bold} select section {bold}r{/bold} refresh {bold}b{/bold} back');
131
+
132
+ function afterPopupClose() {
133
+ list.refresh();
134
+ list.focus();
135
+ screen.render();
136
+ }
137
+
138
+ list.on('select', (item, index) => {
139
+ switch (index) {
140
+ case 0: showModelSelector(screen, config, openPopup, closePopup, 'generation', afterPopupClose); break;
141
+ case 1: showModelSelector(screen, config, openPopup, closePopup, 'eval', afterPopupClose); break;
142
+ case 2: showModelSelector(screen, config, openPopup, closePopup, 'verifier', afterPopupClose); break;
143
+ case 3: showModelSelector(screen, config, openPopup, closePopup, 'secondary', afterPopupClose); break;
144
+ case 4: showPromptEditor(screen, config, 'agent', openPopup, closePopup, afterPopupClose); break;
145
+ case 5: showPromptEditor(screen, config, 'skill', openPopup, closePopup, afterPopupClose); break;
146
+ case 6: showApiSourceEditor(screen, config, openPopup, closePopup, afterPopupClose); break;
147
+ case 7: showEnvManager(screen, config, openPopup, closePopup, afterPopupClose); break;
148
+ case 8: showModelMatrixEditor(screen, config, openPopup, closePopup, afterPopupClose); break;
149
+ }
150
+ });
151
+
152
+ let lastSettingsJson = '';
153
+
154
+ list.refresh = () => {
155
+ const cfg = loadConfig();
156
+ const envMap = loadEnv();
157
+
158
+ // Quick change detection — skip render if nothing changed
159
+ const snapshot = JSON.stringify({ cfg, envKeys: Object.keys(envMap) });
160
+ if (snapshot === lastSettingsJson) return;
161
+ lastSettingsJson = snapshot;
162
+ const envCount = Object.keys(envMap).length;
163
+
164
+ const systemPromptStatus = cfg.systemPromptPath
165
+ ? `{green-fg}${cfg.systemPromptPath}{/green-fg}`
166
+ : '{#888888-fg}not configured{/#888888-fg}';
167
+
168
+ const skillStatus = existsSync(SKILL_FILE)
169
+ ? '{green-fg}skills/forge-tool/SKILL.md{/green-fg}'
170
+ : '{red-fg}not found{/red-fg}';
171
+
172
+ const models = cfg.models || {};
173
+ const generationModelDisplay = (models.generation || cfg.model)
174
+ ? `{cyan-fg}${models.generation || cfg.model}{/cyan-fg}`
175
+ : '{yellow-fg}not set (default: claude-sonnet-4-6){/yellow-fg}';
176
+ const evalModelDisplay = models.eval
177
+ ? `{cyan-fg}${models.eval}{/cyan-fg}`
178
+ : '{#888888-fg}default (claude-sonnet-4-6){/#888888-fg}';
179
+ const verifierModelDisplay = models.verifier
180
+ ? `{cyan-fg}${models.verifier}{/cyan-fg}`
181
+ : '{#888888-fg}default (claude-sonnet-4-6){/#888888-fg}';
182
+ const secondaryModelDisplay = models.secondary
183
+ ? `{cyan-fg}${models.secondary}{/cyan-fg}`
184
+ : '{#888888-fg}not set (model comparison disabled){/#888888-fg}';
185
+
186
+ // Detect which API providers have keys
187
+ const providers = [];
188
+ if (Object.keys(envMap).some((k) => /ANTHROPIC/i.test(k))) providers.push('{green-fg}Anthropic{/green-fg}');
189
+ if (Object.keys(envMap).some((k) => /OPENAI/i.test(k))) providers.push('{green-fg}OpenAI{/green-fg}');
190
+ if (Object.keys(envMap).some((k) => /GOOGLE|GEMINI/i.test(k))) providers.push('{green-fg}Google{/green-fg}');
191
+ if (Object.keys(envMap).some((k) => /DEEPSEEK/i.test(k))) providers.push('{green-fg}DeepSeek{/green-fg}');
192
+ const keysSummary = envCount === 0
193
+ ? '{#888888-fg}no keys{/#888888-fg}'
194
+ : `${envCount} key(s)${providers.length ? ' ' + providers.join(' ') : ''}`;
195
+
196
+ // API source status
197
+ const apiSource = cfg.api?.manifestPath || cfg.api?.discovery?.url || cfg.api?.discovery?.file;
198
+ const apiSourceStatus = apiSource
199
+ ? `{green-fg}${apiSource}{/green-fg}`
200
+ : '{yellow-fg}not configured{/yellow-fg}';
201
+
202
+ const matrix = cfg.modelMatrix || [];
203
+ const matrixDisplay = matrix.length > 0
204
+ ? `{cyan-fg}${matrix.length} model(s): ${matrix.join(', ')}{/cyan-fg}`
205
+ : '{#888888-fg}not configured (add models to compare){/#888888-fg}';
206
+
207
+ const row = (num, label, val) =>
208
+ ` {bold}${num}.{/bold} {white-fg}${label.padEnd(22)}{/white-fg}${val}`;
209
+
210
+ list.setItems([
211
+ row('1', 'Generation Model', generationModelDisplay),
212
+ row('2', 'Eval Model', evalModelDisplay),
213
+ row('3', 'Verifier Model', verifierModelDisplay),
214
+ row('4', 'Secondary Model', secondaryModelDisplay),
215
+ row('5', 'Agent System Prompt', systemPromptStatus),
216
+ row('6', 'Forge Skill Prompt', skillStatus),
217
+ row('7', 'API Source', apiSourceStatus),
218
+ row('8', 'API Keys / Secrets', keysSummary),
219
+ row('9', 'Model Matrix', matrixDisplay)
220
+ ]);
221
+ screen.render();
222
+ };
223
+
224
+ list.refresh();
225
+ list.focus();
226
+ return list;
227
+ }
228
+
229
+ function showModelSelector(screen, config, openPopup, closePopup, role = 'generation', onClose) {
230
+ const cfg = loadConfig();
231
+ const current = (cfg.models?.[role] || (role === 'generation' ? cfg.model : null)) || '';
232
+ const envMap = loadEnv();
233
+ const { items, values } = buildModelList(envMap);
234
+
235
+ // Mark current selection
236
+ const markedItems = items.map((label, i) => {
237
+ const val = values[i];
238
+ if (!val || val === '__custom__') return ` ${label}`;
239
+ return val === current
240
+ ? ` {green-fg}● ${val}{/green-fg}`
241
+ : ` ${val}`;
242
+ });
243
+
244
+ const noteHeight = 2;
245
+ const listHeight = Math.min(items.length + noteHeight + 4, 24);
246
+
247
+ const roleLabel = role.charAt(0).toUpperCase() + role.slice(1);
248
+
249
+ const popup = blessed.box({
250
+ parent: screen,
251
+ border: 'line',
252
+ height: listHeight,
253
+ width: 54,
254
+ top: 'center',
255
+ left: 'center',
256
+ label: ` Select ${roleLabel} Model `,
257
+ tags: true,
258
+ style: { border: { fg: 'blue' } }
259
+ });
260
+
261
+ // Note about what this field controls
262
+ blessed.box({
263
+ parent: popup,
264
+ top: 0,
265
+ left: 1,
266
+ width: '100%-2',
267
+ height: 1,
268
+ tags: true,
269
+ content: `{#888888-fg}Controls which model is used for ${role} tasks{/#888888-fg}`
270
+ });
271
+
272
+ const list = blessed.list({
273
+ parent: popup,
274
+ top: noteHeight,
275
+ left: 0,
276
+ width: '100%',
277
+ height: listHeight - noteHeight - 2,
278
+ tags: true,
279
+ keys: true,
280
+ vi: true,
281
+ style: { selected: { bg: '#1a3a5c', fg: 'white' } },
282
+ items: markedItems
283
+ });
284
+
285
+ function applySelection(idx) {
286
+ const val = values[idx];
287
+ if (val === null) return; // divider row — skip
288
+ if (val === '__custom__') {
289
+ closePopup?.(); // close the model list popup
290
+ popup.destroy();
291
+ screen.render();
292
+ openPopup?.(); // open for the new text prompt
293
+ const prompt = blessed.prompt({
294
+ parent: screen,
295
+ border: 'line',
296
+ height: 'shrink',
297
+ width: 'half',
298
+ top: 'center',
299
+ left: 'center',
300
+ label: ' Custom Model Name ',
301
+ tags: true,
302
+ keys: true
303
+ });
304
+ prompt.input('Enter model ID (e.g. gpt-4o, gemini-2.0-flash):', current, (err, val) => {
305
+ closePopup?.(); // close the text prompt
306
+ if (!err && val && val.trim()) {
307
+ cfg.models = cfg.models || {};
308
+ cfg.models[role] = val.trim();
309
+ try { saveConfig(cfg); } catch (_) { onClose?.(); return; }
310
+ if (!config.models) config.models = {};
311
+ config.models[role] = val.trim();
312
+ if (role === 'generation') config.model = val.trim();
313
+ }
314
+ onClose?.();
315
+ });
316
+ return;
317
+ }
318
+ cfg.models = cfg.models || {};
319
+ cfg.models[role] = val;
320
+ try { saveConfig(cfg); } catch (_) { onClose?.(); return; }
321
+ if (!config.models) config.models = {};
322
+ config.models[role] = val;
323
+ // Backwards compat: also update config.model for generation role
324
+ if (role === 'generation') config.model = val;
325
+ closePopup?.();
326
+ popup.destroy();
327
+ onClose?.();
328
+ }
329
+
330
+ openPopup?.();
331
+ list.on('select', (item, idx) => applySelection(idx));
332
+ list.key(['escape', 'b'], () => { closePopup?.(); popup.destroy(); onClose?.(); });
333
+ list.focus();
334
+ screen.render();
335
+ }
336
+
337
+ function showPromptEditor(screen, config, type, openPopup, closePopup, onClose) {
338
+ const cfg = loadConfig();
339
+ let filePath, label;
340
+
341
+ if (type === 'agent') {
342
+ filePath = cfg.systemPromptPath ? resolve(PROJECT_ROOT, cfg.systemPromptPath) : null;
343
+ label = ' Agent System Prompt ';
344
+ } else {
345
+ filePath = SKILL_FILE;
346
+ label = ' Forge Skill Prompt ';
347
+ }
348
+
349
+ let content = '';
350
+ if (filePath && existsSync(filePath)) {
351
+ try { content = readFileSync(filePath, 'utf-8'); } catch (_) { content = '(could not read file)'; }
352
+ } else if (type === 'agent' && !cfg.systemPromptPath) {
353
+ content = '(systemPromptPath not set in forge.config.json)';
354
+ } else {
355
+ content = '(file not found: ' + filePath + ')';
356
+ }
357
+
358
+ const box = blessed.scrollablebox({
359
+ parent: screen,
360
+ border: 'line',
361
+ top: 1,
362
+ left: 2,
363
+ right: 2,
364
+ bottom: 3,
365
+ label,
366
+ tags: false,
367
+ scrollable: true,
368
+ alwaysScroll: true,
369
+ keys: true,
370
+ content,
371
+ scrollbar: { ch: '│', style: { fg: 'white' } }
372
+ });
373
+
374
+ const helpBar = blessed.box({
375
+ parent: screen,
376
+ bottom: 1,
377
+ left: 0,
378
+ width: '100%',
379
+ height: 1,
380
+ tags: true,
381
+ content: ' {bold}e{/bold} edit in $EDITOR {bold}p{/bold} change path {bold}Escape{/bold} close',
382
+ style: { bg: 'black', fg: 'white' }
383
+ });
384
+
385
+ box.key('e', async () => {
386
+ if (!filePath) return;
387
+ const editor = process.env.EDITOR || 'vi';
388
+ const { spawn } = await import('child_process');
389
+ screen.program.disableMouse();
390
+ screen.program.normalBuffer();
391
+ const child = spawn(editor, [filePath], { stdio: 'inherit' });
392
+ child.on('exit', () => {
393
+ screen.program.alternateBuffer();
394
+ screen.program.enableMouse();
395
+ screen.render();
396
+ });
397
+ });
398
+
399
+ openPopup?.();
400
+
401
+ box.key('p', () => {
402
+ if (type !== 'agent') return; // Only agent path is changeable
403
+ const prompt = blessed.prompt({
404
+ parent: screen,
405
+ border: 'line',
406
+ height: 'shrink',
407
+ width: 'half',
408
+ top: 'center',
409
+ left: 'center',
410
+ label: ' Set System Prompt Path ',
411
+ tags: true,
412
+ keys: true
413
+ });
414
+ prompt.input('Enter path to system prompt file:', cfg.systemPromptPath || '', (err, val) => {
415
+ if (!err && val) {
416
+ cfg.systemPromptPath = val;
417
+ try { saveConfig(cfg); } catch (_) { box.focus(); screen.render(); return; }
418
+ config.systemPromptPath = val;
419
+ // Reload the file content in the box
420
+ const newPath = resolve(PROJECT_ROOT, val);
421
+ let newContent = '';
422
+ if (existsSync(newPath)) {
423
+ try { newContent = readFileSync(newPath, 'utf-8'); } catch (_) { newContent = '(could not read file)'; }
424
+ } else {
425
+ newContent = '(file not found: ' + val + ')';
426
+ }
427
+ filePath = newPath;
428
+ box.setContent(newContent);
429
+ }
430
+ box.focus();
431
+ screen.render();
432
+ });
433
+ });
434
+
435
+ box.key(['escape', 'b', 'q'], () => {
436
+ closePopup?.();
437
+ box.destroy();
438
+ helpBar.destroy();
439
+ onClose?.();
440
+ });
441
+
442
+ box.focus();
443
+ screen.render();
444
+ }
445
+
446
+ function showApiSourceEditor(screen, config, openPopup, closePopup, onClose) {
447
+ const cfg = loadConfig();
448
+ const api = cfg.api || {};
449
+
450
+ // Determine current values
451
+ const currentManifest = api.manifestPath || '';
452
+ const currentDiscoveryUrl = api.discovery?.url || '';
453
+ const currentDiscoveryFile = api.discovery?.file || '';
454
+
455
+ const box = blessed.form({
456
+ parent: screen,
457
+ border: 'line',
458
+ top: 2,
459
+ left: 4,
460
+ right: 4,
461
+ height: 14,
462
+ label: ' Configure API Source ',
463
+ tags: true,
464
+ keys: true
465
+ });
466
+
467
+ blessed.text({ parent: box, top: 0, left: 1, tags: true,
468
+ content: '{#888888-fg}Set one or more sources. Manifest overrides OpenAPI for the same endpoint.{/#888888-fg}' });
469
+
470
+ blessed.text({ parent: box, top: 2, left: 1, content: 'Manifest path: ' });
471
+ const manifestInput = blessed.textbox({
472
+ parent: box, top: 2, left: 18, right: 2, height: 1,
473
+ inputOnFocus: true, style: { fg: 'white', bg: '#1a3a5c' },
474
+ value: currentManifest
475
+ });
476
+
477
+ blessed.text({ parent: box, top: 4, left: 1, content: 'OpenAPI URL: ' });
478
+ const urlInput = blessed.textbox({
479
+ parent: box, top: 4, left: 18, right: 2, height: 1,
480
+ inputOnFocus: true, style: { fg: 'white', bg: '#1a3a5c' },
481
+ value: currentDiscoveryUrl
482
+ });
483
+
484
+ blessed.text({ parent: box, top: 6, left: 1, content: 'OpenAPI file: ' });
485
+ const fileInput = blessed.textbox({
486
+ parent: box, top: 6, left: 18, right: 2, height: 1,
487
+ inputOnFocus: true, style: { fg: 'white', bg: '#1a3a5c' },
488
+ value: currentDiscoveryFile
489
+ });
490
+
491
+ const statusBar = blessed.box({ parent: box, bottom: 2, left: 1, right: 1, height: 1, tags: true });
492
+
493
+ const saveBtn = blessed.button({
494
+ parent: box, bottom: 0, left: 1, width: 10, height: 1, content: ' Save ',
495
+ style: { bg: 'green', fg: 'white', focus: { bg: 'blue' } }, keys: true, mouse: true
496
+ });
497
+ const cancelBtn = blessed.button({
498
+ parent: box, bottom: 0, left: 13, width: 10, height: 1, content: ' Cancel ',
499
+ style: { bg: 'red', fg: 'white', focus: { bg: 'blue' } }, keys: true, mouse: true
500
+ });
501
+
502
+ function doClose() {
503
+ closePopup?.();
504
+ box.destroy();
505
+ onClose?.();
506
+ }
507
+
508
+ saveBtn.on('press', () => {
509
+ const manifest = manifestInput.getValue().trim();
510
+ const url = urlInput.getValue().trim();
511
+ const file = fileInput.getValue().trim();
512
+
513
+ cfg.api = cfg.api || {};
514
+ if (manifest) {
515
+ cfg.api.manifestPath = manifest;
516
+ } else {
517
+ delete cfg.api.manifestPath;
518
+ }
519
+ if (url || file) {
520
+ cfg.api.discovery = { type: 'openapi' };
521
+ if (url) cfg.api.discovery.url = url;
522
+ if (file) cfg.api.discovery.file = file;
523
+ } else {
524
+ delete cfg.api.discovery;
525
+ }
526
+ // Sync runtime config
527
+ config.api = cfg.api;
528
+
529
+ try {
530
+ saveConfig(cfg);
531
+ statusBar.setContent('{green-fg}Saved!{/green-fg}');
532
+ screen.render();
533
+ setTimeout(doClose, 600);
534
+ } catch (err) {
535
+ statusBar.setContent(`{red-fg}Save failed: ${err.message}{/red-fg}`);
536
+ screen.render();
537
+ }
538
+ });
539
+
540
+ cancelBtn.on('press', doClose);
541
+ box.key(['escape', 'b'], doClose);
542
+
543
+ openPopup?.();
544
+ manifestInput.focus();
545
+ screen.render();
546
+ }
547
+
548
+ function showModelMatrixEditor(screen, config, openPopup, closePopup, onClose) {
549
+ const cfg = loadConfig();
550
+ let matrix = [...(cfg.modelMatrix || [])];
551
+
552
+ const container = blessed.box({
553
+ parent: screen,
554
+ border: 'line',
555
+ top: 2,
556
+ left: 4,
557
+ right: 4,
558
+ height: 18,
559
+ label: ' Model Comparison Matrix ',
560
+ tags: true
561
+ });
562
+
563
+ blessed.text({
564
+ parent: container, top: 0, left: 1, tags: true,
565
+ content: '{#888888-fg}Models run in parallel when "Compare models" is triggered from Tools & Evals.{/#888888-fg}'
566
+ });
567
+
568
+ const modelList = blessed.list({
569
+ parent: container,
570
+ top: 2, left: 1, right: 1, height: 10,
571
+ tags: true, keys: true, vi: true,
572
+ style: {
573
+ selected: { bg: '#1a3a5c', fg: 'white' },
574
+ item: { fg: 'white' }
575
+ }
576
+ });
577
+
578
+ const statusBar = blessed.box({
579
+ parent: container, bottom: 2, left: 1, right: 1, height: 1, tags: true
580
+ });
581
+
582
+ const helpBar = blessed.box({
583
+ parent: container, bottom: 0, left: 1, right: 1, height: 1, tags: true,
584
+ content: ' {bold}n{/bold} add {bold}d{/bold} delete {bold}s{/bold} save {bold}Escape{/bold} cancel'
585
+ });
586
+
587
+ function renderList() {
588
+ if (matrix.length === 0) {
589
+ modelList.setItems(['{#888888-fg}(no models — press n to add){/#888888-fg}']);
590
+ } else {
591
+ modelList.setItems(matrix.map((m) => ` ${m}`));
592
+ }
593
+ screen.render();
594
+ }
595
+
596
+ renderList();
597
+
598
+ modelList.key('n', () => {
599
+ const envMap = loadEnv();
600
+ const { items, values } = buildModelList(envMap);
601
+ const popup = blessed.list({
602
+ parent: screen,
603
+ border: 'line',
604
+ height: Math.min(items.length + 4, 20),
605
+ width: 54,
606
+ top: 'center', left: 'center',
607
+ label: ' Add Model to Matrix ',
608
+ tags: true, keys: true, vi: true,
609
+ style: { border: { fg: 'blue' }, selected: { bg: '#1a3a5c', fg: 'white' } },
610
+ items: items.map((it, i) => values[i] === null ? ` ${it}` : ` ${it}`)
611
+ });
612
+ popup.on('select', (item, idx) => {
613
+ const val = values[idx];
614
+ if (!val || val === null) return;
615
+ let modelName = val;
616
+ if (val === '__custom__') {
617
+ popup.destroy();
618
+ const prompt = blessed.prompt({
619
+ parent: screen, border: 'line', height: 'shrink', width: 'half',
620
+ top: 'center', left: 'center', label: ' Custom Model ', tags: true, keys: true
621
+ });
622
+ prompt.input('Model ID:', '', (err, v) => {
623
+ if (!err && v?.trim() && !matrix.includes(v.trim())) {
624
+ matrix.push(v.trim());
625
+ renderList();
626
+ }
627
+ screen.render();
628
+ });
629
+ return;
630
+ }
631
+ popup.destroy();
632
+ if (!matrix.includes(modelName)) {
633
+ matrix.push(modelName);
634
+ renderList();
635
+ }
636
+ modelList.focus();
637
+ screen.render();
638
+ });
639
+ popup.key(['escape', 'q'], () => { popup.destroy(); modelList.focus(); screen.render(); });
640
+ popup.focus();
641
+ screen.render();
642
+ });
643
+
644
+ modelList.key('d', () => {
645
+ const idx = modelList.selected;
646
+ if (matrix.length === 0 || idx >= matrix.length) return;
647
+ matrix.splice(idx, 1);
648
+ renderList();
649
+ });
650
+
651
+ modelList.key('s', () => {
652
+ cfg.modelMatrix = matrix;
653
+ saveConfig(cfg);
654
+ config.modelMatrix = matrix;
655
+ statusBar.setContent('{green-fg}Saved!{/green-fg}');
656
+ screen.render();
657
+ setTimeout(() => {
658
+ closePopup?.();
659
+ container.destroy();
660
+ onClose?.();
661
+ }, 600);
662
+ });
663
+
664
+ openPopup?.();
665
+ container.key(['escape', 'b'], () => { closePopup?.(); container.destroy(); onClose?.(); });
666
+ modelList.focus();
667
+ screen.render();
668
+ }
669
+
670
+ function showEnvManager(screen, config, openPopup, closePopup, onClose) {
671
+ let envMap = loadEnv();
672
+ let unsaved = false;
673
+
674
+ const container = blessed.box({
675
+ parent: screen,
676
+ border: 'line',
677
+ top: 1,
678
+ left: 2,
679
+ right: 2,
680
+ bottom: 3,
681
+ label: ' API Keys / Secrets ',
682
+ tags: true
683
+ });
684
+
685
+ const table = blessed.listtable({
686
+ parent: container,
687
+ top: 0,
688
+ left: 0,
689
+ width: '100%',
690
+ height: '100%-2',
691
+ tags: true,
692
+ keys: true,
693
+ vi: true,
694
+ style: {
695
+ header: { bold: true, fg: 'cyan' },
696
+ cell: { selected: { bg: 'blue', fg: 'white' } }
697
+ },
698
+ pad: 1
699
+ });
700
+
701
+ const statusBar = blessed.box({
702
+ parent: container,
703
+ bottom: 0,
704
+ left: 0,
705
+ width: '100%',
706
+ height: 1,
707
+ tags: true
708
+ });
709
+
710
+ const helpBar = blessed.box({
711
+ parent: screen,
712
+ bottom: 1,
713
+ left: 0,
714
+ width: '100%',
715
+ height: 1,
716
+ tags: true,
717
+ content: ' {bold}Enter{/bold} edit {bold}n{/bold} new key {bold}d{/bold} delete {bold}s{/bold} save {bold}Escape{/bold} close',
718
+ style: { bg: 'black', fg: 'white' }
719
+ });
720
+
721
+ function renderTable() {
722
+ const entries = Object.entries(envMap);
723
+ if (entries.length === 0) {
724
+ table.setData([['Key', 'Value'], ['(no keys)', '']]);
725
+ } else {
726
+ table.setData([['Key', 'Value'], ...entries.map(([k, v]) => [k, maskValue(v)])]);
727
+ }
728
+ statusBar.setContent(unsaved ? ' {yellow-fg}⚠ Unsaved changes — press s to save{/yellow-fg}' : ' {green-fg}Saved{/green-fg}');
729
+ screen.render();
730
+ }
731
+
732
+ renderTable();
733
+
734
+ table.key('enter', () => {
735
+ const idx = table.selected;
736
+ if (idx < 1) return;
737
+ const entries = Object.entries(envMap);
738
+ if (!entries[idx - 1]) return;
739
+ const [key, currentVal] = entries[idx - 1];
740
+
741
+ const prompt = blessed.prompt({
742
+ parent: screen,
743
+ border: 'line',
744
+ height: 'shrink',
745
+ width: 'half',
746
+ top: 'center',
747
+ left: 'center',
748
+ label: ` Edit ${key} `,
749
+ tags: true,
750
+ keys: true
751
+ });
752
+ prompt.input(`New value for ${key}:`, currentVal, (err, val) => {
753
+ if (!err && val !== null && val !== undefined) {
754
+ envMap[key] = val;
755
+ unsaved = true;
756
+ renderTable();
757
+ }
758
+ });
759
+ });
760
+
761
+ table.key('n', () => {
762
+ const prompt = blessed.prompt({
763
+ parent: screen,
764
+ border: 'line',
765
+ height: 'shrink',
766
+ width: 'half',
767
+ top: 'center',
768
+ left: 'center',
769
+ label: ' New Key ',
770
+ tags: true,
771
+ keys: true
772
+ });
773
+ prompt.input('Key name:', '', (err, key) => {
774
+ if (!err && key) {
775
+ const prompt2 = blessed.prompt({
776
+ parent: screen,
777
+ border: 'line',
778
+ height: 'shrink',
779
+ width: 'half',
780
+ top: 'center',
781
+ left: 'center',
782
+ label: ` Value for ${key} `,
783
+ tags: true,
784
+ keys: true
785
+ });
786
+ prompt2.input('Value:', '', (err2, val) => {
787
+ if (!err2) {
788
+ envMap[key] = val || '';
789
+ unsaved = true;
790
+ renderTable();
791
+ }
792
+ });
793
+ }
794
+ });
795
+ });
796
+
797
+ table.key('d', () => {
798
+ const idx = table.selected;
799
+ if (idx < 1) return;
800
+ const entries = Object.entries(envMap);
801
+ if (!entries[idx - 1]) return;
802
+ const [key] = entries[idx - 1];
803
+ delete envMap[key];
804
+ unsaved = true;
805
+ renderTable();
806
+ });
807
+
808
+ table.key('s', () => {
809
+ saveEnv(envMap);
810
+ unsaved = false;
811
+ renderTable();
812
+ });
813
+
814
+ openPopup?.();
815
+
816
+ function closeEnvManager() {
817
+ closePopup?.();
818
+ container.destroy();
819
+ helpBar.destroy();
820
+ onClose?.();
821
+ }
822
+
823
+ container.key(['escape', 'b'], closeEnvManager);
824
+ table.key(['escape', 'b'], closeEnvManager);
825
+
826
+ table.focus();
827
+ screen.render();
828
+ }
829
+