free-coding-models 0.3.24 → 0.3.26

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.
@@ -31,9 +31,12 @@ 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'
38
+ import { scanAllToolConfigs, softDeleteModel } from './installed-models-manager.js'
39
+ import { startExternalTool } from './tool-launchers.js'
37
40
 
38
41
  // 📖 Some providers need an explicit probe model because the first catalog entry
39
42
  // 📖 is not guaranteed to be accepted by their chat endpoint.
@@ -686,6 +689,21 @@ export function createKeyHandler(ctx) {
686
689
  state.changelogSelectedVersion = null
687
690
  }
688
691
 
692
+ function openInstalledModelsOverlay() {
693
+ state.installedModelsOpen = true
694
+ state.installedModelsCursor = 0
695
+ state.installedModelsScrollOffset = 0
696
+ state.installedModelsErrorMsg = 'Scanning...'
697
+
698
+ try {
699
+ const results = scanAllToolConfigs()
700
+ state.installedModelsData = results
701
+ state.installedModelsErrorMsg = null
702
+ } catch (err) {
703
+ state.installedModelsErrorMsg = err.message || 'Failed to scan tool configs'
704
+ }
705
+ }
706
+
689
707
  function cycleToolMode() {
690
708
  const modeOrder = getToolModeOrder()
691
709
  const currentIndex = modeOrder.indexOf(state.mode)
@@ -773,6 +791,7 @@ export function createKeyHandler(ctx) {
773
791
  return state.settingsOpen
774
792
  || state.installEndpointsOpen
775
793
  || state.toolInstallPromptOpen
794
+ || state.installedModelsOpen
776
795
  || state.recommendOpen
777
796
  || state.feedbackOpen
778
797
  || state.helpVisible
@@ -942,6 +961,7 @@ export function createKeyHandler(ctx) {
942
961
  case 'open-feedback': return openFeedbackOverlay()
943
962
  case 'open-recommend': return openRecommendOverlay()
944
963
  case 'open-install-endpoints': return openInstallEndpointsOverlay()
964
+ case 'open-installed-models': return openInstalledModelsOverlay()
945
965
  case 'action-cycle-theme': return cycleGlobalTheme()
946
966
  case 'action-cycle-tool-mode': return cycleToolMode()
947
967
  case 'action-cycle-ping-mode': {
@@ -1288,6 +1308,104 @@ export function createKeyHandler(ctx) {
1288
1308
  return
1289
1309
  }
1290
1310
 
1311
+ // ─── Installed Models overlay keyboard handling ───────────────────────────
1312
+ if (state.installedModelsOpen) {
1313
+ if (key.ctrl && key.name === 'c') { exit(0); return }
1314
+
1315
+ const scanResults = state.installedModelsData || []
1316
+ let maxIndex = 0
1317
+ for (const toolResult of scanResults) {
1318
+ maxIndex += 1
1319
+ maxIndex += toolResult.models.length
1320
+ }
1321
+ if (maxIndex > 0) maxIndex--
1322
+
1323
+ const pageStep = Math.max(1, (state.terminalRows || 1) - 4)
1324
+
1325
+ if (key.name === 'up' || (key.shift && key.name === 'tab')) {
1326
+ state.installedModelsCursor = Math.max(0, state.installedModelsCursor - 1)
1327
+ return
1328
+ }
1329
+ if (key.name === 'down' || key.name === 'tab') {
1330
+ state.installedModelsCursor = Math.min(maxIndex, state.installedModelsCursor + 1)
1331
+ return
1332
+ }
1333
+ if (key.name === 'pageup') {
1334
+ state.installedModelsCursor = Math.max(0, state.installedModelsCursor - pageStep)
1335
+ return
1336
+ }
1337
+ if (key.name === 'pagedown') {
1338
+ state.installedModelsCursor = Math.min(maxIndex, state.installedModelsCursor + pageStep)
1339
+ return
1340
+ }
1341
+ if (key.name === 'home') {
1342
+ state.installedModelsCursor = 0
1343
+ return
1344
+ }
1345
+ if (key.name === 'end') {
1346
+ state.installedModelsCursor = maxIndex
1347
+ return
1348
+ }
1349
+
1350
+ if (key.name === 'escape') {
1351
+ state.installedModelsOpen = false
1352
+ state.installedModelsCursor = 0
1353
+ return
1354
+ }
1355
+
1356
+ if (key.name === 'return') {
1357
+ let currentIdx = 0
1358
+ for (const toolResult of scanResults) {
1359
+ if (currentIdx === state.installedModelsCursor) {
1360
+ return
1361
+ }
1362
+ currentIdx++
1363
+ for (const model of toolResult.models) {
1364
+ if (currentIdx === state.installedModelsCursor) {
1365
+ const selectedModel = {
1366
+ modelId: model.modelId,
1367
+ providerKey: model.providerKey,
1368
+ label: model.label,
1369
+ }
1370
+
1371
+ state.installedModelsOpen = false
1372
+ await startExternalTool(toolResult.toolMode, selectedModel, state.config)
1373
+ return
1374
+ }
1375
+ currentIdx++
1376
+ }
1377
+ }
1378
+ }
1379
+
1380
+ if (key.name === 'd') {
1381
+ let currentIdx = 0
1382
+ for (const toolResult of scanResults) {
1383
+ currentIdx++
1384
+ for (const model of toolResult.models) {
1385
+ if (currentIdx === state.installedModelsCursor) {
1386
+ softDeleteModel(toolResult.toolMode, model.modelId)
1387
+ .then((result) => {
1388
+ if (result.success) {
1389
+ openInstalledModelsOverlay()
1390
+ } else {
1391
+ state.installedModelsErrorMsg = `Failed to disable: ${result.error}`
1392
+ setTimeout(() => { state.installedModelsErrorMsg = null }, 3000)
1393
+ }
1394
+ })
1395
+ .catch((err) => {
1396
+ state.installedModelsErrorMsg = `Failed to disable: ${err.message}`
1397
+ setTimeout(() => { state.installedModelsErrorMsg = null }, 3000)
1398
+ })
1399
+ return
1400
+ }
1401
+ currentIdx++
1402
+ }
1403
+ }
1404
+ }
1405
+
1406
+ return
1407
+ }
1408
+
1291
1409
  // 📖 Incompatible fallback overlay: ↑↓ navigate across tool + model sections, Enter confirms, Esc cancels.
1292
1410
  // 📖 Cursor is a flat index: 0..N-1 = compatible tools, N..N+M-1 = similar models.
1293
1411
  if (state.incompatibleFallbackOpen) {
@@ -2157,3 +2275,433 @@ export function createKeyHandler(ctx) {
2157
2275
  }
2158
2276
  }
2159
2277
  }
2278
+
2279
+ /**
2280
+ * 📖 createMouseEventHandler: Factory that returns a handler for structured mouse events.
2281
+ * 📖 Works alongside the keypress handler — shares the same state and action functions.
2282
+ *
2283
+ * 📖 Supported interactions:
2284
+ * - Click on header row column → sort by that column (or cycle tier filter for Tier column)
2285
+ * - Click on model row → move cursor to that row
2286
+ * - Double-click on model row → select the model (Enter)
2287
+ * - Scroll up/down → navigate cursor up/down (with wrap-around)
2288
+ * - Scroll in overlays → scroll overlay content
2289
+ *
2290
+ * @param {object} ctx — same context object passed to createKeyHandler
2291
+ * @returns {function} — callback for onMouseEvent in createMouseHandler()
2292
+ */
2293
+ export function createMouseEventHandler(ctx) {
2294
+ const {
2295
+ state,
2296
+ adjustScrollOffset,
2297
+ applyTierFilter,
2298
+ TIER_CYCLE,
2299
+ noteUserActivity,
2300
+ sortResultsWithPinnedFavorites,
2301
+ saveConfig,
2302
+ overlayLayout,
2303
+ // 📖 Favorite toggle deps — used by right-click on model rows
2304
+ toggleFavoriteModel,
2305
+ syncFavoriteFlags,
2306
+ toFavoriteKey,
2307
+ // 📖 Tool mode cycling — used by compat column header click
2308
+ cycleToolMode,
2309
+ } = ctx
2310
+
2311
+ // 📖 Shared helper: set the sort column, toggling direction if same column clicked twice.
2312
+ function setSortColumnFromClick(col) {
2313
+ if (state.sortColumn === col) {
2314
+ state.sortDirection = state.sortDirection === 'asc' ? 'desc' : 'asc'
2315
+ } else {
2316
+ state.sortColumn = col
2317
+ state.sortDirection = 'asc'
2318
+ }
2319
+ // 📖 Recompute visible sorted list to reflect new sort order
2320
+ const visible = state.results.filter(r => !r.hidden)
2321
+ state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection, {
2322
+ pinFavorites: state.favoritesPinnedAndSticky,
2323
+ })
2324
+ }
2325
+
2326
+ // 📖 Shared helper: persist UI settings after mouse-triggered changes
2327
+ function persistUiSettings() {
2328
+ if (!state.config) return
2329
+ if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
2330
+ state.config.settings.sortColumn = state.sortColumn
2331
+ state.config.settings.sortDirection = state.sortDirection
2332
+ state.config.settings.tierFilter = TIER_CYCLE[state.tierFilterMode] || null
2333
+ }
2334
+
2335
+ // 📖 Shared helper: toggle favorite on a specific model row index.
2336
+ // 📖 Mirrors the keyboard F-key handler but operates at a given index.
2337
+ function toggleFavoriteAtRow(modelIdx) {
2338
+ const selected = state.visibleSorted[modelIdx]
2339
+ if (!selected) return
2340
+ const wasFavorite = selected.isFavorite
2341
+ toggleFavoriteModel(state.config, selected.providerKey, selected.modelId)
2342
+ syncFavoriteFlags(state.results, state.config)
2343
+ applyTierFilter()
2344
+ const visible = state.results.filter(r => !r.hidden)
2345
+ state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection, {
2346
+ pinFavorites: state.favoritesPinnedAndSticky,
2347
+ })
2348
+ // 📖 If we unfavorited while pinned mode is on, reset cursor to top
2349
+ if (wasFavorite && state.favoritesPinnedAndSticky) {
2350
+ state.cursor = 0
2351
+ state.scrollOffset = 0
2352
+ return
2353
+ }
2354
+ // 📖 Otherwise, track the model's new position after re-sort
2355
+ const selectedKey = toFavoriteKey(selected.providerKey, selected.modelId)
2356
+ const newCursor = state.visibleSorted.findIndex(r => toFavoriteKey(r.providerKey, r.modelId) === selectedKey)
2357
+ if (newCursor >= 0) state.cursor = newCursor
2358
+ adjustScrollOffset(state)
2359
+ }
2360
+
2361
+ // 📖 Shared helper: map a terminal row (1-based) to a cursor index using
2362
+ // 📖 an overlay's cursorLineByRow map and scroll offset.
2363
+ // 📖 Returns the cursor index, or -1 if no match.
2364
+ function overlayRowToCursor(y, cursorToLineMap, scrollOffset) {
2365
+ // 📖 Terminal row Y (1-based) → line index in the overlay lines array.
2366
+ // 📖 sliceOverlayLines shows lines from [scrollOffset .. scrollOffset + terminalRows).
2367
+ // 📖 Terminal row 1 = line[scrollOffset], row 2 = line[scrollOffset+1], etc.
2368
+ const lineIdx = (y - 1) + scrollOffset
2369
+ for (const [cursorStr, lineNum] of Object.entries(cursorToLineMap)) {
2370
+ if (lineNum === lineIdx) return parseInt(cursorStr, 10)
2371
+ }
2372
+ return -1
2373
+ }
2374
+
2375
+ return (evt) => {
2376
+ noteUserActivity()
2377
+ const layout = getLastLayout()
2378
+
2379
+ // ── Scroll events ──────────────────────────────────────────────────
2380
+ if (evt.type === 'scroll-up' || evt.type === 'scroll-down') {
2381
+ // 📖 Overlay scroll: if any overlay is open, scroll its content
2382
+ if (state.helpVisible) {
2383
+ const step = evt.type === 'scroll-up' ? -3 : 3
2384
+ state.helpScrollOffset = Math.max(0, (state.helpScrollOffset || 0) + step)
2385
+ return
2386
+ }
2387
+ if (state.changelogOpen) {
2388
+ const step = evt.type === 'scroll-up' ? -3 : 3
2389
+ state.changelogScrollOffset = Math.max(0, (state.changelogScrollOffset || 0) + step)
2390
+ return
2391
+ }
2392
+ if (state.settingsOpen) {
2393
+ // 📖 Settings overlay uses cursor navigation, not scroll offset.
2394
+ // 📖 Move settingsCursor up/down instead of scrolling.
2395
+ if (evt.type === 'scroll-up') {
2396
+ state.settingsCursor = Math.max(0, (state.settingsCursor || 0) - 1)
2397
+ } else {
2398
+ const max = overlayLayout?.settingsMaxRow ?? 99
2399
+ state.settingsCursor = Math.min(max, (state.settingsCursor || 0) + 1)
2400
+ }
2401
+ return
2402
+ }
2403
+ if (state.recommendOpen) {
2404
+ // 📖 Recommend questionnaire phase: scroll moves cursor through options
2405
+ if (state.recommendPhase === 'questionnaire') {
2406
+ const step = evt.type === 'scroll-up' ? -1 : 1
2407
+ state.recommendCursor = Math.max(0, (state.recommendCursor || 0) + step)
2408
+ } else {
2409
+ const step = evt.type === 'scroll-up' ? -1 : 1
2410
+ state.recommendScrollOffset = Math.max(0, (state.recommendScrollOffset || 0) + step)
2411
+ }
2412
+ return
2413
+ }
2414
+ if (state.feedbackOpen) {
2415
+ // 📖 Feedback overlay doesn't scroll — ignore
2416
+ return
2417
+ }
2418
+ if (state.commandPaletteOpen) {
2419
+ // 📖 Command palette: scroll the results list
2420
+ const count = state.commandPaletteResults?.length || 0
2421
+ if (count === 0) return
2422
+ if (evt.type === 'scroll-up') {
2423
+ state.commandPaletteCursor = state.commandPaletteCursor > 0 ? state.commandPaletteCursor - 1 : count - 1
2424
+ } else {
2425
+ state.commandPaletteCursor = state.commandPaletteCursor < count - 1 ? state.commandPaletteCursor + 1 : 0
2426
+ }
2427
+ return
2428
+ }
2429
+ if (state.installEndpointsOpen) {
2430
+ // 📖 Install endpoints: move cursor up/down
2431
+ if (evt.type === 'scroll-up') {
2432
+ state.installEndpointsCursor = Math.max(0, (state.installEndpointsCursor || 0) - 1)
2433
+ } else {
2434
+ state.installEndpointsCursor = (state.installEndpointsCursor || 0) + 1
2435
+ }
2436
+ return
2437
+ }
2438
+ if (state.toolInstallPromptOpen) {
2439
+ // 📖 Tool install prompt: move cursor up/down
2440
+ if (evt.type === 'scroll-up') {
2441
+ state.toolInstallPromptCursor = Math.max(0, (state.toolInstallPromptCursor || 0) - 1)
2442
+ } else {
2443
+ state.toolInstallPromptCursor = (state.toolInstallPromptCursor || 0) + 1
2444
+ }
2445
+ return
2446
+ }
2447
+ if (state.installedModelsOpen) {
2448
+ const scanResults = state.installedModelsData || []
2449
+ let maxIndex = 0
2450
+ for (const toolResult of scanResults) {
2451
+ maxIndex += 1
2452
+ maxIndex += toolResult.models.length
2453
+ }
2454
+ if (maxIndex > 0) maxIndex--
2455
+
2456
+ if (evt.type === 'scroll-up') {
2457
+ state.installedModelsCursor = Math.max(0, (state.installedModelsCursor || 0) - 1)
2458
+ } else {
2459
+ state.installedModelsCursor = Math.min(maxIndex, (state.installedModelsCursor || 0) + 1)
2460
+ }
2461
+ return
2462
+ }
2463
+
2464
+ // 📖 Main table scroll: move cursor up/down with wrap-around
2465
+ const count = state.visibleSorted.length
2466
+ if (count === 0) return
2467
+ if (evt.type === 'scroll-up') {
2468
+ state.cursor = state.cursor > 0 ? state.cursor - 1 : count - 1
2469
+ } else {
2470
+ state.cursor = state.cursor < count - 1 ? state.cursor + 1 : 0
2471
+ }
2472
+ adjustScrollOffset(state)
2473
+ return
2474
+ }
2475
+
2476
+ // ── Click / double-click events ────────────────────────────────────
2477
+ if (evt.type !== 'click' && evt.type !== 'double-click') return
2478
+
2479
+ const { x, y } = evt
2480
+
2481
+ // ── Overlay click handling ─────────────────────────────────────────
2482
+ // 📖 When an overlay is open, handle clicks inside it or close it.
2483
+ // 📖 Priority order matches the rendering priority in app.js.
2484
+
2485
+ if (state.commandPaletteOpen) {
2486
+ // 📖 Command palette is a floating modal — detect clicks inside vs outside.
2487
+ const cp = overlayLayout
2488
+ const insideModal = cp &&
2489
+ x >= (cp.commandPaletteLeft || 0) && x <= (cp.commandPaletteRight || 0) &&
2490
+ y >= (cp.commandPaletteTop || 0) && y <= (cp.commandPaletteBottom || 0)
2491
+
2492
+ if (insideModal) {
2493
+ // 📖 Check if click is in the body area (result rows)
2494
+ const bodyStart = cp.commandPaletteBodyStartRow || 0
2495
+ const bodyEnd = bodyStart + (cp.commandPaletteBodyRows || 0) - 1
2496
+ if (y >= bodyStart && y <= bodyEnd) {
2497
+ // 📖 Map terminal row → cursor index via the cursorToLine map + scroll offset
2498
+ const cursorIdx = overlayRowToCursor(
2499
+ y - bodyStart + 1, // 📖 Normalize: row within body → 1-based for overlayRowToCursor
2500
+ cp.commandPaletteCursorToLine,
2501
+ cp.commandPaletteScrollOffset
2502
+ )
2503
+ if (cursorIdx >= 0) {
2504
+ state.commandPaletteCursor = cursorIdx
2505
+ if (evt.type === 'double-click') {
2506
+ // 📖 Double-click executes the selected command (same as Enter)
2507
+ process.stdin.emit('keypress', '\r', { name: 'return', ctrl: false, meta: false, shift: false })
2508
+ }
2509
+ return
2510
+ }
2511
+ }
2512
+ // 📖 Click inside modal but not on a result row — ignore (don't close)
2513
+ return
2514
+ }
2515
+
2516
+ // 📖 Click outside the modal → close (Escape equivalent)
2517
+ state.commandPaletteOpen = false
2518
+ state.commandPaletteFrozenTable = null
2519
+ state.commandPaletteQuery = ''
2520
+ state.commandPaletteCursor = 0
2521
+ state.commandPaletteScrollOffset = 0
2522
+ state.commandPaletteResults = []
2523
+ return
2524
+ }
2525
+
2526
+ if (state.installEndpointsOpen) {
2527
+ // 📖 Install endpoints overlay: click closes (Escape equivalent)
2528
+ state.installEndpointsOpen = false
2529
+ return
2530
+ }
2531
+
2532
+ if (state.toolInstallPromptOpen) {
2533
+ // 📖 Tool install prompt: click closes (Escape equivalent)
2534
+ state.toolInstallPromptOpen = false
2535
+ return
2536
+ }
2537
+
2538
+ if (state.installedModelsOpen) {
2539
+ state.installedModelsOpen = false
2540
+ return
2541
+ }
2542
+
2543
+ if (state.incompatibleFallbackOpen) {
2544
+ // 📖 Incompatible fallback: click closes
2545
+ state.incompatibleFallbackOpen = false
2546
+ return
2547
+ }
2548
+
2549
+ if (state.feedbackOpen) {
2550
+ // 📖 Feedback overlay: click anywhere closes (no scroll, no cursor)
2551
+ state.feedbackOpen = false
2552
+ state.feedbackInput = ''
2553
+ return
2554
+ }
2555
+
2556
+ if (state.helpVisible) {
2557
+ // 📖 Help overlay: click anywhere closes (same as K or Escape)
2558
+ state.helpVisible = false
2559
+ return
2560
+ }
2561
+
2562
+ if (state.changelogOpen) {
2563
+ // 📖 Changelog overlay: click on a version row selects it, otherwise close.
2564
+ if (overlayLayout && state.changelogPhase === 'index') {
2565
+ const cursorIdx = overlayRowToCursor(
2566
+ y,
2567
+ overlayLayout.changelogCursorToLine,
2568
+ overlayLayout.changelogScrollOffset
2569
+ )
2570
+ if (cursorIdx >= 0) {
2571
+ state.changelogCursor = cursorIdx
2572
+ // 📖 Double-click opens the selected version's details (same as Enter)
2573
+ if (evt.type === 'double-click') {
2574
+ process.stdin.emit('keypress', '\r', { name: 'return', ctrl: false, meta: false, shift: false })
2575
+ }
2576
+ return
2577
+ }
2578
+ }
2579
+ // 📖 Click outside version list → close (Escape equivalent)
2580
+ // 📖 In details phase, click anywhere goes back (same as B key)
2581
+ if (state.changelogPhase === 'details') {
2582
+ state.changelogPhase = 'index'
2583
+ state.changelogScrollOffset = 0
2584
+ } else {
2585
+ state.changelogOpen = false
2586
+ }
2587
+ return
2588
+ }
2589
+
2590
+ if (state.recommendOpen) {
2591
+ if (state.recommendPhase === 'questionnaire' && overlayLayout?.recommendOptionRows) {
2592
+ // 📖 Map click Y to the specific questionnaire option row
2593
+ const optRows = overlayLayout.recommendOptionRows
2594
+ for (const [idxStr, row] of Object.entries(optRows)) {
2595
+ if (y === row) {
2596
+ state.recommendCursor = parseInt(idxStr, 10)
2597
+ if (evt.type === 'double-click') {
2598
+ // 📖 Double-click confirms the option (same as Enter)
2599
+ process.stdin.emit('keypress', '\r', { name: 'return', ctrl: false, meta: false, shift: false })
2600
+ }
2601
+ return
2602
+ }
2603
+ }
2604
+ // 📖 Click outside option rows in questionnaire — ignore (don't close)
2605
+ return
2606
+ }
2607
+ // 📖 Result phase: click closes. Analyzing phase: click does nothing.
2608
+ if (state.recommendPhase === 'results') {
2609
+ state.recommendOpen = false
2610
+ state.recommendPhase = null
2611
+ state.recommendResults = []
2612
+ state.recommendScrollOffset = 0
2613
+ }
2614
+ return
2615
+ }
2616
+
2617
+ if (state.settingsOpen) {
2618
+ // 📖 Settings overlay: click on a provider/maintenance row moves cursor there.
2619
+ // 📖 Don't handle clicks during edit/add-key mode (keyboard is primary).
2620
+ if (state.settingsEditMode || state.settingsAddKeyMode) return
2621
+
2622
+ if (overlayLayout) {
2623
+ const cursorIdx = overlayRowToCursor(
2624
+ y,
2625
+ overlayLayout.settingsCursorToLine,
2626
+ overlayLayout.settingsScrollOffset
2627
+ )
2628
+ if (cursorIdx >= 0 && cursorIdx <= (overlayLayout.settingsMaxRow || 99)) {
2629
+ state.settingsCursor = cursorIdx
2630
+ // 📖 Double-click triggers the Enter action (edit key / toggle / run action)
2631
+ if (evt.type === 'double-click') {
2632
+ process.stdin.emit('keypress', '\r', { name: 'return', ctrl: false, meta: false, shift: false })
2633
+ }
2634
+ return
2635
+ }
2636
+ }
2637
+ // 📖 Click outside any recognized row does nothing in Settings
2638
+ // 📖 (user can Escape or press P to close)
2639
+ return
2640
+ }
2641
+
2642
+ // ── Main table click handling ──────────────────────────────────────
2643
+ // 📖 No overlay is open — clicks go to the main table.
2644
+
2645
+ // 📖 Check if click is on the column header row → trigger sort
2646
+ if (y === layout.headerRow) {
2647
+ const col = layout.columns.find(c => x >= c.xStart && x <= c.xEnd)
2648
+ if (col) {
2649
+ const sortKey = COLUMN_SORT_MAP[col.name]
2650
+ if (sortKey) {
2651
+ setSortColumnFromClick(sortKey)
2652
+ persistUiSettings()
2653
+ } else if (col.name === 'tier') {
2654
+ // 📖 Clicking the Tier header cycles the tier filter (same as T key)
2655
+ state.tierFilterMode = (state.tierFilterMode + 1) % TIER_CYCLE.length
2656
+ applyTierFilter()
2657
+ const visible = state.results.filter(r => !r.hidden)
2658
+ state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection, {
2659
+ pinFavorites: state.favoritesPinnedAndSticky,
2660
+ })
2661
+ state.cursor = 0
2662
+ state.scrollOffset = 0
2663
+ persistUiSettings()
2664
+ }
2665
+ }
2666
+ return
2667
+ }
2668
+
2669
+ // 📖 Check if click is on a model row → move cursor (or select on double-click)
2670
+ // 📖 Right-click toggles favorite on that row (same as F key)
2671
+ if (y >= layout.firstModelRow && y <= layout.lastModelRow) {
2672
+ const rowOffset = y - layout.firstModelRow
2673
+ const modelIdx = layout.viewportStartIdx + rowOffset
2674
+ if (modelIdx >= layout.viewportStartIdx && modelIdx < layout.viewportEndIdx) {
2675
+ state.cursor = modelIdx
2676
+ adjustScrollOffset(state)
2677
+
2678
+ if (evt.button === 'right') {
2679
+ // 📖 Right-click: toggle favorite on this model row
2680
+ toggleFavoriteAtRow(modelIdx)
2681
+ } else if (evt.type === 'double-click') {
2682
+ // 📖 Double-click triggers the Enter action (select model).
2683
+ process.stdin.emit('keypress', '\r', { name: 'return', ctrl: false, meta: false, shift: false })
2684
+ }
2685
+ }
2686
+ return
2687
+ }
2688
+
2689
+ // ── Footer hotkey click zones ──────────────────────────────────────
2690
+ // 📖 Check if click lands on a footer hotkey zone and emit the corresponding keypress.
2691
+ if (layout.footerHotkeys && layout.footerHotkeys.length > 0) {
2692
+ const zone = layout.footerHotkeys.find(z => y === z.row && x >= z.xStart && x <= z.xEnd)
2693
+ if (zone) {
2694
+ // 📖 Map the footer zone key to a synthetic keypress.
2695
+ // 📖 Most are single-character keys; special cases like ctrl+p need special handling.
2696
+ if (zone.key === 'ctrl+p') {
2697
+ process.stdin.emit('keypress', '\x10', { name: 'p', ctrl: true, meta: false, shift: false })
2698
+ } else {
2699
+ process.stdin.emit('keypress', zone.key, { name: zone.key, ctrl: false, meta: false, shift: false })
2700
+ }
2701
+ return
2702
+ }
2703
+ }
2704
+
2705
+ // 📖 Clicks outside any recognized zone are silently ignored.
2706
+ }
2707
+ }