free-coding-models 0.1.83 → 0.1.85

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,839 @@
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 measurable pings (200 + 401) (ms) ${chalk.dim('Sort:')} ${chalk.yellow('A')}`)
254
+ lines.push(` ${chalk.dim('The long-term truth. Even without a key, a 401 still gives real latency so the average stays useful.')}`)
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')} Toggle ping mode ${chalk.dim('(speed 2s → normal 10s → slow 30s → forced 4s)')}`)
282
+ lines.push(` ${chalk.yellow('X')} Toggle token log page ${chalk.dim('(shows recent request usage from request-log.jsonl)')}`)
283
+ lines.push(` ${chalk.yellow('Z')} Cycle tool mode ${chalk.dim('(OpenCode CLI → OpenCode Desktop → OpenClaw)')}`)
284
+ lines.push(` ${chalk.yellow('F')} Toggle favorite on selected row ${chalk.dim('(⭐ pinned at top, persisted)')}`)
285
+ lines.push(` ${chalk.yellow('Q')} Smart Recommend ${chalk.dim('(🎯 find the best model for your task — questionnaire + live analysis)')}`)
286
+ lines.push(` ${chalk.rgb(57, 255, 20).bold('J')} Request Feature ${chalk.dim('(📝 send anonymous feedback to the project team)')}`)
287
+ lines.push(` ${chalk.rgb(255, 87, 51).bold('I')} Report Bug ${chalk.dim('(🐛 send anonymous bug report to the project team)')}`)
288
+ lines.push(` ${chalk.yellow('P')} Open settings ${chalk.dim('(manage API keys, provider toggles, manual update)')}`)
289
+ lines.push(` ${chalk.yellow('Shift+P')} Cycle config profile ${chalk.dim('(switch between saved profiles live)')}`)
290
+ lines.push(` ${chalk.yellow('Shift+S')} Save current config as a named profile ${chalk.dim('(inline prompt — type name + Enter)')}`)
291
+ lines.push(` ${chalk.dim('Profiles store: favorites, sort, tier filter, ping interval, API keys.')}`)
292
+ lines.push(` ${chalk.dim('Use --profile <name> to load a profile on startup.')}`)
293
+ lines.push(` ${chalk.yellow('K')} / ${chalk.yellow('Esc')} Show/hide this help`)
294
+ lines.push(` ${chalk.yellow('Ctrl+C')} Exit`)
295
+ lines.push('')
296
+ lines.push(` ${chalk.bold('Settings (P)')}`)
297
+ lines.push(` ${chalk.yellow('↑↓')} Navigate rows`)
298
+ lines.push(` ${chalk.yellow('PgUp/PgDn')} Jump by page`)
299
+ lines.push(` ${chalk.yellow('Home/End')} Jump first/last row`)
300
+ lines.push(` ${chalk.yellow('Enter')} Edit key / check-install update`)
301
+ lines.push(` ${chalk.yellow('Space')} Toggle provider enable/disable`)
302
+ lines.push(` ${chalk.yellow('T')} Test selected provider key`)
303
+ lines.push(` ${chalk.yellow('U')} Check updates manually`)
304
+ lines.push(` ${chalk.yellow('Esc')} Close settings`)
305
+ lines.push('')
306
+ lines.push(` ${chalk.bold('CLI Flags')}`)
307
+ lines.push(` ${chalk.dim('Usage: free-coding-models [options]')}`)
308
+ lines.push(` ${chalk.cyan('free-coding-models --opencode')} ${chalk.dim('OpenCode CLI mode')}`)
309
+ lines.push(` ${chalk.cyan('free-coding-models --opencode-desktop')} ${chalk.dim('OpenCode Desktop mode')}`)
310
+ lines.push(` ${chalk.cyan('free-coding-models --openclaw')} ${chalk.dim('OpenClaw mode')}`)
311
+ lines.push(` ${chalk.cyan('free-coding-models --best')} ${chalk.dim('Only top tiers (A+, S, S+)')}`)
312
+ lines.push(` ${chalk.cyan('free-coding-models --fiable')} ${chalk.dim('10s reliability analysis')}`)
313
+ lines.push(` ${chalk.cyan('free-coding-models --tier S|A|B|C')} ${chalk.dim('Filter by tier letter')}`)
314
+ lines.push(` ${chalk.cyan('free-coding-models --no-telemetry')} ${chalk.dim('Disable telemetry for this run')}`)
315
+ lines.push(` ${chalk.cyan('free-coding-models --recommend')} ${chalk.dim('Auto-open Smart Recommend on start')}`)
316
+ lines.push(` ${chalk.cyan('free-coding-models --profile <name>')} ${chalk.dim('Load a saved config profile')}`)
317
+ lines.push(` ${chalk.dim('Flags can be combined: --openclaw --tier S')}`)
318
+ lines.push('')
319
+ // 📖 Help overlay can be longer than viewport, so keep a dedicated scroll offset.
320
+ const { visible, offset } = sliceOverlayLines(lines, state.helpScrollOffset, state.terminalRows)
321
+ state.helpScrollOffset = offset
322
+ const tintedLines = tintOverlayLines(visible, HELP_OVERLAY_BG)
323
+ const cleared = tintedLines.map(l => l + EL)
324
+ return cleared.join('\n')
325
+ }
326
+
327
+ // ─── Log page overlay renderer ────────────────────────────────────────────
328
+ // 📖 renderLog: Draw the log page overlay showing recent requests from
329
+ // 📖 ~/.free-coding-models/request-log.jsonl, newest-first.
330
+ // 📖 Toggled with X key. Esc or X closes.
331
+ function renderLog() {
332
+ const EL = '\x1b[K'
333
+ const lines = []
334
+ lines.push('')
335
+ lines.push(` ${chalk.bold('📋 Request Log')} ${chalk.dim('— recent requests • ↑↓ scroll • X or Esc close')}`)
336
+ lines.push('')
337
+
338
+ // 📖 Load recent log entries — bounded read, newest-first, malformed lines skipped.
339
+ const logRows = loadRecentLogs({ limit: 200 })
340
+
341
+ if (logRows.length === 0) {
342
+ lines.push(chalk.dim(' No log entries found.'))
343
+ lines.push(chalk.dim(' Logs are written to ~/.free-coding-models/request-log.jsonl'))
344
+ lines.push(chalk.dim(' when requests are proxied through the multi-account rotation proxy.'))
345
+ } else {
346
+ // 📖 Column widths for the log table
347
+ const W_TIME = 19
348
+ const W_TYPE = 18
349
+ const W_PROV = 14
350
+ const W_MODEL = 36
351
+ const W_STATUS = 8
352
+ const W_TOKENS = 9
353
+ const W_LAT = 10
354
+
355
+ // 📖 Header row
356
+ const hTime = chalk.dim('Time'.padEnd(W_TIME))
357
+ const hType = chalk.dim('Type'.padEnd(W_TYPE))
358
+ const hProv = chalk.dim('Provider'.padEnd(W_PROV))
359
+ const hModel = chalk.dim('Model'.padEnd(W_MODEL))
360
+ const hStatus = chalk.dim('Status'.padEnd(W_STATUS))
361
+ const hTok = chalk.dim('Used'.padEnd(W_TOKENS))
362
+ const hLat = chalk.dim('Latency'.padEnd(W_LAT))
363
+ lines.push(` ${hTime} ${hType} ${hProv} ${hModel} ${hStatus} ${hTok} ${hLat}`)
364
+ lines.push(chalk.dim(' ' + '─'.repeat(W_TIME + W_TYPE + W_PROV + W_MODEL + W_STATUS + W_TOKENS + W_LAT + 12)))
365
+
366
+ for (const row of logRows) {
367
+ // 📖 Format time as HH:MM:SS (strip the date part for compactness)
368
+ let timeStr = row.time
369
+ try {
370
+ const d = new Date(row.time)
371
+ if (!Number.isNaN(d.getTime())) {
372
+ timeStr = d.toISOString().replace('T', ' ').slice(0, 19)
373
+ }
374
+ } catch { /* keep raw */ }
375
+
376
+ // 📖 Color-code status
377
+ let statusCell
378
+ const sc = String(row.status)
379
+ if (sc === '200') {
380
+ statusCell = chalk.greenBright(sc.padEnd(W_STATUS))
381
+ } else if (sc === '429') {
382
+ statusCell = chalk.yellow(sc.padEnd(W_STATUS))
383
+ } else if (sc.startsWith('5') || sc === 'error') {
384
+ statusCell = chalk.red(sc.padEnd(W_STATUS))
385
+ } else {
386
+ statusCell = chalk.dim(sc.padEnd(W_STATUS))
387
+ }
388
+
389
+ const tokStr = row.tokens > 0 ? String(row.tokens) : '--'
390
+ const latStr = row.latency > 0 ? `${row.latency}ms` : '--'
391
+
392
+ const timeCell = chalk.dim(timeStr.slice(0, W_TIME).padEnd(W_TIME))
393
+ const typeCell = chalk.magenta((row.requestType || '--').slice(0, W_TYPE).padEnd(W_TYPE))
394
+ const provCell = chalk.cyan(row.provider.slice(0, W_PROV).padEnd(W_PROV))
395
+ const modelCell = chalk.white(row.model.slice(0, W_MODEL).padEnd(W_MODEL))
396
+ const tokCell = chalk.dim(tokStr.padEnd(W_TOKENS))
397
+ const latCell = chalk.dim(latStr.padEnd(W_LAT))
398
+
399
+ lines.push(` ${timeCell} ${typeCell} ${provCell} ${modelCell} ${statusCell} ${tokCell} ${latCell}`)
400
+ }
401
+ }
402
+
403
+ lines.push('')
404
+ lines.push(chalk.dim(` Showing up to 200 most recent entries • X or Esc close`))
405
+ lines.push('')
406
+
407
+ const { visible, offset } = sliceOverlayLines(lines, state.logScrollOffset, state.terminalRows)
408
+ state.logScrollOffset = offset
409
+ const tintedLines = tintOverlayLines(visible, LOG_OVERLAY_BG)
410
+ const cleared = tintedLines.map(l => l + EL)
411
+ return cleared.join('\n')
412
+ }
413
+
414
+ // 📖 renderRecommend: Draw the Smart Recommend overlay with 3 phases:
415
+ // 1. 'questionnaire' — ask 3 questions (task type, priority, context budget)
416
+ // 2. 'analyzing' — loading screen with progress bar (10s, 2 pings/sec)
417
+ // 3. 'results' — show Top 3 recommendations with scores
418
+ function renderRecommend() {
419
+ const EL = '\x1b[K'
420
+ const lines = []
421
+
422
+ lines.push('')
423
+ lines.push(` ${chalk.bold('🎯 Smart Recommend')} ${chalk.dim('— find the best model for your task')}`)
424
+ lines.push('')
425
+
426
+ if (state.recommendPhase === 'questionnaire') {
427
+ // 📖 Question definitions — each has a title, options array, and answer key
428
+ const questions = [
429
+ {
430
+ title: 'What are you working on?',
431
+ options: Object.entries(TASK_TYPES).map(([key, val]) => ({ key, label: val.label })),
432
+ answerKey: 'taskType',
433
+ },
434
+ {
435
+ title: 'What matters most?',
436
+ options: Object.entries(PRIORITY_TYPES).map(([key, val]) => ({ key, label: val.label })),
437
+ answerKey: 'priority',
438
+ },
439
+ {
440
+ title: 'How big is your context?',
441
+ options: Object.entries(CONTEXT_BUDGETS).map(([key, val]) => ({ key, label: val.label })),
442
+ answerKey: 'contextBudget',
443
+ },
444
+ ]
445
+
446
+ const q = questions[state.recommendQuestion]
447
+ const qNum = state.recommendQuestion + 1
448
+ const qTotal = questions.length
449
+
450
+ // 📖 Progress breadcrumbs showing answered questions
451
+ let breadcrumbs = ''
452
+ for (let i = 0; i < questions.length; i++) {
453
+ const answered = state.recommendAnswers[questions[i].answerKey]
454
+ if (i < state.recommendQuestion && answered) {
455
+ const answeredLabel = questions[i].options.find(o => o.key === answered)?.label || answered
456
+ breadcrumbs += chalk.greenBright(` ✓ ${questions[i].title} ${chalk.bold(answeredLabel)}`) + '\n'
457
+ }
458
+ }
459
+ if (breadcrumbs) {
460
+ lines.push(breadcrumbs.trimEnd())
461
+ lines.push('')
462
+ }
463
+
464
+ lines.push(` ${chalk.bold(`Question ${qNum}/${qTotal}:`)} ${chalk.cyan(q.title)}`)
465
+ lines.push('')
466
+
467
+ for (let i = 0; i < q.options.length; i++) {
468
+ const opt = q.options[i]
469
+ const isCursor = i === state.recommendCursor
470
+ const bullet = isCursor ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
471
+ const label = isCursor ? chalk.bold.white(opt.label) : chalk.white(opt.label)
472
+ lines.push(`${bullet}${label}`)
473
+ }
474
+
475
+ lines.push('')
476
+ lines.push(chalk.dim(' ↑↓ navigate • Enter select • Esc cancel'))
477
+
478
+ } else if (state.recommendPhase === 'analyzing') {
479
+ // 📖 Loading screen with progress bar
480
+ const pct = Math.min(100, Math.round(state.recommendProgress))
481
+ const barWidth = 40
482
+ const filled = Math.round(barWidth * pct / 100)
483
+ const empty = barWidth - filled
484
+ const bar = chalk.greenBright('█'.repeat(filled)) + chalk.dim('░'.repeat(empty))
485
+
486
+ lines.push(` ${chalk.bold('Analyzing models...')}`)
487
+ lines.push('')
488
+ lines.push(` ${bar} ${chalk.bold(String(pct) + '%')}`)
489
+ lines.push('')
490
+
491
+ // 📖 Show what we're doing
492
+ const taskLabel = TASK_TYPES[state.recommendAnswers.taskType]?.label || '—'
493
+ const prioLabel = PRIORITY_TYPES[state.recommendAnswers.priority]?.label || '—'
494
+ const ctxLabel = CONTEXT_BUDGETS[state.recommendAnswers.contextBudget]?.label || '—'
495
+ lines.push(chalk.dim(` Task: ${taskLabel} • Priority: ${prioLabel} • Context: ${ctxLabel}`))
496
+ lines.push('')
497
+
498
+ // 📖 Spinning indicator
499
+ const spinIdx = state.frame % FRAMES.length
500
+ lines.push(` ${chalk.yellow(FRAMES[spinIdx])} Pinging models at 2 pings/sec to gather fresh latency data...`)
501
+ lines.push('')
502
+ lines.push(chalk.dim(' Esc to cancel'))
503
+
504
+ } else if (state.recommendPhase === 'results') {
505
+ // 📖 Show Top 3 results with detailed info
506
+ const taskLabel = TASK_TYPES[state.recommendAnswers.taskType]?.label || '—'
507
+ const prioLabel = PRIORITY_TYPES[state.recommendAnswers.priority]?.label || '—'
508
+ const ctxLabel = CONTEXT_BUDGETS[state.recommendAnswers.contextBudget]?.label || '—'
509
+ lines.push(chalk.dim(` Task: ${taskLabel} • Priority: ${prioLabel} • Context: ${ctxLabel}`))
510
+ lines.push('')
511
+
512
+ if (state.recommendResults.length === 0) {
513
+ lines.push(` ${chalk.yellow('No models could be scored. Try different criteria or wait for more pings.')}`)
514
+ } else {
515
+ lines.push(` ${chalk.bold('Top Recommendations:')}`)
516
+ lines.push('')
517
+
518
+ for (let i = 0; i < state.recommendResults.length; i++) {
519
+ const rec = state.recommendResults[i]
520
+ const r = rec.result
521
+ const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : '🥉'
522
+ const providerName = sources[r.providerKey]?.name ?? r.providerKey
523
+ const tierFn = TIER_COLOR[r.tier] ?? (t => chalk.white(t))
524
+ const avg = getAvg(r)
525
+ const avgStr = avg === Infinity ? '—' : Math.round(avg) + 'ms'
526
+ const sweStr = r.sweScore ?? '—'
527
+ const ctxStr = r.ctx ?? '—'
528
+ const stability = getStabilityScore(r)
529
+ const stabStr = stability === -1 ? '—' : String(stability)
530
+
531
+ const isCursor = i === state.recommendCursor
532
+ const highlight = isCursor ? chalk.bgRgb(20, 50, 25) : (s => s)
533
+
534
+ lines.push(highlight(` ${medal} ${chalk.bold('#' + (i + 1))} ${chalk.bold.white(r.label)} ${chalk.dim('(' + providerName + ')')}`))
535
+ 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)}`))
536
+ lines.push('')
537
+ }
538
+ }
539
+
540
+ lines.push('')
541
+ lines.push(` ${chalk.dim('These models are now')} ${chalk.greenBright('highlighted')} ${chalk.dim('and')} 🎯 ${chalk.dim('pinned in the main table.')}`)
542
+ lines.push('')
543
+ lines.push(chalk.dim(' ↑↓ navigate • Enter select & close • Esc close • Q new search'))
544
+ }
545
+
546
+ lines.push('')
547
+ const { visible, offset } = sliceOverlayLines(lines, state.recommendScrollOffset, state.terminalRows)
548
+ state.recommendScrollOffset = offset
549
+ const tintedLines = tintOverlayLines(visible, RECOMMEND_OVERLAY_BG)
550
+ const cleared2 = tintedLines.map(l => l + EL)
551
+ return cleared2.join('\n')
552
+ }
553
+
554
+ // ─── Smart Recommend: analysis phase controller ────────────────────────────
555
+ // 📖 startRecommendAnalysis: begins the 10-second analysis phase.
556
+ // 📖 Pings a random subset of visible models at 2 pings/sec while advancing progress.
557
+ // 📖 After 10 seconds, computes recommendations and transitions to results phase.
558
+ function startRecommendAnalysis() {
559
+ state.recommendPhase = 'analyzing'
560
+ state.recommendProgress = 0
561
+ state.recommendResults = []
562
+
563
+ const pingModel = getPingModel?.()
564
+ if (!pingModel) return
565
+
566
+ const startTime = Date.now()
567
+ const ANALYSIS_DURATION = 10_000 // 📖 10 seconds
568
+ const PING_RATE = 500 // 📖 2 pings per second (every 500ms)
569
+
570
+ // 📖 Progress updater — runs every 200ms to update the progress bar
571
+ state.recommendAnalysisTimer = setInterval(() => {
572
+ const elapsed = Date.now() - startTime
573
+ state.recommendProgress = Math.min(100, (elapsed / ANALYSIS_DURATION) * 100)
574
+
575
+ if (elapsed >= ANALYSIS_DURATION) {
576
+ // 📖 Analysis complete — compute recommendations
577
+ clearInterval(state.recommendAnalysisTimer)
578
+ clearInterval(state.recommendPingTimer)
579
+ state.recommendAnalysisTimer = null
580
+ state.recommendPingTimer = null
581
+
582
+ const recs = getTopRecommendations(
583
+ state.results,
584
+ state.recommendAnswers.taskType,
585
+ state.recommendAnswers.priority,
586
+ state.recommendAnswers.contextBudget,
587
+ 3
588
+ )
589
+ state.recommendResults = recs
590
+ state.recommendPhase = 'results'
591
+ state.recommendCursor = 0
592
+
593
+ // 📖 Mark recommended models so the main table can highlight them
594
+ state.recommendedKeys = new Set(recs.map(rec => toFavoriteKey(rec.result.providerKey, rec.result.modelId)))
595
+ // 📖 Tag each result object so sortResultsWithPinnedFavorites can pin them
596
+ state.results.forEach(r => {
597
+ const key = toFavoriteKey(r.providerKey, r.modelId)
598
+ const rec = recs.find(rec => toFavoriteKey(rec.result.providerKey, rec.result.modelId) === key)
599
+ r.isRecommended = !!rec
600
+ r.recommendScore = rec ? rec.score : 0
601
+ })
602
+ }
603
+ }, 200)
604
+
605
+ // 📖 Targeted pinging — ping random visible models at 2/sec for fresh data
606
+ state.recommendPingTimer = setInterval(() => {
607
+ const visible = state.results.filter(r => !r.hidden && r.status !== 'noauth')
608
+ if (visible.length === 0) return
609
+ // 📖 Pick a random model to ping — spreads load across all models over 10s
610
+ const target = visible[Math.floor(Math.random() * visible.length)]
611
+ pingModel(target).catch(() => {})
612
+ }, PING_RATE)
613
+ }
614
+
615
+ // ─── Feature Request overlay renderer ─────────────────────────────────────
616
+ // 📖 renderFeatureRequest: Draw the overlay for anonymous Discord feedback.
617
+ // 📖 Shows an input field where users can type feature requests, then sends to Discord webhook.
618
+ function renderFeatureRequest() {
619
+ const EL = '\x1b[K'
620
+ const lines = []
621
+
622
+ // 📖 Calculate available space for multi-line input
623
+ const maxInputWidth = OVERLAY_PANEL_WIDTH - 8 // 8 = padding (4 spaces each side)
624
+ const maxInputLines = 10 // Show up to 10 lines of input
625
+
626
+ // 📖 Split buffer into lines for display (with wrapping)
627
+ const wrapText = (text, width) => {
628
+ const words = text.split(' ')
629
+ const lines = []
630
+ let currentLine = ''
631
+
632
+ for (const word of words) {
633
+ const testLine = currentLine ? currentLine + ' ' + word : word
634
+ if (testLine.length <= width) {
635
+ currentLine = testLine
636
+ } else {
637
+ if (currentLine) lines.push(currentLine)
638
+ currentLine = word
639
+ }
640
+ }
641
+ if (currentLine) lines.push(currentLine)
642
+ return lines
643
+ }
644
+
645
+ const inputLines = wrapText(state.featureRequestBuffer, maxInputWidth)
646
+ const displayLines = inputLines.slice(0, maxInputLines)
647
+
648
+ // 📖 Header
649
+ lines.push('')
650
+ lines.push(` ${chalk.bold.rgb(57, 255, 20)('📝 Feature Request')} ${chalk.dim('— send anonymous feedback to the project team')}`)
651
+ lines.push('')
652
+
653
+ // 📖 Status messages (if any)
654
+ if (state.featureRequestStatus === 'sending') {
655
+ lines.push(` ${chalk.yellow('⏳ Sending...')}`)
656
+ lines.push('')
657
+ } else if (state.featureRequestStatus === 'success') {
658
+ lines.push(` ${chalk.greenBright.bold('✅ Successfully sent!')} ${chalk.dim('Closing overlay in 3 seconds...')}`)
659
+ lines.push('')
660
+ lines.push(` ${chalk.dim('Thank you for your feedback! Your feature request has been sent to the project team.')}`)
661
+ lines.push('')
662
+ } else if (state.featureRequestStatus === 'error') {
663
+ lines.push(` ${chalk.red('❌ Error:')} ${chalk.yellow(state.featureRequestError || 'Failed to send')}`)
664
+ lines.push(` ${chalk.dim('Press Backspace to edit, or Esc to close')}`)
665
+ lines.push('')
666
+ } else {
667
+ lines.push(` ${chalk.dim('Type your feature request below. Press Enter to send, Esc to cancel.')}`)
668
+ lines.push(` ${chalk.dim('Your message will be sent anonymously to the project team.')}`)
669
+ lines.push('')
670
+ }
671
+
672
+ // 📖 Input box with border
673
+ lines.push(chalk.dim(` ┌─ ${chalk.cyan('Message')} ${chalk.dim(`(${state.featureRequestBuffer.length}/500 chars)`)} ─${'─'.repeat(maxInputWidth - 22)}┐`))
674
+
675
+ // 📖 Display input lines (or placeholder if empty)
676
+ if (displayLines.length === 0 && state.featureRequestStatus === 'idle') {
677
+ lines.push(chalk.dim(` │${' '.repeat(maxInputWidth)}│`))
678
+ lines.push(chalk.dim(` │ ${chalk.white.italic('Type your message here...')}${' '.repeat(Math.max(0, maxInputWidth - 28))}│`))
679
+ } else {
680
+ for (const line of displayLines) {
681
+ const padded = line.padEnd(maxInputWidth)
682
+ lines.push(` │ ${chalk.white(padded)} │`)
683
+ }
684
+ }
685
+
686
+ // 📖 Fill remaining space if needed
687
+ const linesToFill = Math.max(0, maxInputLines - Math.max(displayLines.length, 1))
688
+ for (let i = 0; i < linesToFill; i++) {
689
+ lines.push(chalk.dim(` │${' '.repeat(maxInputWidth)}│`))
690
+ }
691
+
692
+ // 📖 Cursor indicator (only when not sending/success)
693
+ if (state.featureRequestStatus === 'idle' || state.featureRequestStatus === 'error') {
694
+ // Add cursor indicator to the last line
695
+ if (lines.length > 0 && displayLines.length > 0) {
696
+ const lastLineIdx = lines.findIndex(l => l.includes('│ ') && !l.includes('Message'))
697
+ if (lastLineIdx >= 0 && lastLineIdx < lines.length) {
698
+ // Add cursor blink
699
+ const lastLine = lines[lastLineIdx]
700
+ if (lastLine.includes('│')) {
701
+ lines[lastLineIdx] = lastLine.replace(/\s+│$/, chalk.rgb(57, 255, 20).bold('▏') + ' │')
702
+ }
703
+ }
704
+ }
705
+ }
706
+
707
+ lines.push(chalk.dim(` └${'─'.repeat(maxInputWidth + 2)}┘`))
708
+
709
+ lines.push('')
710
+ lines.push(chalk.dim(' Enter Send • Esc Cancel • Backspace Delete'))
711
+
712
+ // 📖 Apply overlay tint and return
713
+ const FEATURE_REQUEST_OVERLAY_BG = chalk.bgRgb(26, 26, 46) // Dark blue-ish background (RGB: 26, 26, 46)
714
+ const tintedLines = tintOverlayLines(lines, FEATURE_REQUEST_OVERLAY_BG)
715
+ const cleared = tintedLines.map(l => l + EL)
716
+ return cleared.join('\n')
717
+ }
718
+
719
+ // ─── Bug Report overlay renderer ─────────────────────────────────────────
720
+ // 📖 renderBugReport: Draw the overlay for anonymous Discord bug reports.
721
+ // 📖 Shows an input field where users can type bug reports, then sends to Discord webhook.
722
+ function renderBugReport() {
723
+ const EL = '\x1b[K'
724
+ const lines = []
725
+
726
+ // 📖 Calculate available space for multi-line input
727
+ const maxInputWidth = OVERLAY_PANEL_WIDTH - 8 // 8 = padding (4 spaces each side)
728
+ const maxInputLines = 10 // Show up to 10 lines of input
729
+
730
+ // 📖 Split buffer into lines for display (with wrapping)
731
+ const wrapText = (text, width) => {
732
+ const words = text.split(' ')
733
+ const lines = []
734
+ let currentLine = ''
735
+
736
+ for (const word of words) {
737
+ const testLine = currentLine ? currentLine + ' ' + word : word
738
+ if (testLine.length <= width) {
739
+ currentLine = testLine
740
+ } else {
741
+ if (currentLine) lines.push(currentLine)
742
+ currentLine = word
743
+ }
744
+ }
745
+ if (currentLine) lines.push(currentLine)
746
+ return lines
747
+ }
748
+
749
+ const inputLines = wrapText(state.bugReportBuffer, maxInputWidth)
750
+ const displayLines = inputLines.slice(0, maxInputLines)
751
+
752
+ // 📖 Header
753
+ lines.push('')
754
+ lines.push(` ${chalk.bold.rgb(255, 87, 51)('🐛 Bug Report')} ${chalk.dim('— send anonymous bug reports to the project team')}`)
755
+ lines.push('')
756
+
757
+ // 📖 Status messages (if any)
758
+ if (state.bugReportStatus === 'sending') {
759
+ lines.push(` ${chalk.yellow('⏳ Sending...')}`)
760
+ lines.push('')
761
+ } else if (state.bugReportStatus === 'success') {
762
+ lines.push(` ${chalk.greenBright.bold('✅ Successfully sent!')} ${chalk.dim('Closing overlay in 3 seconds...')}`)
763
+ lines.push('')
764
+ lines.push(` ${chalk.dim('Thank you for your feedback! Your bug report has been sent to the project team.')}`)
765
+ lines.push('')
766
+ } else if (state.bugReportStatus === 'error') {
767
+ lines.push(` ${chalk.red('❌ Error:')} ${chalk.yellow(state.bugReportError || 'Failed to send')}`)
768
+ lines.push(` ${chalk.dim('Press Backspace to edit, or Esc to close')}`)
769
+ lines.push('')
770
+ } else {
771
+ lines.push(` ${chalk.dim('Describe the bug you encountered. Press Enter to send, Esc to cancel.')}`)
772
+ lines.push(` ${chalk.dim('Your message will be sent anonymously to the project team.')}`)
773
+ lines.push('')
774
+ }
775
+
776
+ // 📖 Input box with border
777
+ lines.push(chalk.dim(` ┌─ ${chalk.cyan('Bug Details')} ${chalk.dim(`(${state.bugReportBuffer.length}/500 chars)`)} ─${'─'.repeat(maxInputWidth - 24)}┐`))
778
+
779
+ // 📖 Display input lines (or placeholder if empty)
780
+ if (displayLines.length === 0 && state.bugReportStatus === 'idle') {
781
+ lines.push(chalk.dim(` │${' '.repeat(maxInputWidth)}│`))
782
+ lines.push(chalk.dim(` │ ${chalk.white.italic('Describe what happened...')}${' '.repeat(Math.max(0, maxInputWidth - 31))}│`))
783
+ } else {
784
+ for (const line of displayLines) {
785
+ const padded = line.padEnd(maxInputWidth)
786
+ lines.push(` │ ${chalk.white(padded)} │`)
787
+ }
788
+ }
789
+
790
+ // 📖 Fill remaining space if needed
791
+ const linesToFill = Math.max(0, maxInputLines - Math.max(displayLines.length, 1))
792
+ for (let i = 0; i < linesToFill; i++) {
793
+ lines.push(chalk.dim(` │${' '.repeat(maxInputWidth)}│`))
794
+ }
795
+
796
+ // 📖 Cursor indicator (only when not sending/success)
797
+ if (state.bugReportStatus === 'idle' || state.bugReportStatus === 'error') {
798
+ // Add cursor indicator to the last line
799
+ if (lines.length > 0 && displayLines.length > 0) {
800
+ const lastLineIdx = lines.findIndex(l => l.includes('│ ') && !l.includes('Bug Details'))
801
+ if (lastLineIdx >= 0 && lastLineIdx < lines.length) {
802
+ // Add cursor blink
803
+ const lastLine = lines[lastLineIdx]
804
+ if (lastLine.includes('│')) {
805
+ lines[lastLineIdx] = lastLine.replace(/\s+│$/, chalk.rgb(255, 87, 51).bold('▏') + ' │')
806
+ }
807
+ }
808
+ }
809
+ }
810
+
811
+ lines.push(chalk.dim(` └${'─'.repeat(maxInputWidth + 2)}┘`))
812
+
813
+ lines.push('')
814
+ lines.push(chalk.dim(' Enter Send • Esc Cancel • Backspace Delete'))
815
+
816
+ // 📖 Apply overlay tint and return
817
+ const BUG_REPORT_OVERLAY_BG = chalk.bgRgb(46, 20, 20) // Dark red-ish background (RGB: 46, 20, 20)
818
+ const tintedLines = tintOverlayLines(lines, BUG_REPORT_OVERLAY_BG)
819
+ const cleared = tintedLines.map(l => l + EL)
820
+ return cleared.join('\n')
821
+ }
822
+
823
+ // 📖 stopRecommendAnalysis: cleanup timers if user cancels during analysis
824
+ function stopRecommendAnalysis() {
825
+ if (state.recommendAnalysisTimer) { clearInterval(state.recommendAnalysisTimer); state.recommendAnalysisTimer = null }
826
+ if (state.recommendPingTimer) { clearInterval(state.recommendPingTimer); state.recommendPingTimer = null }
827
+ }
828
+
829
+ return {
830
+ renderSettings,
831
+ renderHelp,
832
+ renderLog,
833
+ renderRecommend,
834
+ renderFeatureRequest,
835
+ renderBugReport,
836
+ startRecommendAnalysis,
837
+ stopRecommendAnalysis,
838
+ }
839
+ }