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.
@@ -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 } 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.
@@ -109,9 +144,12 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
109
144
 
110
145
  // 📖 Tool badge keeps the active launch target visible in the header, so the
111
146
  // 📖 footer no longer needs a redundant Enter action or mode toggle reminder.
147
+ // 📖 Tool name is colored with its unique tool color for quick recognition.
112
148
  const toolMeta = getToolMeta(mode)
113
149
  const toolBadgeColor = mode === 'openclaw' ? themeColors.warningBold : themeColors.accentBold
114
- const modeBadge = toolBadgeColor(' [ ') + themeColors.hotkey('Z') + toolBadgeColor(` Tool : ${toolMeta.label} ]`)
150
+ const toolColor = toolMeta.color ? chalk.rgb(...toolMeta.color) : toolBadgeColor
151
+ const modeBadge = toolBadgeColor(' [ ') + themeColors.hotkey('Z') + toolBadgeColor(' Tool : ') + toolColor.bold(`${toolMeta.emoji} ${toolMeta.label}`) + toolBadgeColor(' ]')
152
+
115
153
  const activeHeaderBadge = (text, bg) => themeColors.badge(text, bg, getReadableTextRgb(bg))
116
154
  const versionStatus = getVersionStatusInfo(settingsUpdateState, settingsUpdateLatestVersion, startupLatestVersion, versionAlertsEnabled)
117
155
 
@@ -157,6 +195,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
157
195
  const W_STATUS = 18
158
196
  const W_VERDICT = 14
159
197
  const W_UPTIME = 6
198
+
160
199
  // const W_TOKENS = 7 // Used column removed
161
200
  // const W_USAGE = 7 // Usage column removed
162
201
  const MIN_TABLE_WIDTH = WIDTH_WARNING_MIN_COLS
@@ -205,10 +244,38 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
205
244
  if (calcWidth() > terminalCols) showTier = false
206
245
  if (calcWidth() > terminalCols) showStability = false
207
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
+ }
208
275
  const warningDurationMs = 2_000
209
276
  const elapsed = widthWarningStartedAt ? Math.max(0, Date.now() - widthWarningStartedAt) : warningDurationMs
210
277
  const remainingMs = Math.max(0, warningDurationMs - elapsed)
211
- const showWidthWarning = terminalCols > 0 && terminalCols < MIN_TABLE_WIDTH && !widthWarningDismissed && widthWarningShowCount < 2 && remainingMs > 0
278
+ const showWidthWarning = terminalCols > 0 && terminalCols < MIN_TABLE_WIDTH && !widthWarningDismissed && remainingMs > 0
212
279
 
213
280
  if (showWidthWarning) {
214
281
  const lines = []
@@ -261,8 +328,8 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
261
328
  const tierH = 'Tier'
262
329
  const originH = 'Provider'
263
330
  const modelH = 'Model'
264
- const sweH = sortColumn === 'swe' ? dir + ' SWE%' : 'SWE%'
265
- const ctxH = sortColumn === 'ctx' ? dir + ' CTX' : 'CTX'
331
+ const sweH = sortColumn === 'swe' ? (dir + 'SWE%') : 'SWE%'
332
+ const ctxH = sortColumn === 'ctx' ? (dir + 'CTX') : 'CTX'
266
333
  // 📖 Compact labels: 'Lat. P' / 'Avg. P' / 'StaB.' to save horizontal space
267
334
  const pingLabel = isCompact ? 'Lat. P' : 'Latest Ping'
268
335
  const avgLabel = isCompact ? 'Avg. P' : 'Avg Ping'
@@ -271,8 +338,10 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
271
338
  const avgH = sortColumn === 'avg' ? dir + ' ' + avgLabel : avgLabel
272
339
  const healthH = sortColumn === 'condition' ? dir + ' Health' : 'Health'
273
340
  const verdictH = sortColumn === 'verdict' ? dir + ' Verdict' : 'Verdict'
274
- const stabH = sortColumn === 'stability' ? dir + ' ' + stabLabel : stabLabel
275
- 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%'
276
345
 
277
346
  // 📖 Helper to colorize first letter for keyboard shortcuts
278
347
  // 📖 IMPORTANT: Pad PLAIN TEXT first, then apply colors to avoid alignment issues
@@ -319,6 +388,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
319
388
  const padding = ' '.repeat(Math.max(0, W_UPTIME - plain.length))
320
389
  return themeColors.hotkey('U') + themeColors.dim('p%' + padding)
321
390
  })()
