free-coding-models 0.2.17 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/overlays.js CHANGED
@@ -4,20 +4,28 @@
4
4
  *
5
5
  * @details
6
6
  * This module centralizes all overlay rendering in one place:
7
- * - Settings, Install Endpoints, Help, Log, Smart Recommend, Feature Request, Bug Report, Changelog
7
+ * - Settings, Install Endpoints, Help, Log, Smart Recommend, Feedback, Changelog
8
+ * - FCM Proxy V2 overlay with tool selector, auto-sync toggle, and cleanup
8
9
  * - Settings diagnostics for provider key tests, including wrapped retry/error details
9
10
  * - Recommend analysis timer orchestration and progress updates
10
11
  *
11
12
  * The factory pattern keeps stateful UI logic isolated while still
12
13
  * allowing the main CLI to control shared state and dependencies.
13
14
  *
15
+ * 📖 The proxy overlay rows are: Enable → Active tool → Auto-sync → Port → Cleanup → Install/Restart/Stop/Kill/Logs
16
+ * 📖 Tool selector cycles through PROXY_SYNCABLE_TOOLS (12 tools from proxy-sync.js)
17
+ * 📖 Feedback overlay (I key) combines feature requests + bug reports in one left-aligned input
18
+ *
14
19
  * → Functions:
15
20
  * - `createOverlayRenderers` — returns renderer + analysis helpers
16
21
  *
17
22
  * @exports { createOverlayRenderers }
23
+ * @see ./proxy-sync.js — PROXY_SYNCABLE_TOOLS used by the tool selector
24
+ * @see ./key-handler.js — handles keypresses for all overlay interactions
18
25
  */
19
26
 
20
27
  import { loadChangelog } from './changelog-loader.js'
28
+ import { PROXY_SYNCABLE_TOOLS } from './proxy-sync.js'
21
29
 
