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 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
- The TUI shows a **"Compatible with"** column displaying colored emojis for each supported tool. 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.
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** — colored emojis show which tools each model supports; incompatible rows highlighted in dark red when a tool mode is active
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.24",
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
- export const ALT_ENTER = '\x1b[?1049h\x1b[?25l\x1b[?7l'
51
- export const ALT_LEAVE = '\x1b[?7h\x1b[?1049l\x1b[?25h'
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.
@@ -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
  }
@@ -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, COMPAT_COLUMN_SLOTS, getCompatibleTools, isModelCompatibleWithTool } from './tool-metadata.js'
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
- const W_COMPAT = 22 // 📖 "Compatible with" column — 11 emoji slots (10×2 + 1×1 for π + 1 padding)
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 + ' SWE%' : 'SWE%'
272
- const ctxH = sortColumn === 'ctx' ? dir + ' CTX' : 'CTX'
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
- const stabH = sortColumn === 'stability' ? dir + ' ' + stabLabel : stabLabel
282
- const uptimeH = sortColumn === 'uptime' ? dir + ' Up%' : 'Up%'
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
- // 📖 "Compatible with" column header — show all tool emojis in their colors as the header
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', ' Configured Models Only', configuredBadgeBg) : hotkey('E', ' Configured Models Only')) +
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