free-coding-models 0.1.87 → 0.2.0

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.
@@ -39,6 +39,18 @@ import { getAvg, getVerdict, getUptime, getStabilityScore } from './utils.js'
39
39
  import { usagePlaceholderForProvider } from './ping.js'
40
40
  import { formatTokenTotalCompact } from './token-usage-reader.js'
41
41
  import { calculateViewport, sortResultsWithPinnedFavorites, renderProxyStatusLine, padEndDisplay } from './render-helpers.js'
42
+ import { getToolMeta } from './tool-metadata.js'
43
+
44
+ const ACTIVE_FILTER_BG_BY_TIER = {
45
+ 'S+': [57, 255, 20],
46
+ 'S': [57, 255, 20],
47
+ 'A+': [160, 255, 60],
48
+ 'A': [255, 224, 130],
49
+ 'A-': [255, 204, 128],
50
+ 'B+': [255, 171, 64],
51
+ 'B': [239, 83, 80],
52
+ 'C': [186, 104, 200],
53
+ }
42
54
 
43
55
  const require = createRequire(import.meta.url)
44
56
  const { version: LOCAL_VERSION } = require('../package.json')
@@ -77,7 +89,7 @@ export function setActiveProxy(proxyInstance) {
77
89
  }
78
90
 
79
91
  // ─── renderTable: mode param controls footer hint text (opencode vs openclaw) ─────────
