free-coding-models 0.3.24 → 0.3.26

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/mouse.js ADDED
@@ -0,0 +1,186 @@
1
+ /**
2
+ * @file mouse.js
3
+ * @description Terminal mouse tracking infrastructure for the TUI.
4
+ *
5
+ * @details
6
+ * Provides SGR (mode 1006) mouse event parsing and tracking enable/disable sequences.
7
+ * SGR mode is preferred over X10/normal because it supports coordinates > 223
8
+ * and distinguishes press vs release events cleanly.
9
+ *
10
+ * Mouse events arrive as raw escape sequences on stdin when tracking is enabled:
11
+ * Press: \x1b[<Btn;X;YM
12
+ * Release: \x1b[<Btn;X;Ym
13
+ *
14
+ * Button encoding (Btn field):
15
+ * 0 = left click, 1 = middle click, 2 = right click
16
+ * 32 = left drag, 33 = middle drag, 34 = right drag
17
+ * 64 = scroll up, 65 = scroll down
18
+ * +4 = Shift held, +8 = Meta/Alt held, +16 = Control held
19
+ *
20
+ * Coordinates are 1-based in SGR mode (col 1, row 1 = top-left).
21
+ *
22
+ * ⚙️ Key configuration:
23
+ * - MOUSE_ENABLE: appended to ALT_ENTER to start mouse tracking on TUI init
24
+ * - MOUSE_DISABLE: prepended to ALT_LEAVE to stop mouse tracking on TUI exit
25
+ * - DOUBLE_CLICK_MS: maximum gap between two clicks to count as double-click
26
+ *
27
+ * @functions
28
+ * → parseMouseEvent(data) — Parse raw stdin buffer into structured mouse event
29
+ * → createMouseHandler(opts) — Create a stdin 'data' listener that emits mouse events
30
+ *
31
+ * @exports
32
+ * MOUSE_ENABLE, MOUSE_DISABLE,
33
+ * parseMouseEvent, createMouseHandler
34
+ *
35
+ * @see src/app.js — wires the mouse data listener alongside keypress
36
+ * @see src/key-handler.js — receives parsed mouse events for UI actions
37
+ * @see src/constants.js — ALT_ENTER / ALT_LEAVE include mouse sequences
38
+ */
39
+
40
+ // 📖 SGR mouse mode (1006) sends coordinates as decimal numbers terminated by M/m,
41
+ // 📖 supporting terminals wider than 223 columns (unlike X10 mode).
42
+ // 📖 Mode 1000 = basic button tracking (press + release).
43
+ // 📖 Mode 1002 = button-event tracking (adds drag reporting).
44
+ // 📖 Mode 1003 = any-event tracking (adds mouse movement) — intentionally NOT used
45
+ // 📖 because movement floods stdin and we don't need hover.
46
+ export const MOUSE_ENABLE = '\x1b[?1000h\x1b[?1002h\x1b[?1006h'
47
+ export const MOUSE_DISABLE = '\x1b[?1006l\x1b[?1002l\x1b[?1000l'
48
+
49
+ // 📖 Double-click detection window in milliseconds.
50
+ const DOUBLE_CLICK_MS = 400
51
+
52
+ // 📖 Regex to match SGR mouse sequences: \x1b[<Btn;Col;Row[Mm]
53
+ // 📖 Groups: 1=button, 2=column(x), 3=row(y), 4=M(press)|m(release)
54
+ const SGR_MOUSE_RE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g
55
+
56
+ /**
57
+ * 📖 parseMouseEvents: Extract all SGR mouse events from a raw stdin data chunk.
58
+ * 📖 A single data chunk can contain multiple mouse events (e.g. rapid scrolling).
59
+ * @param {string|Buffer} data — raw stdin data
60
+ * @returns {Array<{button: number, x: number, y: number, type: string, shift: boolean, meta: boolean, ctrl: boolean}>}
61
+ */
62
+ export function parseMouseEvents(data) {
63
+ const str = typeof data === 'string' ? data : data.toString('utf8')
64
+ const events = []
65
+ let match
66
+
67
+ // 📖 Reset regex lastIndex for reuse
68
+ SGR_MOUSE_RE.lastIndex = 0
69
+
70
+ while ((match = SGR_MOUSE_RE.exec(str)) !== null) {
71
+ const rawBtn = parseInt(match[1], 10)
72
+ const x = parseInt(match[2], 10) // 📖 1-based column
73
+ const y = parseInt(match[3], 10) // 📖 1-based row
74
+ const isRelease = match[4] === 'm'
75
+
76
+ // 📖 Extract modifier keys from the button field
77
+ const shift = !!(rawBtn & 4)
78
+ const meta = !!(rawBtn & 8)
79
+ const ctrl = !!(rawBtn & 16)
80
+
81
+ // 📖 Strip modifier bits to get the base button
82
+ const baseBtn = rawBtn & ~(4 | 8 | 16)
83
+
84
+ let type, button
85
+
86
+ if (baseBtn === 64) {
87
+ type = 'scroll-up'
88
+ button = 'scroll-up'
89
+ } else if (baseBtn === 65) {
90
+ type = 'scroll-down'
91
+ button = 'scroll-down'
92
+ } else if (baseBtn >= 32 && baseBtn <= 34) {
93
+ type = 'drag'
94
+ button = baseBtn === 32 ? 'left' : baseBtn === 33 ? 'middle' : 'right'
95
+ } else if (isRelease) {
96
+ type = 'release'
97
+ button = baseBtn === 0 ? 'left' : baseBtn === 1 ? 'middle' : 'right'
98
+ } else {
99
+ type = 'press'
100
+ button = baseBtn === 0 ? 'left' : baseBtn === 1 ? 'middle' : 'right'
101
+ }
102
+
103
+ events.push({ type, button, x, y, shift, meta, ctrl })
104
+ }
105
+
106
+ return events
107
+ }
108
+
109
+ /**
110
+ * 📖 containsMouseSequence: Quick check if a data chunk contains any SGR mouse sequence.
111
+ * 📖 Used to prevent the keypress handler from processing mouse data as keypresses.
112
+ * @param {string|Buffer} data
113
+ * @returns {boolean}
114
+ */
115
+ export function containsMouseSequence(data) {
116
+ const str = typeof data === 'string' ? data : data.toString('utf8')
117
+ return str.includes('\x1b[<')
118
+ }
119
+
120
+ /**
121
+ * 📖 createMouseHandler: Factory that returns a stdin 'data' callback for mouse events.
122
+ * 📖 Handles double-click detection internally by tracking the last click position/time.
123
+ *
124
+ * @param {object} opts
125
+ * @param {function} opts.onMouseEvent — callback receiving structured mouse events:
126
+ * { type: 'click'|'double-click'|'scroll-up'|'scroll-down'|'drag', button, x, y, shift, meta, ctrl }
127
+ * @returns {function} — attach to process.stdin.on('data', returnedFn)
128
+ */
129
+ export function createMouseHandler({ onMouseEvent }) {
130
+ // 📖 Double-click tracking state
131
+ let lastClickX = -1
132
+ let lastClickY = -1
133
+ let lastClickTime = 0
134
+
135
+ return (data) => {
136
+ const str = typeof data === 'string' ? data : data.toString('utf8')
137
+
138
+ // 📖 Only process data that contains mouse sequences
139
+ if (!str.includes('\x1b[<')) return
140
+
141
+ const events = parseMouseEvents(str)
142
+
143
+ for (const evt of events) {
144
+ // 📖 Scroll events are emitted immediately (no press/release distinction)
145
+ if (evt.type === 'scroll-up' || evt.type === 'scroll-down') {
146
+ onMouseEvent(evt)
147
+ continue
148
+ }
149
+
150
+ // 📖 Drag events forwarded as-is
151
+ if (evt.type === 'drag') {
152
+ onMouseEvent(evt)
153
+ continue
154
+ }
155
+
156
+ // 📖 Only emit click on release (not press) to match expected click semantics.
157
+ // 📖 This prevents double-firing and feels more natural to the user.
158
+ if (evt.type === 'release' && evt.button === 'left') {
159
+ const now = Date.now()
160
+ const isDoubleClick =
161
+ (now - lastClickTime) < DOUBLE_CLICK_MS &&
162
+ evt.x === lastClickX &&
163
+ evt.y === lastClickY
164
+
165
+ if (isDoubleClick) {
166
+ onMouseEvent({ ...evt, type: 'double-click' })
167
+ // 📖 Reset so a third click doesn't count as another double-click
168
+ lastClickTime = 0
169
+ lastClickX = -1
170
+ lastClickY = -1
171
+ } else {
172
+ onMouseEvent({ ...evt, type: 'click' })
173
+ lastClickTime = now
174
+ lastClickX = evt.x
175
+ lastClickY = evt.y
176
+ }
177
+ continue
178
+ }
179
+
180
+ // 📖 Right-click and middle-click: emit on release
181
+ if (evt.type === 'release') {
182
+ onMouseEvent({ ...evt, type: 'click' })
183
+ }
184
+ }
185
+ }
186
+ }
package/src/overlays.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * 📖 Feedback overlay (I key) combines feature requests + bug reports in one left-aligned input
15
15
  *
