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 +13 -0
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/app.js +89 -2
- package/src/command-palette.js +170 -0
- package/src/key-handler.js +315 -96
- package/src/overlays.js +126 -1
- package/src/render-table.js +1 -0
- package/src/theme.js +3 -0
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.
|
|
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
|
-
:
|
|
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
|
+
}
|
package/src/key-handler.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/src/render-table.js
CHANGED
|
@@ -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
|
}
|