free-coding-models 0.3.18 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ---
4
4
 
5
+ ## 0.3.19
6
+
7
+ ### Added
8
+ - **Command palette overlay (`Ctrl+P`)**: Added a searchable floating palette with fuzzy matching so users can quickly run filters, sorts, overlays, and global actions.
9
+
10
+ ### Changed
11
+ - **Main footer and help discoverability**: surfaced `Ctrl+P` in table hints and Help overlay so the new command launcher is visible immediately.
12
+ - **Command palette spacing polish**: added two-character inner padding around the floating palette so the overlay feels less cramped and visually cleaner.
13
+
14
+ ### Fixed
15
+ - **Command palette visual jitter**: background ping cycles now pause while the command palette is open so table rows stop reshuffling during command search.
16
+ - **Command palette background freeze**: while the palette is open, the full table behind it is now frozen (spinner glyphs, ping countdown, and dynamic row updates) and resumes instantly on close.
17
+
5
18
  ## 0.3.18
6
19
 
7
20
  ### Added
package/README.md CHANGED
@@ -176,6 +176,7 @@ Press **`Z`** in the TUI to cycle between tools without restarting.
176
176
  | `E` | Toggle configured-only mode |
177
177
  | `F` | Favorite / unfavorite model |
178
178
  | `G` | Cycle global theme (`Auto → Dark → Light`) |
179
+ | `Ctrl+P` | Open command palette (search + run actions) |
179
180
  | `R/S/C/M/O/L/A/H/V/B/U` | Sort columns |
180
181
  | `P` | Settings (API keys, providers, updates, theme) |
181
182
  | `Y` | Install Endpoints (push provider into tool config) |
@@ -200,6 +201,7 @@ Press **`Z`** in the TUI to cycle between tools without restarting.
200
201
  - **Configured-only default** — only shows providers you have keys for
201
202
  - **Keyless latency** — models ping even without an API key (show 🔑 NO KEY)
202
203
  - **Smart Recommend** — questionnaire picks the best model for your task type
204
+ - **Command Palette** — `Ctrl+P` opens a searchable action launcher for filters, sorting, overlays, and quick toggles
203
205
  - **Install Endpoints** — push a full provider catalog into any tool's config (`Y`)
204
206
  - **Missing tool bootstrap** — detect absent CLIs, offer one-click install, then continue the selected launch automatically
205
207
  - **Width guardrail** — shows a warning instead of a broken table in narrow terminals
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.3.18",
3
+ "version": "0.3.19",
4
4
  "description": "Find the fastest coding LLM models in seconds — ping free models from multiple providers, pick the best one for OpenCode, Cursor, or any AI coding assistant.",
