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.
- package/README.md +6 -17
- package/bin/free-coding-models.js +297 -4754
- package/package.json +2 -2
- package/src/analysis.js +197 -0
- package/src/constants.js +116 -0
- package/src/favorites.js +98 -0
- package/src/key-handler.js +1005 -0
- package/src/openclaw.js +131 -0
- package/src/opencode.js +952 -0
- package/src/overlays.js +840 -0
- package/src/ping.js +186 -0
- package/src/provider-metadata.js +218 -0
- package/src/quota-capabilities.js +112 -0
- package/src/render-helpers.js +239 -0
- package/src/render-table.js +567 -0
- package/src/setup.js +105 -0
- package/src/telemetry.js +382 -0
- package/src/tier-colors.js +37 -0
- package/{lib → src}/token-stats.js +71 -3
- package/src/token-usage-reader.js +63 -0
- package/src/updater.js +237 -0
- package/{lib → src}/usage-reader.js +63 -21
- package/lib/quota-capabilities.js +0 -79
- /package/{lib → src}/account-manager.js +0 -0
- /package/{lib → src}/config.js +0 -0
- /package/{lib → src}/error-classifier.js +0 -0
- /package/{lib → src}/log-reader.js +0 -0
- /package/{lib → src}/model-merger.js +0 -0
- /package/{lib → src}/opencode-sync.js +0 -0
- /package/{lib → src}/provider-quota-fetchers.js +0 -0
- /package/{lib → src}/proxy-server.js +0 -0
- /package/{lib → src}/request-transformer.js +0 -0
- /package/{lib → src}/utils.js +0 -0
|
@@ -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
|
+
}
|