22
30
  export function createOverlayRenderers(state, deps) {
23
31
  const {
@@ -138,11 +146,8 @@ export function createOverlayRenderers(state, deps) {
138
146
  function renderSettings() {
139
147
  const providerKeys = Object.keys(sources)
140
148
  const updateRowIdx = providerKeys.length
141
- const proxyEnabledRowIdx = updateRowIdx + 1
142
- const proxySyncRowIdx = updateRowIdx + 2
143
- const proxyPortRowIdx = updateRowIdx + 3
144
- const proxyCleanupRowIdx = updateRowIdx + 4
145
- const changelogViewRowIdx = updateRowIdx + 5
149
+ const proxyDaemonRowIdx = updateRowIdx + 1
150
+ const changelogViewRowIdx = updateRowIdx + 2
146
151
  const proxySettings = getProxySettings(state.config)
147
152
  const EL = '\x1b[K'
148
153
  const lines = []
@@ -273,33 +278,23 @@ export function createOverlayRenderers(state, deps) {
273
278
  lines.push(chalk.red(` ${state.settingsUpdateError}`))
274
279
  }
275
280
 
281
+ // 📖 FCM Proxy V2 — single row that opens a dedicated overlay
276
282
  lines.push('')
277
- lines.push(` ${chalk.bold('🔀 Proxy')}`)
283
+ lines.push(` ${chalk.bold('📡 FCM Proxy V2')}`)
278
284
  lines.push(` ${chalk.dim(' ' + '─'.repeat(separatorWidth))}`)
279
285
  lines.push('')
280
286
 
281
- const proxyEnabledBullet = state.settingsCursor === proxyEnabledRowIdx ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
282
- const proxyEnabledRow = `${proxyEnabledBullet}${chalk.bold('Proxy mode (opt-in)').padEnd(44)} ${proxySettings.enabled ? chalk.greenBright('Enabled') : chalk.dim('Disabled by default')}`
283
- cursorLineByRow[proxyEnabledRowIdx] = lines.length
284
- lines.push(state.settingsCursor === proxyEnabledRowIdx ? chalk.bgRgb(20, 45, 60)(proxyEnabledRow) : proxyEnabledRow)
285
-
286
- const proxySyncBullet = state.settingsCursor === proxySyncRowIdx ? chalk.bold.cyan('') : chalk.dim(' ')
287
- const proxySyncRow = `${proxySyncBullet}${chalk.bold('Persist proxy in OpenCode').padEnd(44)} ${proxySettings.syncToOpenCode ? chalk.greenBright('Enabled') : chalk.dim('Disabled')}`
288
- cursorLineByRow[proxySyncRowIdx] = lines.length
289
- lines.push(state.settingsCursor === proxySyncRowIdx ? chalk.bgRgb(20, 45, 60)(proxySyncRow) : proxySyncRow)
290
-
291
- const proxyPortBullet = state.settingsCursor === proxyPortRowIdx ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
292
- const proxyPortValue = state.settingsProxyPortEditMode && state.settingsCursor === proxyPortRowIdx
293
- ? chalk.cyanBright(`${state.settingsProxyPortBuffer}▏`)
294
- : (proxySettings.preferredPort === 0 ? chalk.dim('auto (OS-assigned)') : chalk.green(String(proxySettings.preferredPort)))
295
- const proxyPortRow = `${proxyPortBullet}${chalk.bold('Preferred proxy port').padEnd(44)} ${proxyPortValue}`
296
- cursorLineByRow[proxyPortRowIdx] = lines.length
297
- lines.push(state.settingsCursor === proxyPortRowIdx ? chalk.bgRgb(20, 45, 60)(proxyPortRow) : proxyPortRow)
298
-
299
- const proxyCleanupBullet = state.settingsCursor === proxyCleanupRowIdx ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
300
- const proxyCleanupRow = `${proxyCleanupBullet}${chalk.bold('Clean OpenCode proxy config').padEnd(44)} ${chalk.dim('Enter removes fcm-proxy from opencode.json')}`
301
- cursorLineByRow[proxyCleanupRowIdx] = lines.length
302
- lines.push(state.settingsCursor === proxyCleanupRowIdx ? chalk.bgRgb(45, 30, 30)(proxyCleanupRow) : proxyCleanupRow)
287
+ const proxyDaemonBullet = state.settingsCursor === proxyDaemonRowIdx ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
288
+ const proxyStatus = proxySettings.enabled ? chalk.greenBright('Proxy ON') : chalk.dim('Proxy OFF')
289
+ const daemonStatus = state.daemonStatus || 'not-installed'
290
+ let daemonBadge
291
+ if (daemonStatus === 'running') daemonBadge = chalk.greenBright('Service ON')
292
+ else if (daemonStatus === 'stopped') daemonBadge = chalk.yellow('Service stopped')
293
+ else if (daemonStatus === 'stale' || daemonStatus === 'unhealthy') daemonBadge = chalk.red('Service ' + daemonStatus)
294
+ else daemonBadge = chalk.dim('Service OFF')
295
+ const proxyDaemonRow = `${proxyDaemonBullet}${chalk.bold('FCM Proxy V2 settings →').padEnd(44)} ${proxyStatus} ${chalk.dim('•')} ${daemonBadge}`
296
+ cursorLineByRow[proxyDaemonRowIdx] = lines.length
297
+ lines.push(state.settingsCursor === proxyDaemonRowIdx ? chalk.bgRgb(20, 45, 60)(proxyDaemonRow) : proxyDaemonRow)
303
298
 
304
299
  // 📖 Changelog viewer row
305
300
  const changelogViewBullet = state.settingsCursor === changelogViewRowIdx ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
@@ -309,7 +304,7 @@ export function createOverlayRenderers(state, deps) {
309
304
 
310
305
  // 📖 Profiles section — list saved profiles with active indicator + delete support
311
306
  const savedProfiles = listProfiles(state.config)
312
- const profileStartIdx = updateRowIdx + 6
307
+ const profileStartIdx = updateRowIdx + 3
313
308
  const maxRowIdx = savedProfiles.length > 0 ? profileStartIdx + savedProfiles.length - 1 : updateRowIdx
314
309
 
315
310
  lines.push('')
@@ -416,7 +411,7 @@ export function createOverlayRenderers(state, deps) {
416
411
  : '—'
417
412
 
418
413
  const selectedConnectionLabel = state.installEndpointsConnectionMode === 'proxy'
419
- ? 'FCM Proxy'
414
+ ? 'FCM Proxy V2'
420
415
  : state.installEndpointsConnectionMode === 'direct'
421
416
  ? 'Direct Provider'
422
417
  : '—'
@@ -632,10 +627,10 @@ export function createOverlayRenderers(state, deps) {
632
627
  lines.push(` ${chalk.yellow('X')} Toggle token log page ${chalk.dim('(shows recent request usage from request-log.jsonl)')}`)
633
628
  lines.push(` ${chalk.yellow('Z')} Cycle tool mode ${chalk.dim('(OpenCode → Desktop → OpenClaw → Crush → Goose → Pi → Aider → Claude Code → Codex → Gemini → Qwen → OpenHands → Amp)')}`)
634
629
  lines.push(` ${chalk.yellow('F')} Toggle favorite on selected row ${chalk.dim('(⭐ pinned at top, persisted)')}`)
635
- lines.push(` ${chalk.yellow('Y')} Install endpoints ${chalk.dim('(provider catalog → all tools, Direct or FCM Proxy)')}`)
630
+ lines.push(` ${chalk.yellow('Y')} Install endpoints ${chalk.dim('(provider catalog → all tools, Direct or FCM Proxy V2)')}`)
636
631
  lines.push(` ${chalk.yellow('Q')} Smart Recommend ${chalk.dim('(🎯 find the best model for your task — questionnaire + live analysis)')}`)
637
- lines.push(` ${chalk.rgb(57, 255, 20).bold('J')} Request Feature ${chalk.dim('(📝 send anonymous feedback to the project team)')}`)
638
- lines.push(` ${chalk.rgb(255, 87, 51).bold('I')} Report Bug ${chalk.dim('(🐛 send anonymous bug report to the project team)')}`)
632
+ lines.push(` ${chalk.rgb(255, 87, 51).bold('I')} Feedback, bugs & requests ${chalk.dim('(📝 send anonymous feedback, bug reports, or feature requests)')}`)
633
+ lines.push(` ${chalk.yellow('J')} FCM Proxy V2 settings ${chalk.dim('(📡 open proxy configuration and background service management)')}`)
639
634
  lines.push(` ${chalk.yellow('P')} Open settings ${chalk.dim('(manage API keys, provider toggles, proxy, manual update)')}`)
640
635
  lines.push(` ${chalk.yellow('Shift+P')} Cycle config profile ${chalk.dim('(switch between saved profiles live)')}`)
641
636
  lines.push(` ${chalk.yellow('Shift+S')} Save current config as a named profile ${chalk.dim('(inline prompt — type name + Enter)')}`)
@@ -1057,117 +1052,10 @@ export function createOverlayRenderers(state, deps) {
1057
1052
  }, PING_RATE)
1058
1053
  }
1059
1054
 
1060
- // ─── Feature Request overlay renderer ─────────────────────────────────────
1061
- // 📖 renderFeatureRequest: Draw the overlay for anonymous Discord feedback.
1062
- // 📖 Shows an input field where users can type feature requests, then sends to Discord webhook.
1063
- function renderFeatureRequest() {
1064
- const EL = '\x1b[K'
1065
- const lines = []
1066
-
1067
- // 📖 Calculate available space for multi-line input (dynamic based on terminal width)
1068
- const maxInputWidth = state.terminalCols - 8 // 8 = padding (4 spaces each side)
1069
- const maxInputLines = 10 // Show up to 10 lines of input
1070
-
1071
- // 📖 Split buffer into lines for display (with wrapping)
1072
- const wrapText = (text, width) => {
1073
- const words = text.split(' ')
1074
- const lines = []
1075
- let currentLine = ''
1076
-
1077
- for (const word of words) {
1078
- const testLine = currentLine ? currentLine + ' ' + word : word
1079
- if (testLine.length <= width) {
1080
- currentLine = testLine
1081
- } else {
1082
- if (currentLine) lines.push(currentLine)
1083
- currentLine = word
1084
- }
1085
- }
1086
- if (currentLine) lines.push(currentLine)
1087
- return lines
1088
- }
1089
-
1090
- const inputLines = wrapText(state.featureRequestBuffer, maxInputWidth)
1091
- const displayLines = inputLines.slice(0, maxInputLines)
1092
-
1093
- // 📖 Branding header
1094
- lines.push('')
1095
- lines.push(` ${chalk.cyanBright('🚀')} ${chalk.bold.cyanBright('free-coding-models')} ${chalk.dim(`v${LOCAL_VERSION}`)}`)
1096
- lines.push(` ${chalk.bold.rgb(57, 255, 20)('📝 Feature Request')}`)
1097
- lines.push('')
1098
- lines.push(chalk.dim(' — send anonymous feedback to the project team'))
1099
- lines.push('')
1100
-
1101
- // 📖 Status messages (if any)
1102
- if (state.featureRequestStatus === 'sending') {
1103
- lines.push(` ${chalk.yellow('⏳ Sending...')}`)
1104
- lines.push('')
1105
- } else if (state.featureRequestStatus === 'success') {
1106
- lines.push(` ${chalk.greenBright.bold('✅ Successfully sent!')} ${chalk.dim('Closing overlay in 3 seconds...')}`)
1107
- lines.push('')
1108
- lines.push(` ${chalk.dim('Thank you for your feedback! Your feature request has been sent to the project team.')}`)
1109
- lines.push('')
1110
- } else if (state.featureRequestStatus === 'error') {
1111
- lines.push(` ${chalk.red('❌ Error:')} ${chalk.yellow(state.featureRequestError || 'Failed to send')}`)
1112
- lines.push(` ${chalk.dim('Press Backspace to edit, or Esc to close')}`)
1113
- lines.push('')
1114
- } else {
1115
- lines.push(` ${chalk.dim('Type your feature request below. Press Enter to send, Esc to cancel.')}`)
1116
- lines.push(` ${chalk.dim('Your message will be sent anonymously to the project team.')}`)
1117
- lines.push('')
1118
- }
1119
-
1120
- // 📖 Input box with border
1121
- lines.push(chalk.dim(` ┌─ ${chalk.cyan('Message')} ${chalk.dim(`(${state.featureRequestBuffer.length}/500 chars)`)} ─${'─'.repeat(maxInputWidth - 22)}┐`))
1122
-
1123
- // 📖 Display input lines (or placeholder if empty)
1124
- if (displayLines.length === 0 && state.featureRequestStatus === 'idle') {
1125
- lines.push(chalk.dim(` │${' '.repeat(maxInputWidth)}│`))
1126
- lines.push(chalk.dim(` │ ${chalk.white.italic('Type your message here...')}${' '.repeat(Math.max(0, maxInputWidth - 28))}│`))
1127
- } else {
1128
- for (const line of displayLines) {
1129
- const padded = line.padEnd(maxInputWidth)
1130
- lines.push(` │ ${chalk.white(padded)} │`)
1131
- }
1132
- }
1133
-
1134
- // 📖 Fill remaining space if needed
1135
- const linesToFill = Math.max(0, maxInputLines - Math.max(displayLines.length, 1))
1136
- for (let i = 0; i < linesToFill; i++) {
1137
- lines.push(chalk.dim(` │${' '.repeat(maxInputWidth)}│`))
1138
- }
1139
-
1140
- // 📖 Cursor indicator (only when not sending/success)
1141
- if (state.featureRequestStatus === 'idle' || state.featureRequestStatus === 'error') {
1142
- // Add cursor indicator to the last line
1143
- if (lines.length > 0 && displayLines.length > 0) {
1144
- const lastLineIdx = lines.findIndex(l => l.includes('│ ') && !l.includes('Message'))
1145
- if (lastLineIdx >= 0 && lastLineIdx < lines.length) {
1146
- // Add cursor blink
1147
- const lastLine = lines[lastLineIdx]
1148
- if (lastLine.includes('│')) {
1149
- lines[lastLineIdx] = lastLine.replace(/\s+│$/, chalk.rgb(57, 255, 20).bold('▏') + ' │')
1150
- }
1151
- }
1152
- }
1153
- }
1154
-
1155
- lines.push(chalk.dim(` └${'─'.repeat(maxInputWidth + 2)}┘`))
1156
-
1157
- lines.push('')
1158
- lines.push(chalk.dim(' Enter Send • Esc Cancel • Backspace Delete'))
1159
-
1160
- // 📖 Apply overlay tint and return
1161
- const FEATURE_REQUEST_OVERLAY_BG = chalk.bgRgb(0, 0, 0) // Dark blue-ish background (RGB: 26, 26, 46)
1162
- const tintedLines = tintOverlayLines(lines, FEATURE_REQUEST_OVERLAY_BG, state.terminalCols)
1163
- const cleared = tintedLines.map(l => l + EL)
1164
- return cleared.join('\n')
1165
- }
1166
-
1167
- // ─── Bug Report overlay renderer ─────────────────────────────────────────
1168
- // 📖 renderBugReport: Draw the overlay for anonymous Discord bug reports.
1169
- // 📖 Shows an input field where users can type bug reports, then sends to Discord webhook.
1170
- function renderBugReport() {
1055
+ // ─── Feedback overlay renderer ────────────────────────────────────────────
1056
+ // 📖 renderFeedback: Draw the overlay for anonymous Discord feedback.
1057
+ // 📖 Shows an input field where users can type feedback, bug reports, or any comments.
1058
+ function renderFeedback() {
1171
1059
  const EL = '\x1b[K'
1172
1060
  const lines = []
1173
1061
 
@@ -1200,9 +1088,9 @@ export function createOverlayRenderers(state, deps) {
1200
1088
  // 📖 Branding header
1201
1089
  lines.push('')
1202
1090
  lines.push(` ${chalk.cyanBright('🚀')} ${chalk.bold.cyanBright('free-coding-models')} ${chalk.dim(`v${LOCAL_VERSION}`)}`)
1203
- lines.push(` ${chalk.bold.rgb(255, 87, 51)('🐛 Bug Report')}`)
1091
+ lines.push(` ${chalk.bold.rgb(57, 255, 20)('📝 Feedback, bugs & requests')}`)
1204
1092
  lines.push('')
1205
- lines.push(chalk.dim(' — send anonymous bug reports to the project team'))
1093
+ lines.push(chalk.dim("don't hesitate to send us feedback, bug reports, or just your feeling about the app"))
1206
1094
  lines.push('')
1207
1095
 
1208
1096
  // 📖 Status messages (if any)
@@ -1212,55 +1100,35 @@ export function createOverlayRenderers(state, deps) {
1212
1100
  } else if (state.bugReportStatus === 'success') {
1213
1101
  lines.push(` ${chalk.greenBright.bold('✅ Successfully sent!')} ${chalk.dim('Closing overlay in 3 seconds...')}`)
1214
1102
  lines.push('')
1215
- lines.push(` ${chalk.dim('Thank you for your feedback! Your bug report has been sent to the project team.')}`)
1103
+ lines.push(` ${chalk.dim('Thank you for your feedback! It has been sent to the project team.')}`)
1216
1104
  lines.push('')
1217
1105
  } else if (state.bugReportStatus === 'error') {
1218
1106
  lines.push(` ${chalk.red('❌ Error:')} ${chalk.yellow(state.bugReportError || 'Failed to send')}`)
1219
1107
  lines.push(` ${chalk.dim('Press Backspace to edit, or Esc to close')}`)
1220
1108
  lines.push('')
1221
1109
  } else {
1222
- lines.push(` ${chalk.dim('Describe the bug you encountered. Press Enter to send, Esc to cancel.')}`)
1110
+ lines.push(` ${chalk.dim('Type your feedback below. Press Enter to send, Esc to cancel.')}`)
1223
1111
  lines.push(` ${chalk.dim('Your message will be sent anonymously to the project team.')}`)
1224
1112
  lines.push('')
1225
1113
  }
1226
1114
 
1227
- // 📖 Input box with border
1228
- lines.push(chalk.dim(` ┌─ ${chalk.cyan('Bug Details')} ${chalk.dim(`(${state.bugReportBuffer.length}/500 chars)`)} ─${'─'.repeat(maxInputWidth - 24)}┐`))
1229
-
1230
- // 📖 Display input lines (or placeholder if empty)
1231
- if (displayLines.length === 0 && state.bugReportStatus === 'idle') {
1232
- lines.push(chalk.dim(` │${' '.repeat(maxInputWidth)}│`))
1233
- lines.push(chalk.dim(` │ ${chalk.white.italic('Describe what happened...')}${' '.repeat(Math.max(0, maxInputWidth - 31))}│`))
1234
- } else {
1115
+ // 📖 Simple input area – left-aligned, framed by horizontal lines
1116
+ lines.push(` ${chalk.cyan('Message')} (${state.bugReportBuffer.length}/500 chars)`)
1117
+ lines.push(` ${chalk.dim('─'.repeat(maxInputWidth))}`)
1118
+ // 📖 Input lines — left-aligned, or placeholder when empty
1119
+ if (displayLines.length > 0) {
1235
1120
  for (const line of displayLines) {
1236
- const padded = line.padEnd(maxInputWidth)
1237
- lines.push(` │ ${chalk.white(padded)} │`)
1121
+ lines.push(` ${line}`)
1238
1122
  }
1239
- }
1240
-
1241
- // 📖 Fill remaining space if needed
1242
- const linesToFill = Math.max(0, maxInputLines - Math.max(displayLines.length, 1))
1243
- for (let i = 0; i < linesToFill; i++) {
1244
- lines.push(chalk.dim(` │${' '.repeat(maxInputWidth)}│`))
1245
- }
1246
-
1247
- // 📖 Cursor indicator (only when not sending/success)
1248
- if (state.bugReportStatus === 'idle' || state.bugReportStatus === 'error') {
1249
- // Add cursor indicator to the last line
1250
- if (lines.length > 0 && displayLines.length > 0) {
1251
- const lastLineIdx = lines.findIndex(l => l.includes('│ ') && !l.includes('Bug Details'))
1252
- if (lastLineIdx >= 0 && lastLineIdx < lines.length) {
1253
- // Add cursor blink
1254
- const lastLine = lines[lastLineIdx]
1255
- if (lastLine.includes('│')) {
1256
- lines[lastLineIdx] = lastLine.replace(/\s+│$/, chalk.rgb(255, 87, 51).bold('▏') + ' │')
1257
- }
1258
- }
1123
+ // 📖 Show cursor on last line
1124
+ if (state.bugReportStatus === 'idle' || state.bugReportStatus === 'error') {
1125
+ lines[lines.length - 1] += chalk.cyanBright('▏')
1259
1126
  }
1127
+ } else {
1128
+ const placeholderBR = state.bugReportStatus === 'idle' ? chalk.white.italic('Type your message here...') : ''
1129
+ lines.push(` ${placeholderBR}${chalk.cyanBright('▏')}`)
1260
1130
  }
1261
-
1262
- lines.push(chalk.dim(` └${'─'.repeat(maxInputWidth + 2)}┘`))
1263
-
1131
+ lines.push(` ${chalk.dim('─'.repeat(maxInputWidth))}`)
1264
1132
  lines.push('')
1265
1133
  lines.push(chalk.dim(' Enter Send • Esc Cancel • Backspace Delete'))
1266
1134
 
@@ -1393,6 +1261,226 @@ export function createOverlayRenderers(state, deps) {
1393
1261
  return cleared.join('\n')
1394
1262
  }
1395
1263
 
1264
+ // ─── FCM Proxy V2 overlay renderer ──────────────────────────────────────────
1265
+ // 📖 renderProxyDaemon: Dedicated full-page overlay for FCM Proxy V2 configuration
1266
+ // 📖 and background service management. Opened from Settings → "FCM Proxy V2 settings →".
1267
+ // 📖 Contains all proxy toggles, service status/actions, explanations, and emergency kill.
1268
+ function renderProxyDaemon() {
1269
+ const EL = '\x1b[K'
1270
+ const lines = []
1271
+ const cursorLineByRow = {}
1272
+ const proxySettings = getProxySettings(state.config)
1273
+
1274
+ // 📖 Row indices — these control cursor navigation
1275
+ const ROW_PROXY_ENABLED = 0
1276
+ const ROW_PROXY_TOOL = 1
1277
+ const ROW_PROXY_SYNC = 2
1278
+ const ROW_PROXY_PORT = 3
1279
+ const ROW_PROXY_CLEANUP = 4
1280
+ const ROW_DAEMON_INSTALL = 5
1281
+ const ROW_DAEMON_RESTART = 6
1282
+ const ROW_DAEMON_STOP = 7
1283
+ const ROW_DAEMON_KILL = 8
1284
+ const ROW_DAEMON_LOGS = 9
1285
+
1286
+ const daemonStatus = state.daemonStatus || 'not-installed'
1287
+ const daemonInfo = state.daemonInfo
1288
+ const daemonIsActive = daemonStatus === 'running' || daemonStatus === 'unhealthy' || daemonStatus === 'stale'
1289
+ const daemonIsInstalled = daemonIsActive || daemonStatus === 'stopped'
1290
+
1291
+ // 📖 Compute max row — hide daemon action rows when daemon not installed
1292
+ let maxRow = ROW_DAEMON_INSTALL
1293
+ if (daemonIsInstalled) maxRow = ROW_DAEMON_LOGS
1294
+
1295
+ // 📖 Header
1296
+ lines.push(` ${chalk.cyanBright('🚀')} ${chalk.bold.cyanBright('free-coding-models')} ${chalk.dim(`v${LOCAL_VERSION}`)}`)
1297
+ lines.push(` ${chalk.bold('📡 FCM Proxy V2 Manager')}`)
1298
+ lines.push(` ${chalk.dim('— Esc back to Settings • ↑↓ navigate • Enter select')}`)
1299
+ lines.push('')
1300
+ lines.push(` ${chalk.bgRed.white.bold(' ⚠ EXPERIMENTAL ')} ${chalk.red('This feature is under active development and may not work as expected.')}`)
1301
+ lines.push(` ${chalk.red('Found a bug? Press')} ${chalk.bold.white('I')} ${chalk.red('on the main screen or join our Discord to report issues & suggest improvements.')}`)
1302
+ lines.push('')
1303
+
1304
+ // 📖 Feedback message (auto-clears after 5s)
1305
+ const msg = state.proxyDaemonMessage
1306
+ if (msg && (Date.now() - msg.ts < 5000)) {
1307
+ const msgColor = msg.type === 'success' ? chalk.greenBright : msg.type === 'warning' ? chalk.yellow : chalk.red
1308
+ lines.push(` ${msgColor(msg.msg)}`)
1309
+ lines.push('')
1310
+ }
1311
+
1312
+ // ────────────────────────────── PROXY SECTION ──────────────────────────────
1313
+ lines.push(` ${chalk.bold('🔀 Proxy Configuration')}`)
1314
+ lines.push(` ${chalk.dim(' ─────────────────────────────────────────────')}`)
1315
+ lines.push('')
1316
+ lines.push(` ${chalk.dim(' The local proxy groups all your provider API keys into a single')}`)
1317
+ lines.push(` ${chalk.dim(' endpoint. Tools like OpenCode, Claude Code, Goose, etc. connect')}`)
1318
+ lines.push(` ${chalk.dim(' to this proxy which handles key rotation, rate limiting, and failover.')}`)
1319
+ lines.push('')
1320
+
1321
+ // 📖 Resolve active tool for proxy sync (persisted or fallback to Z-mode)
1322
+ const activeProxyTool = proxySettings.activeTool || state.mode || 'opencode'
1323
+ const activeToolMeta = getToolMeta(activeProxyTool)
1324
+ const activeToolLabel = `${activeToolMeta.emoji} ${activeToolMeta.label}`
1325
+
1326
+ // 📖 Row 0: Proxy enabled toggle
1327
+ const r0b = state.proxyDaemonCursor === ROW_PROXY_ENABLED ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
1328
+ const r0val = proxySettings.enabled ? chalk.greenBright('Enabled') : chalk.dim('Disabled (opt-in)')
1329
+ const r0 = `${r0b}${chalk.bold('Proxy mode').padEnd(44)} ${r0val}`
1330
+ cursorLineByRow[ROW_PROXY_ENABLED] = lines.length
1331
+ lines.push(state.proxyDaemonCursor === ROW_PROXY_ENABLED ? chalk.bgRgb(20, 45, 60)(r0) : r0)
1332
+
1333
+ // 📖 Row 1: Active tool selector — cycles through proxy-syncable tools
1334
+ const r1b = state.proxyDaemonCursor === ROW_PROXY_TOOL ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
1335
+ const r1 = `${r1b}${chalk.bold('Active tool').padEnd(44)} ${chalk.cyanBright(activeToolLabel)} ${chalk.dim('← Enter to cycle')}`
1336
+ cursorLineByRow[ROW_PROXY_TOOL] = lines.length
1337
+ lines.push(state.proxyDaemonCursor === ROW_PROXY_TOOL ? chalk.bgRgb(20, 45, 60)(r1) : r1)
1338
+
1339
+ // 📖 Row 2: Auto-sync proxy config to active tool
1340
+ const r2b = state.proxyDaemonCursor === ROW_PROXY_SYNC ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
1341
+ const r2val = proxySettings.syncToOpenCode ? chalk.greenBright('Enabled') : chalk.dim('Disabled')
1342
+ const r2 = `${r2b}${chalk.bold(`Auto-sync proxy to ${activeToolMeta.label}`).padEnd(44)} ${r2val}`
1343
+ cursorLineByRow[ROW_PROXY_SYNC] = lines.length
1344
+ lines.push(state.proxyDaemonCursor === ROW_PROXY_SYNC ? chalk.bgRgb(20, 45, 60)(r2) : r2)
1345
+
1346
+ // 📖 Row 3: Preferred port
1347
+ const r3b = state.proxyDaemonCursor === ROW_PROXY_PORT ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
1348
+ const r3val = state.settingsProxyPortEditMode && state.proxyDaemonCursor === ROW_PROXY_PORT
1349
+ ? chalk.cyanBright(`${state.settingsProxyPortBuffer}▏`)
1350
+ : (proxySettings.preferredPort === 0 ? chalk.dim('auto (OS-assigned)') : chalk.green(String(proxySettings.preferredPort)))
1351
+ const r3 = `${r3b}${chalk.bold('Preferred proxy port').padEnd(44)} ${r3val}`
1352
+ cursorLineByRow[ROW_PROXY_PORT] = lines.length
1353
+ lines.push(state.proxyDaemonCursor === ROW_PROXY_PORT ? chalk.bgRgb(20, 45, 60)(r3) : r3)
1354
+
1355
+ // 📖 Row 4: Clean tool proxy config
1356
+ const r4b = state.proxyDaemonCursor === ROW_PROXY_CLEANUP ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
1357
+ const r4 = `${r4b}${chalk.bold(`Clean ${activeToolMeta.label} proxy config`).padEnd(44)} ${chalk.dim('Enter → removes all fcm-* entries')}`
1358
+ cursorLineByRow[ROW_PROXY_CLEANUP] = lines.length
1359
+ lines.push(state.proxyDaemonCursor === ROW_PROXY_CLEANUP ? chalk.bgRgb(45, 30, 30)(r4) : r4)
1360
+
1361
+ // ────────────────────────────── DAEMON SECTION ─────────────────────────────
1362
+ lines.push('')
1363
+ lines.push(` ${chalk.bold('📡 FCM Proxy V2 Background Service')}`)
1364
+ lines.push(` ${chalk.dim(' ─────────────────────────────────────────────')}`)
1365
+ lines.push('')
1366
+ lines.push(` ${chalk.dim(' The background service keeps FCM Proxy V2 running 24/7 — even when')}`)
1367
+ lines.push(` ${chalk.dim(' the TUI is closed or after a reboot. Claude Code, Gemini CLI, and')}`)
1368
+ lines.push(` ${chalk.dim(' all tools stay connected at all times.')}`)
1369
+ lines.push('')
1370
+
1371
+ // 📖 Status display
1372
+ let daemonStatusLine = ` ${chalk.bold(' Status:')} `
1373
+ if (daemonStatus === 'running') {
1374
+ daemonStatusLine += chalk.greenBright('● Running')
1375
+ if (daemonInfo) daemonStatusLine += chalk.dim(` — PID ${daemonInfo.pid} • Port ${daemonInfo.port} • ${daemonInfo.accountCount || '?'} accounts • ${daemonInfo.modelCount || '?'} models`)
1376
+ } else if (daemonStatus === 'stopped') {
1377
+ daemonStatusLine += chalk.yellow('○ Stopped') + chalk.dim(' — service installed but not running')
1378
+ } else if (daemonStatus === 'stale') {
1379
+ daemonStatusLine += chalk.red('⚠ Stale') + chalk.dim(' — service crashed, PID no longer alive')
1380
+ } else if (daemonStatus === 'unhealthy') {
1381
+ daemonStatusLine += chalk.red('⚠ Unhealthy') + chalk.dim(' — PID alive but health check failed')
1382
+ } else {
1383
+ daemonStatusLine += chalk.dim('○ Not installed')
1384
+ }
1385
+ lines.push(daemonStatusLine)
1386
+
1387
+ // 📖 Version mismatch warning
1388
+ if (daemonInfo?.version && daemonInfo.version !== LOCAL_VERSION) {
1389
+ lines.push(` ${chalk.yellow(` ⚠ Version mismatch: service v${daemonInfo.version} vs FCM v${LOCAL_VERSION}`)}`)
1390
+ lines.push(` ${chalk.dim(' Restart or reinstall the service to apply the update.')}`)
1391
+ }
1392
+
1393
+ // 📖 Uptime
1394
+ if (daemonStatus === 'running' && daemonInfo?.startedAt) {
1395
+ const upSec = Math.floor((Date.now() - new Date(daemonInfo.startedAt).getTime()) / 1000)
1396
+ const upMin = Math.floor(upSec / 60)
1397
+ const upHr = Math.floor(upMin / 60)
1398
+ const uptimeStr = upHr > 0 ? `${upHr}h ${upMin % 60}m` : upMin > 0 ? `${upMin}m ${upSec % 60}s` : `${upSec}s`
1399
+ lines.push(` ${chalk.dim(` Uptime: ${uptimeStr}`)}`)
1400
+ }
1401
+
1402
+ lines.push('')
1403
+
1404
+ // 📖 Row 5: Install / Uninstall
1405
+ const d0b = state.proxyDaemonCursor === ROW_DAEMON_INSTALL ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
1406
+ const d0label = daemonIsInstalled ? 'Uninstall service' : 'Install background service'
1407
+ const d0hint = daemonIsInstalled
1408
+ ? chalk.dim('Enter → stop service + remove config')
1409
+ : chalk.dim('Enter → install as OS service (launchd/systemd)')
1410
+ const d0 = `${d0b}${chalk.bold(d0label).padEnd(44)} ${d0hint}`
1411
+ cursorLineByRow[ROW_DAEMON_INSTALL] = lines.length
1412
+ lines.push(state.proxyDaemonCursor === ROW_DAEMON_INSTALL ? chalk.bgRgb(daemonIsInstalled ? 45 : 20, daemonIsInstalled ? 30 : 45, daemonIsInstalled ? 30 : 40)(d0) : d0)
1413
+
1414
+ // 📖 Rows 6-9 only shown when service is installed
1415
+ if (daemonIsInstalled) {
1416
+ // 📖 Row 6: Restart
1417
+ const d1b = state.proxyDaemonCursor === ROW_DAEMON_RESTART ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
1418
+ const d1 = `${d1b}${chalk.bold('Restart service').padEnd(44)} ${chalk.dim('Enter → stop + start via OS service manager')}`
1419
+ cursorLineByRow[ROW_DAEMON_RESTART] = lines.length
1420
+ lines.push(state.proxyDaemonCursor === ROW_DAEMON_RESTART ? chalk.bgRgb(20, 45, 60)(d1) : d1)
1421
+
1422
+ // 📖 Row 7: Stop (SIGTERM)
1423
+ const d2b = state.proxyDaemonCursor === ROW_DAEMON_STOP ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
1424
+ const d2warn = chalk.dim(' (service may auto-restart)')
1425
+ const d2 = `${d2b}${chalk.bold('Stop service').padEnd(44)} ${chalk.dim('Enter → graceful shutdown (SIGTERM)')}${d2warn}`
1426
+ cursorLineByRow[ROW_DAEMON_STOP] = lines.length
1427
+ lines.push(state.proxyDaemonCursor === ROW_DAEMON_STOP ? chalk.bgRgb(45, 40, 20)(d2) : d2)
1428
+
1429
+ // 📖 Row 8: Force kill (SIGKILL) — emergency
1430
+ const d3b = state.proxyDaemonCursor === ROW_DAEMON_KILL ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
1431
+ const d3 = `${d3b}${chalk.bold.red('Force kill service').padEnd(44)} ${chalk.dim('Enter → SIGKILL — emergency only')}`
1432
+ cursorLineByRow[ROW_DAEMON_KILL] = lines.length
1433
+ lines.push(state.proxyDaemonCursor === ROW_DAEMON_KILL ? chalk.bgRgb(60, 20, 20)(d3) : d3)
1434
+
1435
+ // 📖 Row 9: View logs
1436
+ const d4b = state.proxyDaemonCursor === ROW_DAEMON_LOGS ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
1437
+ const d4 = `${d4b}${chalk.bold('View service logs').padEnd(44)} ${chalk.dim('Enter → show last 50 log lines')}`
1438
+ cursorLineByRow[ROW_DAEMON_LOGS] = lines.length
1439
+ lines.push(state.proxyDaemonCursor === ROW_DAEMON_LOGS ? chalk.bgRgb(30, 30, 50)(d4) : d4)
1440
+ }
1441
+
1442
+ // ────────────────────────────── INFO SECTION ───────────────────────────────
1443
+ lines.push('')
1444
+ lines.push(` ${chalk.bold('ℹ How it works')}`)
1445
+ lines.push(` ${chalk.dim(' ─────────────────────────────────────────────')}`)
1446
+ lines.push('')
1447
+ lines.push(` ${chalk.dim(' 📖 The proxy starts a local HTTP server on 127.0.0.1 (localhost only).')}`)
1448
+ lines.push(` ${chalk.dim(' 📖 External tools connect to it as if it were OpenAI/Anthropic.')}`)
1449
+ lines.push(` ${chalk.dim(' 📖 The proxy rotates between your API keys across all providers.')}`)
1450
+ lines.push('')
1451
+ lines.push(` ${chalk.dim(' 📖 The background service adds persistence: install it once, and the proxy')}`)
1452
+ lines.push(` ${chalk.dim(' 📖 starts automatically at login and survives reboots.')}`)
1453
+ lines.push('')
1454
+ lines.push(` ${chalk.dim(' 📖 Claude Code support: FCM Proxy V2 translates Anthropic wire format')}`)
1455
+ lines.push(` ${chalk.dim(' 📖 (POST /v1/messages) to OpenAI format for upstream providers.')}`)
1456
+ lines.push('')
1457
+ if (process.platform === 'darwin') {
1458
+ lines.push(` ${chalk.dim(' 📦 macOS: launchd LaunchAgent at ~/Library/LaunchAgents/com.fcm.proxy.plist')}`)
1459
+ } else if (process.platform === 'linux') {
1460
+ lines.push(` ${chalk.dim(' 📦 Linux: systemd user service at ~/.config/systemd/user/fcm-proxy.service')}`)
1461
+ } else {
1462
+ lines.push(` ${chalk.dim(' ⚠ Windows: background service not supported — use in-process proxy (starts with TUI)')}`)
1463
+ }
1464
+ lines.push('')
1465
+
1466
+ // 📖 Clamp cursor
1467
+ if (state.proxyDaemonCursor > maxRow) state.proxyDaemonCursor = maxRow
1468
+
1469
+ // 📖 Scrolling and tinting
1470
+ const PROXY_DAEMON_BG = chalk.bgRgb(15, 25, 45)
1471
+ const targetLine = cursorLineByRow[state.proxyDaemonCursor] ?? 0
1472
+ state.proxyDaemonScrollOffset = keepOverlayTargetVisible(
1473
+ state.proxyDaemonScrollOffset,
1474
+ targetLine,
1475
+ lines.length,
1476
+ state.terminalRows
1477
+ )
1478
+ const { visible, offset } = sliceOverlayLines(lines, state.proxyDaemonScrollOffset, state.terminalRows)
1479
+ state.proxyDaemonScrollOffset = offset
1480
+ const tintedLines = tintOverlayLines(visible, PROXY_DAEMON_BG, state.terminalCols)
1481
+ return tintedLines.map(l => l + EL).join('\n')
1482
+ }
1483
+
1396
1484
  // 📖 stopRecommendAnalysis: cleanup timers if user cancels during analysis
1397
1485
  function stopRecommendAnalysis() {
1398
1486
  if (state.recommendAnalysisTimer) { clearInterval(state.recommendAnalysisTimer); state.recommendAnalysisTimer = null }
@@ -1401,12 +1489,12 @@ export function createOverlayRenderers(state, deps) {
1401
1489
 
1402
1490
  return {
1403
1491
  renderSettings,
1492
+ renderProxyDaemon,
1404
1493
  renderInstallEndpoints,
1405
1494
  renderHelp,
1406
1495
  renderLog,
1407
1496
  renderRecommend,
1408
- renderFeatureRequest,
1409
- renderBugReport,
1497
+ renderFeedback,
1410
1498
  renderChangelog,
1411
1499
  startRecommendAnalysis,
1412
1500
  stopRecommendAnalysis,