free-coding-models 0.1.83 → 0.1.84

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.
@@ -0,0 +1,1005 @@
1
+ /**
2
+ * @file key-handler.js
3
+ * @description Factory for the main TUI keypress handler.
4
+ *
5
+ * @details
6
+ * This module encapsulates the full onKeyPress switch used by the TUI,
7
+ * including settings navigation, overlays, profile management, and
8
+ * OpenCode/OpenClaw launch actions. It also keeps the live key bindings
9
+ * aligned with the highlighted letters shown in the table headers.
10
+ *
11
+ * → Functions:
12
+ * - `createKeyHandler` — returns the async keypress handler
13
+ *
14
+ * @exports { createKeyHandler }
15
+ */
16
+
17
+ export function createKeyHandler(ctx) {
18
+ const {
19
+ state,
20
+ exit,
21
+ cliArgs,
22
+ MODELS,
23
+ sources,
24
+ getApiKey,
25
+ resolveApiKeys,
26
+ addApiKey,
27
+ removeApiKey,
28
+ isProviderEnabled,
29
+ listProfiles,
30
+ loadProfile,
31
+ deleteProfile,
32
+ saveAsProfile,
33
+ setActiveProfile,
34
+ saveConfig,
35
+ syncFavoriteFlags,
36
+ toggleFavoriteModel,
37
+ sortResultsWithPinnedFavorites,
38
+ adjustScrollOffset,
39
+ applyTierFilter,
40
+ PING_INTERVAL,
41
+ TIER_CYCLE,
42
+ ORIGIN_CYCLE,
43
+ ENV_VAR_NAMES,
44
+ ensureProxyRunning,
45
+ syncToOpenCode,
46
+ restoreOpenCodeBackup,
47
+ checkForUpdateDetailed,
48
+ runUpdate,
49
+ startOpenClaw,
50
+ startOpenCodeDesktop,
51
+ startOpenCode,
52
+ startProxyAndLaunch,
53
+ buildProxyTopologyFromConfig,
54
+ startRecommendAnalysis,
55
+ stopRecommendAnalysis,
56
+ sendFeatureRequest,
57
+ sendBugReport,
58
+ stopUi,
59
+ ping,
60
+ getPingModel,
61
+ TASK_TYPES,
62
+ PRIORITY_TYPES,
63
+ CONTEXT_BUDGETS,
64
+ toFavoriteKey,
65
+ mergedModels,
66
+ apiKey,
67
+ chalk,
68
+ setResults,
69
+ readline,
70
+ } = ctx
71
+
72
+ let userSelected = null
73
+
74
+ // ─── Settings key test helper ───────────────────────────────────────────────
75
+ // 📖 Fires a single ping to the selected provider to verify the API key works.
76
+ async function testProviderKey(providerKey) {
77
+ const src = sources[providerKey]
78
+ if (!src) return
79
+ const testKey = getApiKey(state.config, providerKey)
80
+ if (!testKey) { state.settingsTestResults[providerKey] = 'fail'; return }
81
+
82
+ // 📖 Use the first model in the provider's list for the test ping
83
+ const testModel = src.models[0]?.[0]
84
+ if (!testModel) { state.settingsTestResults[providerKey] = 'fail'; return }
85
+
86
+ state.settingsTestResults[providerKey] = 'pending'
87
+ const { code } = await ping(testKey, testModel, providerKey, src.url)
88
+ state.settingsTestResults[providerKey] = code === '200' ? 'ok' : 'fail'
89
+ }
90
+
91
+ // 📖 Manual update checker from settings; keeps status visible in maintenance row.
92
+ async function checkUpdatesFromSettings() {
93
+ if (state.settingsUpdateState === 'checking' || state.settingsUpdateState === 'installing') return
94
+ state.settingsUpdateState = 'checking'
95
+ state.settingsUpdateError = null
96
+ const { latestVersion, error } = await checkForUpdateDetailed()
97
+ if (error) {
98
+ state.settingsUpdateState = 'error'
99
+ state.settingsUpdateLatestVersion = null
100
+ state.settingsUpdateError = error
101
+ return
102
+ }
103
+ if (latestVersion) {
104
+ state.settingsUpdateState = 'available'
105
+ state.settingsUpdateLatestVersion = latestVersion
106
+ state.settingsUpdateError = null
107
+ return
108
+ }
109
+ state.settingsUpdateState = 'up-to-date'
110
+ state.settingsUpdateLatestVersion = null
111
+ state.settingsUpdateError = null
112
+ }
113
+
114
+ // 📖 Leaves TUI cleanly, then runs npm global update command.
115
+ function launchUpdateFromSettings(latestVersion) {
116
+ if (!latestVersion) return
117
+ state.settingsUpdateState = 'installing'
118
+ stopUi({ resetRawMode: true })
119
+ runUpdate(latestVersion)
120
+ }
121
+
122
+ return async (str, key) => {
123
+ if (!key) return
124
+
125
+ // 📖 Profile save mode: intercept ALL keys while inline name input is active.
126
+ // 📖 Enter → save, Esc → cancel, Backspace → delete char, printable → append to buffer.
127
+ if (state.profileSaveMode) {
128
+ if (key.ctrl && key.name === 'c') { exit(0); return }
129
+ if (key.name === 'escape') {
130
+ // 📖 Cancel profile save — discard typed name
131
+ state.profileSaveMode = false
132
+ state.profileSaveBuffer = ''
133
+ return
134
+ }
135
+ if (key.name === 'return') {
136
+ // 📖 Confirm profile save — persist current TUI settings under typed name
137
+ const name = state.profileSaveBuffer.trim()
138
+ if (name.length > 0) {
139
+ saveAsProfile(state.config, name, {
140
+ tierFilter: TIER_CYCLE[state.tierFilterMode],
141
+ sortColumn: state.sortColumn,
142
+ sortAsc: state.sortDirection === 'asc',
143
+ pingInterval: state.pingInterval,
144
+ })
145
+ setActiveProfile(state.config, name)
146
+ state.activeProfile = name
147
+ saveConfig(state.config)
148
+ }
149
+ state.profileSaveMode = false
150
+ state.profileSaveBuffer = ''
151
+ return
152
+ }
153
+ if (key.name === 'backspace') {
154
+ state.profileSaveBuffer = state.profileSaveBuffer.slice(0, -1)
155
+ return
156
+ }
157
+ // 📖 Append printable characters (str is the raw character typed)
158
+ if (str && str.length === 1 && !key.ctrl && !key.meta) {
159
+ state.profileSaveBuffer += str
160
+ }
161
+ return
162
+ }
163
+
164
+ // 📖 Feature Request overlay: intercept ALL keys while overlay is active.
165
+ // 📖 Enter → send to Discord, Esc → cancel, Backspace → delete char, printable → append to buffer.
166
+ if (state.featureRequestOpen) {
167
+ if (key.ctrl && key.name === 'c') { exit(0); return }
168
+
169
+ if (key.name === 'escape') {
170
+ // 📖 Cancel feature request — close overlay
171
+ state.featureRequestOpen = false
172
+ state.featureRequestBuffer = ''
173
+ state.featureRequestStatus = 'idle'
174
+ state.featureRequestError = null
175
+ return
176
+ }
177
+
178
+ if (key.name === 'return') {
179
+ // 📖 Send feature request to Discord webhook
180
+ const message = state.featureRequestBuffer.trim()
181
+ if (message.length > 0 && state.featureRequestStatus !== 'sending') {
182
+ state.featureRequestStatus = 'sending'
183
+ const result = await sendFeatureRequest(message)
184
+ if (result.success) {
185
+ // 📖 Success — show confirmation briefly, then close overlay after 3 seconds
186
+ state.featureRequestStatus = 'success'
187
+ setTimeout(() => {
188
+ state.featureRequestOpen = false
189
+ state.featureRequestBuffer = ''
190
+ state.featureRequestStatus = 'idle'
191
+ state.featureRequestError = null
192
+ }, 3000)
193
+ } else {
194
+ // 📖 Error — show error message, keep overlay open
195
+ state.featureRequestStatus = 'error'
196
+ state.featureRequestError = result.error || 'Unknown error'
197
+ }
198
+ }
199
+ return
200
+ }
201
+
202
+ if (key.name === 'backspace') {
203
+ // 📖 Don't allow editing while sending or after success
204
+ if (state.featureRequestStatus === 'sending' || state.featureRequestStatus === 'success') return
205
+ state.featureRequestBuffer = state.featureRequestBuffer.slice(0, -1)
206
+ // 📖 Clear error status when user starts editing again
207
+ if (state.featureRequestStatus === 'error') {
208
+ state.featureRequestStatus = 'idle'
209
+ state.featureRequestError = null
210
+ }
211
+ return
212
+ }
213
+
214
+ // 📖 Append printable characters (str is the raw character typed)
215
+ // 📖 Limit to 500 characters (Discord embed description limit)
216
+ if (str && str.length === 1 && !key.ctrl && !key.meta) {
217
+ // 📖 Don't allow editing while sending or after success
218
+ if (state.featureRequestStatus === 'sending' || state.featureRequestStatus === 'success') return
219
+ if (state.featureRequestBuffer.length < 500) {
220
+ state.featureRequestBuffer += str
221
+ // 📖 Clear error status when user starts editing again
222
+ if (state.featureRequestStatus === 'error') {
223
+ state.featureRequestStatus = 'idle'
224
+ state.featureRequestError = null
225
+ }
226
+ }
227
+ }
228
+ return
229
+ }
230
+
231
+ // 📖 Bug Report overlay: intercept ALL keys while overlay is active.
232
+ // 📖 Enter → send to Discord, Esc → cancel, Backspace → delete char, printable → append to buffer.
233
+ if (state.bugReportOpen) {
234
+ if (key.ctrl && key.name === 'c') { exit(0); return }
235
+
236
+ if (key.name === 'escape') {
237
+ // 📖 Cancel bug report — close overlay
238
+ state.bugReportOpen = false
239
+ state.bugReportBuffer = ''
240
+ state.bugReportStatus = 'idle'
241
+ state.bugReportError = null
242
+ return
243
+ }
244
+
245
+ if (key.name === 'return') {
246
+ // 📖 Send bug report to Discord webhook
247
+ const message = state.bugReportBuffer.trim()
248
+ if (message.length > 0 && state.bugReportStatus !== 'sending') {
249
+ state.bugReportStatus = 'sending'
250
+ const result = await sendBugReport(message)
251
+ if (result.success) {
252
+ // 📖 Success — show confirmation briefly, then close overlay after 3 seconds
253
+ state.bugReportStatus = 'success'
254
+ setTimeout(() => {
255
+ state.bugReportOpen = false
256
+ state.bugReportBuffer = ''
257
+ state.bugReportStatus = 'idle'
258
+ state.bugReportError = null
259
+ }, 3000)
260
+ } else {
261
+ // 📖 Error — show error message, keep overlay open
262
+ state.bugReportStatus = 'error'
263
+ state.bugReportError = result.error || 'Unknown error'
264
+ }
265
+ }
266
+ return
267
+ }
268
+
269
+ if (key.name === 'backspace') {
270
+ // 📖 Don't allow editing while sending or after success
271
+ if (state.bugReportStatus === 'sending' || state.bugReportStatus === 'success') return
272
+ state.bugReportBuffer = state.bugReportBuffer.slice(0, -1)
273
+ // 📖 Clear error status when user starts editing again
274
+ if (state.bugReportStatus === 'error') {
275
+ state.bugReportStatus = 'idle'
276
+ state.bugReportError = null
277
+ }
278
+ return
279
+ }
280
+
281
+ // 📖 Append printable characters (str is the raw character typed)
282
+ // 📖 Limit to 500 characters (Discord embed description limit)
283
+ if (str && str.length === 1 && !key.ctrl && !key.meta) {
284
+ // 📖 Don't allow editing while sending or after success
285
+ if (state.bugReportStatus === 'sending' || state.bugReportStatus === 'success') return
286
+ if (state.bugReportBuffer.length < 500) {
287
+ state.bugReportBuffer += str
288
+ // 📖 Clear error status when user starts editing again
289
+ if (state.bugReportStatus === 'error') {
290
+ state.bugReportStatus = 'idle'
291
+ state.bugReportError = null
292
+ }
293
+ }
294
+ }
295
+ return
296
+ }
297
+
298
+ // 📖 Help overlay: full keyboard navigation + key swallowing while overlay is open.
299
+ if (state.helpVisible) {
300
+ const pageStep = Math.max(1, (state.terminalRows || 1) - 2)
301
+ if (key.name === 'escape' || key.name === 'k') {
302
+ state.helpVisible = false
303
+ return
304
+ }
305
+ if (key.name === 'up') { state.helpScrollOffset = Math.max(0, state.helpScrollOffset - 1); return }
306
+ if (key.name === 'down') { state.helpScrollOffset += 1; return }
307
+ if (key.name === 'pageup') { state.helpScrollOffset = Math.max(0, state.helpScrollOffset - pageStep); return }
308
+ if (key.name === 'pagedown') { state.helpScrollOffset += pageStep; return }
309
+ if (key.name === 'home') { state.helpScrollOffset = 0; return }
310
+ if (key.name === 'end') { state.helpScrollOffset = Number.MAX_SAFE_INTEGER; return }
311
+ if (key.ctrl && key.name === 'c') { exit(0); return }
312
+ return
313
+ }
314
+
315
+ // 📖 Log page overlay: full keyboard navigation + key swallowing while overlay is open.
316
+ if (state.logVisible) {
317
+ const pageStep = Math.max(1, (state.terminalRows || 1) - 2)
318
+ if (key.name === 'escape' || key.name === 'x') {
319
+ state.logVisible = false
320
+ return
321
+ }
322
+ if (key.name === 'up') { state.logScrollOffset = Math.max(0, state.logScrollOffset - 1); return }
323
+ if (key.name === 'down') { state.logScrollOffset += 1; return }
324
+ if (key.name === 'pageup') { state.logScrollOffset = Math.max(0, state.logScrollOffset - pageStep); return }
325
+ if (key.name === 'pagedown') { state.logScrollOffset += pageStep; return }
326
+ if (key.name === 'home') { state.logScrollOffset = 0; return }
327
+ if (key.name === 'end') { state.logScrollOffset = Number.MAX_SAFE_INTEGER; return }
328
+ if (key.ctrl && key.name === 'c') { exit(0); return }
329
+ return
330
+ }
331
+
332
+ // 📖 Smart Recommend overlay: full keyboard handling while overlay is open.
333
+ if (state.recommendOpen) {
334
+ if (key.ctrl && key.name === 'c') { exit(0); return }
335
+
336
+ if (state.recommendPhase === 'questionnaire') {
337
+ const questions = [
338
+ { options: Object.keys(TASK_TYPES), answerKey: 'taskType' },
339
+ { options: Object.keys(PRIORITY_TYPES), answerKey: 'priority' },
340
+ { options: Object.keys(CONTEXT_BUDGETS), answerKey: 'contextBudget' },
341
+ ]
342
+ const q = questions[state.recommendQuestion]
343
+
344
+ if (key.name === 'escape') {
345
+ // 📖 Cancel recommend — close overlay
346
+ state.recommendOpen = false
347
+ state.recommendPhase = 'questionnaire'
348
+ state.recommendQuestion = 0
349
+ state.recommendCursor = 0
350
+ state.recommendAnswers = { taskType: null, priority: null, contextBudget: null }
351
+ return
352
+ }
353
+ if (key.name === 'up') {
354
+ state.recommendCursor = state.recommendCursor > 0 ? state.recommendCursor - 1 : q.options.length - 1
355
+ return
356
+ }
357
+ if (key.name === 'down') {
358
+ state.recommendCursor = state.recommendCursor < q.options.length - 1 ? state.recommendCursor + 1 : 0
359
+ return
360
+ }
361
+ if (key.name === 'return') {
362
+ // 📖 Record answer and advance to next question or start analysis
363
+ state.recommendAnswers[q.answerKey] = q.options[state.recommendCursor]
364
+ if (state.recommendQuestion < questions.length - 1) {
365
+ state.recommendQuestion++
366
+ state.recommendCursor = 0
367
+ } else {
368
+ // 📖 All questions answered — start analysis phase
369
+ startRecommendAnalysis()
370
+ }
371
+ return
372
+ }
373
+ return // 📖 Swallow all other keys
374
+ }
375
+
376
+ if (state.recommendPhase === 'analyzing') {
377
+ if (key.name === 'escape') {
378
+ // 📖 Cancel analysis — stop timers, return to questionnaire
379
+ stopRecommendAnalysis()
380
+ state.recommendOpen = false
381
+ state.recommendPhase = 'questionnaire'
382
+ state.recommendQuestion = 0
383
+ state.recommendCursor = 0
384
+ state.recommendAnswers = { taskType: null, priority: null, contextBudget: null }
385
+ return
386
+ }
387
+ return // 📖 Swallow all keys during analysis (except Esc and Ctrl+C)
388
+ }
389
+
390
+ if (state.recommendPhase === 'results') {
391
+ if (key.name === 'escape') {
392
+ // 📖 Close results — recommendations stay highlighted in main table
393
+ state.recommendOpen = false
394
+ return
395
+ }
396
+ if (key.name === 'q') {
397
+ // 📖 Start a new search
398
+ state.recommendPhase = 'questionnaire'
399
+ state.recommendQuestion = 0
400
+ state.recommendCursor = 0
401
+ state.recommendAnswers = { taskType: null, priority: null, contextBudget: null }
402
+ state.recommendResults = []
403
+ state.recommendScrollOffset = 0
404
+ return
405
+ }
406
+ if (key.name === 'up') {
407
+ const count = state.recommendResults.length
408
+ if (count === 0) return
409
+ state.recommendCursor = state.recommendCursor > 0 ? state.recommendCursor - 1 : count - 1
410
+ return
411
+ }
412
+ if (key.name === 'down') {
413
+ const count = state.recommendResults.length
414
+ if (count === 0) return
415
+ state.recommendCursor = state.recommendCursor < count - 1 ? state.recommendCursor + 1 : 0
416
+ return
417
+ }
418
+ if (key.name === 'return') {
419
+ // 📖 Select the highlighted recommendation — close overlay, jump cursor to it
420
+ const rec = state.recommendResults[state.recommendCursor]
421
+ if (rec) {
422
+ const recKey = toFavoriteKey(rec.result.providerKey, rec.result.modelId)
423
+ state.recommendOpen = false
424
+ // 📖 Jump to the recommended model in the main table
425
+ const idx = state.visibleSorted.findIndex(r => toFavoriteKey(r.providerKey, r.modelId) === recKey)
426
+ if (idx >= 0) {
427
+ state.cursor = idx
428
+ adjustScrollOffset(state)
429
+ }
430
+ }
431
+ return
432
+ }
433
+ return // 📖 Swallow all other keys
434
+ }
435
+
436
+ return // 📖 Catch-all swallow
437
+ }
438
+
439
+ // ─── Settings overlay keyboard handling ───────────────────────────────────
440
+ if (state.settingsOpen) {
441
+ const providerKeys = Object.keys(sources)
442
+ const updateRowIdx = providerKeys.length
443
+ // 📖 Profile rows start after update row — one row per saved profile
444
+ const savedProfiles = listProfiles(state.config)
445
+ const profileStartIdx = updateRowIdx + 1
446
+ const maxRowIdx = savedProfiles.length > 0 ? profileStartIdx + savedProfiles.length - 1 : updateRowIdx
447
+
448
+ // 📖 Edit/Add-key mode: capture typed characters for the API key
449
+ if (state.settingsEditMode || state.settingsAddKeyMode) {
450
+ if (key.name === 'return') {
451
+ // 📖 Save the new key and exit edit/add mode
452
+ const pk = providerKeys[state.settingsCursor]
453
+ const newKey = state.settingsEditBuffer.trim()
454
+ if (newKey) {
455
+ // 📖 Validate OpenRouter keys start with "sk-or-" to detect corruption
456
+ if (pk === 'openrouter' && !newKey.startsWith('sk-or-')) {
457
+ // 📖 Don't save corrupted keys - show warning and cancel
458
+ state.settingsEditMode = false
459
+ state.settingsAddKeyMode = false
460
+ state.settingsEditBuffer = ''
461
+ state.settingsErrorMsg = '⚠️ OpenRouter keys must start with "sk-or-". Key not saved.'
462
+ setTimeout(() => { state.settingsErrorMsg = null }, 3000)
463
+ return
464
+ }
465
+ if (state.settingsAddKeyMode) {
466
+ // 📖 Add-key mode: append new key (addApiKey handles duplicates/empty)
467
+ addApiKey(state.config, pk, newKey)
468
+ } else {
469
+ // 📖 Edit mode: replace the primary key (string-level)
470
+ state.config.apiKeys[pk] = newKey
471
+ }
472
+ saveConfig(state.config)
473
+ }
474
+ state.settingsEditMode = false
475
+ state.settingsAddKeyMode = false
476
+ state.settingsEditBuffer = ''
477
+ } else if (key.name === 'escape') {
478
+ // 📖 Cancel without saving
479
+ state.settingsEditMode = false
480
+ state.settingsAddKeyMode = false
481
+ state.settingsEditBuffer = ''
482
+ } else if (key.name === 'backspace') {
483
+ state.settingsEditBuffer = state.settingsEditBuffer.slice(0, -1)
484
+ } else if (str && !key.ctrl && !key.meta && str.length === 1) {
485
+ // 📖 Append printable character to buffer
486
+ state.settingsEditBuffer += str
487
+ }
488
+ return
489
+ }
490
+
491
+ // 📖 Normal settings navigation
492
+ if (key.name === 'escape' || key.name === 'p') {
493
+ // 📖 Close settings — rebuild results to reflect provider changes
494
+ state.settingsOpen = false
495
+ state.settingsEditMode = false
496
+ state.settingsAddKeyMode = false
497
+ state.settingsEditBuffer = ''
498
+ state.settingsSyncStatus = null // 📖 Clear sync status on close
499
+ // 📖 Rebuild results: add models from newly enabled providers, remove disabled
500
+ const nextResults = MODELS
501
+ .filter(([,,,,,pk]) => isProviderEnabled(state.config, pk))
502
+ .map(([modelId, label, tier, sweScore, ctx, providerKey], i) => {
503
+ // 📖 Try to reuse existing result to keep ping history
504
+ const existing = state.results.find(r => r.modelId === modelId && r.providerKey === providerKey)
505
+ if (existing) return existing
506
+ return { idx: i + 1, modelId, label, tier, sweScore, ctx, providerKey, status: 'pending', pings: [], httpCode: null, hidden: false }
507
+ })
508
+ // 📖 Re-index results
509
+ nextResults.forEach((r, i) => { r.idx = i + 1 })
510
+ state.results = nextResults
511
+ setResults(nextResults)
512
+ syncFavoriteFlags(state.results, state.config)
513
+ applyTierFilter()
514
+ const visible = state.results.filter(r => !r.hidden)
515
+ state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
516
+ if (state.cursor >= state.visibleSorted.length) state.cursor = Math.max(0, state.visibleSorted.length - 1)
517
+ adjustScrollOffset(state)
518
+ // 📖 Re-ping all models that were 'noauth' (got 401 without key) but now have a key
519
+ // 📖 This makes the TUI react immediately when a user adds an API key in settings
520
+ const pingModel = getPingModel?.()
521
+ if (pingModel) {
522
+ state.results.forEach(r => {
523
+ if (r.status === 'noauth' && getApiKey(state.config, r.providerKey)) {
524
+ r.status = 'pending'
525
+ r.pings = []
526
+ r.httpCode = null
527
+ pingModel(r).catch(() => {})
528
+ }
529
+ })
530
+ }
531
+ return
532
+ }
533
+
534
+ if (key.name === 'up' && state.settingsCursor > 0) {
535
+ state.settingsCursor--
536
+ return
537
+ }
538
+
539
+ if (key.name === 'down' && state.settingsCursor < maxRowIdx) {
540
+ state.settingsCursor++
541
+ return
542
+ }
543
+
544
+ if (key.name === 'pageup') {
545
+ const pageStep = Math.max(1, (state.terminalRows || 1) - 2)
546
+ state.settingsCursor = Math.max(0, state.settingsCursor - pageStep)
547
+ return
548
+ }
549
+
550
+ if (key.name === 'pagedown') {
551
+ const pageStep = Math.max(1, (state.terminalRows || 1) - 2)
552
+ state.settingsCursor = Math.min(maxRowIdx, state.settingsCursor + pageStep)
553
+ return
554
+ }
555
+
556
+ if (key.name === 'home') {
557
+ state.settingsCursor = 0
558
+ return
559
+ }
560
+
561
+ if (key.name === 'end') {
562
+ state.settingsCursor = maxRowIdx
563
+ return
564
+ }
565
+
566
+ if (key.name === 'return') {
567
+ if (state.settingsCursor === updateRowIdx) {
568
+ if (state.settingsUpdateState === 'available' && state.settingsUpdateLatestVersion) {
569
+ launchUpdateFromSettings(state.settingsUpdateLatestVersion)
570
+ return
571
+ }
572
+ checkUpdatesFromSettings()
573
+ return
574
+ }
575
+
576
+ // 📖 Profile row: Enter → load the selected profile (apply its settings live)
577
+ if (state.settingsCursor >= profileStartIdx && savedProfiles.length > 0) {
578
+ const profileIdx = state.settingsCursor - profileStartIdx
579
+ const profileName = savedProfiles[profileIdx]
580
+ if (profileName) {
581
+ const settings = loadProfile(state.config, profileName)
582
+ if (settings) {
583
+ state.sortColumn = settings.sortColumn || 'avg'
584
+ state.sortDirection = settings.sortAsc ? 'asc' : 'desc'
585
+ state.pingInterval = settings.pingInterval || PING_INTERVAL
586
+ if (settings.tierFilter) {
587
+ const tierIdx = TIER_CYCLE.indexOf(settings.tierFilter)
588
+ if (tierIdx >= 0) state.tierFilterMode = tierIdx
589
+ } else {
590
+ state.tierFilterMode = 0
591
+ }
592
+ state.activeProfile = profileName
593
+ syncFavoriteFlags(state.results, state.config)
594
+ applyTierFilter()
595
+ const visible = state.results.filter(r => !r.hidden)
596
+ state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
597
+ saveConfig(state.config)
598
+ }
599
+ }
600
+ return
601
+ }
602
+
603
+ // 📖 Enter edit mode for the selected provider's key
604
+ const pk = providerKeys[state.settingsCursor]
605
+ state.settingsEditBuffer = state.config.apiKeys?.[pk] ?? ''
606
+ state.settingsEditMode = true
607
+ return
608
+ }
609
+
610
+ if (key.name === 'space') {
611
+ if (state.settingsCursor === updateRowIdx) return
612
+ // 📖 Profile rows don't respond to Space
613
+ if (state.settingsCursor >= profileStartIdx) return
614
+
615
+ // 📖 Toggle enabled/disabled for selected provider
616
+ const pk = providerKeys[state.settingsCursor]
617
+ if (!state.config.providers) state.config.providers = {}
618
+ if (!state.config.providers[pk]) state.config.providers[pk] = { enabled: true }
619
+ state.config.providers[pk].enabled = !isProviderEnabled(state.config, pk)
620
+ saveConfig(state.config)
621
+ return
622
+ }
623
+
624
+ if (key.name === 't') {
625
+ if (state.settingsCursor === updateRowIdx) return
626
+ // 📖 Profile rows don't respond to T (test key)
627
+ if (state.settingsCursor >= profileStartIdx) return
628
+
629
+ // 📖 Test the selected provider's key (fires a real ping)
630
+ const pk = providerKeys[state.settingsCursor]
631
+ testProviderKey(pk)
632
+ return
633
+ }
634
+
635
+ if (key.name === 'u') {
636
+ checkUpdatesFromSettings()
637
+ return
638
+ }
639
+
640
+ // 📖 Backspace on a profile row → delete that profile
641
+ if (key.name === 'backspace' && state.settingsCursor >= profileStartIdx && savedProfiles.length > 0) {
642
+ const profileIdx = state.settingsCursor - profileStartIdx
643
+ const profileName = savedProfiles[profileIdx]
644
+ if (profileName) {
645
+ deleteProfile(state.config, profileName)
646
+ // 📖 If the deleted profile was active, clear active state
647
+ if (state.activeProfile === profileName) {
648
+ setActiveProfile(state.config, null)
649
+ state.activeProfile = null
650
+ }
651
+ saveConfig(state.config)
652
+ // 📖 Re-clamp cursor after deletion (profile list just got shorter)
653
+ const newProfiles = listProfiles(state.config)
654
+ const newMaxRowIdx = newProfiles.length > 0 ? profileStartIdx + newProfiles.length - 1 : updateRowIdx
655
+ if (state.settingsCursor > newMaxRowIdx) {
656
+ state.settingsCursor = Math.max(0, newMaxRowIdx)
657
+ }
658
+ }
659
+ return
660
+ }
661
+
662
+ if (key.ctrl && key.name === 'c') { exit(0); return }
663
+
664
+ // 📖 S key: sync FCM provider entries to OpenCode config (merge, don't replace)
665
+ if (key.name === 's' && !key.shift && !key.ctrl) {
666
+ try {
667
+ // 📖 Sync now also ensures proxy is running, so OpenCode can use fcm-proxy immediately.
668
+ const started = await ensureProxyRunning(state.config)
669
+ const result = syncToOpenCode(state.config, sources, mergedModels, {
670
+ proxyPort: started.port,
671
+ proxyToken: started.proxyToken,
672
+ availableModelSlugs: started.availableModelSlugs,
673
+ })
674
+ state.settingsSyncStatus = {
675
+ type: 'success',
676
+ msg: `✅ Synced ${result.providerKey} (${result.modelCount} models), proxy running on :${started.port}`,
677
+ }
678
+ } catch (err) {
679
+ state.settingsSyncStatus = { type: 'error', msg: `❌ Sync failed: ${err.message}` }
680
+ }
681
+ return
682
+ }
683
+
684
+ // 📖 R key: restore OpenCode config from backup (opencode.json.bak)
685
+ if (key.name === 'r' && !key.shift && !key.ctrl) {
686
+ try {
687
+ const restored = restoreOpenCodeBackup()
688
+ state.settingsSyncStatus = restored
689
+ ? { type: 'success', msg: '✅ OpenCode config restored from backup' }
690
+ : { type: 'error', msg: '⚠ No backup found (opencode.json.bak)' }
691
+ } catch (err) {
692
+ state.settingsSyncStatus = { type: 'error', msg: `❌ Restore failed: ${err.message}` }
693
+ }
694
+ return
695
+ }
696
+
697
+ // 📖 + key: open add-key input (empty buffer) — appends new key on Enter
698
+ if ((str === '+' || key.name === '+') && state.settingsCursor < providerKeys.length) {
699
+ state.settingsEditBuffer = '' // 📖 Start with empty buffer (not existing key)
700
+ state.settingsAddKeyMode = true // 📖 Add mode: Enter will append, not replace
701
+ state.settingsEditMode = false
702
+ return
703
+ }
704
+
705
+ // 📖 - key: remove one key (last by default) instead of deleting entire provider
706
+ if ((str === '-' || key.name === '-') && state.settingsCursor < providerKeys.length) {
707
+ const pk = providerKeys[state.settingsCursor]
708
+ const removed = removeApiKey(state.config, pk) // removes last key; collapses array-of-1 to string
709
+ if (removed) {
710
+ saveConfig(state.config)
711
+ const remaining = resolveApiKeys(state.config, pk).length
712
+ const msg = remaining > 0
713
+ ? `✅ Removed one key for ${pk} (${remaining} remaining)`
714
+ : `✅ Removed last API key for ${pk}`
715
+ state.settingsSyncStatus = { type: 'success', msg }
716
+ }
717
+ return
718
+ }
719
+
720
+ return // 📖 Swallow all other keys while settings is open
721
+ }
722
+
723
+ // 📖 P key: open settings screen
724
+ if (key.name === 'p' && !key.shift) {
725
+ state.settingsOpen = true
726
+ state.settingsCursor = 0
727
+ state.settingsEditMode = false
728
+ state.settingsAddKeyMode = false
729
+ state.settingsEditBuffer = ''
730
+ state.settingsScrollOffset = 0
731
+ return
732
+ }
733
+
734
+ // 📖 Q key: open Smart Recommend overlay
735
+ if (key.name === 'q') {
736
+ state.recommendOpen = true
737
+ state.recommendPhase = 'questionnaire'
738
+ state.recommendQuestion = 0
739
+ state.recommendCursor = 0
740
+ state.recommendAnswers = { taskType: null, priority: null, contextBudget: null }
741
+ state.recommendResults = []
742
+ state.recommendScrollOffset = 0
743
+ return
744
+ }
745
+
746
+ // 📖 Shift+P: cycle through profiles (or show profile picker)
747
+ if (key.name === 'p' && key.shift) {
748
+ const profiles = listProfiles(state.config)
749
+ if (profiles.length === 0) {
750
+ // 📖 No profiles saved — save current config as 'default' profile
751
+ saveAsProfile(state.config, 'default', {
752
+ tierFilter: TIER_CYCLE[state.tierFilterMode],
753
+ sortColumn: state.sortColumn,
754
+ sortAsc: state.sortDirection === 'asc',
755
+ pingInterval: state.pingInterval,
756
+ })
757
+ setActiveProfile(state.config, 'default')
758
+ state.activeProfile = 'default'
759
+ saveConfig(state.config)
760
+ } else {
761
+ // 📖 Cycle to next profile (or back to null = raw config)
762
+ const currentIdx = state.activeProfile ? profiles.indexOf(state.activeProfile) : -1
763
+ const nextIdx = (currentIdx + 1) % (profiles.length + 1) // +1 for "no profile"
764
+ if (nextIdx === profiles.length) {
765
+ // 📖 Back to raw config (no profile)
766
+ setActiveProfile(state.config, null)
767
+ state.activeProfile = null
768
+ saveConfig(state.config)
769
+ } else {
770
+ const nextProfile = profiles[nextIdx]
771
+ const settings = loadProfile(state.config, nextProfile)
772
+ if (settings) {
773
+ // 📖 Apply profile's TUI settings to live state
774
+ state.sortColumn = settings.sortColumn || 'avg'
775
+ state.sortDirection = settings.sortAsc ? 'asc' : 'desc'
776
+ state.pingInterval = settings.pingInterval || PING_INTERVAL
777
+ if (settings.tierFilter) {
778
+ const tierIdx = TIER_CYCLE.indexOf(settings.tierFilter)
779
+ if (tierIdx >= 0) state.tierFilterMode = tierIdx
780
+ } else {
781
+ state.tierFilterMode = 0
782
+ }
783
+ state.activeProfile = nextProfile
784
+ // 📖 Rebuild favorites from profile data
785
+ syncFavoriteFlags(state.results, state.config)
786
+ applyTierFilter()
787
+ const visible = state.results.filter(r => !r.hidden)
788
+ state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
789
+ state.cursor = 0
790
+ state.scrollOffset = 0
791
+ saveConfig(state.config)
792
+ }
793
+ }
794
+ }
795
+ return
796
+ }
797
+
798
+ // 📖 Shift+S: enter profile save mode — inline text prompt for typing a profile name
799
+ if (key.name === 's' && key.shift) {
800
+ state.profileSaveMode = true
801
+ state.profileSaveBuffer = ''
802
+ return
803
+ }
804
+
805
+ // 📖 Sorting keys: R=rank, Y=tier, O=origin, M=model, L=latest ping, A=avg ping, S=SWE-bench, C=context, H=health, V=verdict, B=stability, U=uptime, G=usage
806
+ // 📖 T is reserved for tier filter cycling — tier sort moved to Y
807
+ // 📖 D is now reserved for provider filter cycling
808
+ const sortKeys = {
809
+ 'r': 'rank', 'y': 'tier', 'o': 'origin', 'm': 'model',
810
+ 'l': 'ping', 'a': 'avg', 's': 'swe', 'c': 'ctx', 'h': 'condition', 'v': 'verdict', 'b': 'stability', 'u': 'uptime', 'g': 'usage'
811
+ }
812
+
813
+ if (sortKeys[key.name] && !key.ctrl && !key.shift) {
814
+ const col = sortKeys[key.name]
815
+ // 📖 Toggle direction if same column, otherwise reset to asc
816
+ if (state.sortColumn === col) {
817
+ state.sortDirection = state.sortDirection === 'asc' ? 'desc' : 'asc'
818
+ } else {
819
+ state.sortColumn = col
820
+ state.sortDirection = 'asc'
821
+ }
822
+ // 📖 Recompute visible sorted list and reset cursor to top to avoid stale index
823
+ const visible = state.results.filter(r => !r.hidden)
824
+ state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
825
+ state.cursor = 0
826
+ state.scrollOffset = 0
827
+ return
828
+ }
829
+
830
+ // 📖 F key: toggle favorite on the currently selected row and persist to config.
831
+ if (key.name === 'f') {
832
+ const selected = state.visibleSorted[state.cursor]
833
+ if (!selected) return
834
+ const wasFavorite = selected.isFavorite
835
+ toggleFavoriteModel(state.config, selected.providerKey, selected.modelId)
836
+ syncFavoriteFlags(state.results, state.config)
837
+ applyTierFilter()
838
+ const visible = state.results.filter(r => !r.hidden)
839
+ state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
840
+
841
+ // 📖 UX rule: when unpinning a favorite, jump back to the top of the list.
842
+ if (wasFavorite) {
843
+ state.cursor = 0
844
+ state.scrollOffset = 0
845
+ return
846
+ }
847
+
848
+ const selectedKey = toFavoriteKey(selected.providerKey, selected.modelId)
849
+ const newCursor = state.visibleSorted.findIndex(r => toFavoriteKey(r.providerKey, r.modelId) === selectedKey)
850
+ if (newCursor >= 0) state.cursor = newCursor
851
+ else if (state.cursor >= state.visibleSorted.length) state.cursor = Math.max(0, state.visibleSorted.length - 1)
852
+ adjustScrollOffset(state)
853
+ return
854
+ }
855
+
856
+ // 📖 J key: open Feature Request overlay (anonymous Discord feedback)
857
+ if (key.name === 'j') {
858
+ state.featureRequestOpen = true
859
+ state.featureRequestBuffer = ''
860
+ state.featureRequestStatus = 'idle'
861
+ state.featureRequestError = null
862
+ return
863
+ }
864
+
865
+ // 📖 I key: open Bug Report overlay (anonymous Discord bug reports)
866
+ if (key.name === 'i') {
867
+ state.bugReportOpen = true
868
+ state.bugReportBuffer = ''
869
+ state.bugReportStatus = 'idle'
870
+ state.bugReportError = null
871
+ return
872
+ }
873
+
874
+ // 📖 Interval adjustment keys: W=decrease (faster), ==increase (slower)
875
+ // 📖 X was previously used for interval increase but is now reserved for the log page overlay.
876
+ // 📖 Minimum 1s, maximum 60s
877
+ if (key.name === 'w') {
878
+ state.pingInterval = Math.max(1000, state.pingInterval - 1000)
879
+ } else if (str === '=' || key.name === '=') {
880
+ state.pingInterval = Math.min(60000, state.pingInterval + 1000)
881
+ }
882
+
883
+ // 📖 Tier toggle key: T = cycle through each individual tier (All → S+ → S → A+ → A → A- → B+ → B → C → All)
884
+ if (key.name === 't') {
885
+ state.tierFilterMode = (state.tierFilterMode + 1) % TIER_CYCLE.length
886
+ applyTierFilter()
887
+ // 📖 Recompute visible sorted list and reset cursor to avoid stale index into new filtered set
888
+ const visible = state.results.filter(r => !r.hidden)
889
+ state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
890
+ state.cursor = 0
891
+ state.scrollOffset = 0
892
+ return
893
+ }
894
+
895
+ // 📖 Provider filter key: D = cycle through each provider (All → NIM → Groq → ... → All)
896
+ if (key.name === 'd') {
897
+ state.originFilterMode = (state.originFilterMode + 1) % ORIGIN_CYCLE.length
898
+ applyTierFilter()
899
+ // 📖 Recompute visible sorted list and reset cursor to avoid stale index into new filtered set
900
+ const visible = state.results.filter(r => !r.hidden)
901
+ state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
902
+ state.cursor = 0
903
+ state.scrollOffset = 0
904
+ return
905
+ }
906
+
907
+ // 📖 Help overlay key: K = toggle help overlay
908
+ if (key.name === 'k') {
909
+ state.helpVisible = !state.helpVisible
910
+ if (state.helpVisible) state.helpScrollOffset = 0
911
+ return
912
+ }
913
+
914
+ // 📖 Mode toggle key: Z = cycle through modes (CLI → Desktop → OpenClaw)
915
+ if (key.name === 'z') {
916
+ const modeOrder = ['opencode', 'opencode-desktop', 'openclaw']
917
+ const currentIndex = modeOrder.indexOf(state.mode)
918
+ const nextIndex = (currentIndex + 1) % modeOrder.length
919
+ state.mode = modeOrder[nextIndex]
920
+ return
921
+ }
922
+
923
+ // 📖 X key: toggle the log page overlay (shows recent requests from request-log.jsonl).
924
+ // 📖 NOTE: X was previously used for ping-interval increase; that binding moved to '='.
925
+ if (key.name === 'x') {
926
+ state.logVisible = !state.logVisible
927
+ if (state.logVisible) state.logScrollOffset = 0
928
+ return
929
+ }
930
+
931
+ if (key.name === 'up') {
932
+ // 📖 Main list wrap navigation: top -> bottom on Up.
933
+ const count = state.visibleSorted.length
934
+ if (count === 0) return
935
+ state.cursor = state.cursor > 0 ? state.cursor - 1 : count - 1
936
+ adjustScrollOffset(state)
937
+ return
938
+ }
939
+
940
+ if (key.name === 'down') {
941
+ // 📖 Main list wrap navigation: bottom -> top on Down.
942
+ const count = state.visibleSorted.length
943
+ if (count === 0) return
944
+ state.cursor = state.cursor < count - 1 ? state.cursor + 1 : 0
945
+ adjustScrollOffset(state)
946
+ return
947
+ }
948
+
949
+ if (key.name === 'c' && key.ctrl) { // Ctrl+C
950
+ exit(0)
951
+ return
952
+ }
953
+
954
+ if (key.name === 'return') { // Enter
955
+ // 📖 Use the cached visible+sorted array — guaranteed to match what's on screen
956
+ const selected = state.visibleSorted[state.cursor]
957
+ if (!selected) return // 📖 Guard: empty visible list (all filtered out)
958
+ // 📖 Allow selecting ANY model (even timeout/down) - user knows what they're doing
959
+ userSelected = { modelId: selected.modelId, label: selected.label, tier: selected.tier, providerKey: selected.providerKey }
960
+
961
+ // 📖 Stop everything and act on selection immediately
962
+ readline.emitKeypressEvents(process.stdin)
963
+ process.stdin.setRawMode(true)
964
+ stopUi()
965
+
966
+ // 📖 Show selection with status
967
+ if (selected.status === 'timeout') {
968
+ console.log(chalk.yellow(` ⚠ Selected: ${selected.label} (currently timing out)`))
969
+ } else if (selected.status === 'down') {
970
+ console.log(chalk.red(` ⚠ Selected: ${selected.label} (currently down)`))
971
+ } else {
972
+ console.log(chalk.cyan(` ✓ Selected: ${selected.label}`))
973
+ }
974
+ console.log()
975
+
976
+ // 📖 Warn if no API key is configured for the selected model's provider
977
+ if (state.mode !== 'openclaw') {
978
+ const selectedApiKey = getApiKey(state.config, selected.providerKey)
979
+ if (!selectedApiKey) {
980
+ console.log(chalk.yellow(` Warning: No API key configured for ${selected.providerKey}.`))
981
+ console.log(chalk.yellow(` OpenCode may not be able to use ${selected.label}.`))
982
+ console.log(chalk.dim(` Set ${ENV_VAR_NAMES[selected.providerKey] || selected.providerKey.toUpperCase() + '_API_KEY'} or configure via settings (P key).`))
983
+ console.log()
984
+ }
985
+ }
986
+
987
+ // 📖 Dispatch to the correct integration based on active mode
988
+ if (state.mode === 'openclaw') {
989
+ await startOpenClaw(userSelected, apiKey)
990
+ } else if (state.mode === 'opencode-desktop') {
991
+ await startOpenCodeDesktop(userSelected, state.config)
992
+ } else {
993
+ const topology = buildProxyTopologyFromConfig(state.config)
994
+ if (topology.accounts.length === 0) {
995
+ console.log(chalk.yellow(' No API keys found for proxy model catalog. Falling back to direct flow.'))
996
+ console.log()
997
+ await startOpenCode(userSelected, state.config)
998
+ } else {
999
+ await startProxyAndLaunch(userSelected, state.config)
1000
+ }
1001
+ }
1002
+ process.exit(0)
1003
+ }
1004
+ }
1005
+ }