80
- 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, activeProfile = null, profileSaveMode = false, profileSaveBuffer = '', proxyStartupStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false) {
92
+ 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, activeProfile = null, profileSaveMode = false, profileSaveBuffer = '', proxyStartupStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false, widthWarningStartedAt = null, widthWarningDismissed = false) {
81
93
  // 📖 Filter out hidden models for display
82
94
  const visibleResults = results.filter(r => !r.hidden)
83
95
 
@@ -119,20 +131,21 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
119
131
 
120
132
  // 📖 Tool badge keeps the active launch target visible in the header, so the
121
133
  // 📖 footer no longer needs a redundant Enter action or mode toggle reminder.
122
- let modeBadge
123
- if (mode === 'openclaw') {
124
- modeBadge = chalk.bold.rgb(255, 100, 50)(' [ ') + chalk.yellow.bold('Z') + chalk.bold.rgb(255, 100, 50)(' Tool : OpenClaw ]')
125
- } else if (mode === 'opencode-desktop') {
126
- modeBadge = chalk.bold.rgb(0, 200, 255)(' [ ') + chalk.yellow.bold('Z') + chalk.bold.rgb(0, 200, 255)(' Tool : OpenCode Desktop ]')
127
- } else {
128
- modeBadge = chalk.bold.rgb(0, 200, 255)(' [ ') + chalk.yellow.bold('Z') + chalk.bold.rgb(0, 200, 255)(' Tool : OpenCode CLI ]')
129
- }
134
+ const toolMeta = getToolMeta(mode)
135
+ const toolBadgeColor = mode === 'openclaw'
136
+ ? chalk.bold.rgb(255, 100, 50)
137
+ : chalk.bold.rgb(0, 200, 255)
138
+ const modeBadge = toolBadgeColor(' [ ') + chalk.yellow.bold('Z') + toolBadgeColor(` Tool : ${toolMeta.label} ]`)
139
+ const activeHeaderBadge = (text, bg = [57, 255, 20], fg = [0, 0, 0]) => chalk.bgRgb(...bg).rgb(...fg).bold(` ${text} `)
130
140
 
131
141
  // 📖 Tier filter badge shown when filtering is active (shows exact tier name)
132
142
  const TIER_CYCLE_NAMES = [null, 'S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C']
133
143
  let tierBadge = ''
144
+ let activeTierLabel = ''
134
145
  if (tierFilterMode > 0) {
135
- tierBadge = chalk.bold.rgb(255, 200, 0)(` [${TIER_CYCLE_NAMES[tierFilterMode]}]`)
146
+ activeTierLabel = TIER_CYCLE_NAMES[tierFilterMode]
147
+ const tierBg = ACTIVE_FILTER_BG_BY_TIER[activeTierLabel] || [57, 255, 20]
148
+ tierBadge = ` ${activeHeaderBadge(`TIER (${activeTierLabel})`, tierBg)}`
136
149
  }
137
150
 
138
151
  const normalizeOriginLabel = (name, key) => {
@@ -142,15 +155,23 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
142
155
 
143
156
  // 📖 Origin filter badge — shown when filtering by provider is active
144
157
  let originBadge = ''
158
+ let activeOriginLabel = ''
145
159
  if (originFilterMode > 0) {
146
160
  const originKeys = [null, ...Object.keys(sources)]
147
161
  const activeOriginKey = originKeys[originFilterMode]
148
162
  const activeOriginName = activeOriginKey ? sources[activeOriginKey]?.name ?? activeOriginKey : null
149
163
  if (activeOriginName) {
150
- originBadge = chalk.bold.rgb(100, 200, 255)(` [${normalizeOriginLabel(activeOriginName, activeOriginKey)}]`)
164
+ activeOriginLabel = normalizeOriginLabel(activeOriginName, activeOriginKey)
165
+ const providerRgb = PROVIDER_COLOR[activeOriginKey] || [255, 255, 255]
166
+ originBadge = ` ${activeHeaderBadge(`PROVIDER (${activeOriginLabel})`, [0, 0, 0], providerRgb)}`
151
167
  }
152
168
  }
153
169
 
170
+ let configuredBadge = ''
171
+ if (hideUnconfiguredModels) {
172
+ configuredBadge = ` ${activeHeaderBadge('CONFIGURED ONLY')}`
173
+ }
174
+
154
175
  // 📖 Profile badge — shown when a named profile is active (Shift+P to cycle, Shift+S to save)
155
176
  let profileBadge = ''
156
177
  if (activeProfile) {
@@ -173,14 +194,27 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
173
194
  const W_TOKENS = 7
174
195
  const W_USAGE = 7
175
196
  const MIN_TABLE_WIDTH = 166
197
+ const warningDurationMs = 5_000
198
+ const elapsed = widthWarningStartedAt ? Math.max(0, Date.now() - widthWarningStartedAt) : warningDurationMs
199
+ const remainingMs = Math.max(0, warningDurationMs - elapsed)
200
+ const showWidthWarning = terminalCols > 0 && terminalCols < MIN_TABLE_WIDTH && !widthWarningDismissed && remainingMs > 0
176
201
 
177
- if (terminalCols > 0 && terminalCols < MIN_TABLE_WIDTH) {
202
+ if (showWidthWarning) {
178
203
  const lines = []
179
- const blankLines = Math.max(0, Math.floor(((terminalRows || 24) - 3) / 2))
180
- const warning = 'Please maximize your terminal for optimal use. The current terminal width is too small for the full table.'
204
+ const blankLines = Math.max(0, Math.floor(((terminalRows || 24) - 5) / 2))
205
+ const warning = 'Please maximize your terminal for optimal use.'
206
+ const warning2 = 'The current terminal is too small.'
207
+ const warning3 = 'Reduce font size or maximize width of terminal.'
181
208
  const padLeft = Math.max(0, Math.floor((terminalCols - warning.length) / 2))
209
+ const padLeft2 = Math.max(0, Math.floor((terminalCols - warning2.length) / 2))
210
+ const padLeft3 = Math.max(0, Math.floor((terminalCols - warning3.length) / 2))
182
211
  for (let i = 0; i < blankLines; i++) lines.push('')
183
212
  lines.push(' '.repeat(padLeft) + chalk.red.bold(warning))
213
+ lines.push(' '.repeat(padLeft2) + chalk.red(warning2))
214
+ lines.push(' '.repeat(padLeft3) + chalk.red(warning3))
215
+ lines.push('')
216
+ lines.push(' '.repeat(Math.max(0, Math.floor((terminalCols - 34) / 2))) + chalk.yellow(`this message will hide in ${(remainingMs / 1000).toFixed(1)}s`))
217
+ lines.push(' '.repeat(Math.max(0, Math.floor((terminalCols - 20) / 2))) + chalk.dim('press esc to dismiss'))
184
218
  while (terminalRows > 0 && lines.length < terminalRows) lines.push('')
185
219
  const EL = '\x1b[K'
186
220
  return lines.map(line => line + EL).join('\n')
@@ -190,7 +224,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
190
224
  const sorted = sortResultsWithPinnedFavorites(visibleResults, sortColumn, sortDirection)
191
225
 
192
226
  const lines = [
193
- ` ${chalk.greenBright.bold(`✅ Free-Coding-Models v${LOCAL_VERSION}`)}${modeBadge}${pingControlBadge}${tierBadge}${originBadge}${profileBadge}${chalk.reset('')} ` +
227
+ ` ${chalk.greenBright.bold(`✅ Free-Coding-Models v${LOCAL_VERSION}`)}${modeBadge}${pingControlBadge}${tierBadge}${originBadge}${configuredBadge}${profileBadge}${chalk.reset('')} ` +
194
228
  chalk.greenBright(`✅ ${up}`) + chalk.dim(' up ') +
195
229
  chalk.yellow(`⏳ ${timeout}`) + chalk.dim(' timeout ') +
196
230
  chalk.red(`❌ ${down}`) + chalk.dim(' down ') +
@@ -576,8 +610,30 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
576
610
  // 📖 Footer hints keep only navigation and secondary actions now that the
577
611
  // 📖 active tool target is already visible in the header badge.
578
612
  const hotkey = (keyLabel, text) => chalk.yellow(keyLabel) + chalk.dim(text)
613
+ // 📖 Active filter pills use a loud green background so tier/provider/configured-only
614
+ // 📖 states are obvious even when the user misses the smaller header badges.
615
+ const activeHotkey = (keyLabel, text, bg = [57, 255, 20], fg = [0, 0, 0]) => chalk.bgRgb(...bg).rgb(...fg)(` ${keyLabel}${text} `)
579
616
  // 📖 Line 1: core navigation + filtering shortcuts
580
- lines.push(chalk.dim(` ↑↓ Navigate • `) + hotkey('F', ' Toggle Favorite') + chalk.dim(` • `) + hotkey('T', ' Tier') + chalk.dim(` • `) + hotkey('D', ' Provider') + chalk.dim(` • `) + hotkey('E', ' Configured Only') + chalk.dim(` • `) + hotkey('X', ' Token Logs') + chalk.dim(` • `) + hotkey('P', ' Settings') + chalk.dim(` • `) + hotkey('K', ' Help'))
617
+ lines.push(
618
+ chalk.dim(` ↑↓ Navigate • `) +
619
+ hotkey('F', ' Toggle Favorite') +
620
+ chalk.dim(` • `) +
621
+ (tierFilterMode > 0
622
+ ? activeHotkey('T', ` Tier (${activeTierLabel})`, ACTIVE_FILTER_BG_BY_TIER[activeTierLabel] || [57, 255, 20])
623
+ : hotkey('T', ' Tier')) +
624
+ chalk.dim(` • `) +
625
+ (originFilterMode > 0
626
+ ? activeHotkey('D', ` Provider (${activeOriginLabel})`, [0, 0, 0], PROVIDER_COLOR[[null, ...Object.keys(sources)][originFilterMode]] || [255, 255, 255])
627
+ : hotkey('D', ' Provider')) +
628
+ chalk.dim(` • `) +
629
+ (hideUnconfiguredModels ? activeHotkey('E', ' Configured Only') : hotkey('E', ' Configured Only')) +
630
+ chalk.dim(` • `) +
631
+ hotkey('X', ' Token Logs') +
632
+ chalk.dim(` • `) +
633
+ hotkey('P', ' Settings') +
634
+ chalk.dim(` • `) +
635
+ hotkey('K', ' Help')
636
+ )
581
637
  // 📖 Line 2: profiles, recommend, feature request, bug report, and extended hints — gives visibility to less-obvious features
582
638
  lines.push(chalk.dim(` `) + hotkey('⇧P', ' Cycle profile') + chalk.dim(` • `) + hotkey('⇧S', ' Save profile') + chalk.dim(` • `) + hotkey('Q', ' Smart Recommend') + chalk.dim(` • `) + hotkey('J', ' Request feature') + chalk.dim(` • `) + hotkey('I', ' Report bug'))
583
639
  // 📖 Proxy status line — always rendered with explicit state (starting/running/failed/stopped)
@@ -18,7 +18,7 @@
18
18
  * @functions
19
19
  * → `buildProviderModelTokenKey` — creates a stable aggregation key
20
20
  * → `loadTokenUsageByProviderModel` — reads request-log.jsonl and returns total tokens by provider+model
21
- * → `formatTokenTotalCompact` — renders totals as integer K / M strings for narrow columns
21
+ * → `formatTokenTotalCompact` — renders totals as raw ints or compact K / M strings with 2 decimals
22
22
  *
23
23
  * @exports buildProviderModelTokenKey, loadTokenUsageByProviderModel, formatTokenTotalCompact
24
24
  *
@@ -53,11 +53,11 @@ export function loadTokenUsageByProviderModel({ logFile, limit = 50_000 } = {})
53
53
  return totals
54
54
  }
55
55
 
56
- // 📖 formatTokenTotalCompact keeps the new column narrow and scannable:
57
- // 📖 0-999 => raw integer, 1k-999k => Nk, 1m+ => NM, no decimals.
56
+ // 📖 formatTokenTotalCompact keeps token counts readable in both the table and log view:
57
+ // 📖 0-999 => raw integer, 1k-999k => N.NNk, 1m+ => N.NNM.
58
58
  export function formatTokenTotalCompact(totalTokens) {
59
59
  const safeTotal = Number(totalTokens) || 0
60
- if (safeTotal >= 1_000_000) return `${Math.floor(safeTotal / 1_000_000)}M`
61
- if (safeTotal >= 1_000) return `${Math.floor(safeTotal / 1_000)}k`
60
+ if (safeTotal >= 999_500) return `${(safeTotal / 1_000_000).toFixed(2)}M`
61
+ if (safeTotal >= 1_000) return `${(safeTotal / 1_000).toFixed(2)}k`
62
62
  return String(Math.floor(safeTotal))
63
63
  }
@@ -0,0 +1,319 @@
1
+ /**
2
+ * @file src/tool-launchers.js
3
+ * @description Auto-configure and launch external coding tools from the selected model row.
4
+ *
5
+ * @details
6
+ * 📖 This module extends the existing "pick a model and press Enter" workflow to
7
+ * external CLIs that can consume OpenAI-compatible or provider-specific settings.
8
+ *
9
+ * 📖 The design is pragmatic:
10
+ * - Write a small managed config file when the tool's config shape is stable enough
11
+ * - Always export the runtime environment variables before spawning the tool
12
+ * - Keep each launcher isolated so a partial integration does not break others
13
+ *
14
+ * 📖 Some tools still have weaker official support for arbitrary custom providers.
15
+ * For those, we prefer a transparent warning over pretending the integration is
16
+ * fully official. The user still gets a reproducible env/config handoff.
17
+ *
18
+ * @functions
19
+ * → `startExternalTool` — configure and launch the selected external tool mode
20
+ *
21
+ * @exports startExternalTool
22
+ *
23
+ * @see src/tool-metadata.js
24
+ * @see src/provider-metadata.js
25
+ * @see sources.js
26
+ */
27
+
28
+ import chalk from 'chalk'
29
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from 'fs'
30
+ import { homedir } from 'os'
31
+ import { dirname, join } from 'path'
32
+ import { spawn } from 'child_process'
33
+ import { sources } from '../sources.js'
34
+ import { getApiKey, getProxySettings } from './config.js'
35
+ import { ENV_VAR_NAMES, isWindows } from './provider-metadata.js'
36
+ import { getToolMeta } from './tool-metadata.js'
37
+ import { ensureProxyRunning } from './opencode.js'
38
+
39
+ function ensureDir(filePath) {
40
+ const dir = dirname(filePath)
41
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
42
+ }
43
+
44
+ function backupIfExists(filePath) {
45
+ if (!existsSync(filePath)) return null
46
+ const backupPath = `${filePath}.backup-${Date.now()}`
47
+ copyFileSync(filePath, backupPath)
48
+ return backupPath
49
+ }
50
+
51
+ function readJson(filePath, fallback) {
52
+ if (!existsSync(filePath)) return fallback
53
+ try {
54
+ return JSON.parse(readFileSync(filePath, 'utf8'))
55
+ } catch {
56
+ return fallback
57
+ }
58
+ }
59
+
60
+ function writeJson(filePath, value) {
61
+ ensureDir(filePath)
62
+ writeFileSync(filePath, JSON.stringify(value, null, 2))
63
+ }
64
+
65
+ function getProviderBaseUrl(providerKey) {
66
+ const url = sources[providerKey]?.url
67
+ if (!url) return null
68
+ return url
69
+ .replace(/\/chat\/completions$/i, '')
70
+ .replace(/\/responses$/i, '')
71
+ .replace(/\/predictions$/i, '')
72
+ }
73
+
74
+ function buildToolEnv(mode, model, config) {
75
+ const providerKey = model.providerKey
76
+ const providerUrl = sources[providerKey]?.url || ''
77
+ const baseUrl = getProviderBaseUrl(providerKey)
78
+ const apiKey = getApiKey(config, providerKey)
79
+ const env = { ...process.env }
80
+ const providerEnvName = ENV_VAR_NAMES[providerKey]
81
+ if (providerEnvName && apiKey) env[providerEnvName] = apiKey
82
+
83
+ // 📖 OpenAI-compatible defaults reused by multiple CLIs.
84
+ if (apiKey && baseUrl) {
85
+ env.OPENAI_API_KEY = apiKey
86
+ env.OPENAI_BASE_URL = baseUrl
87
+ env.OPENAI_API_BASE = baseUrl
88
+ env.OPENAI_MODEL = model.modelId
89
+ env.LLM_API_KEY = apiKey
90
+ env.LLM_BASE_URL = baseUrl
91
+ env.LLM_MODEL = `openai/${model.modelId}`
92
+ }
93
+
94
+ // 📖 Provider-specific envs for tools that expect a different wire format.
95
+ if (mode === 'claude-code' && apiKey && baseUrl) {
96
+ env.ANTHROPIC_AUTH_TOKEN = apiKey
97
+ env.ANTHROPIC_BASE_URL = baseUrl
98
+ env.ANTHROPIC_MODEL = model.modelId
99
+ }
100
+
101
+ if (mode === 'gemini' && apiKey && baseUrl) {
102
+ env.GOOGLE_API_KEY = apiKey
103
+ env.GOOGLE_GEMINI_BASE_URL = baseUrl
104
+ }
105
+
106
+ return { env, apiKey, baseUrl, providerUrl }
107
+ }
108
+
109
+ function spawnCommand(command, args, env) {
110
+ return new Promise((resolve, reject) => {
111
+ const child = spawn(command, args, {
112
+ stdio: 'inherit',
113
+ shell: isWindows,
114
+ detached: false,
115
+ env,
116
+ })
117
+
118
+ child.on('exit', (code) => resolve(code))
119
+ child.on('error', (err) => {
120
+ if (err.code === 'ENOENT') {
121
+ console.error(chalk.red(` X Could not find "${command}" in PATH.`))
122
+ resolve(1)
123
+ } else {
124
+ reject(err)
125
+ }
126
+ })
127
+ })
128
+ }
129
+
130
+ function writeAiderConfig(model, apiKey, baseUrl) {
131
+ const filePath = join(homedir(), '.aider.conf.yml')
132
+ const backupPath = backupIfExists(filePath)
133
+ const content = [
134
+ '# 📖 Managed by free-coding-models',
135
+ `openai-api-base: ${baseUrl}`,
136
+ `openai-api-key: ${apiKey}`,
137
+ `model: openai/${model.modelId}`,
138
+ '',
139
+ ].join('\n')
140
+ ensureDir(filePath)
141
+ writeFileSync(filePath, content)
142
+ return { filePath, backupPath }
143
+ }
144
+
145
+ function writeCrushConfig(model, apiKey, baseUrl, providerId) {
146
+ const filePath = join(homedir(), '.config', 'crush', 'crush.json')
147
+ const backupPath = backupIfExists(filePath)
148
+ const config = readJson(filePath, { $schema: 'https://charm.land/crush.json' })
149
+ if (!config.options || typeof config.options !== 'object') config.options = {}
150
+ config.options.disable_default_providers = true
151
+ if (!config.providers || typeof config.providers !== 'object') config.providers = {}
152
+ config.providers[providerId] = {
153
+ name: 'Free Coding Models',
154
+ type: 'openai-compat',
155
+ base_url: baseUrl,
156
+ api_key: apiKey,
157
+ models: [
158
+ {
159
+ name: model.label,
160
+ id: model.modelId,
161
+ },
162
+ ],
163
+ }
164
+ // 📖 Crush expects structured selected models at config.models.{large,small}.
165
+ // 📖 Root `crush` reads these defaults in interactive mode, unlike `crush run --model`.
166
+ config.models = {
167
+ ...(config.models && typeof config.models === 'object' ? config.models : {}),
168
+ large: { model: model.modelId, provider: providerId },
169
+ }
170
+ writeJson(filePath, config)
171
+ return { filePath, backupPath }
172
+ }
173
+
174
+ function writeGeminiConfig(model) {
175
+ const filePath = join(homedir(), '.gemini', 'settings.json')
176
+ const backupPath = backupIfExists(filePath)
177
+ const config = readJson(filePath, {})
178
+ config.model = model.modelId
179
+ writeJson(filePath, config)
180
+ return { filePath, backupPath }
181
+ }
182
+
183
+ function writeQwenConfig(model, providerKey, apiKey, baseUrl) {
184
+ const filePath = join(homedir(), '.qwen', 'settings.json')
185
+ const backupPath = backupIfExists(filePath)
186
+ const config = readJson(filePath, {})
187
+ if (!config.modelProviders || typeof config.modelProviders !== 'object') config.modelProviders = {}
188
+ if (!Array.isArray(config.modelProviders.openai)) config.modelProviders.openai = []
189
+ const nextEntry = {
190
+ id: model.modelId,
191
+ name: model.label,
192
+ envKey: ENV_VAR_NAMES[providerKey] || 'OPENAI_API_KEY',
193
+ baseUrl,
194
+ }
195
+ const filtered = config.modelProviders.openai.filter((entry) => entry?.id !== model.modelId)
196
+ filtered.unshift(nextEntry)
197
+ config.modelProviders.openai = filtered
198
+ config.model = model.modelId
199
+ writeJson(filePath, config)
200
+ return { filePath, backupPath, envKey: nextEntry.envKey, apiKey }
201
+ }
202
+
203
+ function writePiConfig(model, apiKey, baseUrl) {
204
+ const filePath = join(homedir(), '.pi', 'agent', 'models.json')
205
+ const backupPath = backupIfExists(filePath)
206
+ const config = readJson(filePath, { providers: {} })
207
+ if (!config.providers || typeof config.providers !== 'object') config.providers = {}
208
+ config.providers.freeCodingModels = {
209
+ baseUrl,
210
+ api: 'openai-completions',
211
+ apiKey,
212
+ models: [{ id: model.modelId, name: model.label }],
213
+ }
214
+ writeJson(filePath, config)
215
+ return { filePath, backupPath }
216
+ }
217
+
218
+ function writeAmpConfig(baseUrl) {
219
+ const filePath = join(homedir(), '.config', 'amp', 'settings.json')
220
+ const backupPath = backupIfExists(filePath)
221
+ const config = readJson(filePath, {})
222
+ config['amp.url'] = baseUrl
223
+ writeJson(filePath, config)
224
+ return { filePath, backupPath }
225
+ }
226
+
227
+ function printConfigResult(toolName, result) {
228
+ if (!result?.filePath) return
229
+ console.log(chalk.dim(` 📄 ${toolName} config updated: ${result.filePath}`))
230
+ if (result.backupPath) console.log(chalk.dim(` 💾 Backup: ${result.backupPath}`))
231
+ }
232
+
233
+ export async function startExternalTool(mode, model, config) {
234
+ const meta = getToolMeta(mode)
235
+ const { env, apiKey, baseUrl } = buildToolEnv(mode, model, config)
236
+
237
+ if (!apiKey && mode !== 'amp') {
238
+ console.log(chalk.yellow(` ⚠ No API key configured for ${model.providerKey}.`))
239
+ console.log(chalk.dim(' Configure the provider first from the Settings screen (P) or via env vars.'))
240
+ console.log()
241
+ return 1
242
+ }
243
+
244
+ console.log(chalk.cyan(` ▶ Launching ${meta.label} with ${chalk.bold(model.label)}...`))
245
+
246
+ if (mode === 'aider') {
247
+ printConfigResult(meta.label, writeAiderConfig(model, apiKey, baseUrl))
248
+ return spawnCommand('aider', ['--model', `openai/${model.modelId}`], env)
249
+ }
250
+
251
+ if (mode === 'crush') {
252
+ let crushApiKey = apiKey
253
+ let crushBaseUrl = baseUrl
254
+ let providerId = 'freeCodingModels'
255
+ const proxySettings = getProxySettings(config)
256
+
257
+ if (proxySettings.enabled) {
258
+ const started = await ensureProxyRunning(config)
259
+ crushApiKey = started.proxyToken
260
+ crushBaseUrl = `http://127.0.0.1:${started.port}/v1`
261
+ providerId = 'freeCodingModelsProxy'
262
+ console.log(chalk.dim(` 📖 Crush will use the local FCM proxy on :${started.port} for this launch.`))
263
+ } else {
264
+ console.log(chalk.dim(' 📖 Crush will use the provider directly for this launch.'))
265
+ }
266
+
267
+ printConfigResult(meta.label, writeCrushConfig(model, crushApiKey, crushBaseUrl, providerId))
268
+ return spawnCommand('crush', [], env)
269
+ }
270
+
271
+ if (mode === 'goose') {
272
+ env.OPENAI_HOST = baseUrl
273
+ env.OPENAI_BASE_PATH = 'v1/chat/completions'
274
+ env.OPENAI_MODEL = model.modelId
275
+ console.log(chalk.dim(' 📖 Goose uses env-based OpenAI-compatible configuration for this launch.'))
276
+ return spawnCommand('goose', [], env)
277
+ }
278
+
279
+ if (mode === 'claude-code') {
280
+ console.log(chalk.yellow(' ⚠ Claude Code expects an Anthropic/Bedrock/Vertex-compatible gateway.'))
281
+ console.log(chalk.dim(' This launch passes proxy env vars, but your endpoint must support Claude Code wire semantics.'))
282
+ return spawnCommand('claude', ['--model', model.modelId], env)
283
+ }
284
+
285
+ if (mode === 'codex') {
286
+ console.log(chalk.dim(' 📖 Codex CLI is launched with proxy env vars for this session.'))
287
+ return spawnCommand('codex', ['--model', model.modelId], env)
288
+ }
289
+
290
+ if (mode === 'gemini') {
291
+ printConfigResult(meta.label, writeGeminiConfig(model))
292
+ return spawnCommand('gemini', ['--model', model.modelId], env)
293
+ }
294
+
295
+ if (mode === 'qwen') {
296
+ printConfigResult(meta.label, writeQwenConfig(model, model.providerKey, apiKey, baseUrl))
297
+ return spawnCommand('qwen', [], env)
298
+ }
299
+
300
+ if (mode === 'openhands') {
301
+ console.log(chalk.dim(' 📖 OpenHands is launched with --override-with-envs so the selected model applies immediately.'))
302
+ return spawnCommand('openhands', ['--override-with-envs'], env)
303
+ }
304
+
305
+ if (mode === 'amp') {
306
+ printConfigResult(meta.label, writeAmpConfig(baseUrl))
307
+ console.log(chalk.yellow(' ⚠ Amp does not officially expose arbitrary model switching like the other CLIs.'))
308
+ console.log(chalk.dim(' The proxy URL is written, then Amp is launched so you can reuse the current endpoint.'))
309
+ return spawnCommand('amp', [], env)
310
+ }
311
+
312
+ if (mode === 'pi') {
313
+ printConfigResult(meta.label, writePiConfig(model, apiKey, baseUrl))
314
+ return spawnCommand('pi', [], env)
315
+ }
316
+
317
+ console.log(chalk.red(` X Unsupported external tool mode: ${mode}`))
318
+ return 1
319
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * @file src/tool-metadata.js
3
+ * @description Shared metadata for supported launch targets and mode ordering.
4
+ *
5
+ * @details
6
+ * 📖 The TUI now supports more than the historical OpenCode/OpenClaw trio.
7
+ * Centralizing mode metadata keeps the header badge, help screen, key handler,
8
+ * and CLI parsing aligned instead of hard-coding tool names in multiple files.
9
+ *
10
+ * 📖 The metadata here is intentionally small:
11
+ * - display label for the active tool badge
12
+ * - optional emoji for compact UI hints
13
+ * - flag name used in CLI help
14
+ *
15
+ * 📖 External tool integrations are still implemented elsewhere. This file only
16
+ * answers "what modes exist?" and "how should they be presented to the user?".
17
+ *
18
+ * @functions
19
+ * → `getToolMeta` — return display metadata for one mode
20
+ * → `getToolModeOrder` — stable mode cycle order for the `Z` hotkey
21
+ *
22
+ * @exports TOOL_METADATA, TOOL_MODE_ORDER, getToolMeta, getToolModeOrder
23
+ */
24
+
25
+ export const TOOL_METADATA = {
26
+ opencode: { label: 'OpenCode CLI', emoji: '💻', flag: '--opencode' },
27
+ 'opencode-desktop': { label: 'OpenCode Desktop', emoji: '🖥', flag: '--opencode-desktop' },
28
+ openclaw: { label: 'OpenClaw', emoji: '🦞', flag: '--openclaw' },
29
+ crush: { label: 'Crush', emoji: '💘', flag: '--crush' },
30
+ goose: { label: 'Goose', emoji: '🪿', flag: '--goose' },
31
+ // aider: { label: 'Aider', emoji: '🛠', flag: '--aider' },
32
+ // 'claude-code': { label: 'Claude Code', emoji: '🧠', flag: '--claude-code' },
33
+ // codex: { label: 'Codex CLI', emoji: '⌘', flag: '--codex' },
34
+ // gemini: { label: 'Gemini CLI', emoji: '✦', flag: '--gemini' },
35
+ // qwen: { label: 'Qwen Code', emoji: '🌊', flag: '--qwen' },
36
+ // openhands: { label: 'OpenHands', emoji: '🤲', flag: '--openhands' },
37
+ // amp: { label: 'Amp', emoji: '⚡', flag: '--amp' },
38
+ // pi: { label: 'Pi', emoji: 'π', flag: '--pi' },
39
+ }
40
+
41
+ export const TOOL_MODE_ORDER = [
42
+ 'opencode',
43
+ 'opencode-desktop',
44
+ 'openclaw',
45
+ 'crush',
46
+ 'goose',
47
+ // 'aider',
48
+ // 'claude-code',
49
+ // 'codex',
50
+ // 'gemini',
51
+ // 'qwen',
52
+ // 'openhands',
53
+ // 'amp',
54
+ // 'pi',
55
+ ]
56
+
57
+ export function getToolMeta(mode) {
58
+ return TOOL_METADATA[mode] || { label: mode, emoji: '•', flag: null }
59
+ }
60
+
61
+ export function getToolModeOrder() {
62
+ return [...TOOL_MODE_ORDER]
63
+ }