free-coding-models 0.3.17 → 0.3.19

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.
@@ -30,6 +30,8 @@
30
30
  import { loadChangelog } from './changelog-loader.js'
31
31
  import { loadConfig, replaceConfigContents } from './config.js'
32
32
  import { cleanupLegacyProxyArtifacts } from './legacy-proxy-cleanup.js'
33
+ import { cycleThemeSetting, detectActiveTheme } from './theme.js'
34
+ import { buildCommandPaletteEntries, filterCommandPaletteEntries } from './command-palette.js'
33
35
 
34
36
  // 📖 Some providers need an explicit probe model because the first catalog entry
35
37
  // 📖 is not guaranteed to be accepted by their chat endpoint.
@@ -184,6 +186,9 @@ export function createKeyHandler(ctx) {
184
186
  startOpenCode,
185
187
  startExternalTool,
186
188
  getToolModeOrder,
189
+ getToolInstallPlan,
190
+ isToolInstalled,
191
+ installToolWithPlan,
187
192
  startRecommendAnalysis,
188
193
  stopRecommendAnalysis,
189
194
  sendBugReport,
@@ -207,6 +212,97 @@ export function createKeyHandler(ctx) {
207
212
 
208
213
  let userSelected = null
209
214
 
215
+ function resetToolInstallPrompt() {
216
+ state.toolInstallPromptOpen = false
217
+ state.toolInstallPromptCursor = 0
218
+ state.toolInstallPromptScrollOffset = 0
219
+ state.toolInstallPromptMode = null
220
+ state.toolInstallPromptModel = null
221
+ state.toolInstallPromptPlan = null
222
+ state.toolInstallPromptErrorMsg = null
223
+ }
224
+
225
+ function shouldCheckMissingTool(mode) {
226
+ return mode !== 'opencode-desktop'
227
+ }
228
+
229
+ async function launchSelectedModel(selected, options = {}) {
230
+ const { uiAlreadyStopped = false } = options
231
+ userSelected = { modelId: selected.modelId, label: selected.label, tier: selected.tier, providerKey: selected.providerKey }
232
+
233
+ if (!uiAlreadyStopped) {
234
+ readline.emitKeypressEvents(process.stdin)
235
+ if (process.stdin.isTTY) process.stdin.setRawMode(true)
236
+ stopUi()
237
+ }
238
+
239
+ // 📖 Show selection status before handing control to the target tool.
240
+ if (selected.status === 'timeout') {
241
+ console.log(chalk.yellow(` ⚠ Selected: ${selected.label} (currently timing out)`))
242
+ } else if (selected.status === 'down') {
243
+ console.log(chalk.red(` ⚠ Selected: ${selected.label} (currently down)`))
244
+ } else {
245
+ console.log(chalk.cyan(` ✓ Selected: ${selected.label}`))
246
+ }
247
+ console.log()
248
+
249
+ // 📖 OpenClaw manages API keys inside its own config file. All other tools
250
+ // 📖 still need a provider key to be useful, so keep the existing warning.
251
+ if (state.mode !== 'openclaw') {
252
+ const selectedApiKey = getApiKey(state.config, selected.providerKey)
253
+ if (!selectedApiKey) {
254
+ console.log(chalk.yellow(` Warning: No API key configured for ${selected.providerKey}.`))
255
+ console.log(chalk.yellow(` The selected tool may not be able to use ${selected.label}.`))
256
+ console.log(chalk.dim(` Set ${ENV_VAR_NAMES[selected.providerKey] || selected.providerKey.toUpperCase() + '_API_KEY'} or configure via settings (P key).`))
257
+ console.log()
258
+ }
259
+ }
260
+
261
+ let exitCode = 0
262
+ if (state.mode === 'openclaw') {
263
+ exitCode = await startOpenClaw(userSelected, state.config, { launchCli: true })
264
+ } else if (state.mode === 'opencode-desktop') {
265
+ exitCode = await startOpenCodeDesktop(userSelected, state.config)
266
+ } else if (state.mode === 'opencode') {
267
+ exitCode = await startOpenCode(userSelected, state.config)
268
+ } else {
269
+ exitCode = await startExternalTool(state.mode, userSelected, state.config)
270
+ }
271
+
272
+ process.exit(typeof exitCode === 'number' ? exitCode : 0)
273
+ }
274
+
275
+ async function installMissingToolAndLaunch(selected, installPlan) {
276
+ const currentPlan = installPlan || getToolInstallPlan(state.mode)
277
+ stopUi({ resetRawMode: true })
278
+
279
+ console.log(chalk.cyan(` 📦 Installing missing tool for ${state.mode}...`))
280
+ if (currentPlan?.summary) console.log(chalk.dim(` ${currentPlan.summary}`))
281
+ if (currentPlan?.shellCommand) console.log(chalk.dim(` ${currentPlan.shellCommand}`))
282
+ if (currentPlan?.note) console.log(chalk.dim(` ${currentPlan.note}`))
283
+ console.log()
284
+
285
+ const installResult = await installToolWithPlan(currentPlan)
286
+ if (!installResult.ok) {
287
+ console.log(chalk.red(` X Tool installation failed with exit code ${installResult.exitCode}.`))
288
+ if (currentPlan?.docsUrl) console.log(chalk.dim(` Docs: ${currentPlan.docsUrl}`))
289
+ console.log()
290
+ process.exit(installResult.exitCode || 1)
291
+ }
292
+
293
+ if (shouldCheckMissingTool(state.mode) && !isToolInstalled(state.mode)) {
294
+ console.log(chalk.yellow(' ⚠ The installer finished, but the tool is still not reachable from this terminal session.'))
295
+ console.log(chalk.dim(' Restart your shell or add the tool bin directory to PATH, then retry the launch.'))
296
+ if (currentPlan?.docsUrl) console.log(chalk.dim(` Docs: ${currentPlan.docsUrl}`))
297
+ console.log()
298
+ process.exit(1)
299
+ }
300
+
301
+ console.log(chalk.green(' ✓ Tool installed successfully. Continuing with the selected model...'))
302
+ console.log()
303
+ await launchSelectedModel(selected, { uiAlreadyStopped: true })
304
+ }
305
+
210
306
  // ─── Settings key test helper ───────────────────────────────────────────────
211
307
  // 📖 Fires a single ping to the selected provider to verify the API key works.
212
308
  async function testProviderKey(providerKey) {
@@ -383,6 +479,20 @@ export function createKeyHandler(ctx) {
383
479
  saveConfig(state.config)
384
480
  }
385
481
 
482
+ // 📖 Theme switches need to update both persisted preference and the live
483
+ // 📖 semantic palette immediately so every screen redraw adopts the new colors.
484
+ function applyThemeSetting(nextTheme) {
485
+ if (!state.config.settings) state.config.settings = {}
486
+ state.config.settings.theme = nextTheme
487
+ saveConfig(state.config)
488
+ detectActiveTheme(nextTheme)
489
+ }
490
+
491
+ function cycleGlobalTheme() {
492
+ const currentTheme = state.config.settings?.theme || 'auto'
493
+ applyThemeSetting(cycleThemeSetting(currentTheme))
494
+ }
495
+
386
496
  function resetInstallEndpointsOverlay() {
387
497
  state.installEndpointsOpen = false
388
498
  state.installEndpointsPhase = 'providers'
@@ -427,10 +537,320 @@ export function createKeyHandler(ctx) {
427
537
  state.installEndpointsErrorMsg = null
428
538
  }
429
539
 
540
+ // 📖 Persist current table-view preferences so sort/filter state survives restarts.
541
+ function persistUiSettings() {
542
+ if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
543
+ state.config.settings.tierFilter = TIER_CYCLE[state.tierFilterMode]
544
+ state.config.settings.originFilter = ORIGIN_CYCLE[state.originFilterMode] ?? null
545
+ state.config.settings.sortColumn = state.sortColumn
546
+ state.config.settings.sortAsc = state.sortDirection === 'asc'
547
+ saveConfig(state.config)
548
+ }
549
+
550
+ // 📖 Shared table refresh helper so command-palette and hotkeys keep identical behavior.
551
+ function refreshVisibleSorted({ resetCursor = true } = {}) {
552
+ const visible = state.results.filter(r => !r.hidden)
553
+ state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
554
+ if (resetCursor) {
555
+ state.cursor = 0
556
+ state.scrollOffset = 0
557
+ return
558
+ }
559
+ if (state.cursor >= state.visibleSorted.length) state.cursor = Math.max(0, state.visibleSorted.length - 1)
560
+ adjustScrollOffset(state)
561
+ }
562
+
563
+ function setSortColumnFromCommand(col) {
564
+ if (state.sortColumn === col) {
565
+ state.sortDirection = state.sortDirection === 'asc' ? 'desc' : 'asc'
566
+ } else {
567
+ state.sortColumn = col
568
+ state.sortDirection = 'asc'
569
+ }
570
+ refreshVisibleSorted({ resetCursor: true })
571
+ persistUiSettings()
572
+ }
573
+
574
+ function setTierFilterFromCommand(tierLabel) {
575
+ const nextMode = tierLabel === null ? 0 : TIER_CYCLE.indexOf(tierLabel)
576
+ state.tierFilterMode = nextMode >= 0 ? nextMode : 0
577
+ applyTierFilter()
578
+ refreshVisibleSorted({ resetCursor: true })
579
+ persistUiSettings()
580
+ }
581
+
582
+ function openSettingsOverlay() {
583
+ state.settingsOpen = true
584
+ state.settingsCursor = 0
585
+ state.settingsEditMode = false
586
+ state.settingsAddKeyMode = false
587
+ state.settingsEditBuffer = ''
588
+ state.settingsScrollOffset = 0
589
+ }
590
+
591
+ function openRecommendOverlay() {
592
+ state.recommendOpen = true
593
+ state.recommendPhase = 'questionnaire'
594
+ state.recommendQuestion = 0
595
+ state.recommendCursor = 0
596
+ state.recommendAnswers = { taskType: null, priority: null, contextBudget: null }
597
+ state.recommendResults = []
598
+ state.recommendScrollOffset = 0
599
+ }
600
+
601
+ function openInstallEndpointsOverlay() {
602
+ state.installEndpointsOpen = true
603
+ state.installEndpointsPhase = 'providers'
604
+ state.installEndpointsCursor = 0
605
+ state.installEndpointsScrollOffset = 0
606
+ state.installEndpointsProviderKey = null
607
+ state.installEndpointsToolMode = null
608
+ state.installEndpointsConnectionMode = null
609
+ state.installEndpointsScope = null
610
+ state.installEndpointsSelectedModelIds = new Set()
611
+ state.installEndpointsErrorMsg = null
612
+ state.installEndpointsResult = null
613
+ }
614
+
615
+ function openFeedbackOverlay() {
616
+ state.feedbackOpen = true
617
+ state.bugReportBuffer = ''
618
+ state.bugReportStatus = 'idle'
619
+ state.bugReportError = null
620
+ }
621
+
622
+ function openChangelogOverlay() {
623
+ state.changelogOpen = true
624
+ state.changelogScrollOffset = 0
625
+ state.changelogPhase = 'index'
626
+ state.changelogCursor = 0
627
+ state.changelogSelectedVersion = null
628
+ }
629
+
630
+ function cycleToolMode() {
631
+ const modeOrder = getToolModeOrder()
632
+ const currentIndex = modeOrder.indexOf(state.mode)
633
+ const nextIndex = (currentIndex + 1) % modeOrder.length
634
+ state.mode = modeOrder[nextIndex]
635
+ if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
636
+ state.config.settings.preferredToolMode = state.mode
637
+ saveConfig(state.config)
638
+ }
639
+
640
+ function resetViewSettings() {
641
+ state.tierFilterMode = 0
642
+ state.originFilterMode = 0
643
+ state.sortColumn = 'avg'
644
+ state.sortDirection = 'asc'
645
+ if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
646
+ delete state.config.settings.tierFilter
647
+ delete state.config.settings.originFilter
648
+ delete state.config.settings.sortColumn
649
+ delete state.config.settings.sortAsc
650
+ saveConfig(state.config)
651
+ applyTierFilter()
652
+ refreshVisibleSorted({ resetCursor: true })
653
+ }
654
+
655
+ function toggleFavoriteOnSelectedRow() {
656
+ const selected = state.visibleSorted[state.cursor]
657
+ if (!selected) return
658
+ const wasFavorite = selected.isFavorite
659
+ toggleFavoriteModel(state.config, selected.providerKey, selected.modelId)
660
+ syncFavoriteFlags(state.results, state.config)
661
+ applyTierFilter()
662
+ refreshVisibleSorted({ resetCursor: false })
663
+
664
+ if (wasFavorite) {
665
+ state.cursor = 0
666
+ state.scrollOffset = 0
667
+ return
668
+ }
669
+
670
+ const selectedKey = toFavoriteKey(selected.providerKey, selected.modelId)
671
+ const newCursor = state.visibleSorted.findIndex(r => toFavoriteKey(r.providerKey, r.modelId) === selectedKey)
672
+ if (newCursor >= 0) state.cursor = newCursor
673
+ adjustScrollOffset(state)
674
+ }
675
+
676
+ function commandPaletteHasBlockingOverlay() {
677
+ return state.settingsOpen
678
+ || state.installEndpointsOpen
679
+ || state.toolInstallPromptOpen
680
+ || state.recommendOpen
681
+ || state.feedbackOpen
682
+ || state.helpVisible
683
+ || state.changelogOpen
684
+ }
685
+
686
+ function refreshCommandPaletteResults() {
687
+ const commands = buildCommandPaletteEntries()
688
+ state.commandPaletteResults = filterCommandPaletteEntries(commands, state.commandPaletteQuery)
689
+ if (state.commandPaletteCursor >= state.commandPaletteResults.length) {
690
+ state.commandPaletteCursor = Math.max(0, state.commandPaletteResults.length - 1)
691
+ }
692
+ }
693
+
694
+ function openCommandPalette() {
695
+ state.commandPaletteOpen = true
696
+ state.commandPaletteFrozenTable = null
697
+ state.commandPaletteQuery = ''
698
+ state.commandPaletteCursor = 0
699
+ state.commandPaletteScrollOffset = 0
700
+ refreshCommandPaletteResults()
701
+ }
702
+
703
+ function closeCommandPalette() {
704
+ state.commandPaletteOpen = false
705
+ state.commandPaletteFrozenTable = null
706
+ state.commandPaletteQuery = ''
707
+ state.commandPaletteCursor = 0
708
+ state.commandPaletteScrollOffset = 0
709
+ state.commandPaletteResults = []
710
+ }
711
+
712
+ function executeCommandPaletteEntry(entry) {
713
+ if (!entry?.id) return
714
+
715
+ if (entry.id.startsWith('filter-tier-')) {
716
+ setTierFilterFromCommand(entry.tierValue ?? null)
717
+ return
718
+ }
719
+
720
+ switch (entry.id) {
721
+ case 'filter-provider-cycle':
722
+ state.originFilterMode = (state.originFilterMode + 1) % ORIGIN_CYCLE.length
723
+ applyTierFilter()
724
+ refreshVisibleSorted({ resetCursor: true })
725
+ persistUiSettings()
726
+ return
727
+ case 'filter-configured-toggle':
728
+ state.hideUnconfiguredModels = !state.hideUnconfiguredModels
729
+ if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
730
+ state.config.settings.hideUnconfiguredModels = state.hideUnconfiguredModels
731
+ saveConfig(state.config)
732
+ applyTierFilter()
733
+ refreshVisibleSorted({ resetCursor: true })
734
+ return
735
+ case 'sort-rank': return setSortColumnFromCommand('rank')
736
+ case 'sort-tier': return setSortColumnFromCommand('tier')
737
+ case 'sort-provider': return setSortColumnFromCommand('origin')
738
+ case 'sort-model': return setSortColumnFromCommand('model')
739
+ case 'sort-latest-ping': return setSortColumnFromCommand('ping')
740
+ case 'sort-avg-ping': return setSortColumnFromCommand('avg')
741
+ case 'sort-swe': return setSortColumnFromCommand('swe')
742
+ case 'sort-ctx': return setSortColumnFromCommand('ctx')
743
+ case 'sort-health': return setSortColumnFromCommand('condition')
744
+ case 'sort-verdict': return setSortColumnFromCommand('verdict')
745
+ case 'sort-stability': return setSortColumnFromCommand('stability')
746
+ case 'sort-uptime': return setSortColumnFromCommand('uptime')
747
+ case 'open-settings': return openSettingsOverlay()
748
+ case 'open-help':
749
+ state.helpVisible = true
750
+ state.helpScrollOffset = 0
751
+ return
752
+ case 'open-changelog': return openChangelogOverlay()
753
+ case 'open-feedback': return openFeedbackOverlay()
754
+ case 'open-recommend': return openRecommendOverlay()
755
+ case 'open-install-endpoints': return openInstallEndpointsOverlay()
756
+ case 'action-cycle-theme': return cycleGlobalTheme()
757
+ case 'action-cycle-tool-mode': return cycleToolMode()
758
+ case 'action-cycle-ping-mode': {
759
+ const currentIdx = PING_MODE_CYCLE.indexOf(state.pingMode)
760
+ const nextIdx = currentIdx >= 0 ? (currentIdx + 1) % PING_MODE_CYCLE.length : 0
761
+ setPingMode(PING_MODE_CYCLE[nextIdx], 'manual')
762
+ return
763
+ }
764
+ case 'action-toggle-favorite': return toggleFavoriteOnSelectedRow()
765
+ case 'action-reset-view': return resetViewSettings()
766
+ default:
767
+ return
768
+ }
769
+ }
770
+
430
771
  return async (str, key) => {
431
772
  if (!key) return
432
773
  noteUserActivity()
433
774
 
775
+ // 📖 Ctrl+P toggles the command palette from the main table only.
776
+ if (key.ctrl && key.name === 'p') {
777
+ if (state.commandPaletteOpen) {
778
+ closeCommandPalette()
779
+ return
780
+ }
781
+ if (!commandPaletteHasBlockingOverlay()) {
782
+ openCommandPalette()
783
+ }
784
+ return
785
+ }
786
+
787
+ // 📖 Command palette captures the keyboard while active.
788
+ if (state.commandPaletteOpen) {
789
+ if (key.ctrl && key.name === 'c') { exit(0); return }
790
+
791
+ const pageStep = Math.max(1, (state.terminalRows || 1) - 10)
792
+
793
+ if (key.name === 'escape') {
794
+ closeCommandPalette()
795
+ return
796
+ }
797
+ if (key.name === 'up') {
798
+ const count = state.commandPaletteResults.length
799
+ if (count === 0) return
800
+ state.commandPaletteCursor = state.commandPaletteCursor > 0 ? state.commandPaletteCursor - 1 : count - 1
801
+ return
802
+ }
803
+ if (key.name === 'down') {
804
+ const count = state.commandPaletteResults.length
805
+ if (count === 0) return
806
+ state.commandPaletteCursor = state.commandPaletteCursor < count - 1 ? state.commandPaletteCursor + 1 : 0
807
+ return
808
+ }
809
+ if (key.name === 'pageup') {
810
+ state.commandPaletteCursor = Math.max(0, state.commandPaletteCursor - pageStep)
811
+ return
812
+ }
813
+ if (key.name === 'pagedown') {
814
+ const max = Math.max(0, state.commandPaletteResults.length - 1)
815
+ state.commandPaletteCursor = Math.min(max, state.commandPaletteCursor + pageStep)
816
+ return
817
+ }
818
+ if (key.name === 'home') {
819
+ state.commandPaletteCursor = 0
820
+ return
821
+ }
822
+ if (key.name === 'end') {
823
+ state.commandPaletteCursor = Math.max(0, state.commandPaletteResults.length - 1)
824
+ return
825
+ }
826
+ if (key.name === 'backspace') {
827
+ state.commandPaletteQuery = state.commandPaletteQuery.slice(0, -1)
828
+ state.commandPaletteCursor = 0
829
+ state.commandPaletteScrollOffset = 0
830
+ refreshCommandPaletteResults()
831
+ return
832
+ }
833
+ if (key.name === 'return') {
834
+ const selectedCommand = state.commandPaletteResults[state.commandPaletteCursor]
835
+ closeCommandPalette()
836
+ executeCommandPaletteEntry(selectedCommand)
837
+ return
838
+ }
839
+ if (str && str.length === 1 && !key.ctrl && !key.meta) {
840
+ state.commandPaletteQuery += str
841
+ state.commandPaletteCursor = 0
842
+ state.commandPaletteScrollOffset = 0
843
+ refreshCommandPaletteResults()
844
+ return
845
+ }
846
+ return
847
+ }
848
+
849
+ if (!state.feedbackOpen && !state.settingsEditMode && !state.settingsAddKeyMode && key.name === 'g' && !key.ctrl && !key.meta) {
850
+ cycleGlobalTheme()
851
+ return
852
+ }
853
+
434
854
  // 📖 Profile system removed - API keys now persist permanently across all sessions
435
855
 
436
856
  // 📖 Install Endpoints overlay: provider → tool → connection → scope → optional model subset.
@@ -614,6 +1034,44 @@ export function createKeyHandler(ctx) {
614
1034
  return
615
1035
  }
616
1036
 
1037
+ if (state.toolInstallPromptOpen) {
1038
+ if (key.ctrl && key.name === 'c') { exit(0); return }
1039
+
1040
+ const installPlan = state.toolInstallPromptPlan || getToolInstallPlan(state.toolInstallPromptMode)
1041
+ const installSupported = Boolean(installPlan?.supported)
1042
+
1043
+ if (key.name === 'escape') {
1044
+ resetToolInstallPrompt()
1045
+ return
1046
+ }
1047
+
1048
+ if (installSupported && key.name === 'up') {
1049
+ state.toolInstallPromptCursor = Math.max(0, state.toolInstallPromptCursor - 1)
1050
+ return
1051
+ }
1052
+
1053
+ if (installSupported && key.name === 'down') {
1054
+ state.toolInstallPromptCursor = Math.min(1, state.toolInstallPromptCursor + 1)
1055
+ return
1056
+ }
1057
+
1058
+ if (key.name === 'return') {
1059
+ if (!installSupported) {
1060
+ resetToolInstallPrompt()
1061
+ return
1062
+ }
1063
+
1064
+ const selectedModel = state.toolInstallPromptModel
1065
+ const shouldInstall = state.toolInstallPromptCursor === 0
1066
+ resetToolInstallPrompt()
1067
+
1068
+ if (!shouldInstall || !selectedModel) return
1069
+ await installMissingToolAndLaunch(selectedModel, installPlan)
1070
+ }
1071
+
1072
+ return
1073
+ }
1074
+
617
1075
  // 📖 Feedback overlay: intercept ALL keys while overlay is active.
618
1076
  // 📖 Enter → send to Discord, Esc → cancel, Backspace → delete char, printable → append to buffer.
619
1077
  if (state.feedbackOpen) {
@@ -1078,6 +1536,11 @@ export function createKeyHandler(ctx) {
1078
1536
  return
1079
1537
  }
1080
1538
 
1539
+ if (state.settingsCursor === themeRowIdx) {
1540
+ cycleGlobalTheme()
1541
+ return
1542
+ }
1543
+
1081
1544
  if (state.settingsCursor === cleanupLegacyProxyRowIdx) {
1082
1545
  runLegacyProxyCleanup()
1083
1546
  return
@@ -1098,6 +1561,7 @@ export function createKeyHandler(ctx) {
1098
1561
 
1099
1562
  // 📖 Enter edit mode for the selected provider's key
1100
1563
  const pk = providerKeys[state.settingsCursor]
1564
+ if (!pk) return
1101
1565
  state.settingsEditBuffer = resolveApiKeys(state.config, pk)[0] ?? ''
1102
1566
  state.settingsEditMode = true
1103
1567
  return
@@ -1112,15 +1576,7 @@ export function createKeyHandler(ctx) {
1112
1576
  ) return
1113
1577
  // 📖 Theme configuration cycle inside settings
1114
1578
  if (state.settingsCursor === themeRowIdx) {
1115
- const themes = ['dark', 'light', 'auto']
1116
- const currentTheme = state.config.settings?.theme || 'dark'
1117
- const nextIndex = (themes.indexOf(currentTheme) + 1) % themes.length
1118
- state.config.settings.theme = themes[nextIndex]
1119
- saveConfig(state.config)
1120
- try {
1121
- const { detectActiveTheme } = await import('../src/theme.js')
1122
- detectActiveTheme(state.config.settings.theme)
1123
- } catch {}
1579
+ cycleGlobalTheme()
1124
1580
  return
1125
1581
  }
1126
1582
  // 📖 Widths Warning toggle (disable/enable)
@@ -1142,6 +1598,8 @@ export function createKeyHandler(ctx) {
1142
1598
  if (key.name === 't') {
1143
1599
  if (
1144
1600
  state.settingsCursor === updateRowIdx
1601
+ || state.settingsCursor === widthWarningRowIdx
1602
+ || state.settingsCursor === themeRowIdx
1145
1603
  || state.settingsCursor === cleanupLegacyProxyRowIdx
1146
1604
  || state.settingsCursor === changelogViewRowIdx
1147
1605
  ) return
@@ -1149,6 +1607,7 @@ export function createKeyHandler(ctx) {
1149
1607
 
1150
1608
  // 📖 Test the selected provider's key (fires a real ping)
1151
1609
  const pk = providerKeys[state.settingsCursor]
1610
+ if (!pk) return
1152
1611
  testProviderKey(pk)
1153
1612
  return
1154
1613
  }
@@ -1193,41 +1652,20 @@ export function createKeyHandler(ctx) {
1193
1652
  }
1194
1653
 
1195
1654
  // 📖 P key: open settings screen
1196
- if (key.name === 'p' && !key.shift) {
1197
- state.settingsOpen = true
1198
- state.settingsCursor = 0
1199
- state.settingsEditMode = false
1200
- state.settingsAddKeyMode = false
1201
- state.settingsEditBuffer = ''
1202
- state.settingsScrollOffset = 0
1655
+ if (key.name === 'p' && !key.shift && !key.ctrl && !key.meta) {
1656
+ openSettingsOverlay()
1203
1657
  return
1204
1658
  }
1205
1659
 
1206
1660
  // 📖 Q key: open Smart Recommend overlay
1207
1661
  if (key.name === 'q') {
1208
- state.recommendOpen = true
1209
- state.recommendPhase = 'questionnaire'
1210
- state.recommendQuestion = 0
1211
- state.recommendCursor = 0
1212
- state.recommendAnswers = { taskType: null, priority: null, contextBudget: null }
1213
- state.recommendResults = []
1214
- state.recommendScrollOffset = 0
1662
+ openRecommendOverlay()
1215
1663
  return
1216
1664
  }
1217
1665
 
1218
1666
  // 📖 Y key: open Install Endpoints flow for configured providers.
1219
1667
  if (key.name === 'y') {
1220
- state.installEndpointsOpen = true
1221
- state.installEndpointsPhase = 'providers'
1222
- state.installEndpointsCursor = 0
1223
- state.installEndpointsScrollOffset = 0
1224
- state.installEndpointsProviderKey = null
1225
- state.installEndpointsToolMode = null
1226
- state.installEndpointsConnectionMode = null
1227
- state.installEndpointsScope = null
1228
- state.installEndpointsSelectedModelIds = new Set()
1229
- state.installEndpointsErrorMsg = null
1230
- state.installEndpointsResult = null
1668
+ openInstallEndpointsOverlay()
1231
1669
  return
1232
1670
  }
1233
1671
 
@@ -1235,34 +1673,9 @@ export function createKeyHandler(ctx) {
1235
1673
 
1236
1674
  // 📖 Profile system removed - API keys now persist permanently across all sessions
1237
1675
 
1238
- // 📖 Helper: persist current UI view settings (tier, provider, sort) to config.settings
1239
- // 📖 Called after every T / D / sort key so preferences survive session restarts.
1240
- function persistUiSettings() {
1241
- if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
1242
- state.config.settings.tierFilter = TIER_CYCLE[state.tierFilterMode]
1243
- state.config.settings.originFilter = ORIGIN_CYCLE[state.originFilterMode] ?? null
1244
- state.config.settings.sortColumn = state.sortColumn
1245
- state.config.settings.sortAsc = state.sortDirection === 'asc'
1246
- saveConfig(state.config)
1247
- }
1248
-
1249
1676
  // 📖 Shift+R: reset all UI view settings to defaults (tier, sort, provider) and clear persisted config
1250
1677
  if (key.name === 'r' && key.shift) {
1251
- state.tierFilterMode = 0
1252
- state.originFilterMode = 0
1253
- state.sortColumn = 'avg'
1254
- state.sortDirection = 'asc'
1255
- if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
1256
- delete state.config.settings.tierFilter
1257
- delete state.config.settings.originFilter
1258
- delete state.config.settings.sortColumn
1259
- delete state.config.settings.sortAsc
1260
- saveConfig(state.config)
1261
- applyTierFilter()
1262
- const visible = state.results.filter(r => !r.hidden)
1263
- state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
1264
- state.cursor = 0
1265
- state.scrollOffset = 0
1678
+ resetViewSettings()
1266
1679
  return
1267
1680
  }
1268
1681
 
@@ -1277,54 +1690,19 @@ export function createKeyHandler(ctx) {
1277
1690
 
1278
1691
  if (sortKeys[key.name] && !key.ctrl && !key.shift) {
1279
1692
  const col = sortKeys[key.name]
1280
- // 📖 Toggle direction if same column, otherwise reset to asc
1281
- if (state.sortColumn === col) {
1282
- state.sortDirection = state.sortDirection === 'asc' ? 'desc' : 'asc'
1283
- } else {
1284
- state.sortColumn = col
1285
- state.sortDirection = 'asc'
1286
- }
1287
- // 📖 Recompute visible sorted list and reset cursor to top to avoid stale index
1288
- const visible = state.results.filter(r => !r.hidden)
1289
- state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
1290
- state.cursor = 0
1291
- state.scrollOffset = 0
1292
- persistUiSettings()
1693
+ setSortColumnFromCommand(col)
1293
1694
  return
1294
1695
  }
1295
1696
 
1296
1697
  // 📖 F key: toggle favorite on the currently selected row and persist to config.
1297
1698
  if (key.name === 'f') {
1298
- const selected = state.visibleSorted[state.cursor]
1299
- if (!selected) return
1300
- const wasFavorite = selected.isFavorite
1301
- toggleFavoriteModel(state.config, selected.providerKey, selected.modelId)
1302
- syncFavoriteFlags(state.results, state.config)
1303
- applyTierFilter()
1304
- const visible = state.results.filter(r => !r.hidden)
1305
- state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
1306
-
1307
- // 📖 UX rule: when unpinning a favorite, jump back to the top of the list.
1308
- if (wasFavorite) {
1309
- state.cursor = 0
1310
- state.scrollOffset = 0
1311
- return
1312
- }
1313
-
1314
- const selectedKey = toFavoriteKey(selected.providerKey, selected.modelId)
1315
- const newCursor = state.visibleSorted.findIndex(r => toFavoriteKey(r.providerKey, r.modelId) === selectedKey)
1316
- if (newCursor >= 0) state.cursor = newCursor
1317
- else if (state.cursor >= state.visibleSorted.length) state.cursor = Math.max(0, state.visibleSorted.length - 1)
1318
- adjustScrollOffset(state)
1699
+ toggleFavoriteOnSelectedRow()
1319
1700
  return
1320
1701
  }
1321
1702
 
1322
1703
  // 📖 I key: open Feedback overlay (anonymous Discord feedback)
1323
1704
  if (key.name === 'i') {
1324
- state.feedbackOpen = true
1325
- state.bugReportBuffer = ''
1326
- state.bugReportStatus = 'idle'
1327
- state.bugReportError = null
1705
+ openFeedbackOverlay()
1328
1706
  return
1329
1707
  }
1330
1708
 
@@ -1399,13 +1777,7 @@ export function createKeyHandler(ctx) {
1399
1777
 
1400
1778
  // 📖 Mode toggle key: Z cycles through the supported tool targets.
1401
1779
  if (key.name === 'z') {
1402
- const modeOrder = getToolModeOrder()
1403
- const currentIndex = modeOrder.indexOf(state.mode)
1404
- const nextIndex = (currentIndex + 1) % modeOrder.length
1405
- state.mode = modeOrder[nextIndex]
1406
- if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
1407
- state.config.settings.preferredToolMode = state.mode
1408
- saveConfig(state.config)
1780
+ cycleToolMode()
1409
1781
  return
1410
1782
  }
1411
1783
 
@@ -1442,46 +1814,24 @@ export function createKeyHandler(ctx) {
1442
1814
  // 📖 Use the cached visible+sorted array — guaranteed to match what's on screen
1443
1815
  const selected = state.visibleSorted[state.cursor]
1444
1816
  if (!selected) return // 📖 Guard: empty visible list (all filtered out)
1445
- // 📖 Allow selecting ANY model (even timeout/down) - user knows what they're doing
1446
- userSelected = { modelId: selected.modelId, label: selected.label, tier: selected.tier, providerKey: selected.providerKey }
1447
-
1448
- // 📖 Stop everything and act on selection immediately
1449
- readline.emitKeypressEvents(process.stdin)
1450
- process.stdin.setRawMode(true)
1451
- stopUi()
1452
-
1453
- // 📖 Show selection with status
1454
- if (selected.status === 'timeout') {
1455
- console.log(chalk.yellow(` ⚠ Selected: ${selected.label} (currently timing out)`))
1456
- } else if (selected.status === 'down') {
1457
- console.log(chalk.red(` ⚠ Selected: ${selected.label} (currently down)`))
1458
- } else {
1459
- console.log(chalk.cyan(` ✓ Selected: ${selected.label}`))
1460
- }
1461
- console.log()
1462
-
1463
- // 📖 Warn if no API key is configured for the selected model's provider
1464
- if (state.mode !== 'openclaw') {
1465
- const selectedApiKey = getApiKey(state.config, selected.providerKey)
1466
- if (!selectedApiKey) {
1467
- console.log(chalk.yellow(` Warning: No API key configured for ${selected.providerKey}.`))
1468
- console.log(chalk.yellow(` The selected tool may not be able to use ${selected.label}.`))
1469
- console.log(chalk.dim(` Set ${ENV_VAR_NAMES[selected.providerKey] || selected.providerKey.toUpperCase() + '_API_KEY'} or configure via settings (P key).`))
1470
- console.log()
1817
+ if (shouldCheckMissingTool(state.mode) && !isToolInstalled(state.mode)) {
1818
+ state.toolInstallPromptOpen = true
1819
+ state.toolInstallPromptCursor = 0
1820
+ state.toolInstallPromptScrollOffset = 0
1821
+ state.toolInstallPromptMode = state.mode
1822
+ state.toolInstallPromptModel = {
1823
+ modelId: selected.modelId,
1824
+ label: selected.label,
1825
+ tier: selected.tier,
1826
+ providerKey: selected.providerKey,
1827
+ status: selected.status,
1471
1828
  }
1829
+ state.toolInstallPromptPlan = getToolInstallPlan(state.mode)
1830
+ state.toolInstallPromptErrorMsg = null
1831
+ return
1472
1832
  }
1473
1833
 
1474
- // 📖 Dispatch to the correct integration based on active mode
1475
- if (state.mode === 'openclaw') {
1476
- await startOpenClaw(userSelected, state.config)
1477
- } else if (state.mode === 'opencode-desktop') {
1478
- await startOpenCodeDesktop(userSelected, state.config)
1479
- } else if (state.mode === 'opencode') {
1480
- await startOpenCode(userSelected, state.config)
1481
- } else {
1482
- await startExternalTool(state.mode, userSelected, state.config)
1483
- }
1484
- process.exit(0)
1834
+ await launchSelectedModel(selected)
1485
1835
  }
1486
1836
  }
1487
1837
  }