free-coding-models 0.1.83 → 0.1.85

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