free-coding-models 0.3.23 → 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 +41 -0
- package/README.md +72 -17
- package/package.json +1 -1
- package/sources.js +60 -0
- package/src/app.js +97 -6
- package/src/command-palette.js +3 -1
- package/src/constants.js +5 -2
- package/src/endpoint-installer.js +2 -1
- package/src/key-handler.js +607 -5
- package/src/mouse.js +186 -0
- package/src/overlays.js +159 -2
- package/src/provider-metadata.js +25 -0
- package/src/render-helpers.js +1 -1
- package/src/render-table.js +181 -8
- package/src/theme.js +6 -0
- package/src/tool-bootstrap.js +22 -0
- package/src/tool-launchers.js +93 -2
- package/src/tool-metadata.js +94 -11
- package/src/utils.js +4 -0
package/src/key-handler.js
CHANGED
|
@@ -28,8 +28,10 @@
|
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
import { loadChangelog } from './changelog-loader.js'
|
|
31
|
+
import { getToolMeta, isModelCompatibleWithTool, getCompatibleTools, findSimilarCompatibleModels } from './tool-metadata.js'
|
|
31
32
|
import { loadConfig, replaceConfigContents } from './config.js'
|
|
32
33
|
import { cleanupLegacyProxyArtifacts } from './legacy-proxy-cleanup.js'
|
|
34
|
+
import { getLastLayout, COLUMN_SORT_MAP } from './render-table.js'
|
|
33
35
|
import { cycleThemeSetting, detectActiveTheme } from './theme.js'
|
|
34
36
|
import { buildCommandPaletteTree, flattenCommandTree, filterCommandPaletteEntries } from './command-palette.js'
|
|
35
37
|
import { WIDTH_WARNING_MIN_COLS } from './constants.js'
|
|
@@ -247,9 +249,61 @@ export function createKeyHandler(ctx) {
|
|
|
247
249
|
}
|
|
248
250
|
console.log()
|
|
249
251
|
|
|
250
|
-
// 📖
|
|
251
|
-
// 📖
|
|
252
|
-
|
|
252
|
+
// 📖 CLI-only tool compatibility checks:
|
|
253
|
+
// 📖 Case A: Active tool mode is CLI-only (rovo/gemini) but selected model doesn't belong to it
|
|
254
|
+
// 📖 Case B: Selected model belongs to a CLI-only provider but active mode is something else
|
|
255
|
+
// 📖 Case C: Selected model is from opencode-zen but active mode is not opencode/opencode-desktop
|
|
256
|
+
const activeMeta = getToolMeta(state.mode)
|
|
257
|
+
const isActiveModeCliOnly = activeMeta.cliOnly === true
|
|
258
|
+
const isModelFromCliOnly = selected.providerKey === 'rovo' || selected.providerKey === 'gemini'
|
|
259
|
+
const isModelFromZen = selected.providerKey === 'opencode-zen'
|
|
260
|
+
const modelBelongsToActiveMode = selected.providerKey === state.mode
|
|
261
|
+
|
|
262
|
+
// 📖 Case A: User is in Rovo/Gemini mode but selected a model from a different provider
|
|
263
|
+
if (isActiveModeCliOnly && !modelBelongsToActiveMode) {
|
|
264
|
+
const availableModels = MODELS.filter(m => m[5] === state.mode)
|
|
265
|
+
console.log(chalk.yellow(` ⚠ ${activeMeta.label} can only launch its own models.`))
|
|
266
|
+
console.log(chalk.yellow(` "${selected.label}" is not a ${activeMeta.label} model.`))
|
|
267
|
+
console.log()
|
|
268
|
+
if (availableModels.length > 0) {
|
|
269
|
+
console.log(chalk.cyan(` Available ${activeMeta.label} models:`))
|
|
270
|
+
for (const m of availableModels) {
|
|
271
|
+
console.log(chalk.white(` • ${m[1]} (${m[2]} tier, ${m[3]} SWE, ${m[4]} ctx)`))
|
|
272
|
+
}
|
|
273
|
+
console.log()
|
|
274
|
+
}
|
|
275
|
+
console.log(chalk.dim(` Switch to another tool mode with Z, or select a ${activeMeta.label} model.`))
|
|
276
|
+
console.log()
|
|
277
|
+
process.exit(0)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 📖 Case B: Selected model is from a CLI-only provider but active mode is different
|
|
281
|
+
if (isModelFromCliOnly && !modelBelongsToActiveMode) {
|
|
282
|
+
const modelMeta = getToolMeta(selected.providerKey)
|
|
283
|
+
console.log(chalk.yellow(` ⚠ ${selected.label} is a ${modelMeta.label}-exclusive model.`))
|
|
284
|
+
console.log(chalk.yellow(` Your current tool is: ${activeMeta.label}`))
|
|
285
|
+
console.log()
|
|
286
|
+
console.log(chalk.cyan(` Switching to ${modelMeta.label} and launching...`))
|
|
287
|
+
setToolMode(selected.providerKey)
|
|
288
|
+
console.log(chalk.green(` ✓ Switched to ${modelMeta.label}`))
|
|
289
|
+
console.log()
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// 📖 Case C: Zen model selected but active mode is not OpenCode CLI / OpenCode Desktop
|
|
293
|
+
// 📖 Auto-switch to OpenCode CLI since Zen models only run on OpenCode
|
|
294
|
+
if (isModelFromZen && state.mode !== 'opencode' && state.mode !== 'opencode-desktop') {
|
|
295
|
+
console.log(chalk.yellow(` ⚠ ${selected.label} is an OpenCode Zen model.`))
|
|
296
|
+
console.log(chalk.yellow(` Zen models only run on OpenCode CLI or OpenCode Desktop.`))
|
|
297
|
+
console.log(chalk.yellow(` Your current tool is: ${activeMeta.label}`))
|
|
298
|
+
console.log()
|
|
299
|
+
console.log(chalk.cyan(` Switching to OpenCode CLI and launching...`))
|
|
300
|
+
setToolMode('opencode')
|
|
301
|
+
console.log(chalk.green(` ✓ Switched to OpenCode CLI`))
|
|
302
|
+
console.log()
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// 📖 OpenClaw, CLI-only tools, and Zen models manage auth differently — skip API key warning for them.
|
|
306
|
+
if (state.mode !== 'openclaw' && !isModelFromCliOnly && !isModelFromZen) {
|
|
253
307
|
const selectedApiKey = getApiKey(state.config, selected.providerKey)
|
|
254
308
|
if (!selectedApiKey) {
|
|
255
309
|
console.log(chalk.yellow(` Warning: No API key configured for ${selected.providerKey}.`))
|
|
@@ -259,6 +313,41 @@ export function createKeyHandler(ctx) {
|
|
|
259
313
|
}
|
|
260
314
|
}
|
|
261
315
|
|
|
316
|
+
// 📖 CLI-only tool auto-install check — verify the CLI binary is available before launch.
|
|
317
|
+
const toolModeForProvider = selected.providerKey
|
|
318
|
+
if (isModelFromCliOnly && !isToolInstalled(toolModeForProvider)) {
|
|
319
|
+
const installPlan = getToolInstallPlan(toolModeForProvider)
|
|
320
|
+
if (installPlan.supported) {
|
|
321
|
+
console.log()
|
|
322
|
+
console.log(chalk.yellow(` ⚠ ${getToolMeta(toolModeForProvider).label} is not installed.`))
|
|
323
|
+
console.log(chalk.dim(` ${installPlan.summary}`))
|
|
324
|
+
if (installPlan.note) console.log(chalk.dim(` Note: ${installPlan.note}`))
|
|
325
|
+
console.log()
|
|
326
|
+
console.log(chalk.cyan(` 📦 Auto-installing ${getToolMeta(toolModeForProvider).label}...`))
|
|
327
|
+
console.log()
|
|
328
|
+
|
|
329
|
+
const installResult = await installToolWithPlan(installPlan)
|
|
330
|
+
if (!installResult.ok) {
|
|
331
|
+
console.log(chalk.red(` X Tool installation failed with exit code ${installResult.exitCode}.`))
|
|
332
|
+
if (installPlan.docsUrl) console.log(chalk.dim(` Docs: ${installPlan.docsUrl}`))
|
|
333
|
+
console.log()
|
|
334
|
+
process.exit(installResult.exitCode || 1)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// 📖 Verify tool is now installed
|
|
338
|
+
if (!isToolInstalled(toolModeForProvider)) {
|
|
339
|
+
console.log(chalk.yellow(' ⚠ The installer finished, but the tool is still not reachable from this terminal session.'))
|
|
340
|
+
console.log(chalk.dim(' Restart your shell or add the tool bin directory to PATH, then retry the launch.'))
|
|
341
|
+
if (installPlan.docsUrl) console.log(chalk.dim(` Docs: ${installPlan.docsUrl}`))
|
|
342
|
+
console.log()
|
|
343
|
+
process.exit(1)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
console.log(chalk.green(' ✓ Tool installed successfully. Continuing with the selected model...'))
|
|
347
|
+
console.log()
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
262
351
|
let exitCode = 0
|
|
263
352
|
if (state.mode === 'openclaw') {
|
|
264
353
|
exitCode = await startOpenClaw(userSelected, state.config, { launchCli: true })
|
|
@@ -1200,6 +1289,85 @@ export function createKeyHandler(ctx) {
|
|
|
1200
1289
|
return
|
|
1201
1290
|
}
|
|
1202
1291
|
|
|
1292
|
+
// 📖 Incompatible fallback overlay: ↑↓ navigate across tool + model sections, Enter confirms, Esc cancels.
|
|
1293
|
+
// 📖 Cursor is a flat index: 0..N-1 = compatible tools, N..N+M-1 = similar models.
|
|
1294
|
+
if (state.incompatibleFallbackOpen) {
|
|
1295
|
+
if (key.ctrl && key.name === 'c') { exit(0); return }
|
|
1296
|
+
|
|
1297
|
+
const tools = state.incompatibleFallbackTools || []
|
|
1298
|
+
const similarModels = state.incompatibleFallbackSimilarModels || []
|
|
1299
|
+
const totalItems = tools.length + similarModels.length
|
|
1300
|
+
|
|
1301
|
+
if (key.name === 'escape') {
|
|
1302
|
+
// 📖 Close the overlay and go back to the main table
|
|
1303
|
+
state.incompatibleFallbackOpen = false
|
|
1304
|
+
state.incompatibleFallbackCursor = 0
|
|
1305
|
+
state.incompatibleFallbackScrollOffset = 0
|
|
1306
|
+
state.incompatibleFallbackModel = null
|
|
1307
|
+
state.incompatibleFallbackTools = []
|
|
1308
|
+
state.incompatibleFallbackSimilarModels = []
|
|
1309
|
+
state.incompatibleFallbackSection = 'tools'
|
|
1310
|
+
return
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
if (key.name === 'up' && totalItems > 0) {
|
|
1314
|
+
state.incompatibleFallbackCursor = state.incompatibleFallbackCursor > 0
|
|
1315
|
+
? state.incompatibleFallbackCursor - 1
|
|
1316
|
+
: totalItems - 1
|
|
1317
|
+
state.incompatibleFallbackSection = state.incompatibleFallbackCursor < tools.length ? 'tools' : 'models'
|
|
1318
|
+
return
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
if (key.name === 'down' && totalItems > 0) {
|
|
1322
|
+
state.incompatibleFallbackCursor = state.incompatibleFallbackCursor < totalItems - 1
|
|
1323
|
+
? state.incompatibleFallbackCursor + 1
|
|
1324
|
+
: 0
|
|
1325
|
+
state.incompatibleFallbackSection = state.incompatibleFallbackCursor < tools.length ? 'tools' : 'models'
|
|
1326
|
+
return
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
if (key.name === 'return' && totalItems > 0) {
|
|
1330
|
+
const cursor = state.incompatibleFallbackCursor
|
|
1331
|
+
const fallbackModel = state.incompatibleFallbackModel
|
|
1332
|
+
|
|
1333
|
+
// 📖 Close overlay state first
|
|
1334
|
+
state.incompatibleFallbackOpen = false
|
|
1335
|
+
state.incompatibleFallbackCursor = 0
|
|
1336
|
+
state.incompatibleFallbackScrollOffset = 0
|
|
1337
|
+
state.incompatibleFallbackModel = null
|
|
1338
|
+
state.incompatibleFallbackTools = []
|
|
1339
|
+
state.incompatibleFallbackSimilarModels = []
|
|
1340
|
+
state.incompatibleFallbackSection = 'tools'
|
|
1341
|
+
|
|
1342
|
+
if (cursor < tools.length) {
|
|
1343
|
+
// 📖 Section 1: Switch to the selected compatible tool, then launch the original model
|
|
1344
|
+
const selectedToolKey = tools[cursor]
|
|
1345
|
+
setToolMode(selectedToolKey)
|
|
1346
|
+
// 📖 Find the full result object for the original model to pass to launchSelectedModel
|
|
1347
|
+
const fullModel = state.results.find(
|
|
1348
|
+
r => r.providerKey === fallbackModel.providerKey && r.modelId === fallbackModel.modelId
|
|
1349
|
+
)
|
|
1350
|
+
if (fullModel) {
|
|
1351
|
+
await launchSelectedModel(fullModel)
|
|
1352
|
+
}
|
|
1353
|
+
} else {
|
|
1354
|
+
// 📖 Section 2: Launch the selected similar model instead
|
|
1355
|
+
const modelIdx = cursor - tools.length
|
|
1356
|
+
const selectedSimilar = similarModels[modelIdx]
|
|
1357
|
+
if (selectedSimilar) {
|
|
1358
|
+
const fullModel = state.results.find(
|
|
1359
|
+
r => r.providerKey === selectedSimilar.providerKey && r.modelId === selectedSimilar.modelId
|
|
1360
|
+
)
|
|
1361
|
+
if (fullModel) {
|
|
1362
|
+
await launchSelectedModel(fullModel)
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
return
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1203
1371
|
// 📖 Feedback overlay: intercept ALL keys while overlay is active.
|
|
1204
1372
|
// 📖 Enter → send to Discord, Esc → cancel, Backspace → delete char, printable → append to buffer.
|
|
1205
1373
|
if (state.feedbackOpen) {
|
|
@@ -1809,8 +1977,6 @@ export function createKeyHandler(ctx) {
|
|
|
1809
1977
|
|
|
1810
1978
|
// 📖 Profile system removed - API keys now persist permanently across all sessions
|
|
1811
1979
|
|
|
1812
|
-
// 📖 Profile system removed - API keys now persist permanently across all sessions
|
|
1813
|
-
|
|
1814
1980
|
// 📖 Shift+R: reset all UI view settings to defaults (tier, sort, provider) and clear persisted config
|
|
1815
1981
|
if (key.name === 'r' && key.shift) {
|
|
1816
1982
|
resetViewSettings()
|
|
@@ -1944,6 +2110,33 @@ export function createKeyHandler(ctx) {
|
|
|
1944
2110
|
// 📖 Use the cached visible+sorted array — guaranteed to match what's on screen
|
|
1945
2111
|
const selected = state.visibleSorted[state.cursor]
|
|
1946
2112
|
if (!selected) return // 📖 Guard: empty visible list (all filtered out)
|
|
2113
|
+
|
|
2114
|
+
// 📖 Incompatibility intercept — if the model can't run on the active tool,
|
|
2115
|
+
// 📖 show the fallback overlay instead of launching. Lets user switch tool or pick similar model.
|
|
2116
|
+
if (!isModelCompatibleWithTool(selected.providerKey, state.mode)) {
|
|
2117
|
+
const compatTools = getCompatibleTools(selected.providerKey)
|
|
2118
|
+
const similarModels = findSimilarCompatibleModels(
|
|
2119
|
+
selected.sweScore || '-',
|
|
2120
|
+
state.mode,
|
|
2121
|
+
state.results.filter(r => r.providerKey !== selected.providerKey || r.modelId !== selected.modelId),
|
|
2122
|
+
3
|
|
2123
|
+
)
|
|
2124
|
+
state.incompatibleFallbackOpen = true
|
|
2125
|
+
state.incompatibleFallbackCursor = 0
|
|
2126
|
+
state.incompatibleFallbackScrollOffset = 0
|
|
2127
|
+
state.incompatibleFallbackModel = {
|
|
2128
|
+
modelId: selected.modelId,
|
|
2129
|
+
label: selected.label,
|
|
2130
|
+
tier: selected.tier,
|
|
2131
|
+
providerKey: selected.providerKey,
|
|
2132
|
+
sweScore: selected.sweScore || '-',
|
|
2133
|
+
}
|
|
2134
|
+
state.incompatibleFallbackTools = compatTools
|
|
2135
|
+
state.incompatibleFallbackSimilarModels = similarModels
|
|
2136
|
+
state.incompatibleFallbackSection = 'tools'
|
|
2137
|
+
return
|
|
2138
|
+
}
|
|
2139
|
+
|
|
1947
2140
|
if (shouldCheckMissingTool(state.mode) && !isToolInstalled(state.mode)) {
|
|
1948
2141
|
state.toolInstallPromptOpen = true
|
|
1949
2142
|
state.toolInstallPromptCursor = 0
|
|
@@ -1965,3 +2158,412 @@ export function createKeyHandler(ctx) {
|
|
|
1965
2158
|
}
|
|
1966
2159
|
}
|
|
1967
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
|
+
}
|