free-coding-models 0.3.23 → 0.3.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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)
@@ -764,7 +804,7 @@ export function createOverlayRenderers(state, deps) {
764
804
  lines.push(` ${key('W')} Toggle ping mode ${hint('(speed 2s → normal 10s → slow 30s → forced 4s)')}`)
765
805
  lines.push(` ${key('Ctrl+P')} Open ⚡️ command palette ${hint('(search and run actions quickly)')}`)
766
806
  lines.push(` ${key('E')} Toggle configured models only ${hint('(enabled by default)')}`)
767
- lines.push(` ${key('Z')} Cycle tool mode ${hint('(OpenCode → Desktop → OpenClaw → Crush → Goose → Pi → Aider → Qwen → OpenHands → Amp)')}`)
807
+ lines.push(` ${key('Z')} Cycle tool mode ${hint('(📦 OpenCode → 📦 Desktop → 🦞 OpenClaw → 💘 Crush → 🪿 Goose → π Pi → 🛠 Aider → 🐉 Qwen → 🤲 OpenHands → Amp → 🦘 Rovo → ♊ Gemini)')}`)
768
808
  lines.push(` ${key('F')} Toggle favorite on selected row ${hint('(⭐ persisted across sessions)')}`)
769
809
  lines.push(` ${key('Y')} Toggle favorites mode ${hint('(Pinned + always visible ↔ Normal filter/sort behavior)')}`)
770
810
  lines.push(` ${key('X')} Clear active text filter ${hint('(remove custom query applied from ⚡️ Command Palette)')}`)
@@ -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')
@@ -1217,6 +1276,102 @@ export function createOverlayRenderers(state, deps) {
1217
1276
  if (state.recommendPingTimer) { clearInterval(state.recommendPingTimer); state.recommendPingTimer = null }
1218
1277
  }
1219
1278
 
1279
+ // ─── Incompatible fallback overlay ─────────────────────────────────────────
1280
+ // 📖 renderIncompatibleFallback shows when user presses Enter on a model that
1281
+ // 📖 is NOT compatible with the active tool. Two sections:
1282
+ // 📖 Section 1: "Switch to a compatible tool" — lists tools the model CAN run on
1283
+ // 📖 Section 2: "Use a similar model" — lists SWE-similar models compatible with current tool
1284
+ // 📖 Cursor navigates a flat list across both sections. Enter executes, Esc cancels.
1285
+ function renderIncompatibleFallback() {
1286
+ const EL = '\x1b[K'
1287
+ const lines = []
1288
+ const cursorLineByRow = {}
1289
+
1290
+ const model = state.incompatibleFallbackModel
1291
+ const tools = state.incompatibleFallbackTools || []
1292
+ const similarModels = state.incompatibleFallbackSimilarModels || []
1293
+ const totalItems = tools.length + similarModels.length
1294
+ const activeMeta = getToolMeta(state.mode)
1295
+
1296
+ lines.push(` ${chalk.cyanBright('🚀')} ${chalk.bold.cyanBright('free-coding-models')}`)
1297
+ lines.push(` ${chalk.bold('⚠️ Incompatible Model')}`)
1298
+ lines.push('')
1299
+
1300
+ if (!model) {
1301
+ lines.push(chalk.red(' No model data available.'))
1302
+ lines.push('')
1303
+ lines.push(chalk.dim(' Esc Close'))
1304
+ } else {
1305
+ // 📖 Header: explain why it's incompatible
1306
+ const tierFn = TIER_COLOR[model.tier] ?? ((text) => themeColors.text(text))
1307
+ lines.push(` ${themeColors.textBold(model.label)} ${tierFn(model.tier)}`)
1308
+ lines.push(chalk.dim(` This model cannot run on ${activeMeta.emoji} ${activeMeta.label}.`))
1309
+ lines.push('')
1310
+
1311
+ // 📖 Section 1: Switch to a compatible tool
1312
+ if (tools.length > 0) {
1313
+ lines.push(` ${themeColors.textBold('Switch to a compatible tool:')}`)
1314
+ lines.push('')
1315
+
1316
+ for (let i = 0; i < tools.length; i++) {
1317
+ const toolKey = tools[i]
1318
+ const meta = getToolMeta(toolKey)
1319
+ const [r, g, b] = meta.color || [200, 200, 200]
1320
+ const coloredLabel = chalk.rgb(r, g, b)(`${meta.emoji} ${meta.label}`)
1321
+ const isCursor = state.incompatibleFallbackCursor === i
1322
+ const bullet = isCursor ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
1323
+ const row = `${bullet}${coloredLabel}`
1324
+ cursorLineByRow[i] = lines.length
1325
+ lines.push(isCursor ? themeColors.bgCursorInstall(row) : row)
1326
+ }
1327
+ lines.push('')
1328
+ }
1329
+
1330
+ // 📖 Section 2: Use a similar model
1331
+ if (similarModels.length > 0) {
1332
+ lines.push(` ${themeColors.textBold('Or pick a similar model for')} ${activeMeta.emoji} ${themeColors.textBold(activeMeta.label + ':')}`)
1333
+ lines.push('')
1334
+
1335
+ for (let i = 0; i < similarModels.length; i++) {
1336
+ const sm = similarModels[i]
1337
+ const flatIdx = tools.length + i
1338
+ const tierFnSm = TIER_COLOR[sm.tier] ?? ((text) => themeColors.text(text))
1339
+ const isCursor = state.incompatibleFallbackCursor === flatIdx
1340
+ const bullet = isCursor ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
1341
+ const sweLabel = sm.sweScore !== '-' ? `SWE ${sm.sweScore}` : 'SWE —'
1342
+ const row = `${bullet}${themeColors.textBold(sm.label)} ${tierFnSm(sm.tier)} ${chalk.dim(sweLabel)}`
1343
+ cursorLineByRow[flatIdx] = lines.length
1344
+ lines.push(isCursor ? themeColors.bgCursorInstall(row) : row)
1345
+ }
1346
+ lines.push('')
1347
+ }
1348
+
1349
+ if (totalItems === 0) {
1350
+ lines.push(chalk.yellow(' No compatible tools or similar models found.'))
1351
+ lines.push('')
1352
+ }
1353
+
1354
+ lines.push(chalk.dim(' ↑↓ Navigate • Enter Confirm • Esc Cancel'))
1355
+ }
1356
+
1357
+ lines.push('')
1358
+
1359
+ // 📖 Scroll management — same pattern as other overlays
1360
+ const targetLine = cursorLineByRow[state.incompatibleFallbackCursor] ?? 0
1361
+ state.incompatibleFallbackScrollOffset = keepOverlayTargetVisible(
1362
+ state.incompatibleFallbackScrollOffset,
1363
+ targetLine,
1364
+ lines.length,
1365
+ state.terminalRows
1366
+ )
1367
+ const { visible, offset } = sliceOverlayLines(lines, state.incompatibleFallbackScrollOffset, state.terminalRows)
1368
+ state.incompatibleFallbackScrollOffset = offset
1369
+
1370
+ const tintedLines = tintOverlayLines(visible, themeColors.overlayBgSettings, state.terminalCols)
1371
+ const cleared = tintedLines.map(l => l + EL)
1372
+ return cleared.join('\n')
1373
+ }
1374
+
1220
1375
  return {
1221
1376
  renderSettings,
1222
1377
  renderInstallEndpoints,
@@ -1226,7 +1381,9 @@ export function createOverlayRenderers(state, deps) {
1226
1381
  renderRecommend,
1227
1382
  renderFeedback,
1228
1383
  renderChangelog,
1384
+ renderIncompatibleFallback,
1229
1385
  startRecommendAnalysis,
1230
1386
  stopRecommendAnalysis,
1387
+ overlayLayout, // 📖 Mouse support: exposes cursor-to-line maps for click handling
1231
1388
  }
1232
1389
  }
@@ -58,6 +58,7 @@ export const ENV_VAR_NAMES = {
58
58
  cloudflare: 'CLOUDFLARE_API_TOKEN',
59
59
  perplexity: 'PERPLEXITY_API_KEY',
60
60
  zai: 'ZAI_API_KEY',
61
+ gemini: 'GEMINI_API_KEY',
61
62
  }
62
63
 
63
64
  // 📖 OPENCODE_MODEL_MAP: sparse table of model IDs that differ between sources.js and OpenCode's
@@ -225,4 +226,28 @@ export const PROVIDER_METADATA = {
225
226
  signupHint: 'Install @mariozechner/pi-coding-agent and set ANTHROPIC_API_KEY',
226
227
  rateLimits: 'Depends on provider subscription (e.g., Anthropic, OpenAI)',
227
228
  },
229
+ rovo: {
230
+ label: 'Rovo Dev CLI',
231
+ color: chalk.rgb(148, 163, 184), // slate blue
232
+ signupUrl: 'https://www.atlassian.com/rovo',
233
+ signupHint: 'Install ACLI and run: acli rovodev auth login',
234
+ rateLimits: 'Free tier: 5M tokens/day (beta, requires Atlassian account)',
235
+ cliOnly: true,
236
+ },
237
+ gemini: {
238
+ label: 'Gemini CLI',
239
+ color: chalk.rgb(66, 165, 245), // blue
240
+ signupUrl: 'https://github.com/google-gemini/gemini-cli',
241
+ signupHint: 'Install: npm install -g @google/gemini-cli',
242
+ rateLimits: 'Free tier: 1,000 req/day (personal Google account, no credit card)',
243
+ cliOnly: true,
244
+ },
245
+ 'opencode-zen': {
246
+ label: 'OpenCode Zen',
247
+ color: chalk.rgb(139, 92, 246), // violet — distinctive from other providers
248
+ signupUrl: 'https://opencode.ai/auth',
249
+ signupHint: 'Login at opencode.ai/auth to get your Zen API key',
250
+ rateLimits: 'Free tier models — requires OpenCode Zen API key',
251
+ zenOnly: true,
252
+ },
228
253
  }
@@ -36,6 +36,7 @@
36
36
  * - chalk: Terminal colors and formatting
37
37
  * - ../src/constants.js: OVERLAY_PANEL_WIDTH, TABLE_FIXED_LINES
38
38
  * - ../src/utils.js: sortResults
39
+ * - ../src/tool-metadata.js: isModelCompatibleWithTool (for compatible-first partition)
39
40
  *
40
41
  * ⚙️ Configuration:
41
42
  * - OVERLAY_PANEL_WIDTH: Fixed width for overlay panels (from constants.js)
@@ -184,7 +185,6 @@ export function sortResultsWithPinnedFavorites(results, sortColumn, sortDirectio
184
185
  )
185
186
  return [...recommendedRows, ...nonRecommendedRows]
186
187
  }
187
-
188
188
  const recommendedRows = results
189
189
  .filter((r) => r.isRecommended && !r.isFavorite)
190
190
  .sort((a, b) => (b.recommendScore || 0) - (a.recommendScore || 0))