5
5
  "keywords": [
6
6
  "nvidia",
package/src/app.js CHANGED
@@ -406,6 +406,12 @@ export async function runApp(cliArgs, config) {
406
406
  settingsUpdateError: null, // 📖 Last update-check error message for maintenance row
407
407
  config, // 📖 Live reference to the config object (updated on save)
408
408
  visibleSorted: [], // 📖 Cached visible+sorted models — shared between render loop and key handlers
409
+ commandPaletteOpen: false, // 📖 Whether the Ctrl+P command palette overlay is active.
410
+ commandPaletteQuery: '', // 📖 Current command palette search query.
411
+ commandPaletteCursor: 0, // 📖 Selected command index in the filtered command list.
412
+ commandPaletteScrollOffset: 0, // 📖 Vertical scroll offset for the command palette result viewport.
413
+ commandPaletteResults: [], // 📖 Cached fuzzy-filtered command entries for the command palette.
414
+ commandPaletteFrozenTable: null, // 📖 Frozen table snapshot rendered behind the command palette overlay.
409
415
  helpVisible: false, // 📖 Whether the help overlay (K key) is active
410
416
  settingsScrollOffset: 0, // 📖 Vertical scroll offset for Settings overlay viewport
411
417
  helpScrollOffset: 0, // 📖 Vertical scroll offset for Help overlay viewport
@@ -761,6 +767,7 @@ export async function runApp(cliArgs, config) {
761
767
  getToolMeta,
762
768
  getToolInstallPlan,
763
769
  padEndDisplay,
770
+ displayWidth,
764
771
  })
765
772
 
766
773
  onKeyPress = createKeyHandler({
@@ -858,16 +865,87 @@ export async function runApp(cliArgs, config) {
858
865
  refreshAutoPingMode()
859
866
  state.frame++
860
867
  // 📖 Cache visible+sorted models each frame so Enter handler always matches the display
861
- if (!state.settingsOpen && !state.installEndpointsOpen && !state.toolInstallPromptOpen && !state.recommendOpen && !state.feedbackOpen && !state.changelogOpen) {
868
+ if (!state.settingsOpen && !state.installEndpointsOpen && !state.toolInstallPromptOpen && !state.recommendOpen && !state.feedbackOpen && !state.changelogOpen && !state.commandPaletteOpen) {
862
869
  const visible = state.results.filter(r => !r.hidden)
863
870
  state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
864
871
  }
872
+ let tableContent = null
873
+ if (state.commandPaletteOpen) {
874
+ if (!state.commandPaletteFrozenTable) {
875
+ // 📖 Freeze the full table (including countdown and spinner glyphs) while
876
+ // 📖 the command palette is open so the background remains perfectly static.
877
+ state.commandPaletteFrozenTable = renderTable(
878
+ state.results,
879
+ state.pendingPings,
880
+ state.frame,
881
+ state.cursor,
882
+ state.sortColumn,
883
+ state.sortDirection,
884
+ state.pingInterval,
885
+ state.lastPingTime,
886
+ state.mode,
887
+ state.tierFilterMode,
888
+ state.scrollOffset,
889
+ state.terminalRows,
890
+ state.terminalCols,
891
+ state.originFilterMode,
892
+ null,
893
+ state.pingMode,
894
+ state.pingModeSource,
895
+ state.hideUnconfiguredModels,
896
+ state.widthWarningStartedAt,
897
+ state.widthWarningDismissed,
898
+ state.widthWarningShowCount,
899
+ state.settingsUpdateState,
900
+ state.settingsUpdateLatestVersion,
901
+ false,
902
+ state.startupLatestVersion,
903
+ state.versionAlertsEnabled,
904
+ state.config.settings?.disableWidthsWarning ?? false
905
+ )
906
+ }
907
+ tableContent = state.commandPaletteFrozenTable
908
+ } else {
909
+ state.commandPaletteFrozenTable = null
910
+ tableContent = renderTable(
911
+ state.results,
912
+ state.pendingPings,
913
+ state.frame,
914
+ state.cursor,
915
+ state.sortColumn,
916
+ state.sortDirection,
917
+ state.pingInterval,
918
+ state.lastPingTime,
919
+ state.mode,
920
+ state.tierFilterMode,
921
+ state.scrollOffset,
922
+ state.terminalRows,
923
+ state.terminalCols,
924
+ state.originFilterMode,
925
+ null,
926
+ state.pingMode,
927
+ state.pingModeSource,
928
+ state.hideUnconfiguredModels,
929
+ state.widthWarningStartedAt,
930
+ state.widthWarningDismissed,
931
+ state.widthWarningShowCount,
932
+ state.settingsUpdateState,
933
+ state.settingsUpdateLatestVersion,
934
+ false,
935
+ state.startupLatestVersion,
936
+ state.versionAlertsEnabled,
937
+ state.config.settings?.disableWidthsWarning ?? false
938
+ )
939
+ }
940
+
865
941
  const content = state.settingsOpen
866
942
  ? overlays.renderSettings()
867
943
  : state.installEndpointsOpen
868
944
  ? overlays.renderInstallEndpoints()
869
945
  : state.toolInstallPromptOpen
870
946
  ? overlays.renderToolInstallPrompt()
947
+ : state.commandPaletteOpen
948
+ ? tableContent + overlays.renderCommandPalette()
871
949
  : state.recommendOpen
872
950
  ? overlays.renderRecommend()
873
951
  : state.feedbackOpen
@@ -876,7 +954,7 @@ export async function runApp(cliArgs, config) {
876
954
  ? overlays.renderHelp()
877
955
  : state.changelogOpen
878
956
  ? overlays.renderChangelog()
879
- : renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, state.tierFilterMode, state.scrollOffset, state.terminalRows, state.terminalCols, state.originFilterMode, null, state.pingMode, state.pingModeSource, state.hideUnconfiguredModels, state.widthWarningStartedAt, state.widthWarningDismissed, state.widthWarningShowCount, state.settingsUpdateState, state.settingsUpdateLatestVersion, false, state.startupLatestVersion, state.versionAlertsEnabled, state.config.settings?.disableWidthsWarning ?? false)
957
+ : tableContent
880
958
  process.stdout.write(ALT_HOME + content)
881
959
  if (process.stdout.isTTY) {
882
960
  process.stdout.flush && process.stdout.flush()
@@ -920,6 +998,15 @@ export async function runApp(cliArgs, config) {
920
998
  const runPingCycle = async () => {
921
999
  try {
922
1000
  refreshAutoPingMode()
1001
+
1002
+ // 📖 Command palette intentionally pauses background ping bursts to avoid
1003
+ // 📖 visible row jitter while users type and navigate commands.
1004
+ if (state.commandPaletteOpen) {
1005
+ state.lastPingTime = Date.now()
1006
+ scheduleNextPing()
1007
+ return
1008
+ }
1009
+
923
1010
  state.lastPingTime = Date.now()
924
1011
 
925
1012
  // 📖 Refresh persisted usage snapshots each cycle so background usage data appears live in table.
@@ -0,0 +1,170 @@
1
+ /**
2
+ * @file command-palette.js
3
+ * @description Command palette registry and fuzzy search helpers for the main TUI.
4
+ *
5
+ * @functions
6
+ * → `buildCommandPaletteEntries` — builds the current command list with dynamic provider/tier context
7
+ * → `fuzzyMatchCommand` — scores a query against one string and returns match positions
8
+ * → `filterCommandPaletteEntries` — returns sorted command matches for a query
9
+ *
10
+ * @exports { COMMAND_CATEGORY_ORDER, buildCommandPaletteEntries, fuzzyMatchCommand, filterCommandPaletteEntries }
11
+ *
12
+ * @see src/key-handler.js
13
+ * @see src/overlays.js
14
+ */
15
+
16
+ export const COMMAND_CATEGORY_ORDER = ['Filters', 'Sort', 'Pages', 'Actions']
17
+
18
+ const COMMANDS = [
19
+ // 📖 Filters
20
+ { id: 'filter-tier-all', category: 'Filters', label: 'Filter tiers: all', shortcut: 'T', keywords: ['filter', 'tier', 'all'] },
21
+ { id: 'filter-tier-splus', category: 'Filters', label: 'Filter tiers: S+', shortcut: null, keywords: ['filter', 'tier', 's+'] },
22
+ { id: 'filter-tier-s', category: 'Filters', label: 'Filter tiers: S', shortcut: null, keywords: ['filter', 'tier', 's'] },
23
+ { id: 'filter-tier-aplus', category: 'Filters', label: 'Filter tiers: A+', shortcut: null, keywords: ['filter', 'tier', 'a+'] },
24
+ { id: 'filter-tier-a', category: 'Filters', label: 'Filter tiers: A', shortcut: null, keywords: ['filter', 'tier', 'a'] },
25
+ { id: 'filter-tier-aminus', category: 'Filters', label: 'Filter tiers: A-', shortcut: null, keywords: ['filter', 'tier', 'a-'] },
26
+ { id: 'filter-tier-bplus', category: 'Filters', label: 'Filter tiers: B+', shortcut: null, keywords: ['filter', 'tier', 'b+'] },
27
+ { id: 'filter-tier-b', category: 'Filters', label: 'Filter tiers: B', shortcut: null, keywords: ['filter', 'tier', 'b'] },
28
+ { id: 'filter-tier-c', category: 'Filters', label: 'Filter tiers: C', shortcut: null, keywords: ['filter', 'tier', 'c'] },
29
+ { id: 'filter-provider-cycle', category: 'Filters', label: 'Filter provider: cycle', shortcut: 'D', keywords: ['filter', 'provider', 'origin'] },
30
+ { id: 'filter-configured-toggle', category: 'Filters', label: 'Toggle configured-only models', shortcut: 'E', keywords: ['filter', 'configured', 'keys'] },
31
+
32
+ // 📖 Sorting
33
+ { id: 'sort-rank', category: 'Sort', label: 'Sort by rank', shortcut: 'R', keywords: ['sort', 'rank'] },
34
+ { id: 'sort-tier', category: 'Sort', label: 'Sort by tier', shortcut: null, keywords: ['sort', 'tier'] },
35
+ { id: 'sort-provider', category: 'Sort', label: 'Sort by provider', shortcut: 'O', keywords: ['sort', 'origin', 'provider'] },
36
+ { id: 'sort-model', category: 'Sort', label: 'Sort by model name', shortcut: 'M', keywords: ['sort', 'model', 'name'] },
37
+ { id: 'sort-latest-ping', category: 'Sort', label: 'Sort by latest ping', shortcut: 'L', keywords: ['sort', 'latest', 'ping'] },
38
+ { id: 'sort-avg-ping', category: 'Sort', label: 'Sort by average ping', shortcut: 'A', keywords: ['sort', 'avg', 'average', 'ping'] },
39
+ { id: 'sort-swe', category: 'Sort', label: 'Sort by SWE score', shortcut: 'S', keywords: ['sort', 'swe', 'score'] },
40
+ { id: 'sort-ctx', category: 'Sort', label: 'Sort by context window', shortcut: 'C', keywords: ['sort', 'context', 'ctx'] },
41
+ { id: 'sort-health', category: 'Sort', label: 'Sort by health', shortcut: 'H', keywords: ['sort', 'health', 'condition'] },
42
+ { id: 'sort-verdict', category: 'Sort', label: 'Sort by verdict', shortcut: 'V', keywords: ['sort', 'verdict'] },
43
+ { id: 'sort-stability', category: 'Sort', label: 'Sort by stability', shortcut: 'B', keywords: ['sort', 'stability'] },
44
+ { id: 'sort-uptime', category: 'Sort', label: 'Sort by uptime', shortcut: 'U', keywords: ['sort', 'uptime'] },
45
+
46
+ // 📖 Pages / overlays
47
+ { id: 'open-settings', category: 'Pages', label: 'Open settings', shortcut: 'P', keywords: ['settings', 'config', 'api key'] },
48
+ { id: 'open-help', category: 'Pages', label: 'Open help', shortcut: 'K', keywords: ['help', 'shortcuts', 'hotkeys'] },
49
+ { id: 'open-changelog', category: 'Pages', label: 'Open changelog', shortcut: 'N', keywords: ['changelog', 'release'] },
50
+ { id: 'open-feedback', category: 'Pages', label: 'Open feedback', shortcut: 'I', keywords: ['feedback', 'bug', 'request'] },
51
+ { id: 'open-recommend', category: 'Pages', label: 'Open smart recommend', shortcut: 'Q', keywords: ['recommend', 'best model'] },
52
+ { id: 'open-install-endpoints', category: 'Pages', label: 'Open install endpoints', shortcut: 'Y', keywords: ['install', 'endpoints', 'providers'] },
53
+
54
+ // 📖 Actions
55
+ { id: 'action-cycle-theme', category: 'Actions', label: 'Cycle theme', shortcut: 'G', keywords: ['theme', 'dark', 'light', 'auto'] },
56
+ { id: 'action-cycle-tool-mode', category: 'Actions', label: 'Cycle tool mode', shortcut: 'Z', keywords: ['tool', 'mode', 'launcher'] },
57
+ { id: 'action-cycle-ping-mode', category: 'Actions', label: 'Cycle ping mode', shortcut: 'W', keywords: ['ping', 'cadence', 'speed', 'slow'] },
58
+ { id: 'action-toggle-favorite', category: 'Actions', label: 'Toggle favorite on selected model', shortcut: 'F', keywords: ['favorite', 'star'] },
59
+ { id: 'action-reset-view', category: 'Actions', label: 'Reset view settings', shortcut: 'Shift+R', keywords: ['reset', 'view', 'sort', 'filters'] },
60
+ ]
61
+
62
+ const ID_TO_TIER = {
63
+ 'filter-tier-all': null,
64
+ 'filter-tier-splus': 'S+',
65
+ 'filter-tier-s': 'S',
66
+ 'filter-tier-aplus': 'A+',
67
+ 'filter-tier-a': 'A',
68
+ 'filter-tier-aminus': 'A-',
69
+ 'filter-tier-bplus': 'B+',
70
+ 'filter-tier-b': 'B',
71
+ 'filter-tier-c': 'C',
72
+ }
73
+
74
+ export function buildCommandPaletteEntries() {
75
+ return COMMANDS.map((entry) => ({
76
+ ...entry,
77
+ tierValue: Object.prototype.hasOwnProperty.call(ID_TO_TIER, entry.id) ? ID_TO_TIER[entry.id] : undefined,
78
+ }))
79
+ }
80
+
81
+ /**
82
+ * 📖 Fuzzy matching optimized for short command labels and keyboard aliases.
83
+ * @param {string} query
84
+ * @param {string} text
85
+ * @returns {{ matched: boolean, score: number, positions: number[] }}
86
+ */
87
+ export function fuzzyMatchCommand(query, text) {
88
+ const q = (query || '').trim().toLowerCase()
89
+ const t = (text || '').toLowerCase()
90
+
91
+ if (!q) return { matched: true, score: 0, positions: [] }
92
+ if (!t) return { matched: false, score: 0, positions: [] }
93
+
94
+ let qIdx = 0
95
+ const positions = []
96
+ for (let i = 0; i < t.length && qIdx < q.length; i++) {
97
+ if (q[qIdx] === t[i]) {
98
+ positions.push(i)
99
+ qIdx++
100
+ }
101
+ }
102
+
103
+ if (qIdx !== q.length) return { matched: false, score: 0, positions: [] }
104
+
105
+ let score = q.length * 10
106
+
107
+ // 📖 Bonus when matches are contiguous.
108
+ for (let i = 1; i < positions.length; i++) {
109
+ if (positions[i] === positions[i - 1] + 1) score += 5
110
+ }
111
+
112
+ // 📖 Bonus for word boundaries and prefix matches.
113
+ for (const pos of positions) {
114
+ if (pos === 0) score += 8
115
+ else {
116
+ const prev = t[pos - 1]
117
+ if (prev === ' ' || prev === ':' || prev === '-' || prev === '/') score += 6
118
+ }
119
+ }
120
+
121
+ // 📖 Small penalty for very long labels so focused labels float up.
122
+ score -= Math.max(0, t.length - q.length)
123
+
124
+ return { matched: true, score, positions }
125
+ }
126
+
127
+ /**
128
+ * 📖 Filter and rank command palette entries by fuzzy score.
129
+ * @param {Array<{ id: string, label: string, category: string, keywords?: string[] }>} entries
130
+ * @param {string} query
131
+ * @returns {Array<{ id: string, label: string, category: string, shortcut?: string|null, keywords?: string[], score: number, matchPositions: number[] }>}
132
+ */
133
+ export function filterCommandPaletteEntries(entries, query) {
134
+ const normalizedQuery = (query || '').trim()
135
+
136
+ const ranked = []
137
+ for (const entry of entries) {
138
+ const labelMatch = fuzzyMatchCommand(normalizedQuery, entry.label)
139
+ let bestScore = labelMatch.score
140
+ let matchPositions = labelMatch.positions
141
+ let matched = labelMatch.matched
142
+
143
+ if (!matched && Array.isArray(entry.keywords)) {
144
+ for (const keyword of entry.keywords) {
145
+ const keywordMatch = fuzzyMatchCommand(normalizedQuery, keyword)
146
+ if (!keywordMatch.matched) continue
147
+ matched = true
148
+ // 📖 Keyword matches should rank below direct label matches.
149
+ const keywordScore = Math.max(1, keywordMatch.score - 7)
150
+ if (keywordScore > bestScore) {
151
+ bestScore = keywordScore
152
+ matchPositions = []
153
+ }
154
+ }
155
+ }
156
+
157
+ if (!matched) continue
158
+ ranked.push({ ...entry, score: bestScore, matchPositions })
159
+ }
160
+
161
+ ranked.sort((a, b) => {
162
+ if (b.score !== a.score) return b.score - a.score
163
+ const aCat = COMMAND_CATEGORY_ORDER.indexOf(a.category)
164
+ const bCat = COMMAND_CATEGORY_ORDER.indexOf(b.category)
165
+ if (aCat !== bCat) return aCat - bCat
166
+ return a.label.localeCompare(b.label)
167
+ })
168
+
169
+ return ranked
170
+ }
@@ -31,6 +31,7 @@ import { loadChangelog } from './changelog-loader.js'
31
31
  import { loadConfig, replaceConfigContents } from './config.js'
32
32
  import { cleanupLegacyProxyArtifacts } from './legacy-proxy-cleanup.js'
33
33
  import { cycleThemeSetting, detectActiveTheme } from './theme.js'
34
+ import { buildCommandPaletteEntries, filterCommandPaletteEntries } from './command-palette.js'
34
35
 
35
36
  // 📖 Some providers need an explicit probe model because the first catalog entry
36
37
  // 📖 is not guaranteed to be accepted by their chat endpoint.
@@ -536,10 +537,315 @@ export function createKeyHandler(ctx) {
536
537
  state.installEndpointsErrorMsg = null
537
538
  }
538
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
+
539
771
  return async (str, key) => {
540
772
  if (!key) return
541
773
  noteUserActivity()
542
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
+
543
849
  if (!state.feedbackOpen && !state.settingsEditMode && !state.settingsAddKeyMode && key.name === 'g' && !key.ctrl && !key.meta) {
544
850
  cycleGlobalTheme()
545
851
  return
@@ -1346,41 +1652,20 @@ export function createKeyHandler(ctx) {
1346
1652
  }
1347
1653
 
1348
1654
  // 📖 P key: open settings screen
1349
- if (key.name === 'p' && !key.shift) {
1350
- state.settingsOpen = true
1351
- state.settingsCursor = 0
1352
- state.settingsEditMode = false
1353
- state.settingsAddKeyMode = false
1354
- state.settingsEditBuffer = ''
1355
- state.settingsScrollOffset = 0
1655
+ if (key.name === 'p' && !key.shift && !key.ctrl && !key.meta) {
1656
+ openSettingsOverlay()
1356
1657
  return
1357
1658
  }
1358
1659
 
1359
1660
  // 📖 Q key: open Smart Recommend overlay
1360
1661
  if (key.name === 'q') {
1361
- state.recommendOpen = true
1362
- state.recommendPhase = 'questionnaire'
1363
- state.recommendQuestion = 0
1364
- state.recommendCursor = 0
1365
- state.recommendAnswers = { taskType: null, priority: null, contextBudget: null }
1366
- state.recommendResults = []
1367
- state.recommendScrollOffset = 0
1662
+ openRecommendOverlay()
1368
1663
  return
1369
1664
  }
1370
1665
 
1371
1666
  // 📖 Y key: open Install Endpoints flow for configured providers.
1372
1667
  if (key.name === 'y') {
1373
- state.installEndpointsOpen = true
1374
- state.installEndpointsPhase = 'providers'
1375
- state.installEndpointsCursor = 0
1376
- state.installEndpointsScrollOffset = 0
1377
- state.installEndpointsProviderKey = null
1378
- state.installEndpointsToolMode = null
1379
- state.installEndpointsConnectionMode = null
1380
- state.installEndpointsScope = null
1381
- state.installEndpointsSelectedModelIds = new Set()
1382
- state.installEndpointsErrorMsg = null
1383
- state.installEndpointsResult = null
1668
+ openInstallEndpointsOverlay()
1384
1669
  return
1385
1670
  }
1386
1671
 
@@ -1388,34 +1673,9 @@ export function createKeyHandler(ctx) {
1388
1673
 
1389
1674
  // 📖 Profile system removed - API keys now persist permanently across all sessions
1390
1675
 
1391
- // 📖 Helper: persist current UI view settings (tier, provider, sort) to config.settings
1392
- // 📖 Called after every T / D / sort key so preferences survive session restarts.
1393
- function persistUiSettings() {
1394
- if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
1395
- state.config.settings.tierFilter = TIER_CYCLE[state.tierFilterMode]
1396
- state.config.settings.originFilter = ORIGIN_CYCLE[state.originFilterMode] ?? null
1397
- state.config.settings.sortColumn = state.sortColumn
1398
- state.config.settings.sortAsc = state.sortDirection === 'asc'
1399
- saveConfig(state.config)
1400
- }
1401
-
1402
1676
  // 📖 Shift+R: reset all UI view settings to defaults (tier, sort, provider) and clear persisted config
1403
1677
  if (key.name === 'r' && key.shift) {
1404
- state.tierFilterMode = 0
1405
- state.originFilterMode = 0
1406
- state.sortColumn = 'avg'
1407
- state.sortDirection = 'asc'
1408
- if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
1409
- delete state.config.settings.tierFilter
1410
- delete state.config.settings.originFilter
1411
- delete state.config.settings.sortColumn
1412
- delete state.config.settings.sortAsc
1413
- saveConfig(state.config)
1414
- applyTierFilter()
1415
- const visible = state.results.filter(r => !r.hidden)
1416
- state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
1417
- state.cursor = 0
1418
- state.scrollOffset = 0
1678
+ resetViewSettings()
1419
1679
  return
1420
1680
  }
1421
1681
 
@@ -1430,54 +1690,19 @@ export function createKeyHandler(ctx) {
1430
1690
 
1431
1691
  if (sortKeys[key.name] && !key.ctrl && !key.shift) {
1432
1692
  const col = sortKeys[key.name]
1433
- // 📖 Toggle direction if same column, otherwise reset to asc
1434
- if (state.sortColumn === col) {
1435
- state.sortDirection = state.sortDirection === 'asc' ? 'desc' : 'asc'
1436
- } else {
1437
- state.sortColumn = col
1438
- state.sortDirection = 'asc'
1439
- }
1440
- // 📖 Recompute visible sorted list and reset cursor to top to avoid stale index
1441
- const visible = state.results.filter(r => !r.hidden)
1442
- state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
1443
- state.cursor = 0
1444
- state.scrollOffset = 0
1445
- persistUiSettings()
1693
+ setSortColumnFromCommand(col)
1446
1694
  return
1447
1695
  }
1448
1696
 
1449
1697
  // 📖 F key: toggle favorite on the currently selected row and persist to config.
1450
1698
  if (key.name === 'f') {
1451
- const selected = state.visibleSorted[state.cursor]
1452
- if (!selected) return
1453
- const wasFavorite = selected.isFavorite
1454
- toggleFavoriteModel(state.config, selected.providerKey, selected.modelId)
1455
- syncFavoriteFlags(state.results, state.config)
1456
- applyTierFilter()
1457
- const visible = state.results.filter(r => !r.hidden)
1458
- state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
1459
-
1460
- // 📖 UX rule: when unpinning a favorite, jump back to the top of the list.
1461
- if (wasFavorite) {
1462
- state.cursor = 0
1463
- state.scrollOffset = 0
1464
- return
1465
- }
1466
-
1467
- const selectedKey = toFavoriteKey(selected.providerKey, selected.modelId)
1468
- const newCursor = state.visibleSorted.findIndex(r => toFavoriteKey(r.providerKey, r.modelId) === selectedKey)
1469
- if (newCursor >= 0) state.cursor = newCursor
1470
- else if (state.cursor >= state.visibleSorted.length) state.cursor = Math.max(0, state.visibleSorted.length - 1)
1471
- adjustScrollOffset(state)
1699
+ toggleFavoriteOnSelectedRow()
1472
1700
  return
1473
1701
  }
1474
1702
 
1475
1703
  // 📖 I key: open Feedback overlay (anonymous Discord feedback)
1476
1704
  if (key.name === 'i') {
1477
- state.feedbackOpen = true
1478
- state.bugReportBuffer = ''
1479
- state.bugReportStatus = 'idle'
1480
- state.bugReportError = null
1705
+ openFeedbackOverlay()
1481
1706
  return
1482
1707
  }
1483
1708
 
@@ -1552,13 +1777,7 @@ export function createKeyHandler(ctx) {
1552
1777
 
1553
1778
  // 📖 Mode toggle key: Z cycles through the supported tool targets.
1554
1779
  if (key.name === 'z') {
1555
- const modeOrder = getToolModeOrder()
1556
- const currentIndex = modeOrder.indexOf(state.mode)
1557
- const nextIndex = (currentIndex + 1) % modeOrder.length
1558
- state.mode = modeOrder[nextIndex]
1559
- if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
1560
- state.config.settings.preferredToolMode = state.mode
1561
- saveConfig(state.config)
1780
+ cycleToolMode()
1562
1781
  return
1563
1782
  }
1564
1783
 
package/src/overlays.js CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * @details
6
6
  * This module centralizes all overlay rendering in one place:
7
- * - Settings, Install Endpoints, Help, Smart Recommend, Feedback, Changelog
7
+ * - Settings, Install Endpoints, Command Palette, Help, Smart Recommend, Feedback, Changelog
8
8
  * - Settings diagnostics for provider key tests, including wrapped retry/error details
9
9
  * - Recommend analysis timer orchestration and progress updates
10
10
  *
@@ -56,6 +56,7 @@ export function createOverlayRenderers(state, deps) {
56
56
  getToolMeta,
57
57
  getToolInstallPlan,
58
58
  padEndDisplay,
59
+ displayWidth,
59
60
  } = deps
60
61
 
61
62
  const bullet = (isCursor) => (isCursor ? themeColors.accentBold(' ❯ ') : themeColors.dim(' '))
@@ -534,6 +535,128 @@ export function createOverlayRenderers(state, deps) {
534
535
  return cleared.join('\n')
535
536
  }
536
537
 
538
+ // ─── Command palette renderer ──────────────────────────────────────────────
539
+ // 📖 renderCommandPalette draws a centered floating modal over the live table.
540
+ // 📖 It returns cursor-positioned ANSI rows instead of replacing the full screen,
541
+ // 📖 so ping updates continue to animate in the background behind the palette.
542
+ function renderCommandPalette() {
543
+ const terminalRows = state.terminalRows || 24
544
+ const terminalCols = state.terminalCols || 80
545
+ const panelWidth = Math.max(44, Math.min(96, terminalCols - 8))
546
+ const panelInnerWidth = Math.max(28, panelWidth - 4)
547
+ const panelPad = 2
548
+ const panelOuterWidth = panelWidth + (panelPad * 2)
549
+ const footerRowCount = 2
550
+ const headerRowCount = 3
551
+ const bodyRows = Math.max(6, Math.min(16, terminalRows - 12))
552
+
553
+ const truncatePlain = (text, width) => {
554
+ if (width <= 1) return ''
555
+ if (displayWidth(text) <= width) return text
556
+ if (width <= 2) return text.slice(0, width)
557
+ return text.slice(0, width - 1) + '…'
558
+ }
559
+
560
+ const highlightMatch = (label, positions = []) => {
561
+ if (!Array.isArray(positions) || positions.length === 0) return label
562
+ const posSet = new Set(positions)
563
+ let out = ''
564
+ for (let i = 0; i < label.length; i++) {
565
+ out += posSet.has(i) ? themeColors.accentBold(label[i]) : label[i]
566
+ }
567
+ return out
568
+ }
569
+
570
+ const allResults = Array.isArray(state.commandPaletteResults) ? state.commandPaletteResults.slice(0, 80) : []
571
+ const groupedLines = []
572
+ const cursorLineByRow = {}
573
+ let category = null
574
+
575
+ if (allResults.length === 0) {
576
+ groupedLines.push(themeColors.dim(' No command found. Try a broader query.'))
577
+ } else {
578
+ for (let idx = 0; idx < allResults.length; idx++) {
579
+ const entry = allResults[idx]
580
+ if (entry.category !== category) {
581
+ category = entry.category
582
+ groupedLines.push(themeColors.textBold(` ${category}`))
583
+ }
584
+
585
+ const isCursor = idx === state.commandPaletteCursor
586
+ const pointer = isCursor ? themeColors.accentBold(' ❯ ') : themeColors.dim(' ')
587
+ const shortcutText = entry.shortcut ? themeColors.dim(entry.shortcut) : ''
588
+ const shortcutWidth = entry.shortcut ? Math.min(16, displayWidth(entry.shortcut)) : 0
589
+ const labelMax = Math.max(12, panelInnerWidth - 8 - shortcutWidth)
590
+ const plainLabel = truncatePlain(entry.label, labelMax)
591
+ const label = highlightMatch(plainLabel, entry.matchPositions)
592
+ const row = `${pointer}${padEndDisplay(label, labelMax)}${entry.shortcut ? ` ${shortcutText}` : ''}`
593
+ cursorLineByRow[idx] = groupedLines.length
594
+ groupedLines.push(isCursor ? themeColors.bgCursor(row) : row)
595
+ }
596
+ }
597
+
598
+ const targetLine = cursorLineByRow[state.commandPaletteCursor] ?? 0
599
+ state.commandPaletteScrollOffset = keepOverlayTargetVisible(
600
+ state.commandPaletteScrollOffset,
601
+ targetLine,
602
+ groupedLines.length,
603
+ bodyRows
604
+ )
605
+ const { visible, offset } = sliceOverlayLines(groupedLines, state.commandPaletteScrollOffset, bodyRows)
606
+ state.commandPaletteScrollOffset = offset
607
+
608
+ const query = state.commandPaletteQuery || ''
609
+ const queryWithCursor = query.length > 0
610
+ ? themeColors.textBold(`${query}▏`)
611
+ : themeColors.dim('type a command…') + themeColors.accentBold('▏')
612
+
613
+ const panelLines = []
614
+ const title = themeColors.textBold('Command Palette')
615
+ const titleLeft = ` ${title}`
616
+ const titleRight = themeColors.dim('Esc close')
617
+ const titleWidth = Math.max(1, panelInnerWidth - 1 - displayWidth('Esc close'))
618
+ panelLines.push(`${padEndDisplay(titleLeft, titleWidth)} ${titleRight}`)
619
+ panelLines.push(` ${padEndDisplay(`> ${queryWithCursor}`, panelInnerWidth)}`)
620
+ panelLines.push(themeColors.dim(` ${'-'.repeat(Math.max(1, panelInnerWidth))}`))
621
+
622
+ for (const line of visible) {
623
+ panelLines.push(` ${padEndDisplay(line, panelInnerWidth)}`)
624
+ }
625
+
626
+ // 📖 Keep panel body stable by filling with blank rows when result list is short.
627
+ while (panelLines.length < bodyRows + headerRowCount) {
628
+ panelLines.push(` ${' '.repeat(panelInnerWidth)}`)
629
+ }
630
+
631
+ panelLines.push(themeColors.dim(` ${'-'.repeat(Math.max(1, panelInnerWidth))}`))
632
+ panelLines.push(` ${padEndDisplay(themeColors.dim('↑↓ navigate • Enter run • Type to search'), panelInnerWidth)}`)
633
+ panelLines.push(` ${padEndDisplay(themeColors.dim('PgUp/PgDn • Home/End'), panelInnerWidth)}`)
634
+
635
+ const blankPaddedLine = ' '.repeat(panelOuterWidth)
636
+ const paddedPanelLines = [
637
+ blankPaddedLine,
638
+ blankPaddedLine,
639
+ ...panelLines.map((line) => `${' '.repeat(panelPad)}${padEndDisplay(line, panelWidth)}${' '.repeat(panelPad)}`),
640
+ blankPaddedLine,
641
+ blankPaddedLine,
642
+ ]
643
+
644
+ const panelHeight = paddedPanelLines.length
645
+ const top = Math.max(1, Math.floor((terminalRows - panelHeight) / 2) + 1)
646
+ const left = Math.max(1, Math.floor((terminalCols - panelOuterWidth) / 2) + 1)
647
+
648
+ const tintedLines = paddedPanelLines.map((line) => {
649
+ const padded = padEndDisplay(line, panelOuterWidth)
650
+ return themeColors.overlayBgCommandPalette(padded)
651
+ })
652
+
653
+ // 📖 Absolute cursor positioning overlays the palette on top of the existing table.
654
+ // 📖 The next frame starts with ALT_HOME, so this remains stable without manual cleanup.
655
+ return tintedLines
656
+ .map((line, idx) => `\x1b[${top + idx};${left}H${line}`)
657
+ .join('')
658
+ }
659
+
537
660
  // ─── Help overlay renderer ────────────────────────────────────────────────
538
661
  // 📖 renderHelp: Draw the help overlay listing all key bindings.
539
662
  // 📖 Toggled with K key. Gives users a quick reference without leaving the TUI.
@@ -602,6 +725,7 @@ export function createOverlayRenderers(state, deps) {
602
725
  lines.push('')
603
726
  lines.push(` ${heading('Controls')}`)
604
727
  lines.push(` ${key('W')} Toggle ping mode ${hint('(speed 2s → normal 10s → slow 30s → forced 4s)')}`)
728
+ lines.push(` ${key('Ctrl+P')} Open command palette ${hint('(search and run actions quickly)')}`)
605
729
  lines.push(` ${key('E')} Toggle configured models only ${hint('(enabled by default)')}`)
606
730
  lines.push(` ${key('Z')} Cycle tool mode ${hint('(OpenCode → Desktop → OpenClaw → Crush → Goose → Pi → Aider → Qwen → OpenHands → Amp)')}`)
607
731
  lines.push(` ${key('F')} Toggle favorite on selected row ${hint('(⭐ pinned at top, persisted)')}`)
@@ -1058,6 +1182,7 @@ export function createOverlayRenderers(state, deps) {
1058
1182
  renderSettings,
1059
1183
  renderInstallEndpoints,
1060
1184
  renderToolInstallPrompt,
1185
+ renderCommandPalette,
1061
1186
  renderHelp,
1062
1187
  renderRecommend,
1063
1188
  renderFeedback,
@@ -570,6 +570,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
570
570
  // 📖 Line 2: install flow, recommend, feedback, and extended hints.
571
571
  lines.push(
572
572
  themeColors.dim(` `) +
573
+ hotkey('Ctrl+P', ' Command palette') + themeColors.dim(` • `) +
573
574
  hotkey('Y', ' Install endpoints') + themeColors.dim(` • `) +
574
575
  hotkey('Q', ' Smart Recommend') + themeColors.dim(` • `) +
575
576
  hotkey('G', ' Theme') + themeColors.dim(` • `) +
package/src/theme.js CHANGED
@@ -52,6 +52,7 @@ const PALETTES = {
52
52
  recommend: [8, 21, 20],
53
53
  feedback: [31, 13, 20],
54
54
  changelog: [12, 24, 44],
55
+ commandPalette: [14, 20, 36],
55
56
  },
56
57
  cursor: {
57
58
  defaultBg: [39, 55, 90],
@@ -97,6 +98,7 @@ const PALETTES = {
97
98
  recommend: [246, 252, 248],
98
99
  feedback: [255, 247, 248],
99
100
  changelog: [244, 248, 255],
101
+ commandPalette: [242, 247, 255],
100
102
  },
101
103
  cursor: {
102
104
  defaultBg: [217, 231, 255],
@@ -312,4 +314,5 @@ export const themeColors = {
312
314
  overlayBgRecommend: (text) => paintBg(currentPalette().overlayBg.recommend, text, currentPalette().overlayFg),
313
315
  overlayBgFeedback: (text) => paintBg(currentPalette().overlayBg.feedback, text, currentPalette().overlayFg),
314
316
  overlayBgChangelog: (text) => paintBg(currentPalette().overlayBg.changelog, text, currentPalette().overlayFg),
317
+ overlayBgCommandPalette: (text) => paintBg(currentPalette().overlayBg.commandPalette, text, currentPalette().overlayFg),
315
318
  }