free-coding-models 0.2.17 → 0.3.1

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 current-tool 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 → Auto-sync current tool → Port → Cleanup → Install/Restart/Stop/Kill/Logs
16
+ * 📖 Feedback overlay (I key) combines feature requests + bug reports in one left-aligned input
17
+ *
14
18
  * → Functions:
15
19
  * - `createOverlayRenderers` — returns renderer + analysis helpers
16
20
  *
17
21
  * @exports { createOverlayRenderers }
22
+ * @see ./proxy-sync.js — resolveProxySyncToolMode powers current-tool proxy sync hints
23
+ * @see ./key-handler.js — handles keypresses for all overlay interactions
18
24
  */
19
25
 
20
26
  import { loadChangelog } from './changelog-loader.js'
27
+ import { buildCliHelpLines } from './cli-help.js'
28
+ import { resolveProxySyncToolMode } 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 → compatible 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)')}`)
@@ -656,29 +651,7 @@ export function createOverlayRenderers(state, deps) {
656
651
  lines.push(` ${chalk.yellow('U')} Check updates manually`)
657
652
  lines.push(` ${chalk.yellow('Esc')} Close settings`)
658
653
  lines.push('')
659
- lines.push(` ${chalk.bold('CLI Flags')}`)
660
- lines.push(` ${chalk.dim('Usage: free-coding-models [options]')}`)
661
- lines.push(` ${chalk.cyan('free-coding-models --opencode')} ${chalk.dim('OpenCode CLI mode')}`)
662
- lines.push(` ${chalk.cyan('free-coding-models --opencode-desktop')} ${chalk.dim('OpenCode Desktop mode')}`)
663
- lines.push(` ${chalk.cyan('free-coding-models --openclaw')} ${chalk.dim('OpenClaw mode')}`)
664
- lines.push(` ${chalk.cyan('free-coding-models --crush')} ${chalk.dim('Crush mode')}`)
665
- lines.push(` ${chalk.cyan('free-coding-models --goose')} ${chalk.dim('Goose mode')}`)
666
- lines.push(` ${chalk.cyan('free-coding-models --pi')} ${chalk.dim('Pi mode')}`)
667
- lines.push(` ${chalk.cyan('free-coding-models --aider')} ${chalk.dim('Aider mode')}`)
668
- lines.push(` ${chalk.cyan('free-coding-models --claude-code')} ${chalk.dim('Claude Code mode')}`)
669
- lines.push(` ${chalk.cyan('free-coding-models --codex')} ${chalk.dim('Codex CLI mode')}`)
670
- lines.push(` ${chalk.cyan('free-coding-models --gemini')} ${chalk.dim('Gemini CLI mode')}`)
671
- lines.push(` ${chalk.cyan('free-coding-models --qwen')} ${chalk.dim('Qwen Code mode')}`)
672
- lines.push(` ${chalk.cyan('free-coding-models --openhands')} ${chalk.dim('OpenHands mode')}`)
673
- lines.push(` ${chalk.cyan('free-coding-models --amp')} ${chalk.dim('Amp mode')}`)
674
- lines.push(` ${chalk.cyan('free-coding-models --best')} ${chalk.dim('Only top tiers (A+, S, S+)')}`)
675
- lines.push(` ${chalk.cyan('free-coding-models --fiable')} ${chalk.dim('10s reliability analysis')}`)
676
- lines.push(` ${chalk.cyan('free-coding-models --tier S|A|B|C')} ${chalk.dim('Filter by tier letter')}`)
677
- lines.push(` ${chalk.cyan('free-coding-models --no-telemetry')} ${chalk.dim('Disable telemetry for this run')}`)
678
- lines.push(` ${chalk.cyan('free-coding-models --recommend')} ${chalk.dim('Auto-open Smart Recommend on start')}`)
679
- lines.push(` ${chalk.cyan('free-coding-models --profile <name>')} ${chalk.dim('Load a saved config profile')}`)
680
- lines.push(` ${chalk.cyan('free-coding-models --clean-proxy')} ${chalk.dim('Remove persisted fcm-proxy config from OpenCode')}`)
681
- lines.push(` ${chalk.dim('Flags can be combined: --openclaw --tier S')}`)
654
+ lines.push(...buildCliHelpLines({ chalk, indent: ' ', title: 'CLI Flags' }))
682
655
  lines.push('')
683
656
  // 📖 Help overlay can be longer than viewport, so keep a dedicated scroll offset.
684
657
  const { visible, offset } = sliceOverlayLines(lines, state.helpScrollOffset, state.terminalRows)
@@ -1057,117 +1030,10 @@ export function createOverlayRenderers(state, deps) {
1057
1030
  }, PING_RATE)
