free-coding-models 0.1.82 → 0.1.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,239 @@
1
+ /**
2
+ * @file render-helpers.js
3
+ * @description Rendering utility functions for TUI display and layout.
4
+ *
5
+ * @details
6
+ * This module provides helper functions for rendering the various UI elements:
7
+ * - String display width calculation for proper alignment (handles emojis)
8
+ * - ANSI code stripping for text width estimation
9
+ * - API key masking for security
10
+ * - Overlay viewport management (scrolling, clamping, visibility)
11
+ * - Table viewport calculation
12
+ * - Sorting with pinned favorites and recommendations
13
+ * - Proxy status rendering
14
+ *
15
+ * 🎯 Key features:
16
+ * - Emoji-aware display width calculation without external dependencies
17
+ * - ANSI color/control sequence stripping
18
+ * - API key masking (keeps first 4 and last 3 chars visible)
19
+ * - Overlay viewport helpers (clamp, slice, scroll target visibility)
20
+ * - Table viewport calculation with scroll indicators
21
+ * - Sorting with pinned favorites/recommendations at top
22
+ * - Proxy status formatting
23
+ *
24
+ * → Functions:
25
+ * - `stripAnsi`: Remove ANSI color codes to estimate visible text width
26
+ * - `maskApiKey`: Mask API keys (first 4 + *** + last 3 chars)
27
+ * - `displayWidth`: Calculate display width of string with emoji support
28
+ * - `padEndDisplay`: Left-pad using display width for proper alignment
29
+ * - `tintOverlayLines`: Apply background color to overlay lines
30
+ * - `clampOverlayOffset`: Clamp scroll offset to valid bounds
31
+ * - `keepOverlayTargetVisible`: Ensure target line is visible in viewport
32
+ * - `sliceOverlayLines`: Slice lines to viewport and pad with blanks
33
+ * - `calculateViewport`: Compute visible slice of model rows
34
+ * - `sortResultsWithPinnedFavorites`: Sort with pinned items at top
35
+ * - `renderProxyStatusLine`: Format proxy status for footer display
36
+ * - `adjustScrollOffset`: Clamp scrollOffset so cursor stays visible
37
+ *
38
+ * 📦 Dependencies:
39
+ * - chalk: Terminal colors and formatting
40
+ * - ../src/constants.js: OVERLAY_PANEL_WIDTH, TABLE_FIXED_LINES
41
+ * - ../src/utils.js: sortResults, getProxyStatusInfo
42
+ *
43
+ * ⚙️ Configuration:
44
+ * - OVERLAY_PANEL_WIDTH: Fixed width for overlay panels (from constants.js)
45
+ * - TABLE_FIXED_LINES: Fixed lines in table (header + footer, from constants.js)
46
+ *
47
+ * @see {@link ../src/constants.js} Constants for overlay and table layout
48
+ * @see {@link ../src/utils.js} Core sorting functions
49
+ */
50
+
51
+ import chalk from 'chalk'
52
+ import { OVERLAY_PANEL_WIDTH, TABLE_FIXED_LINES } from './constants.js'
53
+ import { sortResults, getProxyStatusInfo } from './utils.js'
54
+
55
+ // 📖 stripAnsi: Remove ANSI color/control sequences to estimate visible text width before padding.
56
+ // 📖 Strips CSI sequences (SGR colors) and OSC sequences (hyperlinks).
57
+ export function stripAnsi(input) {
58
+ return String(input).replace(/\x1b\[[0-9;]*m/g, '').replace(/\x1b\][^\x1b]*\x1b\\/g, '')
59
+ }
60
+
61
+ // 📖 maskApiKey: Mask all but first 4 and last 3 characters of an API key.
62
+ // 📖 Prevents accidental disclosure of secrets in TUI display.
63
+ export function maskApiKey(key) {
64
+ if (!key || key.length < 10) return '***'
65
+ return key.slice(0, 4) + '***' + key.slice(-3)
66
+ }
67
+
68
+ // 📖 displayWidth: Calculate display width of a string in terminal columns.
69
+ // 📖 Emojis and other wide characters occupy 2 columns, variation selectors (U+FE0F) are zero-width.
70
+ // 📖 This avoids pulling in a full `string-width` dependency for a lightweight CLI tool.
71
+ export function displayWidth(str) {
72
+ const plain = stripAnsi(String(str))
73
+ let w = 0
74
+ for (const ch of plain) {
75
+ const cp = ch.codePointAt(0)
76
+ // Zero-width: variation selectors (FE00-FE0F), zero-width joiner/non-joiner, combining marks
77
+ if ((cp >= 0xFE00 && cp <= 0xFE0F) || cp === 0x200D || cp === 0x200C || cp === 0x20E3) continue
78
+ // Wide: CJK, emoji (most above U+1F000), fullwidth forms
79
+ if (
80
+ cp > 0x1F000 || // emoji & symbols
81
+ (cp >= 0x2600 && cp <= 0x27BF) || // misc symbols, dingbats
82
+ (cp >= 0x2300 && cp <= 0x23FF) || // misc technical (⏳, ⏰, etc.)
83
+ (cp >= 0x2700 && cp <= 0x27BF) || // dingbats
84
+ (cp >= 0xFE10 && cp <= 0xFE19) || // vertical forms
85
+ (cp >= 0xFF01 && cp <= 0xFF60) || // fullwidth ASCII
86
+ (cp >= 0xFFE0 && cp <= 0xFFE6) || // fullwidth signs
87
+ (cp >= 0x4E00 && cp <= 0x9FFF) || // CJK unified
88
+ (cp >= 0x3000 && cp <= 0x303F) || // CJK symbols
89
+ (cp >= 0x2B50 && cp <= 0x2B55) || // stars, circles
90
+ cp === 0x2705 || cp === 0x2714 || cp === 0x2716 || // check/cross marks
91
+ cp === 0x26A0 // ⚠ warning sign
92
+ ) {
93
+ w += 2
94
+ } else {
95
+ w += 1
96
+ }
97
+ }
98
+ return w
99
+ }
100
+
101
+ // 📖 padEndDisplay: Left-pad (padEnd equivalent) using display width instead of string length.
102
+ // 📖 Ensures columns with emoji text align correctly in the terminal.
103
+ export function padEndDisplay(str, width) {
104
+ const dw = displayWidth(str)
105
+ const need = Math.max(0, width - dw)
106
+ return str + ' '.repeat(need)
107
+ }
108
+
109
+ // 📖 tintOverlayLines: Tint overlay lines with a fixed dark panel width so the background is clearly visible.
110
+ // 📖 Applies bgColor to each line and pads to OVERLAY_PANEL_WIDTH for consistent panel look.
111
+ export function tintOverlayLines(lines, bgColor) {
112
+ return lines.map((line) => {
113
+ const text = String(line)
114
+ const visibleWidth = stripAnsi(text).length
115
+ const padding = ' '.repeat(Math.max(0, OVERLAY_PANEL_WIDTH - visibleWidth))
116
+ return bgColor(text + padding)
117
+ })
118
+ }
119
+
120
+ // 📖 clampOverlayOffset: Clamp overlay scroll to valid bounds for the current terminal height.
121
+ export function clampOverlayOffset(offset, totalLines, terminalRows) {
122
+ const viewportRows = Math.max(1, terminalRows || 1)
123
+ const maxOffset = Math.max(0, totalLines - viewportRows)
124
+ return Math.max(0, Math.min(maxOffset, offset))
125
+ }
126
+
127
+ // 📖 keepOverlayTargetVisible: Ensure a target line is visible inside overlay viewport (used by Settings cursor).
128
+ // 📖 Adjusts offset so the target line is always visible, scrolling if needed.
129
+ export function keepOverlayTargetVisible(offset, targetLine, totalLines, terminalRows) {
130
+ const viewportRows = Math.max(1, terminalRows || 1)
131
+ let next = clampOverlayOffset(offset, totalLines, terminalRows)
132
+ if (targetLine < next) next = targetLine
133
+ else if (targetLine >= next + viewportRows) next = targetLine - viewportRows + 1
134
+ return clampOverlayOffset(next, totalLines, terminalRows)
135
+ }
136
+
137
+ // 📖 sliceOverlayLines: Slice overlay lines to terminal viewport and pad with blanks to avoid stale frames.
138
+ // 📖 Returns { visible, offset } where visible is the sliced/padded lines array.
139
+ export function sliceOverlayLines(lines, offset, terminalRows) {
140
+ const viewportRows = Math.max(1, terminalRows || 1)
141
+ const nextOffset = clampOverlayOffset(offset, lines.length, terminalRows)
142
+ const visible = lines.slice(nextOffset, nextOffset + viewportRows)
143
+ while (visible.length < viewportRows) visible.push('')
144
+ return { visible, offset: nextOffset }
145
+ }
146
+
147
+ // ─── Table viewport calculation ────────────────────────────────────────────────
148
+
149
+ // 📖 calculateViewport: Computes the visible slice of model rows that fits in the terminal.
150
+ // 📖 When scroll indicators are needed, they each consume 1 line from the model budget.
151
+ // 📖 Returns { startIdx, endIdx, hasAbove, hasBelow } for rendering.
152
+ export function calculateViewport(terminalRows, scrollOffset, totalModels) {
153
+ if (terminalRows <= 0) return { startIdx: 0, endIdx: totalModels, hasAbove: false, hasBelow: false }
154
+ let maxSlots = terminalRows - TABLE_FIXED_LINES
155
+ if (maxSlots < 1) maxSlots = 1
156
+ if (totalModels <= maxSlots) return { startIdx: 0, endIdx: totalModels, hasAbove: false, hasBelow: false }
157
+
158
+ const hasAbove = scrollOffset > 0
159
+ const hasBelow = scrollOffset + maxSlots - (hasAbove ? 1 : 0) < totalModels
160
+ // Recalculate with indicator lines accounted for
161
+ const modelSlots = maxSlots - (hasAbove ? 1 : 0) - (hasBelow ? 1 : 0)
162
+ const endIdx = Math.min(scrollOffset + modelSlots, totalModels)
163
+ return { startIdx: scrollOffset, endIdx, hasAbove, hasBelow }
164
+ }
165
+
166
+ // ─── Sorting helpers ───────────────────────────────────────────────────────────
167
+
168
+ // 📖 sortResultsWithPinnedFavorites: Recommended models are pinned above favorites, favorites above non-favorites.
169
+ // 📖 Recommended: sorted by recommendation score (highest first).
170
+ // 📖 Favorites: keep insertion order (favoriteRank).
171
+ // 📖 Non-favorites: active sort column/direction.
172
+ // 📖 Models that are both recommended AND favorite — show in recommended section.
173
+ export function sortResultsWithPinnedFavorites(results, sortColumn, sortDirection) {
174
+ const recommendedRows = results
175
+ .filter((r) => r.isRecommended && !r.isFavorite)
176
+ .sort((a, b) => (b.recommendScore || 0) - (a.recommendScore || 0))
177
+ const favoriteRows = results
178
+ .filter((r) => r.isFavorite && !r.isRecommended)
179
+ .sort((a, b) => a.favoriteRank - b.favoriteRank)
180
+ // 📖 Models that are both recommended AND favorite — show in recommended section
181
+ const bothRows = results
182
+ .filter((r) => r.isRecommended && r.isFavorite)
183
+ .sort((a, b) => (b.recommendScore || 0) - (a.recommendScore || 0))
184
+ const nonSpecialRows = sortResults(results.filter((r) => !r.isFavorite && !r.isRecommended), sortColumn, sortDirection)
185
+ return [...bothRows, ...recommendedRows, ...favoriteRows, ...nonSpecialRows]
186
+ }
187
+
188
+ // ─── Proxy status rendering ───────────────────────────────────────────────────
189
+
190
+ // 📖 renderProxyStatusLine: Maps proxyStartupStatus + active proxy into a chalk-coloured footer line.
191
+ // 📖 Always returns a non-empty string (no hidden states) so the footer row is always present.
192
+ // 📖 Delegates state classification to the pure getProxyStatusInfo helper (testable in utils.js).
193
+ export function renderProxyStatusLine(proxyStartupStatus, proxyInstance) {
194
+ const info = getProxyStatusInfo(proxyStartupStatus, !!proxyInstance)
195
+ switch (info.state) {
196
+ case 'starting':
197
+ return chalk.dim(' ') + chalk.yellow('⟳ Proxy') + chalk.dim(' starting…')
198
+ case 'running': {
199
+ const portPart = info.port ? chalk.dim(` :${info.port}`) : ''
200
+ const acctPart = info.accountCount != null ? chalk.dim(` · ${info.accountCount} account${info.accountCount === 1 ? '' : 's'}`) : ''
201
+ return chalk.dim(' ') + chalk.rgb(57, 255, 20)('🔀 Proxy') + chalk.rgb(57, 255, 20)(' running') + portPart + acctPart
202
+ }
203
+ case 'failed':
204
+ return chalk.dim(' ') + chalk.red('✗ Proxy failed') + chalk.dim(` — ${info.reason}`)
205
+ default:
206
+ // stopped / not configured — dim but always present
207
+ return chalk.dim(' 🔀 Proxy not configured')
208
+ }
209
+ }
210
+
211
+ // ─── Scroll offset adjustment ──────────────────────────────────────────────────
212
+
213
+ // 📖 adjustScrollOffset: Clamp scrollOffset so cursor is always within the visible viewport window.
214
+ // 📖 Called after every cursor move, sort change, and terminal resize.
215
+ // 📖 Modifies st.scrollOffset in-place, returns undefined.
216
+ export function adjustScrollOffset(st) {
217
+ const total = st.visibleSorted ? st.visibleSorted.length : st.results.filter(r => !r.hidden).length
218
+ let maxSlots = st.terminalRows - TABLE_FIXED_LINES
219
+ if (maxSlots < 1) maxSlots = 1
220
+ if (total <= maxSlots) { st.scrollOffset = 0; return }
221
+ // Ensure cursor is not above the visible window
222
+ if (st.cursor < st.scrollOffset) {
223
+ st.scrollOffset = st.cursor
224
+ }
225
+ // Ensure cursor is not below the visible window
226
+ // Account for indicator lines eating into model slots
227
+ const hasAbove = st.scrollOffset > 0
228
+ const tentativeBelow = st.scrollOffset + maxSlots - (hasAbove ? 1 : 0) < total
229
+ const modelSlots = maxSlots - (hasAbove ? 1 : 0) - (tentativeBelow ? 1 : 0)
230
+ if (st.cursor >= st.scrollOffset + modelSlots) {
231
+ st.scrollOffset = st.cursor - modelSlots + 1
232
+ }
233
+ // Final clamp
234
+ // 📖 Keep one extra scroll step when top indicator is visible,
235
+ // 📖 otherwise the last rows become unreachable at the bottom.
236
+ const maxOffset = Math.max(0, total - maxSlots + 1)
237
+ if (st.scrollOffset > maxOffset) st.scrollOffset = maxOffset
238
+ if (st.scrollOffset < 0) st.scrollOffset = 0
239
+ }