free-coding-models 0.3.22 → 0.3.24

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/overlays.js CHANGED
@@ -94,7 +94,8 @@ export function createOverlayRenderers(state, deps) {
94
94
  const providerKeys = Object.keys(sources)
95
95
  const updateRowIdx = providerKeys.length
96
96
  const themeRowIdx = updateRowIdx + 1
97
- const cleanupLegacyProxyRowIdx = themeRowIdx + 1
97
+ const favoritesModeRowIdx = themeRowIdx + 1
98
+ const cleanupLegacyProxyRowIdx = favoritesModeRowIdx + 1
98
99
  const changelogViewRowIdx = cleanupLegacyProxyRowIdx + 1
99
100
  const EL = '\x1b[K'
100
101
  const lines = []
@@ -224,6 +225,16 @@ export function createOverlayRenderers(state, deps) {
224
225
  const themeRow = `${bullet(state.settingsCursor === themeRowIdx)}${themeColors.textBold('Global Theme').padEnd(44)} ${themeStatusColor(themeStatus)}`
225
226
  cursorLineByRow[themeRowIdx] = lines.length
226
227
  lines.push(state.settingsCursor === themeRowIdx ? themeColors.bgCursor(themeRow) : themeRow)
228
+
229
+ // 📖 Favorites mode row mirrors Y-key behavior from the main table.
230
+ const favoritesModeEnabled = state.favoritesPinnedAndSticky === true
231
+ const favoritesModeStatus = favoritesModeEnabled
232
+ ? themeColors.warningBold('Pinned + always visible')
233
+ : themeColors.info('Normal rows (filter/sort)')
234
+ const favoritesModeRow = `${bullet(state.settingsCursor === favoritesModeRowIdx)}${themeColors.textBold('Favorites Display Mode').padEnd(44)} ${favoritesModeStatus}`
235
+ cursorLineByRow[favoritesModeRowIdx] = lines.length
236
+ lines.push(state.settingsCursor === favoritesModeRowIdx ? themeColors.bgCursorSettingsList(favoritesModeRow) : favoritesModeRow)
237
+
227
238
  if (updateState === 'error' && state.settingsUpdateError) {
228
239
  lines.push(themeColors.error(` ${state.settingsUpdateError}`))
229
240
  }
@@ -244,7 +255,7 @@ export function createOverlayRenderers(state, deps) {
244
255
  if (state.settingsEditMode) {
245
256
  lines.push(themeColors.dim(' Type API key • Enter Save • Esc Cancel'))
246
257
  } else {
247
- lines.push(themeColors.dim(' ↑↓ Navigate • Enter Edit/Run/Cycle • + Add key • - Remove key • Space Toggle/Cycle • T Test key • U Updates • G Global theme • Esc Close'))
258
+ lines.push(themeColors.dim(' ↑↓ Navigate • Enter Edit/Run/Cycle • + Add key • - Remove key • Space Toggle/Cycle • T Test key • U Updates • G Global theme • Y Favorites mode • Esc Close'))
248
259
  }
249
260
  // 📖 Show sync/restore status message if set
250
261
  if (state.settingsSyncStatus) {
@@ -283,7 +294,7 @@ export function createOverlayRenderers(state, deps) {
283
294
 
284
295
  // ─── Install Endpoints overlay renderer ───────────────────────────────────
285
296
  // 📖 renderInstallEndpoints drives the provider → tool → scope → model flow
286
- // 📖 behind the `Y` hotkey. It deliberately reuses the same overlay viewport
297
+ // 📖 opened from Settings/Command Palette. It deliberately reuses the same overlay viewport
287
298
  // 📖 helpers as Settings so long provider/model lists stay navigable.
288
299
  function renderInstallEndpoints() {
289
300
  const EL = '\x1b[K'
@@ -713,8 +724,8 @@ export function createOverlayRenderers(state, deps) {
713
724
  lines.push(` ${label('CTX')} Context window size (128k, 200k, 256k, 1m, etc.) ${hint('Sort:')} ${key('C')}`)
714
725
  lines.push(` ${hint('Bigger context = the model can read more of your codebase at once without forgetting.')}`)
715
726
  lines.push('')
716
- lines.push(` ${label('Model')} Model name (⭐ = favorited, pinned at top) ${hint('Sort:')} ${key('M')} ${hint('Favorite:')} ${key('F')}`)
717
- lines.push(` ${hint('Star the ones you like they stay pinned at the top across restarts.')}`)
727
+ lines.push(` ${label('Model')} Model name (⭐ = favorited) ${hint('Sort:')} ${key('M')} ${hint('Favorite:')} ${key('F')}`)
728
+ lines.push(` ${hint('Star the ones you like. Press Y to switch between pinned mode and normal filter/sort mode.')}`)
718
729
  lines.push('')
719
730
  lines.push(` ${label('Provider')} Provider source (NIM, Groq, Cerebras, etc.) ${hint('Sort:')} ${key('O')} ${hint('Cycle:')} ${key('D')}`)
720
731
  lines.push(` ${hint('Same model on different providers can have very different speed and uptime.')}`)
@@ -753,8 +764,10 @@ export function createOverlayRenderers(state, deps) {
753
764
  lines.push(` ${key('W')} Toggle ping mode ${hint('(speed 2s → normal 10s → slow 30s → forced 4s)')}`)
754
765
  lines.push(` ${key('Ctrl+P')} Open ⚡️ command palette ${hint('(search and run actions quickly)')}`)
755
766
  lines.push(` ${key('E')} Toggle configured models only ${hint('(enabled by default)')}`)
756
- lines.push(` ${key('Z')} Cycle tool mode ${hint('(OpenCode → Desktop → OpenClaw → Crush → Goose → Pi → Aider → Qwen → OpenHands → Amp)')}`)
757
- lines.push(` ${key('F')} Toggle favorite on selected row ${hint('(⭐ pinned at top, persisted)')}`)
767
+ lines.push(` ${key('Z')} Cycle tool mode ${hint('(📦 OpenCode → 📦 Desktop → 🦞 OpenClaw → 💘 Crush → 🪿 Goose → π Pi → 🛠 Aider → 🐉 Qwen → 🤲 OpenHands → Amp → 🦘 Rovo → ♊ Gemini)')}`)
768
+ lines.push(` ${key('F')} Toggle favorite on selected row ${hint('(⭐ persisted across sessions)')}`)
769
+ lines.push(` ${key('Y')} Toggle favorites mode ${hint('(Pinned + always visible ↔ Normal filter/sort behavior)')}`)
770
+ lines.push(` ${key('X')} Clear active text filter ${hint('(remove custom query applied from ⚡️ Command Palette)')}`)
758
771
  lines.push(` ${key('Q')} Smart Recommend ${hint('(🎯 find the best model for your task — questionnaire + live analysis)')}`)
759
772
  lines.push(` ${key('G')} Cycle theme ${hint('(auto → dark → light)')}`)
760
773
  lines.push(` ${themeColors.errorBold('I')} Feedback, bugs & requests ${hint('(📝 send anonymous feedback, bug reports, or feature requests)')}`)
@@ -770,7 +783,8 @@ export function createOverlayRenderers(state, deps) {
770
783
  lines.push(` ${key('PgUp/PgDn')} Jump by page`)
771
784
  lines.push(` ${key('Home/End')} Jump first/last row`)
772
785
  lines.push(` ${key('Enter')} Edit key / run selected maintenance action`)
773
- lines.push(` ${key('Space')} Toggle provider enable/disable`)
786
+ lines.push(` ${key('Space')} Toggle selected row option (provider/theme/favorites)`)
787
+ lines.push(` ${key('Y')} Toggle favorites mode (global)`)
774
788
  lines.push(` ${key('T')} Test selected provider key`)
775
789
  lines.push(` ${key('U')} Check updates manually`)
776
790
  lines.push(` ${key('G')} Cycle theme globally`)
@@ -1203,6 +1217,102 @@ export function createOverlayRenderers(state, deps) {
1203
1217
  if (state.recommendPingTimer) { clearInterval(state.recommendPingTimer); state.recommendPingTimer = null }
1204
1218
  }
1205
1219
 
1220
+ // ─── Incompatible fallback overlay ─────────────────────────────────────────
1221
+ // 📖 renderIncompatibleFallback shows when user presses Enter on a model that
1222
+ // 📖 is NOT compatible with the active tool. Two sections:
1223
+ // 📖 Section 1: "Switch to a compatible tool" — lists tools the model CAN run on
1224
+ // 📖 Section 2: "Use a similar model" — lists SWE-similar models compatible with current tool
1225
+ // 📖 Cursor navigates a flat list across both sections. Enter executes, Esc cancels.
1226
+ function renderIncompatibleFallback() {
1227
+ const EL = '\x1b[K'
1228
+ const lines = []
1229
+ const cursorLineByRow = {}
1230
+
1231
+ const model = state.incompatibleFallbackModel
1232
+ const tools = state.incompatibleFallbackTools || []
1233
+ const similarModels = state.incompatibleFallbackSimilarModels || []
1234
+ const totalItems = tools.length + similarModels.length
1235
+ const activeMeta = getToolMeta(state.mode)
1236
+
1237
+ lines.push(` ${chalk.cyanBright('🚀')} ${chalk.bold.cyanBright('free-coding-models')}`)
1238
+ lines.push(` ${chalk.bold('⚠️ Incompatible Model')}`)
1239
+ lines.push('')
1240
+
1241
+ if (!model) {
1242
+ lines.push(chalk.red(' No model data available.'))
1243
+ lines.push('')
1244
+ lines.push(chalk.dim(' Esc Close'))
1245
+ } else {
1246
+ // 📖 Header: explain why it's incompatible
1247
+ const tierFn = TIER_COLOR[model.tier] ?? ((text) => themeColors.text(text))
1248
+ lines.push(` ${themeColors.textBold(model.label)} ${tierFn(model.tier)}`)
1249
+ lines.push(chalk.dim(` This model cannot run on ${activeMeta.emoji} ${activeMeta.label}.`))
1250
+ lines.push('')
1251
+
1252
+ // 📖 Section 1: Switch to a compatible tool
1253
+ if (tools.length > 0) {
1254
+ lines.push(` ${themeColors.textBold('Switch to a compatible tool:')}`)
1255
+ lines.push('')
1256
+
1257
+ for (let i = 0; i < tools.length; i++) {
1258
+ const toolKey = tools[i]
1259
+ const meta = getToolMeta(toolKey)
1260
+ const [r, g, b] = meta.color || [200, 200, 200]
1261
+ const coloredLabel = chalk.rgb(r, g, b)(`${meta.emoji} ${meta.label}`)
1262
+ const isCursor = state.incompatibleFallbackCursor === i
1263
+ const bullet = isCursor ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
1264
+ const row = `${bullet}${coloredLabel}`
1265
+ cursorLineByRow[i] = lines.length
1266
+ lines.push(isCursor ? themeColors.bgCursorInstall(row) : row)
1267
+ }
1268
+ lines.push('')
1269
+ }
1270
+
1271
+ // 📖 Section 2: Use a similar model
1272
+ if (similarModels.length > 0) {
1273
+ lines.push(` ${themeColors.textBold('Or pick a similar model for')} ${activeMeta.emoji} ${themeColors.textBold(activeMeta.label + ':')}`)
1274
+ lines.push('')
1275
+
1276
+ for (let i = 0; i < similarModels.length; i++) {
1277
+ const sm = similarModels[i]
1278
+ const flatIdx = tools.length + i
1279
+ const tierFnSm = TIER_COLOR[sm.tier] ?? ((text) => themeColors.text(text))
1280
+ const isCursor = state.incompatibleFallbackCursor === flatIdx
1281
+ const bullet = isCursor ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
1282
+ const sweLabel = sm.sweScore !== '-' ? `SWE ${sm.sweScore}` : 'SWE —'
1283
+ const row = `${bullet}${themeColors.textBold(sm.label)} ${tierFnSm(sm.tier)} ${chalk.dim(sweLabel)}`
1284
+ cursorLineByRow[flatIdx] = lines.length
1285
+ lines.push(isCursor ? themeColors.bgCursorInstall(row) : row)
1286
+ }
1287
+ lines.push('')
1288
+ }
1289
+
1290
+ if (totalItems === 0) {
1291
+ lines.push(chalk.yellow(' No compatible tools or similar models found.'))
1292
+ lines.push('')
1293
+ }
1294
+
1295
+ lines.push(chalk.dim(' ↑↓ Navigate • Enter Confirm • Esc Cancel'))
1296
+ }
1297
+
1298
+ lines.push('')
1299
+
1300
+ // 📖 Scroll management — same pattern as other overlays
1301
+ const targetLine = cursorLineByRow[state.incompatibleFallbackCursor] ?? 0
1302
+ state.incompatibleFallbackScrollOffset = keepOverlayTargetVisible(
1303
+ state.incompatibleFallbackScrollOffset,
1304
+ targetLine,
1305
+ lines.length,
1306
+ state.terminalRows
1307
+ )
1308
+ const { visible, offset } = sliceOverlayLines(lines, state.incompatibleFallbackScrollOffset, state.terminalRows)
1309
+ state.incompatibleFallbackScrollOffset = offset
1310
+
1311
+ const tintedLines = tintOverlayLines(visible, themeColors.overlayBgSettings, state.terminalCols)
1312
+ const cleared = tintedLines.map(l => l + EL)
1313
+ return cleared.join('\n')
1314
+ }
1315
+
1206
1316
  return {
1207
1317
  renderSettings,
1208
1318
  renderInstallEndpoints,
@@ -1212,6 +1322,7 @@ export function createOverlayRenderers(state, deps) {
1212
1322
  renderRecommend,
1213
1323
  renderFeedback,
1214
1324
  renderChangelog,
1325
+ renderIncompatibleFallback,
1215
1326
  startRecommendAnalysis,
1216
1327
  stopRecommendAnalysis,
1217
1328
  }
@@ -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)
@@ -168,10 +169,22 @@ export function calculateViewport(terminalRows, scrollOffset, totalModels, extra
168
169
 
169
170
  // 📖 sortResultsWithPinnedFavorites: Recommended models are pinned above favorites, favorites above non-favorites.
170
171
  // 📖 Recommended: sorted by recommendation score (highest first).
171
- // 📖 Favorites: keep insertion order (favoriteRank).
172
+ // 📖 Favorites: keep insertion order (favoriteRank) when pinFavorites=true.
172
173
  // 📖 Non-favorites: active sort column/direction.
173
174
  // 📖 Models that are both recommended AND favorite — show in recommended section.
174
- export function sortResultsWithPinnedFavorites(results, sortColumn, sortDirection) {
175
+ // 📖 pinFavorites=false keeps favorites highlighted but lets normal sort/filter order apply.
176
+ export function sortResultsWithPinnedFavorites(results, sortColumn, sortDirection, { pinFavorites = true } = {}) {
177
+ if (!pinFavorites) {
178
+ const recommendedRows = results
179
+ .filter((r) => r.isRecommended)
180
+ .sort((a, b) => (b.recommendScore || 0) - (a.recommendScore || 0))
181
+ const nonRecommendedRows = sortResults(
182
+ results.filter((r) => !r.isRecommended),
183
+ sortColumn,
184
+ sortDirection
185
+ )
186
+ return [...recommendedRows, ...nonRecommendedRows]
187
+ }
175
188
  const recommendedRows = results
176
189
  .filter((r) => r.isRecommended && !r.isFavorite)
177
190
  .sort((a, b) => (b.recommendScore || 0) - (a.recommendScore || 0))
@@ -13,7 +13,8 @@
13
13
  * - Emoji-aware padding via padEndDisplay for aligned verdict/status cells
14
14
  * - Viewport clipping with above/below indicators
15
15
  * - Smart badges (mode, tier filter, origin filter)
16
- * - Install-endpoints shortcut surfaced directly in the footer hints
16
+ * - Favorites mode hint surfaced directly in footer hints (`Y`)
17
+ * - High-visibility active text-filter banner with one-key clear action (`X`)
17
18
  * - Full-width red outdated-version banner when a newer npm release is known
18
19
  * - Distinct auth-failure vs missing-key health labels so configured providers stay honest
19
20
  *
@@ -49,7 +50,7 @@ import { TIER_COLOR } from './tier-colors.js'
49
50
  import { getAvg, getVerdict, getUptime, getStabilityScore, getVersionStatusInfo } from './utils.js'
50
51
  import { usagePlaceholderForProvider } from './ping.js'
51
52
  import { calculateViewport, sortResultsWithPinnedFavorites, padEndDisplay, displayWidth } from './render-helpers.js'
52
- import { getToolMeta } from './tool-metadata.js'
53
+ import { getToolMeta, TOOL_METADATA, TOOL_MODE_ORDER, COMPAT_COLUMN_SLOTS, getCompatibleTools, isModelCompatibleWithTool } from './tool-metadata.js'
53
54
  import { getColumnSpacing } from './ui-config.js'
54
55
 
55
56
  const require = createRequire(import.meta.url)
@@ -66,7 +67,7 @@ export const PROVIDER_COLOR = new Proxy({}, {
66
67
  })
67
68
 
68
69
  // ─── renderTable: mode param controls footer hint text (opencode vs openclaw) ─────────
69
- export function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilterMode = 0, scrollOffset = 0, terminalRows = 0, terminalCols = 0, originFilterMode = 0, legacyStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false, widthWarningStartedAt = null, widthWarningDismissed = false, widthWarningShowCount = 0, settingsUpdateState = 'idle', settingsUpdateLatestVersion = null, legacyFlag = false, startupLatestVersion = null, versionAlertsEnabled = true) {
70
+ export function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilterMode = 0, scrollOffset = 0, terminalRows = 0, terminalCols = 0, originFilterMode = 0, legacyStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false, widthWarningStartedAt = null, widthWarningDismissed = false, widthWarningShowCount = 0, settingsUpdateState = 'idle', settingsUpdateLatestVersion = null, legacyFlag = false, startupLatestVersion = null, versionAlertsEnabled = true, favoritesPinnedAndSticky = false, customTextFilter = null) {
70
71
  // 📖 Filter out hidden models for display
71
72
  const visibleResults = results.filter(r => !r.hidden)
72
73
 
@@ -108,9 +109,12 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
108
109
 
109
110
  // 📖 Tool badge keeps the active launch target visible in the header, so the
110
111
  // 📖 footer no longer needs a redundant Enter action or mode toggle reminder.
112
+ // 📖 Tool name is colored with its unique tool color for quick recognition.
111
113
  const toolMeta = getToolMeta(mode)
112
114
  const toolBadgeColor = mode === 'openclaw' ? themeColors.warningBold : themeColors.accentBold
113
- const modeBadge = toolBadgeColor(' [ ') + themeColors.hotkey('Z') + toolBadgeColor(` Tool : ${toolMeta.label} ]`)
115
+ const toolColor = toolMeta.color ? chalk.rgb(...toolMeta.color) : toolBadgeColor
116
+ const modeBadge = toolBadgeColor(' [ ') + themeColors.hotkey('Z') + toolBadgeColor(' Tool : ') + toolColor.bold(`${toolMeta.emoji} ${toolMeta.label}`) + toolBadgeColor(' ]')
117
+
114
118
  const activeHeaderBadge = (text, bg) => themeColors.badge(text, bg, getReadableTextRgb(bg))
115
119
  const versionStatus = getVersionStatusInfo(settingsUpdateState, settingsUpdateLatestVersion, startupLatestVersion, versionAlertsEnabled)
116
120
 
@@ -156,6 +160,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
156
160
  const W_STATUS = 18
157
161
  const W_VERDICT = 14
158
162
  const W_UPTIME = 6
163
+ const W_COMPAT = 22 // 📖 "Compatible with" column — 11 emoji slots (10×2 + 1×1 for π + 1 padding)
159
164
  // const W_TOKENS = 7 // Used column removed
160
165
  // const W_USAGE = 7 // Usage column removed
161
166
  const MIN_TABLE_WIDTH = WIDTH_WARNING_MIN_COLS
@@ -175,6 +180,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
175
180
  let showUptime = true
176
181
  let showTier = true
177
182
  let showStability = true
183
+ let showCompat = true // 📖 "Compatible with" column — hidden on narrow terminals
178
184
  let isCompact = false
179
185
 
180
186
  if (terminalCols > 0) {
@@ -186,6 +192,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
186
192
  cols.push(W_SWE, W_CTX, W_MODEL, wSource, wPing, wAvg, wStatus, W_VERDICT)
187
193
  if (showStability) cols.push(wStab)
188
194
  if (showUptime) cols.push(W_UPTIME)
195
+ if (showCompat) cols.push(W_COMPAT)
189
196
  return ROW_MARGIN + cols.reduce((a, b) => a + b, 0) + (cols.length - 1) * SEP_W
190
197
  }
191
198
 
@@ -199,6 +206,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
199
206
  wStatus = 13 // Health truncated after 6 chars + '…'
200
207
  }
201
208
  // 📖 Steps 2–5: Progressive column hiding (least useful first)
209
+ if (calcWidth() > terminalCols) showCompat = false
202
210
  if (calcWidth() > terminalCols) showRank = false
203
211
  if (calcWidth() > terminalCols) showUptime = false
204
212
  if (calcWidth() > terminalCols) showTier = false
@@ -207,7 +215,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
207
215
  const warningDurationMs = 2_000
208
216
  const elapsed = widthWarningStartedAt ? Math.max(0, Date.now() - widthWarningStartedAt) : warningDurationMs
209
217
  const remainingMs = Math.max(0, warningDurationMs - elapsed)
210
- const showWidthWarning = terminalCols > 0 && terminalCols < MIN_TABLE_WIDTH && !widthWarningDismissed && widthWarningShowCount < 2 && remainingMs > 0
218
+ const showWidthWarning = terminalCols > 0 && terminalCols < MIN_TABLE_WIDTH && !widthWarningDismissed && remainingMs > 0
211
219
 
212
220
  if (showWidthWarning) {
213
221
  const lines = []
@@ -237,7 +245,9 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
237
245
  }
238
246
 
239
247
  // 📖 Sort models using the shared helper
240
- const sorted = sortResultsWithPinnedFavorites(visibleResults, sortColumn, sortDirection)
248
+ const sorted = sortResultsWithPinnedFavorites(visibleResults, sortColumn, sortDirection, {
249
+ pinFavorites: favoritesPinnedAndSticky,
250
+ })
241
251
 
242
252
  const lines = [
243
253
  ` ${themeColors.accentBold(`🚀 free-coding-models v${LOCAL_VERSION}`)}${modeBadge}${pingControlBadge}${tierBadge}${originBadge}${chalk.reset('')} ` +
@@ -316,6 +326,14 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
316
326
  const padding = ' '.repeat(Math.max(0, W_UPTIME - plain.length))
317
327
  return themeColors.hotkey('U') + themeColors.dim('p%' + padding)
318
328
  })()
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)
319
337
  // 📖 Usage column removed from UI – no header or separator for it.
320
338
  // 📖 Header row: conditionally include columns based on responsive visibility
321
339
  const headerParts = []
@@ -324,6 +342,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
324
342
  headerParts.push(sweH_c, ctxH_c, modelH_c, originH_c, pingH_c, avgH_c, healthH_c, verdictH_c)
325
343
  if (showStability) headerParts.push(stabH_c)
326
344
  if (showUptime) headerParts.push(uptimeH_c)
345
+ if (showCompat) headerParts.push(compatH_c)
327
346
  lines.push(' ' + headerParts.join(COL_SEP))
328
347
 
329
348
 
@@ -339,6 +358,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
339
358
  }
340
359
 
341
360
  // 📖 Viewport clipping: only render models that fit on screen
361
+ const hasCustomFilter = typeof customTextFilter === 'string' && customTextFilter.trim().length > 0
342
362
  const extraFooterLines = versionStatus.isOutdated ? 1 : 0
343
363
  const vp = calculateViewport(terminalRows, scrollOffset, sorted.length, extraFooterLines)
344
364
  const paintSweScore = (score, paddedText) => {
@@ -581,6 +601,31 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
581
601
  const sourceCursorText = providerDisplay.padEnd(wSource)
582
602
  const sourceCell = isCursor ? themeColors.provider(r.providerKey, sourceCursorText, { bold: true }) : source
583
603
 
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
+ // 📖 Check if this model is incompatible with the active tool mode
627
+ const isIncompatible = !isModelCompatibleWithTool(r.providerKey, mode)
628
+
584
629
  // 📖 Usage column removed from UI – no usage data displayed.
585
630
  // (We keep the logic but do not render it.)
586
631
  const usageCell = ''
@@ -592,10 +637,15 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
592
637
  rowParts.push(sweCell, ctxCell, nameCell, sourceCell, pingCell, avgCell, status, speedCell)
593
638
  if (showStability) rowParts.push(stabCell)
594
639
  if (showUptime) rowParts.push(uptimeCell)
640
+ if (showCompat) rowParts.push(compatCell)
595
641
  const row = ' ' + rowParts.join(COL_SEP)
596
642
 
597
643
  if (isCursor) {
598
644
  lines.push(themeColors.bgModelCursor(row))
645
+ } else if (isIncompatible) {
646
+ // 📖 Dark red background for models incompatible with the active tool mode.
647
+ // 📖 This visually warns the user that selecting this model won't work with their current tool.
648
+ lines.push(chalk.bgRgb(60, 15, 15).rgb(180, 130, 130)(row))
599
649
  } else if (r.isRecommended) {
600
650
  // 📖 Medium green background for recommended models (distinguishable from favorites)
601
651
  lines.push(themeColors.bgModelRecommended(row))
@@ -618,10 +668,14 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
618
668
  // 📖 states are obvious even when the user misses the smaller header badges.
619
669
  const configuredBadgeBg = getTheme() === 'dark' ? [52, 120, 88] : [195, 234, 206]
620
670
  const activeHotkey = (keyLabel, text, bg) => themeColors.badge(`${keyLabel}${text}`, bg, getReadableTextRgb(bg))
671
+ const favoritesModeBg = favoritesPinnedAndSticky ? [157, 122, 48] : [95, 95, 95]
672
+ const favoritesModeLabel = favoritesPinnedAndSticky ? ' Favorites Pinned' : ' Favorites Normal'
621
673
  // 📖 Line 1: core navigation + filtering shortcuts
622
674
  lines.push(
623
675
  ' ' + hotkey('F', ' Toggle Favorite') +
624
676
  themeColors.dim(` • `) +
677
+ activeHotkey('Y', favoritesModeLabel, favoritesModeBg) +
678
+ themeColors.dim(` • `) +
625
679
  (tierFilterMode > 0
626
680
  ? activeHotkey('T', ` Tier (${activeTierLabel})`, getTierRgb(activeTierLabel))
627
681
  : hotkey('T', ' Tier')) +
@@ -673,9 +727,29 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
673
727
  lines.push(chalk.bgRed.white.bold(paddedBanner))
674
728
  }
675
729
 
676
- // 📖 Final footer line: changelog shortcut + exit hint (replaces the old proxy notice).
730
+ // 📖 Final footer line: changelog + optional active text-filter badge + exit hint.
731
+ let filterBadge = ''
732
+ if (hasCustomFilter) {
733
+ const normalizedFilter = customTextFilter.trim().replace(/\s+/g, ' ')
734
+ const filterPrefix = 'X Disable filter: "'
735
+ const filterSuffix = '"'
736
+ const separatorPlain = ' • '
737
+ const baseFooterPlain = ' N Changelog' + separatorPlain + 'Ctrl+C Exit'
738
+ const baseBadgeWidth = displayWidth(` ${filterPrefix}${filterSuffix} `)
739
+ const availableFilterWidth = terminalCols > 0
740
+ ? Math.max(8, terminalCols - displayWidth(baseFooterPlain) - displayWidth(separatorPlain) - baseBadgeWidth)
741
+ : normalizedFilter.length
742
+ const visibleFilter = normalizedFilter.length > availableFilterWidth
743
+ ? `${normalizedFilter.slice(0, Math.max(3, availableFilterWidth - 3))}...`
744
+ : normalizedFilter
745
+ filterBadge = chalk.bgYellow.black.bold(` ${filterPrefix}${visibleFilter}${filterSuffix} `)
746
+ }
747
+
677
748
  lines.push(
678
749
  ' ' + themeColors.hotkey('N') + themeColors.dim(' Changelog') +
750
+ (filterBadge
751
+ ? themeColors.dim(' • ') + filterBadge
752
+ : '') +
679
753
  themeColors.dim(' • ') +
680
754
  themeColors.dim('Ctrl+C Exit')
681
755
  )
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
  }