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.
@@ -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
- // 📖 OpenClaw manages API keys inside its own config file. All other tools
251
- // 📖 still need a provider key to be useful, so keep the existing warning.
252
- if (state.mode !== 'openclaw') {
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
+ }