391
+
322
392
  // 📖 Usage column removed from UI – no header or separator for it.
323
393
  // 📖 Header row: conditionally include columns based on responsive visibility
324
394
  const headerParts = []
@@ -329,6 +399,10 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
329
399
  if (showUptime) headerParts.push(uptimeH_c)
330
400
  lines.push(' ' + headerParts.join(COL_SEP))
331
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
+
332
406
 
333
407
 
334
408
  if (sorted.length === 0) {
@@ -360,6 +434,14 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
360
434
  lines.push(themeColors.dim(` ... ${vp.startIdx} more above ...`))
361
435
  }
362
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
+
363
445
  for (let i = vp.startIdx; i < vp.endIdx; i++) {
364
446
  const r = sorted[i]
365
447
  const tierFn = TIER_COLOR[r.tier] ?? ((text) => themeColors.text(text))
@@ -585,6 +667,9 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
585
667
  const sourceCursorText = providerDisplay.padEnd(wSource)
586
668
  const sourceCell = isCursor ? themeColors.provider(r.providerKey, sourceCursorText, { bold: true }) : source
587
669
 
670
+ // 📖 Check if this model is incompatible with the active tool mode
671
+ const isIncompatible = !isModelCompatibleWithTool(r.providerKey, mode)
672
+
588
673
  // 📖 Usage column removed from UI – no usage data displayed.
589
674
  // (We keep the logic but do not render it.)
590
675
  const usageCell = ''
@@ -600,6 +685,10 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
600
685
 
601
686
  if (isCursor) {
602
687
  lines.push(themeColors.bgModelCursor(row))
688
+ } else if (isIncompatible) {
689
+ // 📖 Dark red background for models incompatible with the active tool mode.
690
+ // 📖 This visually warns the user that selecting this model won't work with their current tool.
691
+ lines.push(chalk.bgRgb(60, 15, 15).rgb(180, 130, 130)(row))
603
692
  } else if (r.isRecommended) {
604
693
  // 📖 Medium green background for recommended models (distinguishable from favorites)
605
694
  lines.push(themeColors.bgModelRecommended(row))
@@ -610,6 +699,11 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
610
699
  }
611
700
  }
612
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
+
613
707
  if (vp.hasBelow) {
614
708
  lines.push(themeColors.dim(` ... ${sorted.length - vp.endIdx} more below ...`))
615
709
  }
@@ -624,7 +718,40 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
624
718
  const activeHotkey = (keyLabel, text, bg) => themeColors.badge(`${keyLabel}${text}`, bg, getReadableTextRgb(bg))
625
719
  const favoritesModeBg = favoritesPinnedAndSticky ? [157, 122, 48] : [95, 95, 95]
626
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
+
627
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
+
628
755
  lines.push(
629
756
  ' ' + hotkey('F', ' Toggle Favorite') +
630
757
  themeColors.dim(` • `) +
@@ -638,12 +765,35 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
638
765
  ? activeHotkey('D', ` Provider (${activeOriginLabel})`, PROVIDER_COLOR[[null, ...Object.keys(sources)][originFilterMode]] || [255, 255, 255])
639
766
  : hotkey('D', ' Provider')) +
640
767
  themeColors.dim(` • `) +
641
- (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')) +
642
769
  themeColors.dim(` • `) +
643
770
  hotkey('P', ' Settings') +
644
771
  themeColors.dim(` • `) +
645
772
  hotkey('K', ' Help')
646
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
+
647
797
  // 📖 Line 2: command palette (highlighted as new), recommend, feedback, and extended hints.
648
798
  // 📖 CTRL+P ⚡️ Command Palette uses neon-green-on-dark-green background to highlight the feature.
649
799
  const paletteLabel = chalk.bgRgb(0, 60, 0).rgb(57, 255, 20).bold(' NEW ! CTRL+P ⚡️ Command Palette ')
@@ -699,6 +849,29 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
699
849
  filterBadge = chalk.bgYellow.black.bold(` ${filterPrefix}${visibleFilter}${filterSuffix} `)
700
850
  }
