free-coding-models 0.1.82 → 0.1.84
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/README.md +53 -51
- package/bin/free-coding-models.js +429 -4276
- package/package.json +2 -2
- package/sources.js +3 -2
- package/src/account-manager.js +600 -0
- package/src/analysis.js +197 -0
- package/{lib → src}/config.js +122 -0
- package/src/constants.js +116 -0
- package/src/error-classifier.js +154 -0
- package/src/favorites.js +98 -0
- package/src/key-handler.js +1005 -0
- package/src/log-reader.js +174 -0
- package/src/model-merger.js +78 -0
- package/src/openclaw.js +131 -0
- package/src/opencode-sync.js +159 -0
- package/src/opencode.js +952 -0
- package/src/overlays.js +840 -0
- package/src/ping.js +186 -0
- package/src/provider-metadata.js +218 -0
- package/src/provider-quota-fetchers.js +319 -0
- package/src/proxy-server.js +543 -0
- package/src/quota-capabilities.js +112 -0
- package/src/render-helpers.js +239 -0
- package/src/render-table.js +567 -0
- package/src/request-transformer.js +180 -0
- package/src/setup.js +105 -0
- package/src/telemetry.js +382 -0
- package/src/tier-colors.js +37 -0
- package/src/token-stats.js +310 -0
- package/src/token-usage-reader.js +63 -0
- package/src/updater.js +237 -0
- package/src/usage-reader.js +245 -0
- package/{lib → src}/utils.js +55 -0
package/src/overlays.js
ADDED
|
@@ -0,0 +1,840 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file overlays.js
|
|
3
|
+
* @description Factory for TUI overlay renderers and recommend analysis flow.
|
|
4
|
+
*
|
|
5
|
+
* @details
|
|
6
|
+
* This module centralizes all overlay rendering in one place:
|
|
7
|
+
* - Settings, Help, Log, Smart Recommend, Feature Request, Bug Report
|
|
8
|
+
* - Recommend analysis timer orchestration and progress updates
|
|
9
|
+
*
|
|
10
|
+
* The factory pattern keeps stateful UI logic isolated while still
|
|
11
|
+
* allowing the main CLI to control shared state and dependencies.
|
|
12
|
+
*
|
|
13
|
+
* → Functions:
|
|
14
|
+
* - `createOverlayRenderers` — returns renderer + analysis helpers
|
|
15
|
+
*
|
|
16
|
+
* @exports { createOverlayRenderers }
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export function createOverlayRenderers(state, deps) {
|
|
20
|
+
const {
|
|
21
|
+
chalk,
|
|
22
|
+
sources,
|
|
23
|
+
PROVIDER_METADATA,
|
|
24
|
+
LOCAL_VERSION,
|
|
25
|
+
getApiKey,
|
|
26
|
+
resolveApiKeys,
|
|
27
|
+
isProviderEnabled,
|
|
28
|
+
listProfiles,
|
|
29
|
+
TIER_CYCLE,
|
|
30
|
+
SETTINGS_OVERLAY_BG,
|
|
31
|
+
HELP_OVERLAY_BG,
|
|
32
|
+
RECOMMEND_OVERLAY_BG,
|
|
33
|
+
LOG_OVERLAY_BG,
|
|
34
|
+
OVERLAY_PANEL_WIDTH,
|
|
35
|
+
keepOverlayTargetVisible,
|
|
36
|
+
sliceOverlayLines,
|
|
37
|
+
tintOverlayLines,
|
|
38
|
+
loadRecentLogs,
|
|
39
|
+
TASK_TYPES,
|
|
40
|
+
PRIORITY_TYPES,
|
|
41
|
+
CONTEXT_BUDGETS,
|
|
42
|
+
FRAMES,
|
|
43
|
+
TIER_COLOR,
|
|
44
|
+
getAvg,
|
|
45
|
+
getStabilityScore,
|
|
46
|
+
toFavoriteKey,
|
|
47
|
+
getTopRecommendations,
|
|
48
|
+
adjustScrollOffset,
|
|
49
|
+
getPingModel,
|
|
50
|
+
} = deps
|
|
51
|
+
|
|
52
|
+
// ─── Settings screen renderer ─────────────────────────────────────────────
|
|
53
|
+
// 📖 renderSettings: Draw the settings overlay in the alt screen buffer.
|
|
54
|
+
// 📖 Shows all providers with their API key (masked) + enabled state.
|
|
55
|
+
// 📖 When in edit mode (settingsEditMode=true), shows an inline input field.
|
|
56
|
+
// 📖 Key "T" in settings = test API key for selected provider.
|
|
57
|
+
function renderSettings() {
|
|
58
|
+
const providerKeys = Object.keys(sources)
|
|
59
|
+
const updateRowIdx = providerKeys.length
|
|
60
|
+
const EL = '\x1b[K'
|
|
61
|
+
const lines = []
|
|
62
|
+
const cursorLineByRow = {}
|
|
63
|
+
|
|
64
|
+
lines.push('')
|
|
65
|
+
lines.push(` ${chalk.bold('⚙ Settings')} ${chalk.dim('— free-coding-models v' + LOCAL_VERSION)}`)
|
|
66
|
+
if (state.settingsErrorMsg) {
|
|
67
|
+
lines.push(` ${chalk.red.bold(state.settingsErrorMsg)}`)
|
|
68
|
+
}
|
|
69
|
+
lines.push('')
|
|
70
|
+
lines.push(` ${chalk.bold('🧩 Providers')}`)
|
|
71
|
+
lines.push(` ${chalk.dim(' ' + '─'.repeat(112))}`)
|
|
72
|
+
lines.push('')
|
|
73
|
+
|
|
74
|
+
for (let i = 0; i < providerKeys.length; i++) {
|
|
75
|
+
const pk = providerKeys[i]
|
|
76
|
+
const src = sources[pk]
|
|
77
|
+
const meta = PROVIDER_METADATA[pk] || {}
|
|
78
|
+
const isCursor = i === state.settingsCursor
|
|
79
|
+
const enabled = isProviderEnabled(state.config, pk)
|
|
80
|
+
const keyVal = state.config.apiKeys?.[pk] ?? ''
|
|
81
|
+
// 📖 Resolve all keys for this provider (for multi-key display)
|
|
82
|
+
const allKeys = resolveApiKeys(state.config, pk)
|
|
83
|
+
const keyCount = allKeys.length
|
|
84
|
+
|
|
85
|
+
// 📖 Build API key display — mask most chars, show last 4
|
|
86
|
+
let keyDisplay
|
|
87
|
+
if ((state.settingsEditMode || state.settingsAddKeyMode) && isCursor) {
|
|
88
|
+
// 📖 Inline editing/adding: show typed buffer with cursor indicator
|
|
89
|
+
const modePrefix = state.settingsAddKeyMode ? chalk.dim('[+] ') : ''
|
|
90
|
+
keyDisplay = chalk.cyanBright(`${modePrefix}${state.settingsEditBuffer || ''}▏`)
|
|
91
|
+
} else if (keyCount > 0) {
|
|
92
|
+
// 📖 Show the primary (first/string) key masked + count indicator for extras
|
|
93
|
+
const primaryKey = allKeys[0]
|
|
94
|
+
const visible = primaryKey.slice(-4)
|
|
95
|
+
const masked = '•'.repeat(Math.min(16, Math.max(4, primaryKey.length - 4)))
|
|
96
|
+
const keyMasked = chalk.dim(masked + visible)
|
|
97
|
+
const extra = keyCount > 1 ? chalk.cyan(` (+${keyCount - 1} more)`) : ''
|
|
98
|
+
keyDisplay = keyMasked + extra
|
|
99
|
+
} else {
|
|
100
|
+
keyDisplay = chalk.dim('(no key set)')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 📖 Test result badge
|
|
104
|
+
const testResult = state.settingsTestResults[pk]
|
|
105
|
+
let testBadge = chalk.dim('[Test —]')
|
|
106
|
+
if (testResult === 'pending') testBadge = chalk.yellow('[Testing…]')
|
|
107
|
+
else if (testResult === 'ok') testBadge = chalk.greenBright('[Test ✅]')
|
|
108
|
+
else if (testResult === 'fail') testBadge = chalk.red('[Test ❌]')
|
|
109
|
+
const rateSummary = chalk.dim((meta.rateLimits || 'No limit info').slice(0, 36))
|
|
110
|
+
|
|
111
|
+
const enabledBadge = enabled ? chalk.greenBright('✅') : chalk.redBright('❌')
|
|
112
|
+
const providerName = chalk.bold((meta.label || src.name || pk).slice(0, 22).padEnd(22))
|
|
113
|
+
const bullet = isCursor ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
|
|
114
|
+
|
|
115
|
+
const row = `${bullet}[ ${enabledBadge} ] ${providerName} ${keyDisplay.padEnd(30)} ${testBadge} ${rateSummary}`
|
|
116
|
+
cursorLineByRow[i] = lines.length
|
|
117
|
+
lines.push(isCursor ? chalk.bgRgb(30, 30, 60)(row) : row)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
lines.push('')
|
|
121
|
+
const selectedProviderKey = providerKeys[Math.min(state.settingsCursor, providerKeys.length - 1)]
|
|
122
|
+
const selectedSource = sources[selectedProviderKey]
|
|
123
|
+
const selectedMeta = PROVIDER_METADATA[selectedProviderKey] || {}
|
|
124
|
+
if (selectedSource && state.settingsCursor < providerKeys.length) {
|
|
125
|
+
const selectedKey = getApiKey(state.config, selectedProviderKey)
|
|
126
|
+
const setupStatus = selectedKey ? chalk.green('API key detected ✅') : chalk.yellow('API key missing ⚠')
|
|
127
|
+
lines.push(` ${chalk.bold('Setup Instructions')} — ${selectedMeta.label || selectedSource.name || selectedProviderKey}`)
|
|
128
|
+
lines.push(chalk.dim(` 1) Create a ${selectedMeta.label || selectedSource.name} account: ${selectedMeta.signupUrl || 'signup link missing'}`))
|
|
129
|
+
lines.push(chalk.dim(` 2) ${selectedMeta.signupHint || 'Generate an API key and paste it with Enter on this row'}`))
|
|
130
|
+
lines.push(chalk.dim(` 3) Press ${chalk.yellow('T')} to test your key. Status: ${setupStatus}`))
|
|
131
|
+
if (selectedProviderKey === 'cloudflare') {
|
|
132
|
+
const hasAccountId = Boolean((process.env.CLOUDFLARE_ACCOUNT_ID || '').trim())
|
|
133
|
+
const accountIdStatus = hasAccountId ? chalk.green('CLOUDFLARE_ACCOUNT_ID detected ✅') : chalk.yellow('Set CLOUDFLARE_ACCOUNT_ID ⚠')
|
|
134
|
+
lines.push(chalk.dim(` 4) Export ${chalk.yellow('CLOUDFLARE_ACCOUNT_ID')} in your shell. Status: ${accountIdStatus}`))
|
|
135
|
+
}
|
|
136
|
+
lines.push('')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
lines.push('')
|
|
140
|
+
lines.push(` ${chalk.bold('🛠 Maintenance')}`)
|
|
141
|
+
lines.push(` ${chalk.dim(' ' + '─'.repeat(112))}`)
|
|
142
|
+
lines.push('')
|
|
143
|
+
|
|
144
|
+
const updateCursor = state.settingsCursor === updateRowIdx
|
|
145
|
+
const updateBullet = updateCursor ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
|
|
146
|
+
const updateState = state.settingsUpdateState
|
|
147
|
+
const latestFound = state.settingsUpdateLatestVersion
|
|
148
|
+
const updateActionLabel = updateState === 'available' && latestFound
|
|
149
|
+
? `Install update (v${latestFound})`
|
|
150
|
+
: 'Check for updates manually'
|
|
151
|
+
let updateStatus = chalk.dim('Press Enter or U to check npm registry')
|
|
152
|
+
if (updateState === 'checking') updateStatus = chalk.yellow('Checking npm registry…')
|
|
153
|
+
if (updateState === 'available' && latestFound) updateStatus = chalk.greenBright(`Update available: v${latestFound} (Enter to install)`)
|
|
154
|
+
if (updateState === 'up-to-date') updateStatus = chalk.green('Already on latest version')
|
|
155
|
+
if (updateState === 'error') updateStatus = chalk.red('Check failed (press U to retry)')
|
|
156
|
+
if (updateState === 'installing') updateStatus = chalk.cyan('Installing update…')
|
|
157
|
+
const updateRow = `${updateBullet}${chalk.bold(updateActionLabel).padEnd(44)} ${updateStatus}`
|
|
158
|
+
cursorLineByRow[updateRowIdx] = lines.length
|
|
159
|
+
lines.push(updateCursor ? chalk.bgRgb(30, 30, 60)(updateRow) : updateRow)
|
|
160
|
+
if (updateState === 'error' && state.settingsUpdateError) {
|
|
161
|
+
lines.push(chalk.red(` ${state.settingsUpdateError}`))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 📖 Profiles section — list saved profiles with active indicator + delete support
|
|
165
|
+
const savedProfiles = listProfiles(state.config)
|
|
166
|
+
const profileStartIdx = updateRowIdx + 1
|
|
167
|
+
const maxRowIdx = savedProfiles.length > 0 ? profileStartIdx + savedProfiles.length - 1 : updateRowIdx
|
|
168
|
+
|
|
169
|
+
lines.push('')
|
|
170
|
+
lines.push(` ${chalk.bold('📋 Profiles')} ${chalk.dim(savedProfiles.length > 0 ? `(${savedProfiles.length} saved)` : '(none — press Shift+S in main view to save)')}`)
|
|
171
|
+
lines.push(` ${chalk.dim(' ' + '─'.repeat(112))}`)
|
|
172
|
+
lines.push('')
|
|
173
|
+
|
|
174
|
+
if (savedProfiles.length === 0) {
|
|
175
|
+
lines.push(chalk.dim(' No saved profiles. Press Shift+S in the main table to save your current settings as a profile.'))
|
|
176
|
+
} else {
|
|
177
|
+
for (let i = 0; i < savedProfiles.length; i++) {
|
|
178
|
+
const pName = savedProfiles[i]
|
|
179
|
+
const rowIdx = profileStartIdx + i
|
|
180
|
+
const isCursor = state.settingsCursor === rowIdx
|
|
181
|
+
const isActive = state.activeProfile === pName
|
|
182
|
+
const activeBadge = isActive ? chalk.greenBright(' ✅ active') : ''
|
|
183
|
+
const bullet = isCursor ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
|
|
184
|
+
const profileLabel = chalk.rgb(200, 150, 255).bold(pName.padEnd(30))
|
|
185
|
+
const deleteHint = isCursor ? chalk.dim(' Enter→Load • Backspace→Delete') : ''
|
|
186
|
+
const row = `${bullet}${profileLabel}${activeBadge}${deleteHint}`
|
|
187
|
+
cursorLineByRow[rowIdx] = lines.length
|
|
188
|
+
lines.push(isCursor ? chalk.bgRgb(40, 20, 60)(row) : row)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
lines.push('')
|
|
193
|
+
if (state.settingsEditMode) {
|
|
194
|
+
lines.push(chalk.dim(' Type API key • Enter Save • Esc Cancel'))
|
|
195
|
+
} else {
|
|
196
|
+
lines.push(chalk.dim(' ↑↓ Navigate • Enter Edit key • + Add key • - Remove key • Space Toggle • T Test key • S Sync→OpenCode • R Restore backup • U Updates • ⌫ Delete profile • Esc Close'))
|
|
197
|
+
}
|
|
198
|
+
// 📖 Show sync/restore status message if set
|
|
199
|
+
if (state.settingsSyncStatus) {
|
|
200
|
+
const { type, msg } = state.settingsSyncStatus
|
|
201
|
+
lines.push(type === 'success' ? chalk.greenBright(` ${msg}`) : chalk.yellow(` ${msg}`))
|
|
202
|
+
}
|
|
203
|
+
lines.push('')
|
|
204
|
+
|
|
205
|
+
// 📖 Keep selected Settings row visible on small terminals by scrolling the overlay viewport.
|
|
206
|
+
const targetLine = cursorLineByRow[state.settingsCursor] ?? 0
|
|
207
|
+
state.settingsScrollOffset = keepOverlayTargetVisible(
|
|
208
|
+
state.settingsScrollOffset,
|
|
209
|
+
targetLine,
|
|
210
|
+
lines.length,
|
|
211
|
+
state.terminalRows
|
|
212
|
+
)
|
|
213
|
+
const { visible, offset } = sliceOverlayLines(lines, state.settingsScrollOffset, state.terminalRows)
|
|
214
|
+
state.settingsScrollOffset = offset
|
|
215
|
+
|
|
216
|
+
const tintedLines = tintOverlayLines(visible, SETTINGS_OVERLAY_BG)
|
|
217
|
+
const cleared = tintedLines.map(l => l + EL)
|
|
218
|
+
return cleared.join('\n')
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ─── Help overlay renderer ────────────────────────────────────────────────
|
|
222
|
+
// 📖 renderHelp: Draw the help overlay listing all key bindings.
|
|
223
|
+
// 📖 Toggled with K key. Gives users a quick reference without leaving the TUI.
|
|
224
|
+
function renderHelp() {
|
|
225
|
+
const EL = '\x1b[K'
|
|
226
|
+
const lines = []
|
|
227
|
+
lines.push('')
|
|
228
|
+
lines.push(` ${chalk.bold('❓ Keyboard Shortcuts')} ${chalk.dim('— ↑↓ / PgUp / PgDn / Home / End scroll • K or Esc close')}`)
|
|
229
|
+
lines.push('')
|
|
230
|
+
lines.push(` ${chalk.bold('Columns')}`)
|
|
231
|
+
lines.push('')
|
|
232
|
+
lines.push(` ${chalk.cyan('Rank')} SWE-bench rank (1 = best coding score) ${chalk.dim('Sort:')} ${chalk.yellow('R')}`)
|
|
233
|
+
lines.push(` ${chalk.dim('Quick glance at which model is objectively the best coder right now.')}`)
|
|
234
|
+
lines.push('')
|
|
235
|
+
lines.push(` ${chalk.cyan('Tier')} S+ / S / A+ / A / A- / B+ / B / C based on SWE-bench score ${chalk.dim('Sort:')} ${chalk.yellow('Y')} ${chalk.dim('Cycle:')} ${chalk.yellow('T')}`)
|
|
236
|
+
lines.push(` ${chalk.dim('Skip the noise — S/S+ models solve real GitHub issues, C models are for light tasks.')}`)
|
|
237
|
+
lines.push('')
|
|
238
|
+
lines.push(` ${chalk.cyan('SWE%')} SWE-bench score — coding ability benchmark (color-coded) ${chalk.dim('Sort:')} ${chalk.yellow('S')}`)
|
|
239
|
+
lines.push(` ${chalk.dim('The raw number behind the tier. Higher = better at writing, fixing, and refactoring code.')}`)
|
|
240
|
+
lines.push('')
|
|
241
|
+
lines.push(` ${chalk.cyan('CTX')} Context window size (128k, 200k, 256k, 1m, etc.) ${chalk.dim('Sort:')} ${chalk.yellow('C')}`)
|
|
242
|
+
lines.push(` ${chalk.dim('Bigger context = the model can read more of your codebase at once without forgetting.')}`)
|
|
243
|
+
lines.push('')
|
|
244
|
+
lines.push(` ${chalk.cyan('Model')} Model name (⭐ = favorited, pinned at top) ${chalk.dim('Sort:')} ${chalk.yellow('M')} ${chalk.dim('Favorite:')} ${chalk.yellow('F')}`)
|
|
245
|
+
lines.push(` ${chalk.dim('Star the ones you like — they stay pinned at the top across restarts.')}`)
|
|
246
|
+
lines.push('')
|
|
247
|
+
lines.push(` ${chalk.cyan('Provider')} Provider source (NIM, Groq, Cerebras, etc.) ${chalk.dim('Sort:')} ${chalk.yellow('O')} ${chalk.dim('Cycle:')} ${chalk.yellow('D')}`)
|
|
248
|
+
lines.push(` ${chalk.dim('Same model on different providers can have very different speed and uptime.')}`)
|
|
249
|
+
lines.push('')
|
|
250
|
+
lines.push(` ${chalk.cyan('Latest')} Most recent ping response time (ms) ${chalk.dim('Sort:')} ${chalk.yellow('L')}`)
|
|
251
|
+
lines.push(` ${chalk.dim('Shows how fast the server is responding right now — useful to catch live slowdowns.')}`)
|
|
252
|
+
lines.push('')
|
|
253
|
+
lines.push(` ${chalk.cyan('Avg Ping')} Average response time across all successful pings (ms) ${chalk.dim('Sort:')} ${chalk.yellow('A')}`)
|
|
254
|
+
lines.push(` ${chalk.dim('The long-term truth. Ignore lucky one-off pings, this tells you real everyday speed.')}`)
|
|
255
|
+
lines.push('')
|
|
256
|
+
lines.push(` ${chalk.cyan('Health')} Live status: ✅ UP / 🔥 429 / ⏳ TIMEOUT / ❌ ERR / 🔑 NO KEY ${chalk.dim('Sort:')} ${chalk.yellow('H')}`)
|
|
257
|
+
lines.push(` ${chalk.dim('Tells you instantly if a model is reachable or down — no guesswork needed.')}`)
|
|
258
|
+
lines.push('')
|
|
259
|
+
lines.push(` ${chalk.cyan('Verdict')} Overall assessment: Perfect / Normal / Spiky / Slow / Overloaded ${chalk.dim('Sort:')} ${chalk.yellow('V')}`)
|
|
260
|
+
lines.push(` ${chalk.dim('One-word summary so you don\'t have to cross-check speed, health, and stability yourself.')}`)
|
|
261
|
+
lines.push('')
|
|
262
|
+
lines.push(` ${chalk.cyan('Stability')} Composite 0–100 score: p95 + jitter + spike rate + uptime ${chalk.dim('Sort:')} ${chalk.yellow('B')}`)
|
|
263
|
+
lines.push(` ${chalk.dim('A fast model that randomly freezes is worse than a steady one. This catches that.')}`)
|
|
264
|
+
lines.push('')
|
|
265
|
+
lines.push(` ${chalk.cyan('Up%')} Uptime — ratio of successful pings to total pings ${chalk.dim('Sort:')} ${chalk.yellow('U')}`)
|
|
266
|
+
lines.push(` ${chalk.dim('If a model only works half the time, you\'ll waste time retrying. Higher = more reliable.')}`)
|
|
267
|
+
lines.push('')
|
|
268
|
+
lines.push(` ${chalk.cyan('Used')} Total prompt+completion tokens consumed in logs for this exact provider/model pair`)
|
|
269
|
+
lines.push(` ${chalk.dim('Loaded once at startup from request-log.jsonl. Displayed in K tokens, or M tokens above one million.')}`)
|
|
270
|
+
lines.push('')
|
|
271
|
+
lines.push(` ${chalk.cyan('Usage')} Remaining quota for this exact provider when quota telemetry is exposed ${chalk.dim('Sort:')} ${chalk.yellow('G')}`)
|
|
272
|
+
lines.push(` ${chalk.dim('If a provider does not expose a trustworthy remaining %, the table shows a green dot instead of a fake number.')}`)
|
|
273
|
+
|
|
274
|
+
lines.push('')
|
|
275
|
+
lines.push(` ${chalk.bold('Main TUI')}`)
|
|
276
|
+
lines.push(` ${chalk.bold('Navigation')}`)
|
|
277
|
+
lines.push(` ${chalk.yellow('↑↓')} Navigate rows`)
|
|
278
|
+
lines.push(` ${chalk.yellow('Enter')} Select model and launch`)
|
|
279
|
+
lines.push('')
|
|
280
|
+
lines.push(` ${chalk.bold('Controls')}`)
|
|
281
|
+
lines.push(` ${chalk.yellow('W')} Decrease ping interval (faster)`)
|
|
282
|
+
lines.push(` ${chalk.yellow('=')} Increase ping interval (slower) ${chalk.dim('(was X — X is now the log page)')}`)
|
|
283
|
+
lines.push(` ${chalk.yellow('X')} Toggle request log page ${chalk.dim('(shows recent requests from request-log.jsonl)')}`)
|
|
284
|
+
lines.push(` ${chalk.yellow('Z')} Cycle launch mode ${chalk.dim('(OpenCode CLI → OpenCode Desktop → OpenClaw)')}`)
|
|
285
|
+
lines.push(` ${chalk.yellow('F')} Toggle favorite on selected row ${chalk.dim('(⭐ pinned at top, persisted)')}`)
|
|
286
|
+
lines.push(` ${chalk.yellow('Q')} Smart Recommend ${chalk.dim('(🎯 find the best model for your task — questionnaire + live analysis)')}`)
|
|
287
|
+
lines.push(` ${chalk.rgb(57, 255, 20).bold('J')} Request Feature ${chalk.dim('(📝 send anonymous feedback to the project team)')}`)
|
|
288
|
+
lines.push(` ${chalk.rgb(255, 87, 51).bold('I')} Report Bug ${chalk.dim('(🐛 send anonymous bug report to the project team)')}`)
|
|
289
|
+
lines.push(` ${chalk.yellow('P')} Open settings ${chalk.dim('(manage API keys, provider toggles, manual update)')}`)
|
|
290
|
+
lines.push(` ${chalk.yellow('Shift+P')} Cycle config profile ${chalk.dim('(switch between saved profiles live)')}`)
|
|
291
|
+
lines.push(` ${chalk.yellow('Shift+S')} Save current config as a named profile ${chalk.dim('(inline prompt — type name + Enter)')}`)
|
|
292
|
+
lines.push(` ${chalk.dim('Profiles store: favorites, sort, tier filter, ping interval, API keys.')}`)
|
|
293
|
+
lines.push(` ${chalk.dim('Use --profile <name> to load a profile on startup.')}`)
|
|
294
|
+
lines.push(` ${chalk.yellow('K')} / ${chalk.yellow('Esc')} Show/hide this help`)
|
|
295
|
+
lines.push(` ${chalk.yellow('Ctrl+C')} Exit`)
|
|
296
|
+
lines.push('')
|
|
297
|
+
lines.push(` ${chalk.bold('Settings (P)')}`)
|
|
298
|
+
lines.push(` ${chalk.yellow('↑↓')} Navigate rows`)
|
|
299
|
+
lines.push(` ${chalk.yellow('PgUp/PgDn')} Jump by page`)
|
|
300
|
+
lines.push(` ${chalk.yellow('Home/End')} Jump first/last row`)
|
|
301
|
+
lines.push(` ${chalk.yellow('Enter')} Edit key / check-install update`)
|
|
302
|
+
lines.push(` ${chalk.yellow('Space')} Toggle provider enable/disable`)
|
|
303
|
+
lines.push(` ${chalk.yellow('T')} Test selected provider key`)
|
|
304
|
+
lines.push(` ${chalk.yellow('U')} Check updates manually`)
|
|
305
|
+
lines.push(` ${chalk.yellow('Esc')} Close settings`)
|
|
306
|
+
lines.push('')
|
|
307
|
+
lines.push(` ${chalk.bold('CLI Flags')}`)
|
|
308
|
+
lines.push(` ${chalk.dim('Usage: free-coding-models [options]')}`)
|
|
309
|
+
lines.push(` ${chalk.cyan('free-coding-models --opencode')} ${chalk.dim('OpenCode CLI mode')}`)
|
|
310
|
+
lines.push(` ${chalk.cyan('free-coding-models --opencode-desktop')} ${chalk.dim('OpenCode Desktop mode')}`)
|
|
311
|
+
lines.push(` ${chalk.cyan('free-coding-models --openclaw')} ${chalk.dim('OpenClaw mode')}`)
|
|
312
|
+
lines.push(` ${chalk.cyan('free-coding-models --best')} ${chalk.dim('Only top tiers (A+, S, S+)')}`)
|
|
313
|
+
lines.push(` ${chalk.cyan('free-coding-models --fiable')} ${chalk.dim('10s reliability analysis')}`)
|
|
314
|
+
lines.push(` ${chalk.cyan('free-coding-models --tier S|A|B|C')} ${chalk.dim('Filter by tier letter')}`)
|
|
315
|
+
lines.push(` ${chalk.cyan('free-coding-models --no-telemetry')} ${chalk.dim('Disable telemetry for this run')}`)
|
|
316
|
+
lines.push(` ${chalk.cyan('free-coding-models --recommend')} ${chalk.dim('Auto-open Smart Recommend on start')}`)
|
|
317
|
+
lines.push(` ${chalk.cyan('free-coding-models --profile <name>')} ${chalk.dim('Load a saved config profile')}`)
|
|
318
|
+
lines.push(` ${chalk.dim('Flags can be combined: --openclaw --tier S')}`)
|
|
319
|
+
lines.push('')
|
|
320
|
+
// 📖 Help overlay can be longer than viewport, so keep a dedicated scroll offset.
|
|
321
|
+
const { visible, offset } = sliceOverlayLines(lines, state.helpScrollOffset, state.terminalRows)
|
|
322
|
+
state.helpScrollOffset = offset
|
|
323
|
+
const tintedLines = tintOverlayLines(visible, HELP_OVERLAY_BG)
|
|
324
|
+
const cleared = tintedLines.map(l => l + EL)
|
|
325
|
+
return cleared.join('\n')
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ─── Log page overlay renderer ────────────────────────────────────────────
|
|
329
|
+
// 📖 renderLog: Draw the log page overlay showing recent requests from
|
|
330
|
+
// 📖 ~/.free-coding-models/request-log.jsonl, newest-first.
|
|
331
|
+
// 📖 Toggled with X key. Esc or X closes.
|
|
332
|
+
function renderLog() {
|
|
333
|
+
const EL = '\x1b[K'
|
|
334
|
+
const lines = []
|
|
335
|
+
lines.push('')
|
|
336
|
+
lines.push(` ${chalk.bold('📋 Request Log')} ${chalk.dim('— recent requests • ↑↓ scroll • X or Esc close')}`)
|
|
337
|
+
lines.push('')
|
|
338
|
+
|
|
339
|
+
// 📖 Load recent log entries — bounded read, newest-first, malformed lines skipped.
|
|
340
|
+
const logRows = loadRecentLogs({ limit: 200 })
|
|
341
|
+
|
|
342
|
+
if (logRows.length === 0) {
|
|
343
|
+
lines.push(chalk.dim(' No log entries found.'))
|
|
344
|
+
lines.push(chalk.dim(' Logs are written to ~/.free-coding-models/request-log.jsonl'))
|
|
345
|
+
lines.push(chalk.dim(' when requests are proxied through the multi-account rotation proxy.'))
|
|
346
|
+
} else {
|
|
347
|
+
// 📖 Column widths for the log table
|
|
348
|
+
const W_TIME = 19
|
|
349
|
+
const W_TYPE = 18
|
|
350
|
+
const W_PROV = 14
|
|
351
|
+
const W_MODEL = 36
|
|
352
|
+
const W_STATUS = 8
|
|
353
|
+
const W_TOKENS = 9
|
|
354
|
+
const W_LAT = 10
|
|
355
|
+
|
|
356
|
+
// 📖 Header row
|
|
357
|
+
const hTime = chalk.dim('Time'.padEnd(W_TIME))
|
|
358
|
+
const hType = chalk.dim('Type'.padEnd(W_TYPE))
|
|
359
|
+
const hProv = chalk.dim('Provider'.padEnd(W_PROV))
|
|
360
|
+
const hModel = chalk.dim('Model'.padEnd(W_MODEL))
|
|
361
|
+
const hStatus = chalk.dim('Status'.padEnd(W_STATUS))
|
|
362
|
+
const hTok = chalk.dim('Used'.padEnd(W_TOKENS))
|
|
363
|
+
const hLat = chalk.dim('Latency'.padEnd(W_LAT))
|
|
364
|
+
lines.push(` ${hTime} ${hType} ${hProv} ${hModel} ${hStatus} ${hTok} ${hLat}`)
|
|
365
|
+
lines.push(chalk.dim(' ' + '─'.repeat(W_TIME + W_TYPE + W_PROV + W_MODEL + W_STATUS + W_TOKENS + W_LAT + 12)))
|
|
366
|
+
|
|
367
|
+
for (const row of logRows) {
|
|
368
|
+
// 📖 Format time as HH:MM:SS (strip the date part for compactness)
|
|
369
|
+
let timeStr = row.time
|
|
370
|
+
try {
|
|
371
|
+
const d = new Date(row.time)
|
|
372
|
+
if (!Number.isNaN(d.getTime())) {
|
|
373
|
+
timeStr = d.toISOString().replace('T', ' ').slice(0, 19)
|
|
374
|
+
}
|
|
375
|
+
} catch { /* keep raw */ }
|
|
376
|
+
|
|
377
|
+
// 📖 Color-code status
|
|
378
|
+
let statusCell
|
|
379
|
+
const sc = String(row.status)
|
|
380
|
+
if (sc === '200') {
|
|
381
|
+
statusCell = chalk.greenBright(sc.padEnd(W_STATUS))
|
|
382
|
+
} else if (sc === '429') {
|
|
383
|
+
statusCell = chalk.yellow(sc.padEnd(W_STATUS))
|
|
384
|
+
} else if (sc.startsWith('5') || sc === 'error') {
|
|
385
|
+
statusCell = chalk.red(sc.padEnd(W_STATUS))
|
|
386
|
+
} else {
|
|
387
|
+
statusCell = chalk.dim(sc.padEnd(W_STATUS))
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const tokStr = row.tokens > 0 ? String(row.tokens) : '--'
|
|
391
|
+
const latStr = row.latency > 0 ? `${row.latency}ms` : '--'
|
|
392
|
+
|
|
393
|
+
const timeCell = chalk.dim(timeStr.slice(0, W_TIME).padEnd(W_TIME))
|
|
394
|
+
const typeCell = chalk.magenta((row.requestType || '--').slice(0, W_TYPE).padEnd(W_TYPE))
|
|
395
|
+
const provCell = chalk.cyan(row.provider.slice(0, W_PROV).padEnd(W_PROV))
|
|
396
|
+
const modelCell = chalk.white(row.model.slice(0, W_MODEL).padEnd(W_MODEL))
|
|
397
|
+
const tokCell = chalk.dim(tokStr.padEnd(W_TOKENS))
|
|
398
|
+
const latCell = chalk.dim(latStr.padEnd(W_LAT))
|
|
399
|
+
|
|
400
|
+
lines.push(` ${timeCell} ${typeCell} ${provCell} ${modelCell} ${statusCell} ${tokCell} ${latCell}`)
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
lines.push('')
|
|
405
|
+
lines.push(chalk.dim(` Showing up to 200 most recent entries • X or Esc close`))
|
|
406
|
+
lines.push('')
|
|
407
|
+
|
|
408
|
+
const { visible, offset } = sliceOverlayLines(lines, state.logScrollOffset, state.terminalRows)
|
|
409
|
+
state.logScrollOffset = offset
|
|
410
|
+
const tintedLines = tintOverlayLines(visible, LOG_OVERLAY_BG)
|
|
411
|
+
const cleared = tintedLines.map(l => l + EL)
|
|
412
|
+
return cleared.join('\n')
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// 📖 renderRecommend: Draw the Smart Recommend overlay with 3 phases:
|
|
416
|
+
// 1. 'questionnaire' — ask 3 questions (task type, priority, context budget)
|
|
417
|
+
// 2. 'analyzing' — loading screen with progress bar (10s, 2 pings/sec)
|
|
418
|
+
// 3. 'results' — show Top 3 recommendations with scores
|
|
419
|
+
function renderRecommend() {
|
|
420
|
+
const EL = '\x1b[K'
|
|
421
|
+
const lines = []
|
|
422
|
+
|
|
423
|
+
lines.push('')
|
|
424
|
+
lines.push(` ${chalk.bold('🎯 Smart Recommend')} ${chalk.dim('— find the best model for your task')}`)
|
|
425
|
+
lines.push('')
|
|
426
|
+
|
|
427
|
+
if (state.recommendPhase === 'questionnaire') {
|
|
428
|
+
// 📖 Question definitions — each has a title, options array, and answer key
|
|
429
|
+
const questions = [
|
|
430
|
+
{
|
|
431
|
+
title: 'What are you working on?',
|
|
432
|
+
options: Object.entries(TASK_TYPES).map(([key, val]) => ({ key, label: val.label })),
|
|
433
|
+
answerKey: 'taskType',
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
title: 'What matters most?',
|
|
437
|
+
options: Object.entries(PRIORITY_TYPES).map(([key, val]) => ({ key, label: val.label })),
|
|
438
|
+
answerKey: 'priority',
|
|
439
|
+
},
|
|
440
|
+
{
|
|
441
|
+
title: 'How big is your context?',
|
|
442
|
+
options: Object.entries(CONTEXT_BUDGETS).map(([key, val]) => ({ key, label: val.label })),
|
|
443
|
+
answerKey: 'contextBudget',
|
|
444
|
+
},
|
|
445
|
+
]
|
|
446
|
+
|
|
447
|
+
const q = questions[state.recommendQuestion]
|
|
448
|
+
const qNum = state.recommendQuestion + 1
|
|
449
|
+
const qTotal = questions.length
|
|
450
|
+
|
|
451
|
+
// 📖 Progress breadcrumbs showing answered questions
|
|
452
|
+
let breadcrumbs = ''
|
|
453
|
+
for (let i = 0; i < questions.length; i++) {
|
|
454
|
+
const answered = state.recommendAnswers[questions[i].answerKey]
|
|
455
|
+
if (i < state.recommendQuestion && answered) {
|
|
456
|
+
const answeredLabel = questions[i].options.find(o => o.key === answered)?.label || answered
|
|
457
|
+
breadcrumbs += chalk.greenBright(` ✓ ${questions[i].title} ${chalk.bold(answeredLabel)}`) + '\n'
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (breadcrumbs) {
|
|
461
|
+
lines.push(breadcrumbs.trimEnd())
|
|
462
|
+
lines.push('')
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
lines.push(` ${chalk.bold(`Question ${qNum}/${qTotal}:`)} ${chalk.cyan(q.title)}`)
|
|
466
|
+
lines.push('')
|
|
467
|
+
|
|
468
|
+
for (let i = 0; i < q.options.length; i++) {
|
|
469
|
+
const opt = q.options[i]
|
|
470
|
+
const isCursor = i === state.recommendCursor
|
|
471
|
+
const bullet = isCursor ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
|
|
472
|
+
const label = isCursor ? chalk.bold.white(opt.label) : chalk.white(opt.label)
|
|
473
|
+
lines.push(`${bullet}${label}`)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
lines.push('')
|
|
477
|
+
lines.push(chalk.dim(' ↑↓ navigate • Enter select • Esc cancel'))
|
|
478
|
+
|
|
479
|
+
} else if (state.recommendPhase === 'analyzing') {
|
|
480
|
+
// 📖 Loading screen with progress bar
|
|
481
|
+
const pct = Math.min(100, Math.round(state.recommendProgress))
|
|
482
|
+
const barWidth = 40
|
|
483
|
+
const filled = Math.round(barWidth * pct / 100)
|
|
484
|
+
const empty = barWidth - filled
|
|
485
|
+
const bar = chalk.greenBright('█'.repeat(filled)) + chalk.dim('░'.repeat(empty))
|
|
486
|
+
|
|
487
|
+
lines.push(` ${chalk.bold('Analyzing models...')}`)
|
|
488
|
+
lines.push('')
|
|
489
|
+
lines.push(` ${bar} ${chalk.bold(String(pct) + '%')}`)
|
|
490
|
+
lines.push('')
|
|
491
|
+
|
|
492
|
+
// 📖 Show what we're doing
|
|
493
|
+
const taskLabel = TASK_TYPES[state.recommendAnswers.taskType]?.label || '—'
|
|
494
|
+
const prioLabel = PRIORITY_TYPES[state.recommendAnswers.priority]?.label || '—'
|
|
495
|
+
const ctxLabel = CONTEXT_BUDGETS[state.recommendAnswers.contextBudget]?.label || '—'
|
|
496
|
+
lines.push(chalk.dim(` Task: ${taskLabel} • Priority: ${prioLabel} • Context: ${ctxLabel}`))
|
|
497
|
+
lines.push('')
|
|
498
|
+
|
|
499
|
+
// 📖 Spinning indicator
|
|
500
|
+
const spinIdx = state.frame % FRAMES.length
|
|
501
|
+
lines.push(` ${chalk.yellow(FRAMES[spinIdx])} Pinging models at 2 pings/sec to gather fresh latency data...`)
|
|
502
|
+
lines.push('')
|
|
503
|
+
lines.push(chalk.dim(' Esc to cancel'))
|
|
504
|
+
|
|
505
|
+
} else if (state.recommendPhase === 'results') {
|
|
506
|
+
// 📖 Show Top 3 results with detailed info
|
|
507
|
+
const taskLabel = TASK_TYPES[state.recommendAnswers.taskType]?.label || '—'
|
|
508
|
+
const prioLabel = PRIORITY_TYPES[state.recommendAnswers.priority]?.label || '—'
|
|
509
|
+
const ctxLabel = CONTEXT_BUDGETS[state.recommendAnswers.contextBudget]?.label || '—'
|
|
510
|
+
lines.push(chalk.dim(` Task: ${taskLabel} • Priority: ${prioLabel} • Context: ${ctxLabel}`))
|
|
511
|
+
lines.push('')
|
|
512
|
+
|
|
513
|
+
if (state.recommendResults.length === 0) {
|
|
514
|
+
lines.push(` ${chalk.yellow('No models could be scored. Try different criteria or wait for more pings.')}`)
|
|
515
|
+
} else {
|
|
516
|
+
lines.push(` ${chalk.bold('Top Recommendations:')}`)
|
|
517
|
+
lines.push('')
|
|
518
|
+
|
|
519
|
+
for (let i = 0; i < state.recommendResults.length; i++) {
|
|
520
|
+
const rec = state.recommendResults[i]
|
|
521
|
+
const r = rec.result
|
|
522
|
+
const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : '🥉'
|
|
523
|
+
const providerName = sources[r.providerKey]?.name ?? r.providerKey
|
|
524
|
+
const tierFn = TIER_COLOR[r.tier] ?? (t => chalk.white(t))
|
|
525
|
+
const avg = getAvg(r)
|
|
526
|
+
const avgStr = avg === Infinity ? '—' : Math.round(avg) + 'ms'
|
|
527
|
+
const sweStr = r.sweScore ?? '—'
|
|
528
|
+
const ctxStr = r.ctx ?? '—'
|
|
529
|
+
const stability = getStabilityScore(r)
|
|
530
|
+
const stabStr = stability === -1 ? '—' : String(stability)
|
|
531
|
+
|
|
532
|
+
const isCursor = i === state.recommendCursor
|
|
533
|
+
const highlight = isCursor ? chalk.bgRgb(20, 50, 25) : (s => s)
|
|
534
|
+
|
|
535
|
+
lines.push(highlight(` ${medal} ${chalk.bold('#' + (i + 1))} ${chalk.bold.white(r.label)} ${chalk.dim('(' + providerName + ')')}`))
|
|
536
|
+
lines.push(highlight(` Score: ${chalk.bold.greenBright(String(rec.score) + '/100')} │ Tier: ${tierFn(r.tier)} │ SWE: ${chalk.cyan(sweStr)} │ Avg: ${chalk.yellow(avgStr)} │ CTX: ${chalk.cyan(ctxStr)} │ Stability: ${chalk.cyan(stabStr)}`))
|
|
537
|
+
lines.push('')
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
lines.push('')
|
|
542
|
+
lines.push(` ${chalk.dim('These models are now')} ${chalk.greenBright('highlighted')} ${chalk.dim('and')} 🎯 ${chalk.dim('pinned in the main table.')}`)
|
|
543
|
+
lines.push('')
|
|
544
|
+
lines.push(chalk.dim(' ↑↓ navigate • Enter select & close • Esc close • Q new search'))
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
lines.push('')
|
|
548
|
+
const { visible, offset } = sliceOverlayLines(lines, state.recommendScrollOffset, state.terminalRows)
|
|
549
|
+
state.recommendScrollOffset = offset
|
|
550
|
+
const tintedLines = tintOverlayLines(visible, RECOMMEND_OVERLAY_BG)
|
|
551
|
+
const cleared2 = tintedLines.map(l => l + EL)
|
|
552
|
+
return cleared2.join('\n')
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ─── Smart Recommend: analysis phase controller ────────────────────────────
|
|
556
|
+
// 📖 startRecommendAnalysis: begins the 10-second analysis phase.
|
|
557
|
+
// 📖 Pings a random subset of visible models at 2 pings/sec while advancing progress.
|
|
558
|
+
// 📖 After 10 seconds, computes recommendations and transitions to results phase.
|
|
559
|
+
function startRecommendAnalysis() {
|
|
560
|
+
state.recommendPhase = 'analyzing'
|
|
561
|
+
state.recommendProgress = 0
|
|
562
|
+
state.recommendResults = []
|
|
563
|
+
|
|
564
|
+
const pingModel = getPingModel?.()
|
|
565
|
+
if (!pingModel) return
|
|
566
|
+
|
|
567
|
+
const startTime = Date.now()
|
|
568
|
+
const ANALYSIS_DURATION = 10_000 // 📖 10 seconds
|
|
569
|
+
const PING_RATE = 500 // 📖 2 pings per second (every 500ms)
|
|
570
|
+
|
|
571
|
+
// 📖 Progress updater — runs every 200ms to update the progress bar
|
|
572
|
+
state.recommendAnalysisTimer = setInterval(() => {
|
|
573
|
+
const elapsed = Date.now() - startTime
|
|
574
|
+
state.recommendProgress = Math.min(100, (elapsed / ANALYSIS_DURATION) * 100)
|
|
575
|
+
|
|
576
|
+
if (elapsed >= ANALYSIS_DURATION) {
|
|
577
|
+
// 📖 Analysis complete — compute recommendations
|
|
578
|
+
clearInterval(state.recommendAnalysisTimer)
|
|
579
|
+
clearInterval(state.recommendPingTimer)
|
|
580
|
+
state.recommendAnalysisTimer = null
|
|
581
|
+
state.recommendPingTimer = null
|
|
582
|
+
|
|
583
|
+
const recs = getTopRecommendations(
|
|
584
|
+
state.results,
|
|
585
|
+
state.recommendAnswers.taskType,
|
|
586
|
+
state.recommendAnswers.priority,
|
|
587
|
+
state.recommendAnswers.contextBudget,
|
|
588
|
+
3
|
|
589
|
+
)
|
|
590
|
+
state.recommendResults = recs
|
|
591
|
+
state.recommendPhase = 'results'
|
|
592
|
+
state.recommendCursor = 0
|
|
593
|
+
|
|
594
|
+
// 📖 Mark recommended models so the main table can highlight them
|
|
595
|
+
state.recommendedKeys = new Set(recs.map(rec => toFavoriteKey(rec.result.providerKey, rec.result.modelId)))
|
|
596
|
+
// 📖 Tag each result object so sortResultsWithPinnedFavorites can pin them
|
|
597
|
+
state.results.forEach(r => {
|
|
598
|
+
const key = toFavoriteKey(r.providerKey, r.modelId)
|
|
599
|
+
const rec = recs.find(rec => toFavoriteKey(rec.result.providerKey, rec.result.modelId) === key)
|
|
600
|
+
r.isRecommended = !!rec
|
|
601
|
+
r.recommendScore = rec ? rec.score : 0
|
|
602
|
+
})
|
|
603
|
+
}
|
|
604
|
+
}, 200)
|
|
605
|
+
|
|
606
|
+
// 📖 Targeted pinging — ping random visible models at 2/sec for fresh data
|
|
607
|
+
state.recommendPingTimer = setInterval(() => {
|
|
608
|
+
const visible = state.results.filter(r => !r.hidden && r.status !== 'noauth')
|
|
609
|
+
if (visible.length === 0) return
|
|
610
|
+
// 📖 Pick a random model to ping — spreads load across all models over 10s
|
|
611
|
+
const target = visible[Math.floor(Math.random() * visible.length)]
|
|
612
|
+
pingModel(target).catch(() => {})
|
|
613
|
+
}, PING_RATE)
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// ─── Feature Request overlay renderer ─────────────────────────────────────
|
|
617
|
+
// 📖 renderFeatureRequest: Draw the overlay for anonymous Discord feedback.
|
|
618
|
+
// 📖 Shows an input field where users can type feature requests, then sends to Discord webhook.
|
|
619
|
+
function renderFeatureRequest() {
|
|
620
|
+
const EL = '\x1b[K'
|
|
621
|
+
const lines = []
|
|
622
|
+
|
|
623
|
+
// 📖 Calculate available space for multi-line input
|
|
624
|
+
const maxInputWidth = OVERLAY_PANEL_WIDTH - 8 // 8 = padding (4 spaces each side)
|
|
625
|
+
const maxInputLines = 10 // Show up to 10 lines of input
|
|
626
|
+
|
|
627
|
+
// 📖 Split buffer into lines for display (with wrapping)
|
|
628
|
+
const wrapText = (text, width) => {
|
|
629
|
+
const words = text.split(' ')
|
|
630
|
+
const lines = []
|
|
631
|
+
let currentLine = ''
|
|
632
|
+
|
|
633
|
+
for (const word of words) {
|
|
634
|
+
const testLine = currentLine ? currentLine + ' ' + word : word
|
|
635
|
+
if (testLine.length <= width) {
|
|
636
|
+
currentLine = testLine
|
|
637
|
+
} else {
|
|
638
|
+
if (currentLine) lines.push(currentLine)
|
|
639
|
+
currentLine = word
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
if (currentLine) lines.push(currentLine)
|
|
643
|
+
return lines
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const inputLines = wrapText(state.featureRequestBuffer, maxInputWidth)
|
|
647
|
+
const displayLines = inputLines.slice(0, maxInputLines)
|
|
648
|
+
|
|
649
|
+
// 📖 Header
|
|
650
|
+
lines.push('')
|
|
651
|
+
lines.push(` ${chalk.bold.rgb(57, 255, 20)('📝 Feature Request')} ${chalk.dim('— send anonymous feedback to the project team')}`)
|
|
652
|
+
lines.push('')
|
|
653
|
+
|
|
654
|
+
// 📖 Status messages (if any)
|
|
655
|
+
if (state.featureRequestStatus === 'sending') {
|
|
656
|
+
lines.push(` ${chalk.yellow('⏳ Sending...')}`)
|
|
657
|
+
lines.push('')
|
|
658
|
+
} else if (state.featureRequestStatus === 'success') {
|
|
659
|
+
lines.push(` ${chalk.greenBright.bold('✅ Successfully sent!')} ${chalk.dim('Closing overlay in 3 seconds...')}`)
|
|
660
|
+
lines.push('')
|
|
661
|
+
lines.push(` ${chalk.dim('Thank you for your feedback! Your feature request has been sent to the project team.')}`)
|
|
662
|
+
lines.push('')
|
|
663
|
+
} else if (state.featureRequestStatus === 'error') {
|
|
664
|
+
lines.push(` ${chalk.red('❌ Error:')} ${chalk.yellow(state.featureRequestError || 'Failed to send')}`)
|
|
665
|
+
lines.push(` ${chalk.dim('Press Backspace to edit, or Esc to close')}`)
|
|
666
|
+
lines.push('')
|
|
667
|
+
} else {
|
|
668
|
+
lines.push(` ${chalk.dim('Type your feature request below. Press Enter to send, Esc to cancel.')}`)
|
|
669
|
+
lines.push(` ${chalk.dim('Your message will be sent anonymously to the project team.')}`)
|
|
670
|
+
lines.push('')
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// 📖 Input box with border
|
|
674
|
+
lines.push(chalk.dim(` ┌─ ${chalk.cyan('Message')} ${chalk.dim(`(${state.featureRequestBuffer.length}/500 chars)`)} ─${'─'.repeat(maxInputWidth - 22)}┐`))
|
|
675
|
+
|
|
676
|
+
// 📖 Display input lines (or placeholder if empty)
|
|
677
|
+
if (displayLines.length === 0 && state.featureRequestStatus === 'idle') {
|
|
678
|
+
lines.push(chalk.dim(` │${' '.repeat(maxInputWidth)}│`))
|
|
679
|
+
lines.push(chalk.dim(` │ ${chalk.white.italic('Type your message here...')}${' '.repeat(Math.max(0, maxInputWidth - 28))}│`))
|
|
680
|
+
} else {
|
|
681
|
+
for (const line of displayLines) {
|
|
682
|
+
const padded = line.padEnd(maxInputWidth)
|
|
683
|
+
lines.push(` │ ${chalk.white(padded)} │`)
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// 📖 Fill remaining space if needed
|
|
688
|
+
const linesToFill = Math.max(0, maxInputLines - Math.max(displayLines.length, 1))
|
|
689
|
+
for (let i = 0; i < linesToFill; i++) {
|
|
690
|
+
lines.push(chalk.dim(` │${' '.repeat(maxInputWidth)}│`))
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// 📖 Cursor indicator (only when not sending/success)
|
|
694
|
+
if (state.featureRequestStatus === 'idle' || state.featureRequestStatus === 'error') {
|
|
695
|
+
// Add cursor indicator to the last line
|
|
696
|
+
if (lines.length > 0 && displayLines.length > 0) {
|
|
697
|
+
const lastLineIdx = lines.findIndex(l => l.includes('│ ') && !l.includes('Message'))
|
|
698
|
+
if (lastLineIdx >= 0 && lastLineIdx < lines.length) {
|
|
699
|
+
// Add cursor blink
|
|
700
|
+
const lastLine = lines[lastLineIdx]
|
|
701
|
+
if (lastLine.includes('│')) {
|
|
702
|
+
lines[lastLineIdx] = lastLine.replace(/\s+│$/, chalk.rgb(57, 255, 20).bold('▏') + ' │')
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
lines.push(chalk.dim(` └${'─'.repeat(maxInputWidth + 2)}┘`))
|
|
709
|
+
|
|
710
|
+
lines.push('')
|
|
711
|
+
lines.push(chalk.dim(' Enter Send • Esc Cancel • Backspace Delete'))
|
|
712
|
+
|
|
713
|
+
// 📖 Apply overlay tint and return
|
|
714
|
+
const FEATURE_REQUEST_OVERLAY_BG = chalk.bgRgb(26, 26, 46) // Dark blue-ish background (RGB: 26, 26, 46)
|
|
715
|
+
const tintedLines = tintOverlayLines(lines, FEATURE_REQUEST_OVERLAY_BG)
|
|
716
|
+
const cleared = tintedLines.map(l => l + EL)
|
|
717
|
+
return cleared.join('\n')
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// ─── Bug Report overlay renderer ─────────────────────────────────────────
|
|
721
|
+
// 📖 renderBugReport: Draw the overlay for anonymous Discord bug reports.
|
|
722
|
+
// 📖 Shows an input field where users can type bug reports, then sends to Discord webhook.
|
|
723
|
+
function renderBugReport() {
|
|
724
|
+
const EL = '\x1b[K'
|
|
725
|
+
const lines = []
|
|
726
|
+
|
|
727
|
+
// 📖 Calculate available space for multi-line input
|
|
728
|
+
const maxInputWidth = OVERLAY_PANEL_WIDTH - 8 // 8 = padding (4 spaces each side)
|
|
729
|
+
const maxInputLines = 10 // Show up to 10 lines of input
|
|
730
|
+
|
|
731
|
+
// 📖 Split buffer into lines for display (with wrapping)
|
|
732
|
+
const wrapText = (text, width) => {
|
|
733
|
+
const words = text.split(' ')
|
|
734
|
+
const lines = []
|
|
735
|
+
let currentLine = ''
|
|
736
|
+
|
|
737
|
+
for (const word of words) {
|
|
738
|
+
const testLine = currentLine ? currentLine + ' ' + word : word
|
|
739
|
+
if (testLine.length <= width) {
|
|
740
|
+
currentLine = testLine
|
|
741
|
+
} else {
|
|
742
|
+
if (currentLine) lines.push(currentLine)
|
|
743
|
+
currentLine = word
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
if (currentLine) lines.push(currentLine)
|
|
747
|
+
return lines
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const inputLines = wrapText(state.bugReportBuffer, maxInputWidth)
|
|
751
|
+
const displayLines = inputLines.slice(0, maxInputLines)
|
|
752
|
+
|
|
753
|
+
// 📖 Header
|
|
754
|
+
lines.push('')
|
|
755
|
+
lines.push(` ${chalk.bold.rgb(255, 87, 51)('🐛 Bug Report')} ${chalk.dim('— send anonymous bug reports to the project team')}`)
|
|
756
|
+
lines.push('')
|
|
757
|
+
|
|
758
|
+
// 📖 Status messages (if any)
|
|
759
|
+
if (state.bugReportStatus === 'sending') {
|
|
760
|
+
lines.push(` ${chalk.yellow('⏳ Sending...')}`)
|
|
761
|
+
lines.push('')
|
|
762
|
+
} else if (state.bugReportStatus === 'success') {
|
|
763
|
+
lines.push(` ${chalk.greenBright.bold('✅ Successfully sent!')} ${chalk.dim('Closing overlay in 3 seconds...')}`)
|
|
764
|
+
lines.push('')
|
|
765
|
+
lines.push(` ${chalk.dim('Thank you for your feedback! Your bug report has been sent to the project team.')}`)
|
|
766
|
+
lines.push('')
|
|
767
|
+
} else if (state.bugReportStatus === 'error') {
|
|
768
|
+
lines.push(` ${chalk.red('❌ Error:')} ${chalk.yellow(state.bugReportError || 'Failed to send')}`)
|
|
769
|
+
lines.push(` ${chalk.dim('Press Backspace to edit, or Esc to close')}`)
|
|
770
|
+
lines.push('')
|
|
771
|
+
} else {
|
|
772
|
+
lines.push(` ${chalk.dim('Describe the bug you encountered. Press Enter to send, Esc to cancel.')}`)
|
|
773
|
+
lines.push(` ${chalk.dim('Your message will be sent anonymously to the project team.')}`)
|
|
774
|
+
lines.push('')
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// 📖 Input box with border
|
|
778
|
+
lines.push(chalk.dim(` ┌─ ${chalk.cyan('Bug Details')} ${chalk.dim(`(${state.bugReportBuffer.length}/500 chars)`)} ─${'─'.repeat(maxInputWidth - 24)}┐`))
|
|
779
|
+
|
|
780
|
+
// 📖 Display input lines (or placeholder if empty)
|
|
781
|
+
if (displayLines.length === 0 && state.bugReportStatus === 'idle') {
|
|
782
|
+
lines.push(chalk.dim(` │${' '.repeat(maxInputWidth)}│`))
|
|
783
|
+
lines.push(chalk.dim(` │ ${chalk.white.italic('Describe what happened...')}${' '.repeat(Math.max(0, maxInputWidth - 31))}│`))
|
|
784
|
+
} else {
|
|
785
|
+
for (const line of displayLines) {
|
|
786
|
+
const padded = line.padEnd(maxInputWidth)
|
|
787
|
+
lines.push(` │ ${chalk.white(padded)} │`)
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// 📖 Fill remaining space if needed
|
|
792
|
+
const linesToFill = Math.max(0, maxInputLines - Math.max(displayLines.length, 1))
|
|
793
|
+
for (let i = 0; i < linesToFill; i++) {
|
|
794
|
+
lines.push(chalk.dim(` │${' '.repeat(maxInputWidth)}│`))
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// 📖 Cursor indicator (only when not sending/success)
|
|
798
|
+
if (state.bugReportStatus === 'idle' || state.bugReportStatus === 'error') {
|
|
799
|
+
// Add cursor indicator to the last line
|
|
800
|
+
if (lines.length > 0 && displayLines.length > 0) {
|
|
801
|
+
const lastLineIdx = lines.findIndex(l => l.includes('│ ') && !l.includes('Bug Details'))
|
|
802
|
+
if (lastLineIdx >= 0 && lastLineIdx < lines.length) {
|
|
803
|
+
// Add cursor blink
|
|
804
|
+
const lastLine = lines[lastLineIdx]
|
|
805
|
+
if (lastLine.includes('│')) {
|
|
806
|
+
lines[lastLineIdx] = lastLine.replace(/\s+│$/, chalk.rgb(255, 87, 51).bold('▏') + ' │')
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
lines.push(chalk.dim(` └${'─'.repeat(maxInputWidth + 2)}┘`))
|
|
813
|
+
|
|
814
|
+
lines.push('')
|
|
815
|
+
lines.push(chalk.dim(' Enter Send • Esc Cancel • Backspace Delete'))
|
|
816
|
+
|
|
817
|
+
// 📖 Apply overlay tint and return
|
|
818
|
+
const BUG_REPORT_OVERLAY_BG = chalk.bgRgb(46, 20, 20) // Dark red-ish background (RGB: 46, 20, 20)
|
|
819
|
+
const tintedLines = tintOverlayLines(lines, BUG_REPORT_OVERLAY_BG)
|
|
820
|
+
const cleared = tintedLines.map(l => l + EL)
|
|
821
|
+
return cleared.join('\n')
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// 📖 stopRecommendAnalysis: cleanup timers if user cancels during analysis
|
|
825
|
+
function stopRecommendAnalysis() {
|
|
826
|
+
if (state.recommendAnalysisTimer) { clearInterval(state.recommendAnalysisTimer); state.recommendAnalysisTimer = null }
|
|
827
|
+
if (state.recommendPingTimer) { clearInterval(state.recommendPingTimer); state.recommendPingTimer = null }
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
return {
|
|
831
|
+
renderSettings,
|
|
832
|
+
renderHelp,
|
|
833
|
+
renderLog,
|
|
834
|
+
renderRecommend,
|
|
835
|
+
renderFeatureRequest,
|
|
836
|
+
renderBugReport,
|
|
837
|
+
startRecommendAnalysis,
|
|
838
|
+
stopRecommendAnalysis,
|
|
839
|
+
}
|
|
840
|
+
}
|