free-coding-models 0.3.24 → 0.3.25
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 +6 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/app.js +75 -1
- package/src/constants.js +5 -2
- package/src/key-handler.js +410 -0
- package/src/mouse.js +186 -0
- package/src/overlays.js +61 -1
- package/src/render-table.js +169 -42
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
---
|
|
3
3
|
|
|
4
|
+
## [0.3.25] - 2026-03-19
|
|
5
|
+
|
|
6
|
+
### Changed
|
|
7
|
+
- **Removed "CLI Tools" column** — The compat emoji column has been removed from the TUI table, freeing ~22 characters of horizontal space for other columns
|
|
8
|
+
- **Cleaner table layout** — Responsive column hiding no longer needs to drop the compat column first on narrow terminals
|
|
9
|
+
|
|
4
10
|
## [0.3.24] - 2026-03-19
|
|
5
11
|
|
|
6
12
|
### Added
|
package/README.md
CHANGED
|
@@ -214,7 +214,7 @@ To use Zen models: sign up at [opencode.ai/auth](https://opencode.ai/auth) and e
|
|
|
214
214
|
|
|
215
215
|
### Tool Compatibility
|
|
216
216
|
|
|
217
|
-
|
|
217
|
+
When a tool mode is active (via `Z`), models incompatible with that tool are highlighted with a dark red background so you can instantly see which models work with your current tool.
|
|
218
218
|
|
|
219
219
|
| Model Type | Compatible Tools |
|
|
220
220
|
|------------|-----------------|
|
|
@@ -268,7 +268,7 @@ The TUI shows a **"Compatible with"** column displaying colored emojis for each
|
|
|
268
268
|
- **⚡️ Command Palette** — `Ctrl+P` opens a searchable action launcher for filters, sorting, overlays, and quick toggles
|
|
269
269
|
- **Install Endpoints** — push a full provider catalog into any tool's config (from Settings `P` or ⚡️ Command Palette)
|
|
270
270
|
- **Missing tool bootstrap** — detect absent CLIs, offer one-click install, then continue the selected launch automatically
|
|
271
|
-
- **Tool compatibility matrix** —
|
|
271
|
+
- **Tool compatibility matrix** — incompatible rows highlighted in dark red when a tool mode is active
|
|
272
272
|
- **OpenCode Zen models** — 8 free models exclusive to OpenCode CLI/Desktop, powered by the Zen AI gateway
|
|
273
273
|
- **Width guardrail** — shows a warning instead of a broken table in narrow terminals
|
|
274
274
|
- **Readable everywhere** — semantic theme palette keeps table rows, overlays, badges, and help screens legible in dark and light 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.25",
|
|
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
|
@@ -119,7 +119,8 @@ import { renderTable, PROVIDER_COLOR } from '../src/render-table.js'
|
|
|
119
119
|
import { setOpenCodeModelData, startOpenCode, startOpenCodeDesktop } from '../src/opencode.js'
|
|
120
120
|
import { startOpenClaw } from '../src/openclaw.js'
|
|
121
121
|
import { createOverlayRenderers } from '../src/overlays.js'
|
|
122
|
-
import { createKeyHandler } from '../src/key-handler.js'
|
|
122
|
+
import { createKeyHandler, createMouseEventHandler } from '../src/key-handler.js'
|
|
123
|
+
import { createMouseHandler, containsMouseSequence } from '../src/mouse.js'
|
|
123
124
|
import { getToolModeOrder, getToolMeta } from '../src/tool-metadata.js'
|
|
124
125
|
import { startExternalTool } from '../src/tool-launchers.js'
|
|
125
126
|
import { getToolInstallPlan, installToolWithPlan, isToolInstalled } from '../src/tool-bootstrap.js'
|
|
@@ -495,6 +496,7 @@ export async function runApp(cliArgs, config) {
|
|
|
495
496
|
|
|
496
497
|
let ticker = null
|
|
497
498
|
let onKeyPress = null
|
|
499
|
+
let onMouseData = null // 📖 Mouse data listener — set after createMouseEventHandler
|
|
498
500
|
let pingModel = null
|
|
499
501
|
|
|
500
502
|
const scheduleNextPing = () => {
|
|
@@ -736,6 +738,7 @@ export async function runApp(cliArgs, config) {
|
|
|
736
738
|
if (ticker) clearInterval(ticker)
|
|
737
739
|
clearTimeout(state.pingIntervalObj)
|
|
738
740
|
if (onKeyPress) process.stdin.removeListener('keypress', onKeyPress)
|
|
741
|
+
if (onMouseData) process.stdin.removeListener('data', onMouseData)
|
|
739
742
|
if (process.stdin.isTTY && resetRawMode) process.stdin.setRawMode(false)
|
|
740
743
|
process.stdin.pause()
|
|
741
744
|
process.stdout.write(ALT_LEAVE)
|
|
@@ -837,6 +840,38 @@ export async function runApp(cliArgs, config) {
|
|
|
837
840
|
readline,
|
|
838
841
|
})
|
|
839
842
|
|
|
843
|
+
// 📖 Mouse event handler: translates parsed mouse events into TUI actions (sort, cursor, scroll).
|
|
844
|
+
const onMouseEvent = createMouseEventHandler({
|
|
845
|
+
state,
|
|
846
|
+
adjustScrollOffset,
|
|
847
|
+
applyTierFilter,
|
|
848
|
+
TIER_CYCLE,
|
|
849
|
+
ORIGIN_CYCLE,
|
|
850
|
+
noteUserActivity,
|
|
851
|
+
sortResultsWithPinnedFavorites,
|
|
852
|
+
saveConfig,
|
|
853
|
+
overlayLayout: overlays.overlayLayout, // 📖 Overlay cursor-to-line maps for click handling
|
|
854
|
+
// 📖 Favorite toggle — right-click on model rows
|
|
855
|
+
toggleFavoriteModel,
|
|
856
|
+
syncFavoriteFlags,
|
|
857
|
+
toFavoriteKey,
|
|
858
|
+
// 📖 Tool mode cycling — compat header click
|
|
859
|
+
cycleToolMode: () => {
|
|
860
|
+
// 📖 Inline cycle matching the Z-key handler in createKeyHandler
|
|
861
|
+
const modeOrder = getToolModeOrder()
|
|
862
|
+
const currentIndex = modeOrder.indexOf(state.mode)
|
|
863
|
+
const nextIndex = (currentIndex + 1) % modeOrder.length
|
|
864
|
+
state.mode = modeOrder[nextIndex]
|
|
865
|
+
if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
|
|
866
|
+
state.config.settings.preferredToolMode = state.mode
|
|
867
|
+
saveConfig(state.config)
|
|
868
|
+
},
|
|
869
|
+
})
|
|
870
|
+
|
|
871
|
+
// 📖 Wire the raw stdin data listener for mouse events.
|
|
872
|
+
// 📖 createMouseHandler returns a function that parses SGR sequences and calls onMouseEvent.
|
|
873
|
+
onMouseData = createMouseHandler({ onMouseEvent })
|
|
874
|
+
|
|
840
875
|
// Apply CLI --tier filter if provided
|
|
841
876
|
if (cliArgs.tierFilter) {
|
|
842
877
|
const allowed = TIER_LETTER_MAP[cliArgs.tierFilter]
|
|
@@ -858,8 +893,35 @@ export async function runApp(cliArgs, config) {
|
|
|
858
893
|
process.stdin.setRawMode(true)
|
|
859
894
|
}
|
|
860
895
|
|
|
896
|
+
// 📖 Mouse sequence suppression: readline.emitKeypressEvents() registers its own
|
|
897
|
+
// 📖 internal `data` listener that parses bytes and fires `keypress` events.
|
|
898
|
+
// 📖 When a mouse SGR sequence like \x1b[<0;35;20m arrives, readline fragments it
|
|
899
|
+
// 📖 and emits individual keypress events for chars like 'm', '0', ';' etc.
|
|
900
|
+
// 📖 The 'm' at the end of a release event maps to the Model sort hotkey!
|
|
901
|
+
// 📖
|
|
902
|
+
// 📖 Fix: use prependListener to register a `data` handler BEFORE readline's,
|
|
903
|
+
// 📖 so we can set a suppression flag before any keypress events fire.
|
|
904
|
+
// 📖 The flag is cleared on the next tick via setImmediate after all synchronous
|
|
905
|
+
// 📖 keypress emissions from readline have completed.
|
|
906
|
+
let _suppressMouseKeypresses = false
|
|
907
|
+
|
|
908
|
+
process.stdin.prependListener('data', (data) => {
|
|
909
|
+
const str = typeof data === 'string' ? data : data.toString('utf8')
|
|
910
|
+
if (str.includes('\x1b[<')) {
|
|
911
|
+
_suppressMouseKeypresses = true
|
|
912
|
+
// 📖 Reset after current tick — all synchronous keypress events from this data
|
|
913
|
+
// 📖 chunk will have fired by then.
|
|
914
|
+
setImmediate(() => { _suppressMouseKeypresses = false })
|
|
915
|
+
}
|
|
916
|
+
})
|
|
917
|
+
|
|
861
918
|
process.stdin.on('keypress', async (str, key) => {
|
|
862
919
|
try {
|
|
920
|
+
// 📖 Skip keypress events that originate from mouse escape sequences.
|
|
921
|
+
// 📖 readline may partially parse SGR mouse sequences as garbage keypresses.
|
|
922
|
+
if (str && containsMouseSequence(str)) return
|
|
923
|
+
// 📖 Suppress fragmented mouse bytes that readline emits as individual keypresses.
|
|
924
|
+
if (_suppressMouseKeypresses) return
|
|
863
925
|
await onKeyPress(str, key);
|
|
864
926
|
} catch (err) {
|
|
865
927
|
process.stdout.write(ALT_LEAVE);
|
|
@@ -869,6 +931,18 @@ export async function runApp(cliArgs, config) {
|
|
|
869
931
|
process.exit(1);
|
|
870
932
|
}
|
|
871
933
|
})
|
|
934
|
+
|
|
935
|
+
// 📖 Mouse data listener: parses SGR mouse escape sequences from raw stdin
|
|
936
|
+
// 📖 and dispatches structured events (click, scroll, double-click) to the mouse handler.
|
|
937
|
+
process.stdin.on('data', (data) => {
|
|
938
|
+
try {
|
|
939
|
+
if (onMouseData) onMouseData(data)
|
|
940
|
+
} catch (err) {
|
|
941
|
+
// 📖 Mouse errors are non-fatal — log and continue so the TUI doesn't crash.
|
|
942
|
+
// 📖 This could happen on terminals that send unexpected mouse sequences.
|
|
943
|
+
}
|
|
944
|
+
})
|
|
945
|
+
|
|
872
946
|
process.on('SIGCONT', noteUserActivity)
|
|
873
947
|
|
|
874
948
|
// 📖 Animation loop: render settings overlay, recommend overlay, help overlay, feature request overlay, bug report overlay, changelog overlay, OR main table
|
package/src/constants.js
CHANGED
|
@@ -47,8 +47,11 @@ import chalk from 'chalk'
|
|
|
47
47
|
// 📖 \x1b[H = cursor to top
|
|
48
48
|
// 📖 \x1b[?7l disables auto-wrap so wide rows clip at the right edge instead of
|
|
49
49
|
// 📖 wrapping to the next line (which would double the row height and overflow).
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
// 📖 Mouse tracking sequences are appended/prepended so clicks and scroll work in the TUI.
|
|
51
|
+
import { MOUSE_ENABLE, MOUSE_DISABLE } from './mouse.js'
|
|
52
|
+
|
|
53
|
+
export const ALT_ENTER = '\x1b[?1049h\x1b[?25l\x1b[?7l' + MOUSE_ENABLE
|
|
54
|
+
export const ALT_LEAVE = MOUSE_DISABLE + '\x1b[?7h\x1b[?1049l\x1b[?25h'
|
|
52
55
|
export const ALT_HOME = '\x1b[H'
|
|
53
56
|
|
|
54
57
|
// 📖 Timing constants — control how fast the health-check loop runs.
|
package/src/key-handler.js
CHANGED
|
@@ -31,6 +31,7 @@ import { loadChangelog } from './changelog-loader.js'
|
|
|
31
31
|
import { getToolMeta, isModelCompatibleWithTool, getCompatibleTools, findSimilarCompatibleModels } from './tool-metadata.js'
|
|
32
32
|
import { loadConfig, replaceConfigContents } from './config.js'
|
|
33
33
|
import { cleanupLegacyProxyArtifacts } from './legacy-proxy-cleanup.js'
|
|
34
|
+
import { getLastLayout, COLUMN_SORT_MAP } from './render-table.js'
|
|
34
35
|
import { cycleThemeSetting, detectActiveTheme } from './theme.js'
|
|
35
36
|
import { buildCommandPaletteTree, flattenCommandTree, filterCommandPaletteEntries } from './command-palette.js'
|
|
36
37
|
import { WIDTH_WARNING_MIN_COLS } from './constants.js'
|
|
@@ -2157,3 +2158,412 @@ export function createKeyHandler(ctx) {
|
|
|
2157
2158
|
}
|
|
2158
2159
|
}
|
|
2159
2160
|
}
|
|
2161
|
+
|
|
2162
|
+
/**
|
|
2163
|
+
* 📖 createMouseEventHandler: Factory that returns a handler for structured mouse events.
|
|
2164
|
+
* 📖 Works alongside the keypress handler — shares the same state and action functions.
|
|
2165
|
+
*
|
|
2166
|
+
* 📖 Supported interactions:
|
|
2167
|
+
* - Click on header row column → sort by that column (or cycle tier filter for Tier column)
|
|
2168
|
+
* - Click on model row → move cursor to that row
|
|
2169
|
+
* - Double-click on model row → select the model (Enter)
|
|
2170
|
+
* - Scroll up/down → navigate cursor up/down (with wrap-around)
|
|
2171
|
+
* - Scroll in overlays → scroll overlay content
|
|
2172
|
+
*
|
|
2173
|
+
* @param {object} ctx — same context object passed to createKeyHandler
|
|
2174
|
+
* @returns {function} — callback for onMouseEvent in createMouseHandler()
|
|
2175
|
+
*/
|
|
2176
|
+
export function createMouseEventHandler(ctx) {
|
|
2177
|
+
const {
|
|
2178
|
+
state,
|
|
2179
|
+
adjustScrollOffset,
|
|
2180
|
+
applyTierFilter,
|
|
2181
|
+
TIER_CYCLE,
|
|
2182
|
+
noteUserActivity,
|
|
2183
|
+
sortResultsWithPinnedFavorites,
|
|
2184
|
+
saveConfig,
|
|
2185
|
+
overlayLayout,
|
|
2186
|
+
// 📖 Favorite toggle deps — used by right-click on model rows
|
|
2187
|
+
toggleFavoriteModel,
|
|
2188
|
+
syncFavoriteFlags,
|
|
2189
|
+
toFavoriteKey,
|
|
2190
|
+
// 📖 Tool mode cycling — used by compat column header click
|
|
2191
|
+
cycleToolMode,
|
|
2192
|
+
} = ctx
|
|
2193
|
+
|
|
2194
|
+
// 📖 Shared helper: set the sort column, toggling direction if same column clicked twice.
|
|
2195
|
+
function setSortColumnFromClick(col) {
|
|
2196
|
+
if (state.sortColumn === col) {
|
|
2197
|
+
state.sortDirection = state.sortDirection === 'asc' ? 'desc' : 'asc'
|
|
2198
|
+
} else {
|
|
2199
|
+
state.sortColumn = col
|
|
2200
|
+
state.sortDirection = 'asc'
|
|
2201
|
+
}
|
|
2202
|
+
// 📖 Recompute visible sorted list to reflect new sort order
|
|
2203
|
+
const visible = state.results.filter(r => !r.hidden)
|
|
2204
|
+
state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection, {
|
|
2205
|
+
pinFavorites: state.favoritesPinnedAndSticky,
|
|
2206
|
+
})
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
// 📖 Shared helper: persist UI settings after mouse-triggered changes
|
|
2210
|
+
function persistUiSettings() {
|
|
2211
|
+
if (!state.config) return
|
|
2212
|
+
if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
|
|
2213
|
+
state.config.settings.sortColumn = state.sortColumn
|
|
2214
|
+
state.config.settings.sortDirection = state.sortDirection
|
|
2215
|
+
state.config.settings.tierFilter = TIER_CYCLE[state.tierFilterMode] || null
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
// 📖 Shared helper: toggle favorite on a specific model row index.
|
|
2219
|
+
// 📖 Mirrors the keyboard F-key handler but operates at a given index.
|
|
2220
|
+
function toggleFavoriteAtRow(modelIdx) {
|
|
2221
|
+
const selected = state.visibleSorted[modelIdx]
|
|
2222
|
+
if (!selected) return
|
|
2223
|
+
const wasFavorite = selected.isFavorite
|
|
2224
|
+
toggleFavoriteModel(state.config, selected.providerKey, selected.modelId)
|
|
2225
|
+
syncFavoriteFlags(state.results, state.config)
|
|
2226
|
+
applyTierFilter()
|
|
2227
|
+
const visible = state.results.filter(r => !r.hidden)
|
|
2228
|
+
state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection, {
|
|
2229
|
+
pinFavorites: state.favoritesPinnedAndSticky,
|
|
2230
|
+
})
|
|
2231
|
+
// 📖 If we unfavorited while pinned mode is on, reset cursor to top
|
|
2232
|
+
if (wasFavorite && state.favoritesPinnedAndSticky) {
|
|
2233
|
+
state.cursor = 0
|
|
2234
|
+
state.scrollOffset = 0
|
|
2235
|
+
return
|
|
2236
|
+
}
|
|
2237
|
+
// 📖 Otherwise, track the model's new position after re-sort
|
|
2238
|
+
const selectedKey = toFavoriteKey(selected.providerKey, selected.modelId)
|
|
2239
|
+
const newCursor = state.visibleSorted.findIndex(r => toFavoriteKey(r.providerKey, r.modelId) === selectedKey)
|
|
2240
|
+
if (newCursor >= 0) state.cursor = newCursor
|
|
2241
|
+
adjustScrollOffset(state)
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
// 📖 Shared helper: map a terminal row (1-based) to a cursor index using
|
|
2245
|
+
// 📖 an overlay's cursorLineByRow map and scroll offset.
|
|
2246
|
+
// 📖 Returns the cursor index, or -1 if no match.
|
|
2247
|
+
function overlayRowToCursor(y, cursorToLineMap, scrollOffset) {
|
|
2248
|
+
// 📖 Terminal row Y (1-based) → line index in the overlay lines array.
|
|
2249
|
+
// 📖 sliceOverlayLines shows lines from [scrollOffset .. scrollOffset + terminalRows).
|
|
2250
|
+
// 📖 Terminal row 1 = line[scrollOffset], row 2 = line[scrollOffset+1], etc.
|
|
2251
|
+
const lineIdx = (y - 1) + scrollOffset
|
|
2252
|
+
for (const [cursorStr, lineNum] of Object.entries(cursorToLineMap)) {
|
|
2253
|
+
if (lineNum === lineIdx) return parseInt(cursorStr, 10)
|
|
2254
|
+
}
|
|
2255
|
+
return -1
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
return (evt) => {
|
|
2259
|
+
noteUserActivity()
|
|
2260
|
+
const layout = getLastLayout()
|
|
2261
|
+
|
|
2262
|
+
// ── Scroll events ──────────────────────────────────────────────────
|
|
2263
|
+
if (evt.type === 'scroll-up' || evt.type === 'scroll-down') {
|
|
2264
|
+
// 📖 Overlay scroll: if any overlay is open, scroll its content
|
|
2265
|
+
if (state.helpVisible) {
|
|
2266
|
+
const step = evt.type === 'scroll-up' ? -3 : 3
|
|
2267
|
+
state.helpScrollOffset = Math.max(0, (state.helpScrollOffset || 0) + step)
|
|
2268
|
+
return
|
|
2269
|
+
}
|
|
2270
|
+
if (state.changelogOpen) {
|
|
2271
|
+
const step = evt.type === 'scroll-up' ? -3 : 3
|
|
2272
|
+
state.changelogScrollOffset = Math.max(0, (state.changelogScrollOffset || 0) + step)
|
|
2273
|
+
return
|
|
2274
|
+
}
|
|
2275
|
+
if (state.settingsOpen) {
|
|
2276
|
+
// 📖 Settings overlay uses cursor navigation, not scroll offset.
|
|
2277
|
+
// 📖 Move settingsCursor up/down instead of scrolling.
|
|
2278
|
+
if (evt.type === 'scroll-up') {
|
|
2279
|
+
state.settingsCursor = Math.max(0, (state.settingsCursor || 0) - 1)
|
|
2280
|
+
} else {
|
|
2281
|
+
const max = overlayLayout?.settingsMaxRow ?? 99
|
|
2282
|
+
state.settingsCursor = Math.min(max, (state.settingsCursor || 0) + 1)
|
|
2283
|
+
}
|
|
2284
|
+
return
|
|
2285
|
+
}
|
|
2286
|
+
if (state.recommendOpen) {
|
|
2287
|
+
// 📖 Recommend questionnaire phase: scroll moves cursor through options
|
|
2288
|
+
if (state.recommendPhase === 'questionnaire') {
|
|
2289
|
+
const step = evt.type === 'scroll-up' ? -1 : 1
|
|
2290
|
+
state.recommendCursor = Math.max(0, (state.recommendCursor || 0) + step)
|
|
2291
|
+
} else {
|
|
2292
|
+
const step = evt.type === 'scroll-up' ? -1 : 1
|
|
2293
|
+
state.recommendScrollOffset = Math.max(0, (state.recommendScrollOffset || 0) + step)
|
|
2294
|
+
}
|
|
2295
|
+
return
|
|
2296
|
+
}
|
|
2297
|
+
if (state.feedbackOpen) {
|
|
2298
|
+
// 📖 Feedback overlay doesn't scroll — ignore
|
|
2299
|
+
return
|
|
2300
|
+
}
|
|
2301
|
+
if (state.commandPaletteOpen) {
|
|
2302
|
+
// 📖 Command palette: scroll the results list
|
|
2303
|
+
const count = state.commandPaletteResults?.length || 0
|
|
2304
|
+
if (count === 0) return
|
|
2305
|
+
if (evt.type === 'scroll-up') {
|
|
2306
|
+
state.commandPaletteCursor = state.commandPaletteCursor > 0 ? state.commandPaletteCursor - 1 : count - 1
|
|
2307
|
+
} else {
|
|
2308
|
+
state.commandPaletteCursor = state.commandPaletteCursor < count - 1 ? state.commandPaletteCursor + 1 : 0
|
|
2309
|
+
}
|
|
2310
|
+
return
|
|
2311
|
+
}
|
|
2312
|
+
if (state.installEndpointsOpen) {
|
|
2313
|
+
// 📖 Install endpoints: move cursor up/down
|
|
2314
|
+
if (evt.type === 'scroll-up') {
|
|
2315
|
+
state.installEndpointsCursor = Math.max(0, (state.installEndpointsCursor || 0) - 1)
|
|
2316
|
+
} else {
|
|
2317
|
+
state.installEndpointsCursor = (state.installEndpointsCursor || 0) + 1
|
|
2318
|
+
}
|
|
2319
|
+
return
|
|
2320
|
+
}
|
|
2321
|
+
if (state.toolInstallPromptOpen) {
|
|
2322
|
+
// 📖 Tool install prompt: move cursor up/down
|
|
2323
|
+
if (evt.type === 'scroll-up') {
|
|
2324
|
+
state.toolInstallPromptCursor = Math.max(0, (state.toolInstallPromptCursor || 0) - 1)
|
|
2325
|
+
} else {
|
|
2326
|
+
state.toolInstallPromptCursor = (state.toolInstallPromptCursor || 0) + 1
|
|
2327
|
+
}
|
|
2328
|
+
return
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
// 📖 Main table scroll: move cursor up/down with wrap-around
|
|
2332
|
+
const count = state.visibleSorted.length
|
|
2333
|
+
if (count === 0) return
|
|
2334
|
+
if (evt.type === 'scroll-up') {
|
|
2335
|
+
state.cursor = state.cursor > 0 ? state.cursor - 1 : count - 1
|
|
2336
|
+
} else {
|
|
2337
|
+
state.cursor = state.cursor < count - 1 ? state.cursor + 1 : 0
|
|
2338
|
+
}
|
|
2339
|
+
adjustScrollOffset(state)
|
|
2340
|
+
return
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
// ── Click / double-click events ────────────────────────────────────
|
|
2344
|
+
if (evt.type !== 'click' && evt.type !== 'double-click') return
|
|
2345
|
+
|
|
2346
|
+
const { x, y } = evt
|
|
2347
|
+
|
|
2348
|
+
// ── Overlay click handling ─────────────────────────────────────────
|
|
2349
|
+
// 📖 When an overlay is open, handle clicks inside it or close it.
|
|
2350
|
+
// 📖 Priority order matches the rendering priority in app.js.
|
|
2351
|
+
|
|
2352
|
+
if (state.commandPaletteOpen) {
|
|
2353
|
+
// 📖 Command palette is a floating modal — detect clicks inside vs outside.
|
|
2354
|
+
const cp = overlayLayout
|
|
2355
|
+
const insideModal = cp &&
|
|
2356
|
+
x >= (cp.commandPaletteLeft || 0) && x <= (cp.commandPaletteRight || 0) &&
|
|
2357
|
+
y >= (cp.commandPaletteTop || 0) && y <= (cp.commandPaletteBottom || 0)
|
|
2358
|
+
|
|
2359
|
+
if (insideModal) {
|
|
2360
|
+
// 📖 Check if click is in the body area (result rows)
|
|
2361
|
+
const bodyStart = cp.commandPaletteBodyStartRow || 0
|
|
2362
|
+
const bodyEnd = bodyStart + (cp.commandPaletteBodyRows || 0) - 1
|
|
2363
|
+
if (y >= bodyStart && y <= bodyEnd) {
|
|
2364
|
+
// 📖 Map terminal row → cursor index via the cursorToLine map + scroll offset
|
|
2365
|
+
const cursorIdx = overlayRowToCursor(
|
|
2366
|
+
y - bodyStart + 1, // 📖 Normalize: row within body → 1-based for overlayRowToCursor
|
|
2367
|
+
cp.commandPaletteCursorToLine,
|
|
2368
|
+
cp.commandPaletteScrollOffset
|
|
2369
|
+
)
|
|
2370
|
+
if (cursorIdx >= 0) {
|
|
2371
|
+
state.commandPaletteCursor = cursorIdx
|
|
2372
|
+
if (evt.type === 'double-click') {
|
|
2373
|
+
// 📖 Double-click executes the selected command (same as Enter)
|
|
2374
|
+
process.stdin.emit('keypress', '\r', { name: 'return', ctrl: false, meta: false, shift: false })
|
|
2375
|
+
}
|
|
2376
|
+
return
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
// 📖 Click inside modal but not on a result row — ignore (don't close)
|
|
2380
|
+
return
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
// 📖 Click outside the modal → close (Escape equivalent)
|
|
2384
|
+
state.commandPaletteOpen = false
|
|
2385
|
+
state.commandPaletteFrozenTable = null
|
|
2386
|
+
state.commandPaletteQuery = ''
|
|
2387
|
+
state.commandPaletteCursor = 0
|
|
2388
|
+
state.commandPaletteScrollOffset = 0
|
|
2389
|
+
state.commandPaletteResults = []
|
|
2390
|
+
return
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
if (state.installEndpointsOpen) {
|
|
2394
|
+
// 📖 Install endpoints overlay: click closes (Escape equivalent)
|
|
2395
|
+
state.installEndpointsOpen = false
|
|
2396
|
+
return
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
if (state.toolInstallPromptOpen) {
|
|
2400
|
+
// 📖 Tool install prompt: click closes (Escape equivalent)
|
|
2401
|
+
state.toolInstallPromptOpen = false
|
|
2402
|
+
return
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
if (state.incompatibleFallbackOpen) {
|
|
2406
|
+
// 📖 Incompatible fallback: click closes
|
|
2407
|
+
state.incompatibleFallbackOpen = false
|
|
2408
|
+
return
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
if (state.feedbackOpen) {
|
|
2412
|
+
// 📖 Feedback overlay: click anywhere closes (no scroll, no cursor)
|
|
2413
|
+
state.feedbackOpen = false
|
|
2414
|
+
state.feedbackInput = ''
|
|
2415
|
+
return
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
if (state.helpVisible) {
|
|
2419
|
+
// 📖 Help overlay: click anywhere closes (same as K or Escape)
|
|
2420
|
+
state.helpVisible = false
|
|
2421
|
+
return
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
if (state.changelogOpen) {
|
|
2425
|
+
// 📖 Changelog overlay: click on a version row selects it, otherwise close.
|
|
2426
|
+
if (overlayLayout && state.changelogPhase === 'index') {
|
|
2427
|
+
const cursorIdx = overlayRowToCursor(
|
|
2428
|
+
y,
|
|
2429
|
+
overlayLayout.changelogCursorToLine,
|
|
2430
|
+
overlayLayout.changelogScrollOffset
|
|
2431
|
+
)
|
|
2432
|
+
if (cursorIdx >= 0) {
|
|
2433
|
+
state.changelogCursor = cursorIdx
|
|
2434
|
+
// 📖 Double-click opens the selected version's details (same as Enter)
|
|
2435
|
+
if (evt.type === 'double-click') {
|
|
2436
|
+
process.stdin.emit('keypress', '\r', { name: 'return', ctrl: false, meta: false, shift: false })
|
|
2437
|
+
}
|
|
2438
|
+
return
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
// 📖 Click outside version list → close (Escape equivalent)
|
|
2442
|
+
// 📖 In details phase, click anywhere goes back (same as B key)
|
|
2443
|
+
if (state.changelogPhase === 'details') {
|
|
2444
|
+
state.changelogPhase = 'index'
|
|
2445
|
+
state.changelogScrollOffset = 0
|
|
2446
|
+
} else {
|
|
2447
|
+
state.changelogOpen = false
|
|
2448
|
+
}
|
|
2449
|
+
return
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
if (state.recommendOpen) {
|
|
2453
|
+
if (state.recommendPhase === 'questionnaire' && overlayLayout?.recommendOptionRows) {
|
|
2454
|
+
// 📖 Map click Y to the specific questionnaire option row
|
|
2455
|
+
const optRows = overlayLayout.recommendOptionRows
|
|
2456
|
+
for (const [idxStr, row] of Object.entries(optRows)) {
|
|
2457
|
+
if (y === row) {
|
|
2458
|
+
state.recommendCursor = parseInt(idxStr, 10)
|
|
2459
|
+
if (evt.type === 'double-click') {
|
|
2460
|
+
// 📖 Double-click confirms the option (same as Enter)
|
|
2461
|
+
process.stdin.emit('keypress', '\r', { name: 'return', ctrl: false, meta: false, shift: false })
|
|
2462
|
+
}
|
|
2463
|
+
return
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
// 📖 Click outside option rows in questionnaire — ignore (don't close)
|
|
2467
|
+
return
|
|
2468
|
+
}
|
|
2469
|
+
// 📖 Result phase: click closes. Analyzing phase: click does nothing.
|
|
2470
|
+
if (state.recommendPhase === 'results') {
|
|
2471
|
+
state.recommendOpen = false
|
|
2472
|
+
state.recommendPhase = null
|
|
2473
|
+
state.recommendResults = []
|
|
2474
|
+
state.recommendScrollOffset = 0
|
|
2475
|
+
}
|
|
2476
|
+
return
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
if (state.settingsOpen) {
|
|
2480
|
+
// 📖 Settings overlay: click on a provider/maintenance row moves cursor there.
|
|
2481
|
+
// 📖 Don't handle clicks during edit/add-key mode (keyboard is primary).
|
|
2482
|
+
if (state.settingsEditMode || state.settingsAddKeyMode) return
|
|
2483
|
+
|
|
2484
|
+
if (overlayLayout) {
|
|
2485
|
+
const cursorIdx = overlayRowToCursor(
|
|
2486
|
+
y,
|
|
2487
|
+
overlayLayout.settingsCursorToLine,
|
|
2488
|
+
overlayLayout.settingsScrollOffset
|
|
2489
|
+
)
|
|
2490
|
+
if (cursorIdx >= 0 && cursorIdx <= (overlayLayout.settingsMaxRow || 99)) {
|
|
2491
|
+
state.settingsCursor = cursorIdx
|
|
2492
|
+
// 📖 Double-click triggers the Enter action (edit key / toggle / run action)
|
|
2493
|
+
if (evt.type === 'double-click') {
|
|
2494
|
+
process.stdin.emit('keypress', '\r', { name: 'return', ctrl: false, meta: false, shift: false })
|
|
2495
|
+
}
|
|
2496
|
+
return
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
// 📖 Click outside any recognized row does nothing in Settings
|
|
2500
|
+
// 📖 (user can Escape or press P to close)
|
|
2501
|
+
return
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
// ── Main table click handling ──────────────────────────────────────
|
|
2505
|
+
// 📖 No overlay is open — clicks go to the main table.
|
|
2506
|
+
|
|
2507
|
+
// 📖 Check if click is on the column header row → trigger sort
|
|
2508
|
+
if (y === layout.headerRow) {
|
|
2509
|
+
const col = layout.columns.find(c => x >= c.xStart && x <= c.xEnd)
|
|
2510
|
+
if (col) {
|
|
2511
|
+
const sortKey = COLUMN_SORT_MAP[col.name]
|
|
2512
|
+
if (sortKey) {
|
|
2513
|
+
setSortColumnFromClick(sortKey)
|
|
2514
|
+
persistUiSettings()
|
|
2515
|
+
} else if (col.name === 'tier') {
|
|
2516
|
+
// 📖 Clicking the Tier header cycles the tier filter (same as T key)
|
|
2517
|
+
state.tierFilterMode = (state.tierFilterMode + 1) % TIER_CYCLE.length
|
|
2518
|
+
applyTierFilter()
|
|
2519
|
+
const visible = state.results.filter(r => !r.hidden)
|
|
2520
|
+
state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection, {
|
|
2521
|
+
pinFavorites: state.favoritesPinnedAndSticky,
|
|
2522
|
+
})
|
|
2523
|
+
state.cursor = 0
|
|
2524
|
+
state.scrollOffset = 0
|
|
2525
|
+
persistUiSettings()
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
return
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
// 📖 Check if click is on a model row → move cursor (or select on double-click)
|
|
2532
|
+
// 📖 Right-click toggles favorite on that row (same as F key)
|
|
2533
|
+
if (y >= layout.firstModelRow && y <= layout.lastModelRow) {
|
|
2534
|
+
const rowOffset = y - layout.firstModelRow
|
|
2535
|
+
const modelIdx = layout.viewportStartIdx + rowOffset
|
|
2536
|
+
if (modelIdx >= layout.viewportStartIdx && modelIdx < layout.viewportEndIdx) {
|
|
2537
|
+
state.cursor = modelIdx
|
|
2538
|
+
adjustScrollOffset(state)
|
|
2539
|
+
|
|
2540
|
+
if (evt.button === 'right') {
|
|
2541
|
+
// 📖 Right-click: toggle favorite on this model row
|
|
2542
|
+
toggleFavoriteAtRow(modelIdx)
|
|
2543
|
+
} else if (evt.type === 'double-click') {
|
|
2544
|
+
// 📖 Double-click triggers the Enter action (select model).
|
|
2545
|
+
process.stdin.emit('keypress', '\r', { name: 'return', ctrl: false, meta: false, shift: false })
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
return
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
// ── Footer hotkey click zones ──────────────────────────────────────
|
|
2552
|
+
// 📖 Check if click lands on a footer hotkey zone and emit the corresponding keypress.
|
|
2553
|
+
if (layout.footerHotkeys && layout.footerHotkeys.length > 0) {
|
|
2554
|
+
const zone = layout.footerHotkeys.find(z => y === z.row && x >= z.xStart && x <= z.xEnd)
|
|
2555
|
+
if (zone) {
|
|
2556
|
+
// 📖 Map the footer zone key to a synthetic keypress.
|
|
2557
|
+
// 📖 Most are single-character keys; special cases like ctrl+p need special handling.
|
|
2558
|
+
if (zone.key === 'ctrl+p') {
|
|
2559
|
+
process.stdin.emit('keypress', '\x10', { name: 'p', ctrl: true, meta: false, shift: false })
|
|
2560
|
+
} else {
|
|
2561
|
+
process.stdin.emit('keypress', zone.key, { name: zone.key, ctrl: false, meta: false, shift: false })
|
|
2562
|
+
}
|
|
2563
|
+
return
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
// 📖 Clicks outside any recognized zone are silently ignored.
|
|
2568
|
+
}
|
|
2569
|
+
}
|
package/src/mouse.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file mouse.js
|
|
3
|
+
* @description Terminal mouse tracking infrastructure for the TUI.
|
|
4
|
+
*
|
|
5
|
+
* @details
|
|
6
|
+
* Provides SGR (mode 1006) mouse event parsing and tracking enable/disable sequences.
|
|
7
|
+
* SGR mode is preferred over X10/normal because it supports coordinates > 223
|
|
8
|
+
* and distinguishes press vs release events cleanly.
|
|
9
|
+
*
|
|
10
|
+
* Mouse events arrive as raw escape sequences on stdin when tracking is enabled:
|
|
11
|
+
* Press: \x1b[<Btn;X;YM
|
|
12
|
+
* Release: \x1b[<Btn;X;Ym
|
|
13
|
+
*
|
|
14
|
+
* Button encoding (Btn field):
|
|
15
|
+
* 0 = left click, 1 = middle click, 2 = right click
|
|
16
|
+
* 32 = left drag, 33 = middle drag, 34 = right drag
|
|
17
|
+
* 64 = scroll up, 65 = scroll down
|
|
18
|
+
* +4 = Shift held, +8 = Meta/Alt held, +16 = Control held
|
|
19
|
+
*
|
|
20
|
+
* Coordinates are 1-based in SGR mode (col 1, row 1 = top-left).
|
|
21
|
+
*
|
|
22
|
+
* ⚙️ Key configuration:
|
|
23
|
+
* - MOUSE_ENABLE: appended to ALT_ENTER to start mouse tracking on TUI init
|
|
24
|
+
* - MOUSE_DISABLE: prepended to ALT_LEAVE to stop mouse tracking on TUI exit
|
|
25
|
+
* - DOUBLE_CLICK_MS: maximum gap between two clicks to count as double-click
|
|
26
|
+
*
|
|
27
|
+
* @functions
|
|
28
|
+
* → parseMouseEvent(data) — Parse raw stdin buffer into structured mouse event
|
|
29
|
+
* → createMouseHandler(opts) — Create a stdin 'data' listener that emits mouse events
|
|
30
|
+
*
|
|
31
|
+
* @exports
|
|
32
|
+
* MOUSE_ENABLE, MOUSE_DISABLE,
|
|
33
|
+
* parseMouseEvent, createMouseHandler
|
|
34
|
+
*
|
|
35
|
+
* @see src/app.js — wires the mouse data listener alongside keypress
|
|
36
|
+
* @see src/key-handler.js — receives parsed mouse events for UI actions
|
|
37
|
+
* @see src/constants.js — ALT_ENTER / ALT_LEAVE include mouse sequences
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
// 📖 SGR mouse mode (1006) sends coordinates as decimal numbers terminated by M/m,
|
|
41
|
+
// 📖 supporting terminals wider than 223 columns (unlike X10 mode).
|
|
42
|
+
// 📖 Mode 1000 = basic button tracking (press + release).
|
|
43
|
+
// 📖 Mode 1002 = button-event tracking (adds drag reporting).
|
|
44
|
+
// 📖 Mode 1003 = any-event tracking (adds mouse movement) — intentionally NOT used
|
|
45
|
+
// 📖 because movement floods stdin and we don't need hover.
|
|
46
|
+
export const MOUSE_ENABLE = '\x1b[?1000h\x1b[?1002h\x1b[?1006h'
|
|
47
|
+
export const MOUSE_DISABLE = '\x1b[?1006l\x1b[?1002l\x1b[?1000l'
|
|
48
|
+
|
|
49
|
+
// 📖 Double-click detection window in milliseconds.
|
|
50
|
+
const DOUBLE_CLICK_MS = 400
|
|
51
|
+
|
|
52
|
+
// 📖 Regex to match SGR mouse sequences: \x1b[<Btn;Col;Row[Mm]
|
|
53
|
+
// 📖 Groups: 1=button, 2=column(x), 3=row(y), 4=M(press)|m(release)
|
|
54
|
+
const SGR_MOUSE_RE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 📖 parseMouseEvents: Extract all SGR mouse events from a raw stdin data chunk.
|
|
58
|
+
* 📖 A single data chunk can contain multiple mouse events (e.g. rapid scrolling).
|
|
59
|
+
* @param {string|Buffer} data — raw stdin data
|
|
60
|
+
* @returns {Array<{button: number, x: number, y: number, type: string, shift: boolean, meta: boolean, ctrl: boolean}>}
|
|
61
|
+
*/
|
|
62
|
+
export function parseMouseEvents(data) {
|
|
63
|
+
const str = typeof data === 'string' ? data : data.toString('utf8')
|
|
64
|
+
const events = []
|
|
65
|
+
let match
|
|
66
|
+
|
|
67
|
+
// 📖 Reset regex lastIndex for reuse
|
|
68
|
+
SGR_MOUSE_RE.lastIndex = 0
|
|
69
|
+
|
|
70
|
+
while ((match = SGR_MOUSE_RE.exec(str)) !== null) {
|
|
71
|
+
const rawBtn = parseInt(match[1], 10)
|
|
72
|
+
const x = parseInt(match[2], 10) // 📖 1-based column
|
|
73
|
+
const y = parseInt(match[3], 10) // 📖 1-based row
|
|
74
|
+
const isRelease = match[4] === 'm'
|
|
75
|
+
|
|
76
|
+
// 📖 Extract modifier keys from the button field
|
|
77
|
+
const shift = !!(rawBtn & 4)
|
|
78
|
+
const meta = !!(rawBtn & 8)
|
|
79
|
+
const ctrl = !!(rawBtn & 16)
|
|
80
|
+
|
|
81
|
+
// 📖 Strip modifier bits to get the base button
|
|
82
|
+
const baseBtn = rawBtn & ~(4 | 8 | 16)
|
|
83
|
+
|
|
84
|
+
let type, button
|
|
85
|
+
|
|
86
|
+
if (baseBtn === 64) {
|
|
87
|
+
type = 'scroll-up'
|
|
88
|
+
button = 'scroll-up'
|
|
89
|
+
} else if (baseBtn === 65) {
|
|
90
|
+
type = 'scroll-down'
|
|
91
|
+
button = 'scroll-down'
|
|
92
|
+
} else if (baseBtn >= 32 && baseBtn <= 34) {
|
|
93
|
+
type = 'drag'
|
|
94
|
+
button = baseBtn === 32 ? 'left' : baseBtn === 33 ? 'middle' : 'right'
|
|
95
|
+
} else if (isRelease) {
|
|
96
|
+
type = 'release'
|
|
97
|
+
button = baseBtn === 0 ? 'left' : baseBtn === 1 ? 'middle' : 'right'
|
|
98
|
+
} else {
|
|
99
|
+
type = 'press'
|
|
100
|
+
button = baseBtn === 0 ? 'left' : baseBtn === 1 ? 'middle' : 'right'
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
events.push({ type, button, x, y, shift, meta, ctrl })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return events
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 📖 containsMouseSequence: Quick check if a data chunk contains any SGR mouse sequence.
|
|
111
|
+
* 📖 Used to prevent the keypress handler from processing mouse data as keypresses.
|
|
112
|
+
* @param {string|Buffer} data
|
|
113
|
+
* @returns {boolean}
|
|
114
|
+
*/
|
|
115
|
+
export function containsMouseSequence(data) {
|
|
116
|
+
const str = typeof data === 'string' ? data : data.toString('utf8')
|
|
117
|
+
return str.includes('\x1b[<')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 📖 createMouseHandler: Factory that returns a stdin 'data' callback for mouse events.
|
|
122
|
+
* 📖 Handles double-click detection internally by tracking the last click position/time.
|
|
123
|
+
*
|
|
124
|
+
* @param {object} opts
|
|
125
|
+
* @param {function} opts.onMouseEvent — callback receiving structured mouse events:
|
|
126
|
+
* { type: 'click'|'double-click'|'scroll-up'|'scroll-down'|'drag', button, x, y, shift, meta, ctrl }
|
|
127
|
+
* @returns {function} — attach to process.stdin.on('data', returnedFn)
|
|
128
|
+
*/
|
|
129
|
+
export function createMouseHandler({ onMouseEvent }) {
|
|
130
|
+
// 📖 Double-click tracking state
|
|
131
|
+
let lastClickX = -1
|
|
132
|
+
let lastClickY = -1
|
|
133
|
+
let lastClickTime = 0
|
|
134
|
+
|
|
135
|
+
return (data) => {
|
|
136
|
+
const str = typeof data === 'string' ? data : data.toString('utf8')
|
|
137
|
+
|
|
138
|
+
// 📖 Only process data that contains mouse sequences
|
|
139
|
+
if (!str.includes('\x1b[<')) return
|
|
140
|
+
|
|
141
|
+
const events = parseMouseEvents(str)
|
|
142
|
+
|
|
143
|
+
for (const evt of events) {
|
|
144
|
+
// 📖 Scroll events are emitted immediately (no press/release distinction)
|
|
145
|
+
if (evt.type === 'scroll-up' || evt.type === 'scroll-down') {
|
|
146
|
+
onMouseEvent(evt)
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 📖 Drag events forwarded as-is
|
|
151
|
+
if (evt.type === 'drag') {
|
|
152
|
+
onMouseEvent(evt)
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 📖 Only emit click on release (not press) to match expected click semantics.
|
|
157
|
+
// 📖 This prevents double-firing and feels more natural to the user.
|
|
158
|
+
if (evt.type === 'release' && evt.button === 'left') {
|
|
159
|
+
const now = Date.now()
|
|
160
|
+
const isDoubleClick =
|
|
161
|
+
(now - lastClickTime) < DOUBLE_CLICK_MS &&
|
|
162
|
+
evt.x === lastClickX &&
|
|
163
|
+
evt.y === lastClickY
|
|
164
|
+
|
|
165
|
+
if (isDoubleClick) {
|
|
166
|
+
onMouseEvent({ ...evt, type: 'double-click' })
|
|
167
|
+
// 📖 Reset so a third click doesn't count as another double-click
|
|
168
|
+
lastClickTime = 0
|
|
169
|
+
lastClickX = -1
|
|
170
|
+
lastClickY = -1
|
|
171
|
+
} else {
|
|
172
|
+
onMouseEvent({ ...evt, type: 'click' })
|
|
173
|
+
lastClickTime = now
|
|
174
|
+
lastClickX = evt.x
|
|
175
|
+
lastClickY = evt.y
|
|
176
|
+
}
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 📖 Right-click and middle-click: emit on release
|
|
181
|
+
if (evt.type === 'release') {
|
|
182
|
+
onMouseEvent({ ...evt, type: 'click' })
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
package/src/overlays.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* 📖 Feedback overlay (I key) combines feature requests + bug reports in one left-aligned input
|
|
15
15
|
*
|
|
16
16
|
* → Functions:
|
|
17
|
-
* - `createOverlayRenderers` — returns renderer + analysis helpers
|
|
17
|
+
* - `createOverlayRenderers` — returns renderer + analysis helpers + overlayLayout
|
|
18
18
|
*
|
|
19
19
|
* @exports { createOverlayRenderers }
|
|
20
20
|
* @see ./key-handler.js — handles keypresses for all overlay interactions
|
|
@@ -85,6 +85,29 @@ export function createOverlayRenderers(state, deps) {
|
|
|
85
85
|
return lines
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// 📖 Overlay layout tracking: records cursor-to-line mappings and scroll offsets
|
|
89
|
+
// 📖 so the mouse handler can map terminal click coordinates → overlay cursor positions.
|
|
90
|
+
// 📖 Updated each render frame by the active overlay renderer.
|
|
91
|
+
const overlayLayout = {
|
|
92
|
+
settingsCursorToLine: {}, // 📖 cursor index → line index in pre-scroll lines array
|
|
93
|
+
settingsScrollOffset: 0, // 📖 current scroll offset applied by sliceOverlayLines
|
|
94
|
+
settingsMaxRow: 0, // 📖 maximum valid settingsCursor index
|
|
95
|
+
installEndpointsCursorToLine: {},
|
|
96
|
+
installEndpointsScrollOffset: 0,
|
|
97
|
+
installEndpointsMaxRow: 0,
|
|
98
|
+
commandPaletteCursorToLine: {},
|
|
99
|
+
commandPaletteScrollOffset: 0,
|
|
100
|
+
commandPaletteBodyStartRow: 0, // 📖 1-based terminal row where CP results begin
|
|
101
|
+
commandPaletteBodyRows: 0,
|
|
102
|
+
commandPaletteLeft: 0,
|
|
103
|
+
commandPaletteRight: 0,
|
|
104
|
+
commandPaletteTop: 0,
|
|
105
|
+
commandPaletteBottom: 0,
|
|
106
|
+
changelogCursorToLine: {},
|
|
107
|
+
changelogScrollOffset: 0,
|
|
108
|
+
recommendOptionRows: {}, // 📖 option index → 1-based terminal row (questionnaire phase)
|
|
109
|
+
}
|
|
110
|
+
|
|
88
111
|
// ─── Settings screen renderer ─────────────────────────────────────────────
|
|
89
112
|
// 📖 renderSettings: Draw the settings overlay in the alt screen buffer.
|
|
90
113
|
// 📖 Shows all providers with their API key (masked) + enabled state.
|
|
@@ -287,6 +310,11 @@ export function createOverlayRenderers(state, deps) {
|
|
|
287
310
|
const { visible, offset } = sliceOverlayLines(lines, state.settingsScrollOffset, state.terminalRows)
|
|
288
311
|
state.settingsScrollOffset = offset
|
|
289
312
|
|
|
313
|
+
// 📖 Mouse support: record layout so click handler can map Y → settingsCursor
|
|
314
|
+
overlayLayout.settingsCursorToLine = { ...cursorLineByRow }
|
|
315
|
+
overlayLayout.settingsScrollOffset = offset
|
|
316
|
+
overlayLayout.settingsMaxRow = changelogViewRowIdx
|
|
317
|
+
|
|
290
318
|
const tintedLines = tintOverlayLines(visible, themeColors.overlayBgSettings, state.terminalCols)
|
|
291
319
|
const cleared = tintedLines.map(l => l + EL)
|
|
292
320
|
return cleared.join('\n')
|
|
@@ -684,6 +712,18 @@ export function createOverlayRenderers(state, deps) {
|
|
|
684
712
|
const top = Math.max(1, Math.floor((terminalRows - panelHeight) / 2) + 1)
|
|
685
713
|
const left = Math.max(1, Math.floor((terminalCols - panelOuterWidth) / 2) + 1)
|
|
686
714
|
|
|
715
|
+
// 📖 Mouse support: record CP layout so clicks inside the modal can select items.
|
|
716
|
+
// 📖 Body rows start after 2 blank-padding lines + headerLines (3).
|
|
717
|
+
const bodyStartRow = top + 2 + headerLines.length // 📖 1-based terminal row of first body line
|
|
718
|
+
overlayLayout.commandPaletteCursorToLine = { ...cursorLineByRow }
|
|
719
|
+
overlayLayout.commandPaletteScrollOffset = state.commandPaletteScrollOffset
|
|
720
|
+
overlayLayout.commandPaletteBodyStartRow = bodyStartRow
|
|
721
|
+
overlayLayout.commandPaletteBodyRows = bodyRows
|
|
722
|
+
overlayLayout.commandPaletteLeft = left
|
|
723
|
+
overlayLayout.commandPaletteRight = left + panelOuterWidth - 1
|
|
724
|
+
overlayLayout.commandPaletteTop = top
|
|
725
|
+
overlayLayout.commandPaletteBottom = top + panelHeight - 1
|
|
726
|
+
|
|
687
727
|
const tintedLines = paddedPanelLines.map((line) => {
|
|
688
728
|
const padded = padEndDisplay(line, panelOuterWidth)
|
|
689
729
|
return themeColors.overlayBgCommandPalette(padded)
|
|
@@ -861,6 +901,10 @@ export function createOverlayRenderers(state, deps) {
|
|
|
861
901
|
const opt = q.options[i]
|
|
862
902
|
const isCursor = i === state.recommendCursor
|
|
863
903
|
const label = isCursor ? themeColors.textBold(opt.label) : themeColors.text(opt.label)
|
|
904
|
+
// 📖 Mouse support: record the 1-based terminal row of each option
|
|
905
|
+
// 📖 lines.length is the 0-based index → +1 = 1-based row
|
|
906
|
+
overlayLayout.recommendOptionRows = overlayLayout.recommendOptionRows || {}
|
|
907
|
+
overlayLayout.recommendOptionRows[i] = lines.length + 1
|
|
864
908
|
lines.push(`${bullet(isCursor)}${label}`)
|
|
865
909
|
}
|
|
866
910
|
|
|
@@ -1206,6 +1250,21 @@ export function createOverlayRenderers(state, deps) {
|
|
|
1206
1250
|
// 📖 Use scrolling with overlay handler
|
|
1207
1251
|
const { visible, offset } = sliceOverlayLines(lines, state.changelogScrollOffset, state.terminalRows)
|
|
1208
1252
|
state.changelogScrollOffset = offset
|
|
1253
|
+
|
|
1254
|
+
// 📖 Mouse support: record changelog layout for click-to-select versions
|
|
1255
|
+
overlayLayout.changelogScrollOffset = offset
|
|
1256
|
+
// 📖 In index phase, version items start at line 4 (header + blank + title + instructions)
|
|
1257
|
+
// 📖 Each version occupies exactly one line. changelogCursorToLine maps cursor → line index.
|
|
1258
|
+
if (state.changelogPhase === 'index') {
|
|
1259
|
+
const map = {}
|
|
1260
|
+
for (let i = 0; i < versionList.length; i++) {
|
|
1261
|
+
map[i] = 4 + i // 📖 3 header-ish lines + 1 blank before version list
|
|
1262
|
+
}
|
|
1263
|
+
overlayLayout.changelogCursorToLine = map
|
|
1264
|
+
} else {
|
|
1265
|
+
overlayLayout.changelogCursorToLine = {}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1209
1268
|
const tintedLines = tintOverlayLines(visible, themeColors.overlayBgChangelog, state.terminalCols)
|
|
1210
1269
|
const cleared = tintedLines.map(l => l + EL)
|
|
1211
1270
|
return cleared.join('\n')
|
|
@@ -1325,5 +1384,6 @@ export function createOverlayRenderers(state, deps) {
|
|
|
1325
1384
|
renderIncompatibleFallback,
|
|
1326
1385
|
startRecommendAnalysis,
|
|
1327
1386
|
stopRecommendAnalysis,
|
|
1387
|
+
overlayLayout, // 📖 Mouse support: exposes cursor-to-line maps for click handling
|
|
1328
1388
|
}
|
|
1329
1389
|
}
|
package/src/render-table.js
CHANGED
|
@@ -50,12 +50,47 @@ import { TIER_COLOR } from './tier-colors.js'
|
|
|
50
50
|
import { getAvg, getVerdict, getUptime, getStabilityScore, getVersionStatusInfo } from './utils.js'
|
|
51
51
|
import { usagePlaceholderForProvider } from './ping.js'
|
|
52
52
|
import { calculateViewport, sortResultsWithPinnedFavorites, padEndDisplay, displayWidth } from './render-helpers.js'
|
|
53
|
-
import { getToolMeta, TOOL_METADATA, TOOL_MODE_ORDER,
|
|
53
|
+
import { getToolMeta, TOOL_METADATA, TOOL_MODE_ORDER, isModelCompatibleWithTool } from './tool-metadata.js'
|
|
54
54
|
import { getColumnSpacing } from './ui-config.js'
|
|
55
55
|
|
|
56
56
|
const require = createRequire(import.meta.url)
|
|
57
57
|
const { version: LOCAL_VERSION } = require('../package.json')
|
|
58
58
|
|
|
59
|
+
// 📖 Mouse support: column boundary map updated every frame by renderTable().
|
|
60
|
+
// 📖 Each entry maps a column name to its display X-start and X-end (1-based, inclusive).
|
|
61
|
+
// 📖 headerRow is the 1-based terminal row of the column header line.
|
|
62
|
+
// 📖 firstModelRow/lastModelRow are the 1-based terminal rows of the first/last visible model row.
|
|
63
|
+
// 📖 Exported so the mouse handler can translate click coordinates into column/row targets.
|
|
64
|
+
let _lastLayout = {
|
|
65
|
+
columns: [], // 📖 Array of { name, xStart, xEnd } in display order
|
|
66
|
+
headerRow: 0, // 📖 1-based terminal row of the column headers
|
|
67
|
+
firstModelRow: 0, // 📖 1-based terminal row of the first visible model
|
|
68
|
+
lastModelRow: 0, // 📖 1-based terminal row of the last visible model
|
|
69
|
+
viewportStartIdx: 0, // 📖 index into sorted[] of the first visible model
|
|
70
|
+
viewportEndIdx: 0, // 📖 index into sorted[] past the last visible model
|
|
71
|
+
hasAboveIndicator: false, // 📖 whether "... N more above ..." is shown
|
|
72
|
+
hasBelowIndicator: false, // 📖 whether "... N more below ..." is shown
|
|
73
|
+
footerHotkeys: [], // 📖 Array of { key, row, xStart, xEnd } for footer click zones
|
|
74
|
+
}
|
|
75
|
+
export function getLastLayout() { return _lastLayout }
|
|
76
|
+
|
|
77
|
+
// 📖 Column name → sort key mapping for mouse click-to-sort on header row
|
|
78
|
+
const COLUMN_SORT_MAP = {
|
|
79
|
+
rank: 'rank',
|
|
80
|
+
tier: null, // 📖 Tier column click cycles tier filter rather than sorting
|
|
81
|
+
swe: 'swe',
|
|
82
|
+
ctx: 'ctx',
|
|
83
|
+
model: 'model',
|
|
84
|
+
source: 'origin',
|
|
85
|
+
ping: 'ping',
|
|
86
|
+
avg: 'avg',
|
|
87
|
+
health: 'condition',
|
|
88
|
+
verdict: 'verdict',
|
|
89
|
+
stability: 'stability',
|
|
90
|
+
uptime: 'uptime',
|
|
91
|
+
}
|
|
92
|
+
export { COLUMN_SORT_MAP }
|
|
93
|
+
|
|
59
94
|
// 📖 Provider column palette: soft pastel rainbow so each provider stays easy
|
|
60
95
|
// 📖 to spot without turning the table into a harsh neon wall.
|
|
61
96
|
// 📖 Exported for use in overlays (settings screen) and logs.
|
|
@@ -160,7 +195,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
160
195
|
const W_STATUS = 18
|
|
161
196
|
const W_VERDICT = 14
|
|
162
197
|
const W_UPTIME = 6
|
|
163
|
-
|
|
198
|
+
|
|
164
199
|
// const W_TOKENS = 7 // Used column removed
|
|
165
200
|
// const W_USAGE = 7 // Usage column removed
|
|
166
201
|
const MIN_TABLE_WIDTH = WIDTH_WARNING_MIN_COLS
|
|
@@ -180,7 +215,6 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
180
215
|
let showUptime = true
|
|
181
216
|
let showTier = true
|
|
182
217
|
let showStability = true
|
|
183
|
-
let showCompat = true // 📖 "Compatible with" column — hidden on narrow terminals
|
|
184
218
|
let isCompact = false
|
|
185
219
|
|
|
186
220
|
if (terminalCols > 0) {
|
|
@@ -192,7 +226,6 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
192
226
|
cols.push(W_SWE, W_CTX, W_MODEL, wSource, wPing, wAvg, wStatus, W_VERDICT)
|
|
193
227
|
if (showStability) cols.push(wStab)
|
|
194
228
|
if (showUptime) cols.push(W_UPTIME)
|
|
195
|
-
if (showCompat) cols.push(W_COMPAT)
|
|
196
229
|
return ROW_MARGIN + cols.reduce((a, b) => a + b, 0) + (cols.length - 1) * SEP_W
|
|
197
230
|
}
|
|
198
231
|
|
|
@@ -206,12 +239,39 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
206
239
|
wStatus = 13 // Health truncated after 6 chars + '…'
|
|
207
240
|
}
|
|
208
241
|
// 📖 Steps 2–5: Progressive column hiding (least useful first)
|
|
209
|
-
if (calcWidth() > terminalCols) showCompat = false
|
|
210
242
|
if (calcWidth() > terminalCols) showRank = false
|
|
211
243
|
if (calcWidth() > terminalCols) showUptime = false
|
|
212
244
|
if (calcWidth() > terminalCols) showTier = false
|
|
213
245
|
if (calcWidth() > terminalCols) showStability = false
|
|
214
246
|
}
|
|
247
|
+
|
|
248
|
+
// 📖 Mouse support: compute column boundaries from the resolved responsive widths.
|
|
249
|
+
// 📖 This builds an ordered array of { name, xStart, xEnd } (1-based display columns)
|
|
250
|
+
// 📖 matching exactly what renderTable paints so click-to-sort hits the right column.
|
|
251
|
+
{
|
|
252
|
+
const colDefs = []
|
|
253
|
+
if (showRank) colDefs.push({ name: 'rank', width: W_RANK })
|
|
254
|
+
if (showTier) colDefs.push({ name: 'tier', width: W_TIER })
|
|
255
|
+
colDefs.push({ name: 'swe', width: W_SWE })
|
|
256
|
+
colDefs.push({ name: 'ctx', width: W_CTX })
|
|
257
|
+
colDefs.push({ name: 'model', width: W_MODEL })
|
|
258
|
+
colDefs.push({ name: 'source', width: wSource })
|
|
259
|
+
colDefs.push({ name: 'ping', width: wPing })
|
|
260
|
+
colDefs.push({ name: 'avg', width: wAvg })
|
|
261
|
+
colDefs.push({ name: 'health', width: wStatus })
|
|
262
|
+
colDefs.push({ name: 'verdict', width: W_VERDICT })
|
|
263
|
+
if (showStability) colDefs.push({ name: 'stability', width: wStab })
|
|
264
|
+
if (showUptime) colDefs.push({ name: 'uptime', width: W_UPTIME })
|
|
265
|
+
let x = ROW_MARGIN + 1 // 📖 1-based: first column starts after the 2-char left margin
|
|
266
|
+
const columns = []
|
|
267
|
+
for (let i = 0; i < colDefs.length; i++) {
|
|
268
|
+
const { name, width } = colDefs[i]
|
|
269
|
+
const xEnd = x + width - 1
|
|
270
|
+
columns.push({ name, xStart: x, xEnd })
|
|
271
|
+
x = xEnd + 1 + SEP_W // 📖 skip past the ' │ ' separator
|
|
272
|
+
}
|
|
273
|
+
_lastLayout.columns = columns
|
|
274
|
+
}
|
|
215
275
|
const warningDurationMs = 2_000
|
|
216
276
|
const elapsed = widthWarningStartedAt ? Math.max(0, Date.now() - widthWarningStartedAt) : warningDurationMs
|
|
217
277
|
const remainingMs = Math.max(0, warningDurationMs - elapsed)
|
|
@@ -268,8 +328,8 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
268
328
|
const tierH = 'Tier'
|
|
269
329
|
const originH = 'Provider'
|
|
270
330
|
const modelH = 'Model'
|
|
271
|
-
const sweH = sortColumn === 'swe' ? dir + '
|
|
272
|
-
const ctxH = sortColumn === 'ctx' ? dir + '
|
|
331
|
+
const sweH = sortColumn === 'swe' ? (dir + 'SWE%') : 'SWE%'
|
|
332
|
+
const ctxH = sortColumn === 'ctx' ? (dir + 'CTX') : 'CTX'
|
|
273
333
|
// 📖 Compact labels: 'Lat. P' / 'Avg. P' / 'StaB.' to save horizontal space
|
|
274
334
|
const pingLabel = isCompact ? 'Lat. P' : 'Latest Ping'
|
|
275
335
|
const avgLabel = isCompact ? 'Avg. P' : 'Avg Ping'
|
|
@@ -278,8 +338,10 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
278
338
|
const avgH = sortColumn === 'avg' ? dir + ' ' + avgLabel : avgLabel
|
|
279
339
|
const healthH = sortColumn === 'condition' ? dir + ' Health' : 'Health'
|
|
280
340
|
const verdictH = sortColumn === 'verdict' ? dir + ' Verdict' : 'Verdict'
|
|
281
|
-
|
|
282
|
-
|
|
341
|
+
// 📖 Stability: in non-compact the arrow eats 2 chars ('↑ '), so truncate to fit wStab.
|
|
342
|
+
// 📖 Compact is fine because '↑ StaB.' (7) < wStab (8).
|
|
343
|
+
const stabH = sortColumn === 'stability' ? (dir + (isCompact ? ' ' + stabLabel : 'Stability')) : stabLabel
|
|
344
|
+
const uptimeH = sortColumn === 'uptime' ? (dir + 'Up%') : 'Up%'
|
|
283
345
|
|
|
284
346
|
// 📖 Helper to colorize first letter for keyboard shortcuts
|
|
285
347
|
// 📖 IMPORTANT: Pad PLAIN TEXT first, then apply colors to avoid alignment issues
|
|
@@ -326,14 +388,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
326
388
|
const padding = ' '.repeat(Math.max(0, W_UPTIME - plain.length))
|
|
327
389
|
return themeColors.hotkey('U') + themeColors.dim('p%' + padding)
|
|
328
390
|
})()
|
|
329
|
-
|
|
330
|
-
const compatHeaderEmojis = COMPAT_COLUMN_SLOTS.map(slot => {
|
|
331
|
-
return chalk.rgb(...slot.color)(slot.emoji)
|
|
332
|
-
}).join('')
|
|
333
|
-
// 📖 padEndDisplay accounts for emoji widths (most are 2-wide, π is 1-wide)
|
|
334
|
-
const compatHeaderRaw = COMPAT_COLUMN_SLOTS.reduce((w, slot) => w + displayWidth(slot.emoji), 0)
|
|
335
|
-
const compatHeaderPad = Math.max(0, W_COMPAT - compatHeaderRaw)
|
|
336
|
-
const compatH_c = compatHeaderEmojis + ' '.repeat(compatHeaderPad)
|
|
391
|
+
|
|
337
392
|
// 📖 Usage column removed from UI – no header or separator for it.
|
|
338
393
|
// 📖 Header row: conditionally include columns based on responsive visibility
|
|
339
394
|
const headerParts = []
|
|
@@ -342,9 +397,12 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
342
397
|
headerParts.push(sweH_c, ctxH_c, modelH_c, originH_c, pingH_c, avgH_c, healthH_c, verdictH_c)
|
|
343
398
|
if (showStability) headerParts.push(stabH_c)
|
|
344
399
|
if (showUptime) headerParts.push(uptimeH_c)
|
|
345
|
-
if (showCompat) headerParts.push(compatH_c)
|
|
346
400
|
lines.push(' ' + headerParts.join(COL_SEP))
|
|
347
401
|
|
|
402
|
+
// 📖 Mouse support: the column header row is the last line we just pushed.
|
|
403
|
+
// 📖 Terminal rows are 1-based, so line index (lines.length-1) → terminal row lines.length.
|
|
404
|
+
_lastLayout.headerRow = lines.length
|
|
405
|
+
|
|
348
406
|
|
|
349
407
|
|
|
350
408
|
if (sorted.length === 0) {
|
|
@@ -376,6 +434,14 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
376
434
|
lines.push(themeColors.dim(` ... ${vp.startIdx} more above ...`))
|
|
377
435
|
}
|
|
378
436
|
|
|
437
|
+
// 📖 Mouse support: record where model rows begin in the terminal (1-based).
|
|
438
|
+
// 📖 The next line pushed will be the first visible model row.
|
|
439
|
+
const _firstModelLineIdx = lines.length // 📖 0-based index into lines[]
|
|
440
|
+
_lastLayout.viewportStartIdx = vp.startIdx
|
|
441
|
+
_lastLayout.viewportEndIdx = vp.endIdx
|
|
442
|
+
_lastLayout.hasAboveIndicator = vp.hasAbove
|
|
443
|
+
_lastLayout.hasBelowIndicator = vp.hasBelow
|
|
444
|
+
|
|
379
445
|
for (let i = vp.startIdx; i < vp.endIdx; i++) {
|
|
380
446
|
const r = sorted[i]
|
|
381
447
|
const tierFn = TIER_COLOR[r.tier] ?? ((text) => themeColors.text(text))
|
|
@@ -601,28 +667,6 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
601
667
|
const sourceCursorText = providerDisplay.padEnd(wSource)
|
|
602
668
|
const sourceCell = isCursor ? themeColors.provider(r.providerKey, sourceCursorText, { bold: true }) : source
|
|
603
669
|
|
|
604
|
-
// 📖 "Compatible with" column — show colored emojis for compatible tools
|
|
605
|
-
// 📖 Each slot in COMPAT_COLUMN_SLOTS maps to one or more tool keys.
|
|
606
|
-
// 📖 OpenCode CLI + Desktop are merged into a single 📦 slot.
|
|
607
|
-
let compatCell = ''
|
|
608
|
-
if (showCompat) {
|
|
609
|
-
const compatTools = getCompatibleTools(r.providerKey)
|
|
610
|
-
let compatDisplayWidth = 0
|
|
611
|
-
const emojiCells = COMPAT_COLUMN_SLOTS.map(slot => {
|
|
612
|
-
const isCompat = slot.toolKeys.some(tk => compatTools.includes(tk))
|
|
613
|
-
const ew = displayWidth(slot.emoji)
|
|
614
|
-
compatDisplayWidth += isCompat ? ew : ew
|
|
615
|
-
if (isCompat) {
|
|
616
|
-
return chalk.rgb(...slot.color)(slot.emoji)
|
|
617
|
-
}
|
|
618
|
-
// 📖 Replace incompatible emoji with dim spaces matching its display width
|
|
619
|
-
return themeColors.dim(' '.repeat(ew))
|
|
620
|
-
}).join('')
|
|
621
|
-
// 📖 Pad to W_COMPAT — account for actual emoji display widths
|
|
622
|
-
const extraPad = Math.max(0, W_COMPAT - compatDisplayWidth)
|
|
623
|
-
compatCell = emojiCells + ' '.repeat(extraPad)
|
|
624
|
-
}
|
|
625
|
-
|
|
626
670
|
// 📖 Check if this model is incompatible with the active tool mode
|
|
627
671
|
const isIncompatible = !isModelCompatibleWithTool(r.providerKey, mode)
|
|
628
672
|
|
|
@@ -637,7 +681,6 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
637
681
|
rowParts.push(sweCell, ctxCell, nameCell, sourceCell, pingCell, avgCell, status, speedCell)
|
|
638
682
|
if (showStability) rowParts.push(stabCell)
|
|
639
683
|
if (showUptime) rowParts.push(uptimeCell)
|
|
640
|
-
if (showCompat) rowParts.push(compatCell)
|
|
641
684
|
const row = ' ' + rowParts.join(COL_SEP)
|
|
642
685
|
|
|
643
686
|
if (isCursor) {
|
|
@@ -656,6 +699,11 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
656
699
|
}
|
|
657
700
|
}
|
|
658
701
|
|
|
702
|
+
// 📖 Mouse support: record the 1-based terminal row range of model data rows.
|
|
703
|
+
// 📖 _firstModelLineIdx was captured before the loop; lines.length is now past the last model row.
|
|
704
|
+
_lastLayout.firstModelRow = _firstModelLineIdx + 1 // 📖 convert 0-based line index → 1-based terminal row
|
|
705
|
+
_lastLayout.lastModelRow = lines.length // 📖 last pushed line is at lines.length (1-based)
|
|
706
|
+
|
|
659
707
|
if (vp.hasBelow) {
|
|
660
708
|
lines.push(themeColors.dim(` ... ${sorted.length - vp.endIdx} more below ...`))
|
|
661
709
|
}
|
|
@@ -670,7 +718,40 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
670
718
|
const activeHotkey = (keyLabel, text, bg) => themeColors.badge(`${keyLabel}${text}`, bg, getReadableTextRgb(bg))
|
|
671
719
|
const favoritesModeBg = favoritesPinnedAndSticky ? [157, 122, 48] : [95, 95, 95]
|
|
672
720
|
const favoritesModeLabel = favoritesPinnedAndSticky ? ' Favorites Pinned' : ' Favorites Normal'
|
|
721
|
+
|
|
722
|
+
// 📖 Mouse support: build footer hotkey zones alongside the footer lines.
|
|
723
|
+
// 📖 Each zone records { key, row (1-based terminal row), xStart, xEnd (1-based display cols) }.
|
|
724
|
+
// 📖 We accumulate display position as we build each footer line's parts.
|
|
725
|
+
const footerHotkeys = []
|
|
726
|
+
|
|
673
727
|
// 📖 Line 1: core navigation + filtering shortcuts
|
|
728
|
+
// 📖 Build as parts array so we can compute click zones and still join for display.
|
|
729
|
+
{
|
|
730
|
+
const parts = [
|
|
731
|
+
{ text: ' ', key: null },
|
|
732
|
+
{ text: 'F Toggle Favorite', key: 'f' },
|
|
733
|
+
{ text: ' • ', key: null },
|
|
734
|
+
{ text: 'Y' + favoritesModeLabel, key: 'y' },
|
|
735
|
+
{ text: ' • ', key: null },
|
|
736
|
+
{ text: tierFilterMode > 0 ? `T Tier (${activeTierLabel})` : 'T Tier', key: 't' },
|
|
737
|
+
{ text: ' • ', key: null },
|
|
738
|
+
{ text: originFilterMode > 0 ? `D Provider (${activeOriginLabel})` : 'D Provider', key: 'd' },
|
|
739
|
+
{ text: ' • ', key: null },
|
|
740
|
+
{ text: 'E Show only configured models', key: 'e' },
|
|
741
|
+
{ text: ' • ', key: null },
|
|
742
|
+
{ text: 'P Settings', key: 'p' },
|
|
743
|
+
{ text: ' • ', key: null },
|
|
744
|
+
{ text: 'K Help', key: 'k' },
|
|
745
|
+
]
|
|
746
|
+
const footerRow1 = lines.length + 1 // 📖 1-based terminal row (line hasn't been pushed yet)
|
|
747
|
+
let xPos = 1
|
|
748
|
+
for (const part of parts) {
|
|
749
|
+
const w = displayWidth(part.text)
|
|
750
|
+
if (part.key) footerHotkeys.push({ key: part.key, row: footerRow1, xStart: xPos, xEnd: xPos + w - 1 })
|
|
751
|
+
xPos += w
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
674
755
|
lines.push(
|
|
675
756
|
' ' + hotkey('F', ' Toggle Favorite') +
|
|
676
757
|
themeColors.dim(` • `) +
|
|
@@ -684,12 +765,35 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
684
765
|
? activeHotkey('D', ` Provider (${activeOriginLabel})`, PROVIDER_COLOR[[null, ...Object.keys(sources)][originFilterMode]] || [255, 255, 255])
|
|
685
766
|
: hotkey('D', ' Provider')) +
|
|
686
767
|
themeColors.dim(` • `) +
|
|
687
|
-
(hideUnconfiguredModels ? activeHotkey('E', '
|
|
768
|
+
(hideUnconfiguredModels ? activeHotkey('E', ' Show only configured models', configuredBadgeBg) : hotkey('E', ' Show only configured models')) +
|
|
688
769
|
themeColors.dim(` • `) +
|
|
689
770
|
hotkey('P', ' Settings') +
|
|
690
771
|
themeColors.dim(` • `) +
|
|
691
772
|
hotkey('K', ' Help')
|
|
692
773
|
)
|
|
774
|
+
|
|
775
|
+
// 📖 Line 2: command palette, recommend, feedback, theme
|
|
776
|
+
{
|
|
777
|
+
const cpText = ' NEW ! CTRL+P ⚡️ Command Palette '
|
|
778
|
+
const parts = [
|
|
779
|
+
{ text: ' ', key: null },
|
|
780
|
+
{ text: cpText, key: 'ctrl+p' },
|
|
781
|
+
{ text: ' • ', key: null },
|
|
782
|
+
{ text: 'Q Smart Recommend', key: 'q' },
|
|
783
|
+
{ text: ' • ', key: null },
|
|
784
|
+
{ text: 'G Theme', key: 'g' },
|
|
785
|
+
{ text: ' • ', key: null },
|
|
786
|
+
{ text: 'I Feedback, bugs & requests', key: 'i' },
|
|
787
|
+
]
|
|
788
|
+
const footerRow2 = lines.length + 1
|
|
789
|
+
let xPos = 1
|
|
790
|
+
for (const part of parts) {
|
|
791
|
+
const w = displayWidth(part.text)
|
|
792
|
+
if (part.key) footerHotkeys.push({ key: part.key, row: footerRow2, xStart: xPos, xEnd: xPos + w - 1 })
|
|
793
|
+
xPos += w
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
693
797
|
// 📖 Line 2: command palette (highlighted as new), recommend, feedback, and extended hints.
|
|
694
798
|
// 📖 CTRL+P ⚡️ Command Palette uses neon-green-on-dark-green background to highlight the feature.
|
|
695
799
|
const paletteLabel = chalk.bgRgb(0, 60, 0).rgb(57, 255, 20).bold(' NEW ! CTRL+P ⚡️ Command Palette ')
|
|
@@ -745,6 +849,29 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
745
849
|
filterBadge = chalk.bgYellow.black.bold(` ${filterPrefix}${visibleFilter}${filterSuffix} `)
|
|
746
850
|
}
|
|
747
851
|
|
|
852
|
+
// 📖 Mouse support: track last footer line hotkey zones
|
|
853
|
+
{
|
|
854
|
+
const lastFooterRow = lines.length + 1 // 📖 1-based terminal row (line about to be pushed)
|
|
855
|
+
const parts = [
|
|
856
|
+
{ text: ' ', key: null },
|
|
857
|
+
{ text: 'N Changelog', key: 'n' },
|
|
858
|
+
]
|
|
859
|
+
if (hasCustomFilter) {
|
|
860
|
+
parts.push({ text: ' • ', key: null })
|
|
861
|
+
// 📖 X key clears filter — compute width from rendered badge text
|
|
862
|
+
const badgePlain = `X Disable filter: "${customTextFilter.trim().replace(/\s+/g, ' ')}"`
|
|
863
|
+
parts.push({ text: ` ${badgePlain} `, key: 'x' })
|
|
864
|
+
}
|
|
865
|
+
let xPos = 1
|
|
866
|
+
for (const part of parts) {
|
|
867
|
+
const w = displayWidth(part.text)
|
|
868
|
+
if (part.key) footerHotkeys.push({ key: part.key, row: lastFooterRow, xStart: xPos, xEnd: xPos + w - 1 })
|
|
869
|
+
xPos += w
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
_lastLayout.footerHotkeys = footerHotkeys
|
|
874
|
+
|
|
748
875
|
lines.push(
|
|
749
876
|
' ' + themeColors.hotkey('N') + themeColors.dim(' Changelog') +
|
|
750
877
|
(filterBadge
|