701
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
+
702
875
  lines.push(
703
876
  ' ' + themeColors.hotkey('N') + themeColors.dim(' Changelog') +
704
877
  (filterBadge
package/src/theme.js CHANGED
@@ -141,6 +141,9 @@ const PROVIDER_PALETTES = {
141
141
  qwen: [255, 213, 128],
142
142
  zai: [150, 208, 255],
143
143
  iflow: [211, 229, 101],
144
+ rovo: [148, 163, 184],
145
+ gemini: [66, 165, 245],
146
+ 'opencode-zen': [185, 146, 255],
144
147
  },
145
148
  light: {
146
149
  nvidia: [0, 126, 73],
@@ -163,6 +166,9 @@ const PROVIDER_PALETTES = {
163
166
  qwen: [132, 89, 0],
164
167
  zai: [0, 104, 171],
165
168
  iflow: [107, 130, 0],
169
+ rovo: [90, 100, 126],
170
+ gemini: [15, 97, 175],
171
+ 'opencode-zen': [108, 58, 183],
166
172
  },
167
173
  }
168
174
 
@@ -217,6 +217,28 @@ export const TOOL_BOOTSTRAP_METADATA = {
217
217
  },
218
218
  },
219
219
  },
220
+ rovo: {
221
+ binary: 'acli',
222
+ docsUrl: 'https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/',
223
+ install: {
224
+ default: {
225
+ shellCommand: 'npm install -g acli',
226
+ summary: 'Rovo Dev CLI requires ACLI installation. Visit the documentation for platform-specific instructions.',
227
+ note: 'Rovo is an Atlassian tool that requires an Atlassian account with Rovo Dev activated.',
228
+ },
229
+ },
230
+ },
231
+ gemini: {
232
+ binary: 'gemini',
233
+ docsUrl: 'https://github.com/google-gemini/gemini-cli',
234
+ install: {
235
+ default: {
236
+ shellCommand: 'npm install -g @google/gemini-cli',
237
+ summary: 'Install Gemini CLI globally via npm.',
238
+ note: 'After installation, run `gemini` to authenticate with your Google account.',
239
+ },
240
+ },
241
+ },
220
242
  }
221
243
 