1058
1031
  }
1059
1032
 
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() {
1033
+ // ─── Feedback overlay renderer ────────────────────────────────────────────
1034
+ // 📖 renderFeedback: Draw the overlay for anonymous Discord feedback.
1035
+ // 📖 Shows an input field where users can type feedback, bug reports, or any comments.
1036
+ function renderFeedback() {
1171
1037
  const EL = '\x1b[K'
1172
1038
  const lines = []
1173
1039
 
@@ -1200,9 +1066,9 @@ export function createOverlayRenderers(state, deps) {
1200
1066
  // 📖 Branding header
1201
1067
  lines.push('')
1202
1068
  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')}`)
1069
+ lines.push(` ${chalk.bold.rgb(57, 255, 20)('📝 Feedback, bugs & requests')}`)
1204
1070
  lines.push('')
1205
- lines.push(chalk.dim(' — send anonymous bug reports to the project team'))
1071
+ lines.push(chalk.dim("don't hesitate to send us feedback, bug reports, or just your feeling about the app"))
1206
1072
  lines.push('')
1207
1073
 
1208
1074
  // 📖 Status messages (if any)
@@ -1212,55 +1078,35 @@ export function createOverlayRenderers(state, deps) {
1212
1078
  } else if (state.bugReportStatus === 'success') {
1213
1079
  lines.push(` ${chalk.greenBright.bold('✅ Successfully sent!')} ${chalk.dim('Closing overlay in 3 seconds...')}`)
1214
1080
  lines.push('')
1215
- lines.push(` ${chalk.dim('Thank you for your feedback! Your bug report has been sent to the project team.')}`)
1081
+ lines.push(` ${chalk.dim('Thank you for your feedback! It has been sent to the project team.')}`)
1216
1082
  lines.push('')
1217
1083
  } else if (state.bugReportStatus === 'error') {
1218
1084
  lines.push(` ${chalk.red('❌ Error:')} ${chalk.yellow(state.bugReportError || 'Failed to send')}`)
1219
1085
  lines.push(` ${chalk.dim('Press Backspace to edit, or Esc to close')}`)
1220
1086
  lines.push('')
1221
1087
  } else {
1222
- lines.push(` ${chalk.dim('Describe the bug you encountered. Press Enter to send, Esc to cancel.')}`)
1088
+ lines.push(` ${chalk.dim('Type your feedback below. Press Enter to send, Esc to cancel.')}`)
1223
1089
  lines.push(` ${chalk.dim('Your message will be sent anonymously to the project team.')}`)
1224
1090
  lines.push('')
1225
1091
  }
1226
1092
 
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 {
1093
+ // 📖 Simple input area – left-aligned, framed by horizontal lines
1094
+ lines.push(` ${chalk.cyan('Message')} (${state.bugReportBuffer.length}/500 chars)`)
1095
+ lines.push(` ${chalk.dim('─'.repeat(maxInputWidth))}`)
1096
+ // 📖 Input lines — left-aligned, or placeholder when empty
1097
+ if (displayLines.length > 0) {
1235
1098
  for (const line of displayLines) {
1236
- const padded = line.padEnd(maxInputWidth)
1237
- lines.push(` │ ${chalk.white(padded)} │`)
1099
+ lines.push(` ${line}`)
1238
1100
  }
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
- }
1101
+ // 📖 Show cursor on last line
1102
+ if (state.bugReportStatus === 'idle' || state.bugReportStatus === 'error') {
1103
+ lines[lines.length - 1] += chalk.cyanBright('▏')
1259
1104
  }
1105
+ } else {
1106
+ const placeholderBR = state.bugReportStatus === 'idle' ? chalk.white.italic('Type your message here...') : ''
1107
+ lines.push(` ${placeholderBR}${chalk.cyanBright('▏')}`)
1260
1108
  }
1261
-
1262
- lines.push(chalk.dim(` └${'─'.repeat(maxInputWidth + 2)}┘`))
1263
-
1109
+ lines.push(` ${chalk.dim('─'.repeat(maxInputWidth))}`)
1264
1110
  lines.push('')
1265
1111
  lines.push(chalk.dim(' Enter Send • Esc Cancel • Backspace Delete'))
1266
1112
 
@@ -1393,6 +1239,235 @@ export function createOverlayRenderers(state, deps) {
1393
1239
  return cleared.join('\n')
1394
1240
  }
1395
1241
 
1242
+ // ─── FCM Proxy V2 overlay renderer ──────────────────────────────────────────
1243
+ // 📖 renderProxyDaemon: Dedicated full-page overlay for FCM Proxy V2 configuration
1244
+ // 📖 and background service management. Opened from Settings → "FCM Proxy V2 settings →".
1245
+ // 📖 Contains all proxy toggles, service status/actions, explanations, and emergency kill.
1246
+ function renderProxyDaemon() {
1247
+ const EL = '\x1b[K'
1248
+ const lines = []
1249
+ const cursorLineByRow = {}
1250
+ const proxySettings = getProxySettings(state.config)
1251
+
1252
+ // 📖 Row indices — these control cursor navigation
1253
+ const ROW_PROXY_ENABLED = 0
1254
+ const ROW_PROXY_SYNC = 1
1255
+ const ROW_PROXY_PORT = 2
1256
+ const ROW_PROXY_CLEANUP = 3
1257
+ const ROW_DAEMON_INSTALL = 4
1258
+ const ROW_DAEMON_RESTART = 5
1259
+ const ROW_DAEMON_STOP = 6
1260
+ const ROW_DAEMON_KILL = 7
1261
+ const ROW_DAEMON_LOGS = 8
1262
+
1263
+ const daemonStatus = state.daemonStatus || 'not-installed'
1264
+ const daemonInfo = state.daemonInfo
1265
+ const daemonIsActive = daemonStatus === 'running' || daemonStatus === 'unhealthy' || daemonStatus === 'stale'
1266
+ const daemonIsInstalled = daemonIsActive || daemonStatus === 'stopped'
1267
+
1268
+ // 📖 Compute max row — hide daemon action rows when daemon not installed
1269
+ let maxRow = ROW_DAEMON_INSTALL
1270
+ if (daemonIsInstalled) maxRow = ROW_DAEMON_LOGS
1271
+
1272
+ // 📖 Header
1273
+ lines.push(` ${chalk.cyanBright('🚀')} ${chalk.bold.cyanBright('free-coding-models')} ${chalk.dim(`v${LOCAL_VERSION}`)}`)
1274
+ lines.push(` ${chalk.bold('📡 FCM Proxy V2 Manager')}`)
1275
+ lines.push(` ${chalk.dim('— Esc back to Settings • ↑↓ navigate • Enter select')}`)
1276
+ lines.push('')
1277
+ lines.push(` ${chalk.bgRed.white.bold(' ⚠ EXPERIMENTAL ')} ${chalk.red('This feature is under active development and may not work as expected.')}`)
1278
+ 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.')}`)
1279
+ lines.push('')
1280
+
1281
+ // 📖 Feedback message (auto-clears after 5s)
1282
+ const msg = state.proxyDaemonMessage
1283
+ if (msg && (Date.now() - msg.ts < 5000)) {
1284
+ const msgColor = msg.type === 'success' ? chalk.greenBright : msg.type === 'warning' ? chalk.yellow : chalk.red
1285
+ lines.push(` ${msgColor(msg.msg)}`)
1286
+ lines.push('')
1287
+ }
1288
+
1289
+ // ────────────────────────────── PROXY SECTION ──────────────────────────────
1290
+ lines.push(` ${chalk.bold('🔀 Proxy Configuration')}`)
1291
+ lines.push(` ${chalk.dim(' ─────────────────────────────────────────────')}`)
1292
+ lines.push('')
1293
+ lines.push(` ${chalk.dim(' The local proxy groups all your provider API keys into a single')}`)
1294
+ lines.push(` ${chalk.dim(' endpoint. Tools like OpenCode, Claude Code, Goose, etc. connect')}`)
1295
+ lines.push(` ${chalk.dim(' to this proxy which handles key rotation, rate limiting, and failover.')}`)
1296
+ lines.push('')
1297
+
1298
+ // 📖 Proxy sync now always follows the currently selected Z-mode when supported.
1299
+ const currentToolMode = state.mode || 'opencode'
1300
+ const currentToolMeta = getToolMeta(currentToolMode)
1301
+ const currentToolLabel = `${currentToolMeta.emoji} ${currentToolMeta.label}`
1302
+ const proxySyncTool = resolveProxySyncToolMode(currentToolMode)
1303
+ const proxySyncHint = proxySyncTool
1304
+ ? chalk.dim(` Current tool: ${currentToolLabel}`)
1305
+ : chalk.yellow(` Current tool: ${currentToolLabel} (launcher-only, no persisted proxy config)`)
1306
+ lines.push(proxySyncHint)
1307
+ lines.push('')
1308
+
1309
+ // 📖 Row 0: Proxy enabled toggle
1310
+ const r0b = state.proxyDaemonCursor === ROW_PROXY_ENABLED ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
1311
+ const r0val = proxySettings.enabled ? chalk.greenBright('Enabled') : chalk.dim('Disabled (opt-in)')
1312
+ const r0 = `${r0b}${chalk.bold('Proxy mode').padEnd(44)} ${r0val}`
1313
+ cursorLineByRow[ROW_PROXY_ENABLED] = lines.length
1314
+ lines.push(state.proxyDaemonCursor === ROW_PROXY_ENABLED ? chalk.bgRgb(20, 45, 60)(r0) : r0)
1315
+
1316
+ // 📖 Row 1: Auto-sync proxy config to the current tool when that tool supports persisted sync.
1317
+ const r2b = state.proxyDaemonCursor === ROW_PROXY_SYNC ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
1318
+ const r2val = proxySettings.syncToOpenCode ? chalk.greenBright('Enabled') : chalk.dim('Disabled')
1319
+ const r2label = proxySyncTool
1320
+ ? `Auto-sync proxy to ${currentToolMeta.label}`
1321
+ : 'Auto-sync proxy to current tool'
1322
+ const r2note = proxySyncTool ? '' : ` ${chalk.dim('(unavailable for this mode)')}`
1323
+ const r2 = `${r2b}${chalk.bold(r2label).padEnd(44)} ${r2val}${r2note}`
1324
+ cursorLineByRow[ROW_PROXY_SYNC] = lines.length
1325
+ lines.push(state.proxyDaemonCursor === ROW_PROXY_SYNC ? chalk.bgRgb(20, 45, 60)(r2) : r2)
1326
+
1327
+ // 📖 Row 2: Preferred port
1328
+ const r3b = state.proxyDaemonCursor === ROW_PROXY_PORT ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
1329
+ const r3val = state.settingsProxyPortEditMode && state.proxyDaemonCursor === ROW_PROXY_PORT
1330
+ ? chalk.cyanBright(`${state.settingsProxyPortBuffer}▏`)
1331
+ : (proxySettings.preferredPort === 0 ? chalk.dim('auto (OS-assigned)') : chalk.green(String(proxySettings.preferredPort)))
1332
+ const r3 = `${r3b}${chalk.bold('Preferred proxy port').padEnd(44)} ${r3val}`
1333
+ cursorLineByRow[ROW_PROXY_PORT] = lines.length
1334
+ lines.push(state.proxyDaemonCursor === ROW_PROXY_PORT ? chalk.bgRgb(20, 45, 60)(r3) : r3)
1335
+
1336
+ // 📖 Row 3: Clean current tool proxy config
1337
+ const r4b = state.proxyDaemonCursor === ROW_PROXY_CLEANUP ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
1338
+ const r4title = proxySyncTool
1339
+ ? `Clean ${currentToolMeta.label} proxy config`
1340
+ : `Clean ${currentToolMeta.label} proxy config`
1341
+ const r4hint = proxySyncTool
1342
+ ? chalk.dim('Enter → removes all fcm-* entries')
1343
+ : chalk.dim('Unavailable for this mode')
1344
+ const r4 = `${r4b}${chalk.bold(r4title).padEnd(44)} ${r4hint}`
1345
+ cursorLineByRow[ROW_PROXY_CLEANUP] = lines.length
1346
+ lines.push(state.proxyDaemonCursor === ROW_PROXY_CLEANUP ? chalk.bgRgb(45, 30, 30)(r4) : r4)
1347
+
1348
+ // ────────────────────────────── DAEMON SECTION ─────────────────────────────
1349
+ lines.push('')
1350
+ lines.push(` ${chalk.bold('📡 FCM Proxy V2 Background Service')}`)
1351
+ lines.push(` ${chalk.dim(' ─────────────────────────────────────────────')}`)
1352
+ lines.push('')
1353
+ lines.push(` ${chalk.dim(' The background service keeps FCM Proxy V2 running 24/7 — even when')}`)
1354
+ lines.push(` ${chalk.dim(' the TUI is closed or after a reboot. Claude Code, Gemini CLI, and')}`)
1355
+ lines.push(` ${chalk.dim(' all tools stay connected at all times.')}`)
1356
+ lines.push('')
1357
+
1358
+ // 📖 Status display
1359
+ let daemonStatusLine = ` ${chalk.bold(' Status:')} `
1360
+ if (daemonStatus === 'running') {
1361
+ daemonStatusLine += chalk.greenBright('● Running')
1362
+ if (daemonInfo) daemonStatusLine += chalk.dim(` — PID ${daemonInfo.pid} • Port ${daemonInfo.port} • ${daemonInfo.accountCount || '?'} accounts • ${daemonInfo.modelCount || '?'} models`)
1363
+ } else if (daemonStatus === 'stopped') {
1364
+ daemonStatusLine += chalk.yellow('○ Stopped') + chalk.dim(' — service installed but not running')
1365
+ } else if (daemonStatus === 'stale') {
1366
+ daemonStatusLine += chalk.red('⚠ Stale') + chalk.dim(' — service crashed, PID no longer alive')
1367
+ } else if (daemonStatus === 'unhealthy') {
1368
+ daemonStatusLine += chalk.red('⚠ Unhealthy') + chalk.dim(' — PID alive but health check failed')
1369
+ } else {
1370
+ daemonStatusLine += chalk.dim('○ Not installed')
1371
+ }
1372
+ lines.push(daemonStatusLine)
1373
+
1374
+ // 📖 Version mismatch warning
1375
+ if (daemonInfo?.version && daemonInfo.version !== LOCAL_VERSION) {
1376
+ lines.push(` ${chalk.yellow(` ⚠ Version mismatch: service v${daemonInfo.version} vs FCM v${LOCAL_VERSION}`)}`)
1377
+ lines.push(` ${chalk.dim(' Restart or reinstall the service to apply the update.')}`)
1378
+ }
1379
+
1380
+ // 📖 Uptime
1381
+ if (daemonStatus === 'running' && daemonInfo?.startedAt) {
1382
+ const upSec = Math.floor((Date.now() - new Date(daemonInfo.startedAt).getTime()) / 1000)
1383
+ const upMin = Math.floor(upSec / 60)
1384
+ const upHr = Math.floor(upMin / 60)
1385
+ const uptimeStr = upHr > 0 ? `${upHr}h ${upMin % 60}m` : upMin > 0 ? `${upMin}m ${upSec % 60}s` : `${upSec}s`
1386
+ lines.push(` ${chalk.dim(` Uptime: ${uptimeStr}`)}`)
1387
+ }
1388
+
1389
+ lines.push('')
1390
+
1391
+ // 📖 Row 5: Install / Uninstall
1392
+ const d0b = state.proxyDaemonCursor === ROW_DAEMON_INSTALL ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
1393
+ const d0label = daemonIsInstalled ? 'Uninstall service' : 'Install background service'
1394
+ const d0hint = daemonIsInstalled
1395
+ ? chalk.dim('Enter → stop service + remove config')
1396
+ : chalk.dim('Enter → install as OS service (launchd/systemd)')
1397
+ const d0 = `${d0b}${chalk.bold(d0label).padEnd(44)} ${d0hint}`
1398
+ cursorLineByRow[ROW_DAEMON_INSTALL] = lines.length
1399
+ lines.push(state.proxyDaemonCursor === ROW_DAEMON_INSTALL ? chalk.bgRgb(daemonIsInstalled ? 45 : 20, daemonIsInstalled ? 30 : 45, daemonIsInstalled ? 30 : 40)(d0) : d0)
1400
+
1401
+ // 📖 Rows 6-9 only shown when service is installed
1402
+ if (daemonIsInstalled) {
1403
+ // 📖 Row 6: Restart
1404
+ const d1b = state.proxyDaemonCursor === ROW_DAEMON_RESTART ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
1405
+ const d1 = `${d1b}${chalk.bold('Restart service').padEnd(44)} ${chalk.dim('Enter → stop + start via OS service manager')}`
1406
+ cursorLineByRow[ROW_DAEMON_RESTART] = lines.length
1407
+ lines.push(state.proxyDaemonCursor === ROW_DAEMON_RESTART ? chalk.bgRgb(20, 45, 60)(d1) : d1)
1408
+
1409
+ // 📖 Row 7: Stop (SIGTERM)
1410
+ const d2b = state.proxyDaemonCursor === ROW_DAEMON_STOP ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
1411
+ const d2warn = chalk.dim(' (service may auto-restart)')
1412
+ const d2 = `${d2b}${chalk.bold('Stop service').padEnd(44)} ${chalk.dim('Enter → graceful shutdown (SIGTERM)')}${d2warn}`
1413
+ cursorLineByRow[ROW_DAEMON_STOP] = lines.length
1414
+ lines.push(state.proxyDaemonCursor === ROW_DAEMON_STOP ? chalk.bgRgb(45, 40, 20)(d2) : d2)
1415
+
1416
+ // 📖 Row 8: Force kill (SIGKILL) — emergency
1417
+ const d3b = state.proxyDaemonCursor === ROW_DAEMON_KILL ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
1418
+ const d3 = `${d3b}${chalk.bold.red('Force kill service').padEnd(44)} ${chalk.dim('Enter → SIGKILL — emergency only')}`
1419
+ cursorLineByRow[ROW_DAEMON_KILL] = lines.length
1420
+ lines.push(state.proxyDaemonCursor === ROW_DAEMON_KILL ? chalk.bgRgb(60, 20, 20)(d3) : d3)
1421
+
1422
+ // 📖 Row 9: View logs
1423
+ const d4b = state.proxyDaemonCursor === ROW_DAEMON_LOGS ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
1424
+ const d4 = `${d4b}${chalk.bold('View service logs').padEnd(44)} ${chalk.dim('Enter → show last 50 log lines')}`
1425
+ cursorLineByRow[ROW_DAEMON_LOGS] = lines.length
1426
+ lines.push(state.proxyDaemonCursor === ROW_DAEMON_LOGS ? chalk.bgRgb(30, 30, 50)(d4) : d4)
1427
+ }
1428
+
1429
+ // ────────────────────────────── INFO SECTION ───────────────────────────────
1430
+ lines.push('')
1431
+ lines.push(` ${chalk.bold('ℹ How it works')}`)
1432
+ lines.push(` ${chalk.dim(' ─────────────────────────────────────────────')}`)
1433
+ lines.push('')
1434
+ lines.push(` ${chalk.dim(' 📖 The proxy starts a local HTTP server on 127.0.0.1 (localhost only).')}`)
1435
+ lines.push(` ${chalk.dim(' 📖 External tools connect to it as if it were OpenAI/Anthropic.')}`)
1436
+ lines.push(` ${chalk.dim(' 📖 The proxy rotates between your API keys across all providers.')}`)
1437
+ lines.push('')
1438
+ lines.push(` ${chalk.dim(' 📖 The background service adds persistence: install it once, and the proxy')}`)
1439
+ lines.push(` ${chalk.dim(' 📖 starts automatically at login and survives reboots.')}`)
1440
+ lines.push('')
1441
+ lines.push(` ${chalk.dim(' 📖 Claude Code support: FCM Proxy V2 translates Anthropic wire format')}`)
1442
+ lines.push(` ${chalk.dim(' 📖 (POST /v1/messages) to OpenAI format for upstream providers.')}`)
1443
+ lines.push('')
1444
+ if (process.platform === 'darwin') {
1445
+ lines.push(` ${chalk.dim(' 📦 macOS: launchd LaunchAgent at ~/Library/LaunchAgents/com.fcm.proxy.plist')}`)
1446
+ } else if (process.platform === 'linux') {
1447
+ lines.push(` ${chalk.dim(' 📦 Linux: systemd user service at ~/.config/systemd/user/fcm-proxy.service')}`)
1448
+ } else {
1449
+ lines.push(` ${chalk.dim(' ⚠ Windows: background service not supported — use in-process proxy (starts with TUI)')}`)
1450
+ }
1451
+ lines.push('')
1452
+
1453
+ // 📖 Clamp cursor
1454
+ if (state.proxyDaemonCursor > maxRow) state.proxyDaemonCursor = maxRow
1455
+
1456
+ // 📖 Scrolling and tinting
1457
+ const PROXY_DAEMON_BG = chalk.bgRgb(15, 25, 45)
1458
+ const targetLine = cursorLineByRow[state.proxyDaemonCursor] ?? 0
1459
+ state.proxyDaemonScrollOffset = keepOverlayTargetVisible(
1460
+ state.proxyDaemonScrollOffset,
1461
+ targetLine,
1462
+ lines.length,
1463
+ state.terminalRows
1464
+ )
1465
+ const { visible, offset } = sliceOverlayLines(lines, state.proxyDaemonScrollOffset, state.terminalRows)
1466
+ state.proxyDaemonScrollOffset = offset
1467
+ const tintedLines = tintOverlayLines(visible, PROXY_DAEMON_BG, state.terminalCols)
1468
+ return tintedLines.map(l => l + EL).join('\n')
1469
+ }
1470
+
1396
1471
  // 📖 stopRecommendAnalysis: cleanup timers if user cancels during analysis
1397
1472
  function stopRecommendAnalysis() {
1398
1473
  if (state.recommendAnalysisTimer) { clearInterval(state.recommendAnalysisTimer); state.recommendAnalysisTimer = null }
@@ -1401,12 +1476,12 @@ export function createOverlayRenderers(state, deps) {
1401
1476
 
1402
1477
  return {
1403
1478
  renderSettings,
1479
+ renderProxyDaemon,
1404
1480
  renderInstallEndpoints,
1405
1481
  renderHelp,
1406
1482
  renderLog,
1407
1483
  renderRecommend,
1408
- renderFeatureRequest,
1409
- renderBugReport,
1484
+ renderFeedback,
1410
1485
  renderChangelog,
1411
1486
  startRecommendAnalysis,
1412
1487
  stopRecommendAnalysis,