16
16
  * → Functions:
17
- * - `createOverlayRenderers` — returns renderer + analysis helpers
17
+ * - `createOverlayRenderers` — returns renderer + analysis helpers + overlayLayout
18
18
  *
19
19
  * @exports { createOverlayRenderers }
20
20
  * @see ./key-handler.js — handles keypresses for all overlay interactions
@@ -85,6 +85,29 @@ export function createOverlayRenderers(state, deps) {
85
85
  return lines
86
86
  }
87
87
 
88
+ // 📖 Overlay layout tracking: records cursor-to-line mappings and scroll offsets
89
+ // 📖 so the mouse handler can map terminal click coordinates → overlay cursor positions.
90
+ // 📖 Updated each render frame by the active overlay renderer.
91
+ const overlayLayout = {
92
+ settingsCursorToLine: {}, // 📖 cursor index → line index in pre-scroll lines array
93
+ settingsScrollOffset: 0, // 📖 current scroll offset applied by sliceOverlayLines
94
+ settingsMaxRow: 0, // 📖 maximum valid settingsCursor index
95
+ installEndpointsCursorToLine: {},
96
+ installEndpointsScrollOffset: 0,
97
+ installEndpointsMaxRow: 0,
98
+ commandPaletteCursorToLine: {},
99
+ commandPaletteScrollOffset: 0,
100
+ commandPaletteBodyStartRow: 0, // 📖 1-based terminal row where CP results begin
101
+ commandPaletteBodyRows: 0,
102
+ commandPaletteLeft: 0,
103
+ commandPaletteRight: 0,
104
+ commandPaletteTop: 0,
105
+ commandPaletteBottom: 0,
106
+ changelogCursorToLine: {},
107
+ changelogScrollOffset: 0,
108
+ recommendOptionRows: {}, // 📖 option index → 1-based terminal row (questionnaire phase)
109
+ }
110
+
88
111
  // ─── Settings screen renderer ─────────────────────────────────────────────