222
244
  export function getToolBootstrapMeta(mode) {
@@ -41,7 +41,7 @@ import { sources } from '../sources.js'
41
41
  import { PROVIDER_COLOR } from './render-table.js'
42
42
  import { getApiKey } from './config.js'
43
43
  import { ENV_VAR_NAMES, isWindows } from './provider-metadata.js'
44
- import { getToolMeta } from './tool-metadata.js'
44
+ import { getToolMeta, TOOL_METADATA } from './tool-metadata.js'
45
45
  import { PROVIDER_METADATA } from './provider-metadata.js'
46
46
  import { resolveToolBinaryPath } from './tool-bootstrap.js'
47
47
 
@@ -378,6 +378,57 @@ function writeOpenHandsEnv(model, apiKey, baseUrl, paths = getDefaultToolPaths()
378
378
  return { filePath, backupPath }
379
379
  }
380
380
 
381
+ /**
382
+ * 📖 writeRovoConfig - Configure Rovo Dev CLI model selection
383
+ *
384
+ * Rovo Dev CLI uses ~/.rovodev/config.yml for configuration.
385
+ * We write the model ID to the config file before launching.
386
+ *
387
+ * @param {Object} model - Selected model with modelId
388
+ * @param {string} configPath - Path to Rovo config file
389
+ * @returns {{ filePath: string, backupPath: string | null }}
390
+ */
391
+ function writeRovoConfig(model, configPath = join(homedir(), '.rovodev', 'config.yml')) {
392
+ const backupPath = backupIfExists(configPath)
393
+ const config = {
394
+ agent: {
395
+ modelId: model.modelId,
396
+ },
397
+ }
398
+
399
+ ensureDir(configPath)
400
+ writeFileSync(configPath, `agent:\n modelId: "${model.modelId}"\n`)
401
+ return { filePath: configPath, backupPath }
402
+ }
403
+
404
+ /**
405
+ * 📖 buildGeminiEnv - Build environment variables for Gemini CLI
406
+ *
407
+ * Gemini CLI supports OpenAI-compatible APIs via environment variables:
408
+ * - GEMINI_API_BASE_URL: Custom API endpoint
409
+ * - GEMINI_API_KEY: API key for custom endpoint
410
+ *
411
+ * @param {Object} model - Selected model with providerKey
412
+ * @param {Object} config - Full app config
413
+ * @param {Object} options - Env options
414
+ * @returns {NodeJS.ProcessEnv}
415
+ */
416
+ function buildGeminiEnv(model, config, options = {}) {
417
+ const providerKey = model.providerKey || 'gemini'
418
+ const apiKey = getApiKey(config, providerKey)
419
+ const baseUrl = getProviderBaseUrl(providerKey)
420
+
421
+ const env = cloneInheritedEnv(process.env, SANITIZED_TOOL_ENV_KEYS)
422
+
423
+ // If we have a custom API key and base URL, configure OpenAI-compatible mode
424
+ if (apiKey && baseUrl && options.includeProviderEnv) {
425
+ env.GEMINI_API_BASE_URL = baseUrl
426
+ env.GEMINI_API_KEY = apiKey
427
+ }
428
+
429
+ return env
430
+ }
431
+
381
432
  function printConfigArtifacts(toolName, artifacts = []) {
382
433
  for (const artifact of artifacts) {
383
434
  if (!artifact?.path) continue
@@ -420,7 +471,9 @@ export function prepareExternalToolLaunch(mode, model, config, options = {}) {
420
471
  inheritedEnv: options.inheritedEnv,
421
472
  })
422
473
 
423
- if (!apiKey && mode !== 'amp') {
474
+ const isCliOnlyTool = TOOL_METADATA[mode]?.cliOnly === true
475
+
476
+ if (!apiKey && mode !== 'amp' && !isCliOnlyTool) {
424
477
  const providerRgb = PROVIDER_COLOR[model.providerKey] ?? [105, 190, 245]
425
478
  const providerName = sources[model.providerKey]?.name || model.providerKey
426
479
  const coloredProviderName = chalk.bold.rgb(...providerRgb)(providerName)
@@ -544,6 +597,34 @@ export function prepareExternalToolLaunch(mode, model, config, options = {}) {
544
597
  }
545
598
  }
546
599
 
600
+ if (mode === 'rovo') {
601
+ const result = writeRovoConfig(model, join(homedir(), '.rovodev', 'config.yml'), paths)
602
+ console.log(chalk.dim(` 📖 Rovo Dev CLI configured with model: ${model.modelId}`))
603
+ return {
604
+ command: 'acli',
605
+ args: ['rovodev', 'run'],
606
+ env,
607
+ apiKey: null,
608
+ baseUrl: null,
609
+ meta,
610
+ configArtifacts: [{ path: result.filePath, backupPath: result.backupPath, label: 'config' }],
611
+ }
612
+ }
613
+
614
+ if (mode === 'gemini') {
615
+ const geminiEnv = buildGeminiEnv(model, config, { includeProviderEnv: options.includeProviderEnv })
616
+ console.log(chalk.dim(` 📖 Gemini CLI will use model: ${model.modelId}`))
617
+ return {
618
+ command: 'gemini',
619
+ args: [],
620
+ env: { ...env, ...geminiEnv },
621
+ apiKey: geminiEnv.GEMINI_API_KEY || null,
622
+ baseUrl: geminiEnv.GEMINI_API_BASE_URL || null,
623
+ meta,
624
+ configArtifacts: [],
625
+ }
626
+ }
627
+
547
628
  return {
548
629
  blocked: true,
549
630
  exitCode: 1,
@@ -598,6 +679,16 @@ export async function startExternalTool(mode, model, config) {
598
679
  return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
599
680
  }
600
681
 
682
+ if (mode === 'rovo') {
683
+ console.log(chalk.dim(` 📖 Launching Rovo Dev CLI in interactive mode...`))
684
+ return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
685
+ }
686
+
687
+ if (mode === 'gemini') {
688
+ console.log(chalk.dim(` 📖 Launching Gemini CLI...`))
689
+ return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
690
+ }
691
+
601
692
  console.log(chalk.red(` X Unsupported external tool mode: ${mode}`))
602
693
  return 1
603
694
  }
@@ -19,21 +19,44 @@
19
19
  * → `getToolMeta` — return display metadata for one mode
20
20
  * → `getToolModeOrder` — stable mode cycle order for the `Z` hotkey
21
21
  *
22
- * @exports TOOL_METADATA, TOOL_MODE_ORDER, getToolMeta, getToolModeOrder
22
+ * @exports TOOL_METADATA, TOOL_MODE_ORDER, COMPAT_COLUMN_SLOTS, getToolMeta, getToolModeOrder
23
23
  */
24
+ // 📖 Each tool has a unique `color` RGB tuple used for the "Compatible with" column
25
+ // 📖 and for coloring the tool name in the Z cycle header badge.
26
+ // 📖 `emoji` is the unique icon shown everywhere (header badge, compat column, palette, overlays).
27
+ // 📖 OpenCode CLI and Desktop share 📦 — they are the same platform, split only for launch logic.
24
28
  export const TOOL_METADATA = {
25
- opencode: { label: 'OpenCode CLI', emoji: '💻', flag: '--opencode' },
26
- 'opencode-desktop': { label: 'OpenCode Desktop', emoji: '🖥', flag: '--opencode-desktop' },
27
- openclaw: { label: 'OpenClaw', emoji: '🦞', flag: '--openclaw' },
28
- crush: { label: 'Crush', emoji: '💘', flag: '--crush' },
29
- goose: { label: 'Goose', emoji: '🪿', flag: '--goose' },
30
- pi: { label: 'Pi', emoji: 'π', flag: '--pi' },
31
- aider: { label: 'Aider', emoji: '🛠', flag: '--aider' },
32
- qwen: { label: 'Qwen Code', emoji: '🌊', flag: '--qwen' },
33
- openhands: { label: 'OpenHands', emoji: '🤲', flag: '--openhands' },
34
- amp: { label: 'Amp', emoji: '⚡', flag: '--amp' },
29
+ opencode: { label: 'OpenCode CLI', emoji: '📦', flag: '--opencode', color: [110, 214, 255] },
30
+ 'opencode-desktop': { label: 'OpenCode Desktop', emoji: '📦', flag: '--opencode-desktop', color: [149, 205, 255] },
31
+ openclaw: { label: 'OpenClaw', emoji: '🦞', flag: '--openclaw', color: [255, 129, 129] },
32
+ crush: { label: 'Crush', emoji: '💘', flag: '--crush', color: [255, 168, 209] },
33
+ goose: { label: 'Goose', emoji: '🪿', flag: '--goose', color: [132, 235, 168] },
34
+ pi: { label: 'Pi', emoji: 'π', flag: '--pi', color: [173, 216, 230] },
35
+ aider: { label: 'Aider', emoji: '🛠', flag: '--aider', color: [255, 208, 102] },
36
+ qwen: { label: 'Qwen Code', emoji: '🐉', flag: '--qwen', color: [255, 213, 128] },
37
+ openhands: { label: 'OpenHands', emoji: '🤲', flag: '--openhands', color: [228, 191, 239] },
38
+ amp: { label: 'Amp', emoji: '⚡', flag: '--amp', color: [255, 232, 98] },
39
+ rovo: { label: 'Rovo Dev CLI', emoji: '🦘', flag: '--rovo', color: [148, 163, 184], cliOnly: true },
40
+ gemini: { label: 'Gemini CLI', emoji: '♊', flag: '--gemini', color: [66, 165, 245], cliOnly: true },
35
41
  }
36
42
 
43
+ // 📖 Deduplicated emoji order for the "Compatible with" column.
44
+ // 📖 OpenCode CLI + Desktop are merged into a single 📦 slot since they share compatibility.
45
+ // 📖 Each slot maps to one or more toolKeys for compatibility checking.
46
+ export const COMPAT_COLUMN_SLOTS = [
47
+ { emoji: '📦', toolKeys: ['opencode', 'opencode-desktop'], color: [110, 214, 255] },
48
+ { emoji: '🦞', toolKeys: ['openclaw'], color: [255, 129, 129] },
49
+ { emoji: '💘', toolKeys: ['crush'], color: [255, 168, 209] },
50
+ { emoji: '🪿', toolKeys: ['goose'], color: [132, 235, 168] },
51
+ { emoji: 'π', toolKeys: ['pi'], color: [173, 216, 230] },
52
+ { emoji: '🛠', toolKeys: ['aider'], color: [255, 208, 102] },
53
+ { emoji: '🐉', toolKeys: ['qwen'], color: [255, 213, 128] },
54
+ { emoji: '🤲', toolKeys: ['openhands'], color: [228, 191, 239] },
55
+ { emoji: '⚡', toolKeys: ['amp'], color: [255, 232, 98] },
56
+ { emoji: '🦘', toolKeys: ['rovo'], color: [148, 163, 184] },
57
+ { emoji: '♊', toolKeys: ['gemini'], color: [66, 165, 245] },
58
+ ]
59
+
37
60
  export const TOOL_MODE_ORDER = [
38
61
  'opencode',
39
62
  'opencode-desktop',
@@ -45,6 +68,8 @@ export const TOOL_MODE_ORDER = [
45
68
  'qwen',
46
69
  'openhands',
47
70
  'amp',
71
+ 'rovo',
72
+ 'gemini',
48
73
  ]
49
74
 
50
75
  export function getToolMeta(mode) {
@@ -54,3 +79,61 @@ export function getToolMeta(mode) {
54
79
  export function getToolModeOrder() {
55
80
  return [...TOOL_MODE_ORDER]
56
81
  }
82
+
83
+ // 📖 Regular tools: all tools EXCEPT rovo, gemini (which are CLI-only exclusives).
84
+ // 📖 Used as the default compatible set for normal provider models.
85
+ const REGULAR_TOOLS = Object.keys(TOOL_METADATA).filter(k => !TOOL_METADATA[k].cliOnly)
86
+
87
+ // 📖 Zen-only tools: OpenCode Zen models can ONLY run on OpenCode CLI / OpenCode Desktop.
88
+ const ZEN_COMPATIBLE_TOOLS = ['opencode', 'opencode-desktop']
89
+
90
+ /**
91
+ * 📖 Returns the list of tool keys a model is compatible with.
92
+ * - Rovo models → only 'rovo'
93
+ * - Gemini models → only 'gemini'
94
+ * - OpenCode Zen models → only 'opencode', 'opencode-desktop'
95
+ * - Regular models → all non-cliOnly tools
96
+ * @param {string} providerKey — the source key from sources.js (e.g. 'nvidia', 'rovo', 'opencode-zen')
97
+ * @returns {string[]} — array of compatible tool keys
98
+ */
99
+ export function getCompatibleTools(providerKey) {
100
+ if (providerKey === 'rovo') return ['rovo']
101
+ if (providerKey === 'gemini') return ['gemini']
102
+ if (providerKey === 'opencode-zen') return ZEN_COMPATIBLE_TOOLS
103
+ return REGULAR_TOOLS
104
+ }
105
+
106
+ /**
107
+ * 📖 Checks whether a model from the given provider can run on the specified tool mode.
108
+ * @param {string} providerKey — source key
109
+ * @param {string} toolMode — active tool mode
110
+ * @returns {boolean}
111
+ */
112
+ export function isModelCompatibleWithTool(providerKey, toolMode) {
113
+ return getCompatibleTools(providerKey).includes(toolMode)
114
+ }
115
+
116
+ /**
117
+ * 📖 Finds compatible models with a similar SWE score to the selected one.
118
+ * 📖 Used by the incompatibility fallback overlay to suggest alternatives.
119
+ * @param {string} selectedSwe — SWE score string like '72.0%' or '-'
120
+ * @param {string} toolMode — current active tool mode
121
+ * @param {Array} allResults — the state.results array (each has .providerKey, .modelId, .label, .tier, .sweScore)
122
+ * @param {number} [maxResults=3] — max suggestions to return
123
+ * @returns {{ modelId: string, label: string, tier: string, sweScore: string, providerKey: string, sweDelta: number }[]}
124
+ */
125
+ export function findSimilarCompatibleModels(selectedSwe, toolMode, allResults, maxResults = 3) {
126
+ const targetSwe = parseFloat(selectedSwe) || 0
127
+ return allResults
128
+ .filter(r => !r.hidden && isModelCompatibleWithTool(r.providerKey, toolMode))
129
+ .map(r => ({
130
+ modelId: r.modelId,
131
+ label: r.label,
132
+ tier: r.tier,
133
+ sweScore: r.sweScore || '-',
134
+ providerKey: r.providerKey,
135
+ sweDelta: Math.abs((parseFloat(r.sweScore) || 0) - targetSwe),
136
+ }))
137
+ .sort((a, b) => a.sweDelta - b.sweDelta)
138
+ .slice(0, maxResults)
139
+ }
package/src/utils.js CHANGED
@@ -454,6 +454,8 @@ export function parseArgs(argv) {
454
454
  const openHandsMode = flags.includes('--openhands')
455
455
  const ampMode = flags.includes('--amp')
456
456
  const piMode = flags.includes('--pi')
457
+ const rovoMode = flags.includes('--rovo')
458
+ const geminiMode = flags.includes('--gemini')
457
459
  const noTelemetry = flags.includes('--no-telemetry')
458
460
  const jsonMode = flags.includes('--json')
459
461
  const helpMode = flags.includes('--help') || flags.includes('-h')
@@ -490,6 +492,8 @@ export function parseArgs(argv) {
490
492
  openHandsMode,
491
493
  ampMode,
492
494
  piMode,
495
+ rovoMode,
496
+ geminiMode,
493
497
  noTelemetry,
494
498
  jsonMode,
495
499
  helpMode,