free-coding-models 0.1.83 → 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.
@@ -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
+ }