89
112
  // 📖 renderSettings: Draw the settings overlay in the alt screen buffer.
90
113
  // 📖 Shows all providers with their API key (masked) + enabled state.
@@ -287,6 +310,11 @@ export function createOverlayRenderers(state, deps) {
287
310
  const { visible, offset } = sliceOverlayLines(lines, state.settingsScrollOffset, state.terminalRows)
288
311
  state.settingsScrollOffset = offset
289
312
 
313
+ // 📖 Mouse support: record layout so click handler can map Y → settingsCursor
314
+ overlayLayout.settingsCursorToLine = { ...cursorLineByRow }
315
+ overlayLayout.settingsScrollOffset = offset
316
+ overlayLayout.settingsMaxRow = changelogViewRowIdx
317
+
290
318
  const tintedLines = tintOverlayLines(visible, themeColors.overlayBgSettings, state.terminalCols)
291
319
  const cleared = tintedLines.map(l => l + EL)
292
320
  return cleared.join('\n')
@@ -451,6 +479,114 @@ export function createOverlayRenderers(state, deps) {
451
479
  return cleared.join('\n')
452
480
  }
453
481
 
482
+ // ─── Installed Models Manager overlay renderer ─────────────────────────────
483
+ // 📖 renderInstalledModels displays all models configured in external tools
484
+ // 📖 Shows tool configs, model lists, and provides actions (Launch, Disable, Reinstall)
485
+ function renderInstalledModels() {
486
+ const EL = '\x1b[K'
487
+ const lines = []
488
+ const cursorLineByRow = {}
489
+
490
+ lines.push('')
491
+ lines.push(` ${themeColors.accent('🚀')} ${themeColors.accentBold('free-coding-models')} ${themeColors.dim(`v${LOCAL_VERSION}`)}`)
492
+ lines.push(` ${themeColors.textBold('🗂️ Installed Models Manager')}`)
493
+ lines.push('')
494
+ lines.push(themeColors.dim(' — models configured in your tools'))
495
+
496
+ if (state.installedModelsErrorMsg) {
497
+ lines.push(` ${themeColors.warning(state.installedModelsErrorMsg)}`)
498
+ }
499
+
500
+ if (state.installedModelsErrorMsg === 'Scanning...') {
501
+ lines.push(themeColors.dim(' Scanning tool configs, please wait...'))
502
+ const targetLine = 5
503
+ state.installedModelsScrollOffset = keepOverlayTargetVisible(
504
+ state.installedModelsScrollOffset,
505
+ targetLine,
506
+ lines.length,
507
+ state.terminalRows
508
+ )
509
+ const { visible, offset } = sliceOverlayLines(lines, state.installedModelsScrollOffset, state.terminalRows)
510
+ state.installedModelsScrollOffset = offset
511
+
512
+ overlayLayout.installedModelsCursorToLine = cursorLineByRow
513
+ overlayLayout.installedModelsScrollOffset = offset
514
+
515
+ const tintedLines = tintOverlayLines(visible, themeColors.overlayBgSettings, state.terminalCols)
516
+ const cleared = tintedLines.map((l) => l + EL)
517
+ return cleared.join('\n')
518
+ }
519
+
520
+ lines.push('')
521
+
522
+ const scanResults = state.installedModelsData || []
523
+
524
+ if (scanResults.length === 0) {
525
+ lines.push(themeColors.dim(' No tool configs found.'))
526
+ lines.push(themeColors.dim(' Install a tool (Goose, Crush, Aider, etc.) to get started.'))
527
+ } else {
528
+ let globalIdx = 0
529
+
530
+ for (const toolResult of scanResults) {
531
+ const { toolMode, toolLabel, toolEmoji, configPath, isValid, hasManagedMarker, models } = toolResult
532
+
533
+ lines.push('')
534
+ const isCursor = globalIdx === state.installedModelsCursor
535
+
536
+ const statusIcon = isValid ? themeColors.successBold('✅') : themeColors.errorBold('⚠️')
537
+ const toolHeader = `${bullet(isCursor)}${toolEmoji} ${themeColors.textBold(toolLabel)} ${statusIcon}`
538
+ cursorLineByRow[globalIdx++] = lines.length
539
+ lines.push(isCursor ? themeColors.bgCursor(toolHeader) : toolHeader)
540
+
541
+ const configShortPath = configPath.replace(process.env.HOME || homedir(), '~')
542
+ lines.push(` ${themeColors.dim(configShortPath)}`)
543
+
544
+ if (!isValid) {
545
+ lines.push(themeColors.dim(' ⚠️ Config invalid or missing'))
546
+ } else if (models.length === 0) {
547
+ lines.push(themeColors.dim(' No models configured'))
548
+ } else {
549
+ const managedBadge = hasManagedMarker ? themeColors.info('• Managed by FCM') : themeColors.dim('• External config')
550
+ lines.push(` ${themeColors.success(`${models.length} model${models.length > 1 ? 's' : ''} configured`)} ${managedBadge}`)
551
+
552
+ for (const model of models) {
553
+ const isModelCursor = globalIdx === state.installedModelsCursor
554
+ const tierBadge = model.tier !== '-' ? themeColors.info(model.tier.padEnd(2)) : themeColors.dim(' ')
555
+ const externalBadge = model.isExternal ? themeColors.dim('[external]') : ''
556
+
557
+ const modelRow = ` • ${model.label} ${tierBadge} ${externalBadge}`
558
+ cursorLineByRow[globalIdx++] = lines.length
559
+ lines.push(isModelCursor ? themeColors.bgCursor(modelRow) : modelRow)
560
+
561
+ if (isModelCursor) {
562
+ lines.push(` ${themeColors.dim('[Enter] Launch [D] Disable')}`)
563
+ }
564
+ }
565
+ }
566
+ }
567
+ }
568
+
569
+ lines.push('')
570
+ lines.push(themeColors.dim(' ↑↓ Navigate Enter=Launch D=Disable Esc=Close'))
571
+
572
+ const targetLine = cursorLineByRow[state.installedModelsCursor] ?? 0
573
+ state.installedModelsScrollOffset = keepOverlayTargetVisible(
574
+ state.installedModelsScrollOffset,
575
+ targetLine,
576
+ lines.length,
577
+ state.terminalRows
578
+ )
579
+ const { visible, offset } = sliceOverlayLines(lines, state.installedModelsScrollOffset, state.terminalRows)
580
+ state.installedModelsScrollOffset = offset
581
+
582
+ overlayLayout.installedModelsCursorToLine = cursorLineByRow
583
+ overlayLayout.installedModelsScrollOffset = offset
584
+
585
+ const tintedLines = tintOverlayLines(visible, themeColors.overlayBgSettings, state.terminalCols)
586
+ const cleared = tintedLines.map((l) => l + EL)
587
+ return cleared.join('\n')
588
+ }
589
+
454
590
  // ─── Missing-tool install confirmation overlay ────────────────────────────
455
591
  // 📖 renderToolInstallPrompt keeps the user inside the TUI long enough to
456
592
  // 📖 confirm the auto-install, then the key handler exits the alt screen and
@@ -684,6 +820,18 @@ export function createOverlayRenderers(state, deps) {
684
820
  const top = Math.max(1, Math.floor((terminalRows - panelHeight) / 2) + 1)
685
821
  const left = Math.max(1, Math.floor((terminalCols - panelOuterWidth) / 2) + 1)
686
822
 
823
+ // 📖 Mouse support: record CP layout so clicks inside the modal can select items.
824
+ // 📖 Body rows start after 2 blank-padding lines + headerLines (3).
825
+ const bodyStartRow = top + 2 + headerLines.length // 📖 1-based terminal row of first body line
826
+ overlayLayout.commandPaletteCursorToLine = { ...cursorLineByRow }
827
+ overlayLayout.commandPaletteScrollOffset = state.commandPaletteScrollOffset
828
+ overlayLayout.commandPaletteBodyStartRow = bodyStartRow
829
+ overlayLayout.commandPaletteBodyRows = bodyRows
830
+ overlayLayout.commandPaletteLeft = left
831
+ overlayLayout.commandPaletteRight = left + panelOuterWidth - 1
832
+ overlayLayout.commandPaletteTop = top
833
+ overlayLayout.commandPaletteBottom = top + panelHeight - 1
834
+
687
835
  const tintedLines = paddedPanelLines.map((line) => {
688
836
  const padded = padEndDisplay(line, panelOuterWidth)
689
837
  return themeColors.overlayBgCommandPalette(padded)
@@ -861,6 +1009,10 @@ export function createOverlayRenderers(state, deps) {
861
1009
  const opt = q.options[i]
862
1010
  const isCursor = i === state.recommendCursor
863
1011
  const label = isCursor ? themeColors.textBold(opt.label) : themeColors.text(opt.label)
1012
+ // 📖 Mouse support: record the 1-based terminal row of each option
1013
+ // 📖 lines.length is the 0-based index → +1 = 1-based row
1014
+ overlayLayout.recommendOptionRows = overlayLayout.recommendOptionRows || {}
1015
+ overlayLayout.recommendOptionRows[i] = lines.length + 1
864
1016
  lines.push(`${bullet(isCursor)}${label}`)
865
1017
  }
866
1018
 
@@ -1206,6 +1358,21 @@ export function createOverlayRenderers(state, deps) {
1206
1358
  // 📖 Use scrolling with overlay handler
1207
1359
  const { visible, offset } = sliceOverlayLines(lines, state.changelogScrollOffset, state.terminalRows)
1208
1360
  state.changelogScrollOffset = offset
1361
+
1362
+ // 📖 Mouse support: record changelog layout for click-to-select versions
1363
+ overlayLayout.changelogScrollOffset = offset
1364
+ // 📖 In index phase, version items start at line 4 (header + blank + title + instructions)
1365
+ // 📖 Each version occupies exactly one line. changelogCursorToLine maps cursor → line index.
1366
+ if (state.changelogPhase === 'index') {
1367
+ const map = {}
1368
+ for (let i = 0; i < versionList.length; i++) {
1369
+ map[i] = 4 + i // 📖 3 header-ish lines + 1 blank before version list
1370
+ }
1371
+ overlayLayout.changelogCursorToLine = map
1372
+ } else {
1373
+ overlayLayout.changelogCursorToLine = {}
1374
+ }
1375
+
1209
1376
  const tintedLines = tintOverlayLines(visible, themeColors.overlayBgChangelog, state.terminalCols)
1210
1377
  const cleared = tintedLines.map(l => l + EL)
1211
1378
  return cleared.join('\n')
@@ -1322,8 +1489,10 @@ export function createOverlayRenderers(state, deps) {
1322
1489
  renderRecommend,
1323
1490
  renderFeedback,
1324
1491
  renderChangelog,
1492
+ renderInstalledModels,
1325
1493
  renderIncompatibleFallback,
1326
1494
  startRecommendAnalysis,
1327
1495
  stopRecommendAnalysis,
1496
+ overlayLayout, // 📖 Mouse support: exposes cursor-to-line maps for click handling
1328
1497
  }
1329
1498
  }