free-coding-models 0.3.50 → 0.3.52

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.
@@ -48,6 +48,7 @@ import {
48
48
  import { themeColors, getProviderRgb, getTierRgb, getReadableTextRgb, getTheme } from './theme.js'
49
49
  import { TIER_COLOR } from './tier-colors.js'
50
50
  import { getAvg, getVerdict, getUptime, getStabilityScore, getVersionStatusInfo } from './utils.js'
51
+ import { VERDICT_CYCLE } from './constants.js'
51
52
  import { usagePlaceholderForProvider } from './ping.js'
52
53
  import { calculateViewport, sortResultsWithPinnedFavorites, padEndDisplay, displayWidth } from './render-helpers.js'
53
54
  import { getToolMeta, TOOL_METADATA, TOOL_MODE_ORDER, isModelCompatibleWithTool } from './tool-metadata.js'
@@ -57,6 +58,10 @@ import { detectPackageManager, getManualInstallCmd } from './updater.js'
57
58
  const require = createRequire(import.meta.url)
58
59
  const { version: LOCAL_VERSION } = require('../package.json')
59
60
 
61
+ // 📖 HEALTH_CYCLE: cycles through health/status states (local constant for render-table.js)
62
+ // VERDICT_CYCLE is now imported from constants.js
63
+ const HEALTH_CYCLE = [null, 'up', 'timeout', 'down', 'auth_error', 'noauth', 'pending']
64
+
60
65
  // 📖 Mouse support: column boundary map updated every frame by renderTable().
61
66
  // 📖 Each entry maps a column name to its display X-start and X-end (1-based, inclusive).
62
67
  // 📖 headerRow is the 1-based terminal row of the column header line.
@@ -104,7 +109,7 @@ export const PROVIDER_COLOR = new Proxy({}, {
104
109
  })
105
110
 
106
111
  // ─── renderTable: mode param controls footer hint text (opencode vs openclaw) ─────────
107
- export function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilterMode = 0, scrollOffset = 0, terminalRows = 0, terminalCols = 0, originFilterMode = 0, legacyStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false, widthWarningStartedAt = null, widthWarningDismissed = false, widthWarningShowCount = 0, settingsUpdateState = 'idle', settingsUpdateLatestVersion = null, legacyFlag = false, startupLatestVersion = null, versionAlertsEnabled = true, favoritesPinnedAndSticky = false, customTextFilter = null, lastReleaseDate = null, footerHidden = false) {
112
+ export function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilterMode = 0, scrollOffset = 0, terminalRows = 0, terminalCols = 0, originFilterMode = 0, legacyStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false, widthWarningStartedAt = null, widthWarningDismissed = false, widthWarningShowCount = 0, settingsUpdateState = 'idle', settingsUpdateLatestVersion = null, legacyFlag = false, startupLatestVersion = null, versionAlertsEnabled = true, favoritesPinnedAndSticky = false, customTextFilter = null, lastReleaseDate = null, footerHidden = false, verdictFilterMode = 0, healthFilterMode = 0) {
108
113
  // 📖 Filter out hidden models for display
109
114
  const visibleResults = results.filter(r => !r.hidden)
110
115
 
@@ -321,6 +326,77 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
321
326
  '',
322
327
  ]
323
328
 
329
+ // 📖 Filter bar — llmfit-style horizontal filter pills (1 dedicated row above table)
330
+ // 📖 Each block: title with hotkey hint + active value colored by filter state
331
+ {
332
+ const filterParts = []
333
+ const filterSep = themeColors.dim(' │ ')
334
+ const blockSep = ' │ '
335
+
336
+ // 📖 Search filter block — shows active text filter or prompt
337
+ if (customTextFilter && customTextFilter.trim()) {
338
+ const badgeText = ` Search "/" ${blockSep} ${customTextFilter.trim().slice(0, 20)} `
339
+ filterParts.push(themeColors.badge(badgeText, [52, 120, 88], [255, 255, 255]))
340
+ } else {
341
+ filterParts.push(themeColors.dim(' Search "/" '))
342
+ }
343
+
344
+ // 📖 Tier filter block — T key cycles through TIER_CYCLE
345
+ if (tierFilterMode > 0) {
346
+ const tierLabel = TIER_CYCLE_NAMES[tierFilterMode]
347
+ const tierBg = getTierRgb(tierLabel)
348
+ filterParts.push(themeColors.badge(` Tier (${tierLabel}) `, tierBg, [255, 255, 255]))
349
+ } else {
350
+ filterParts.push(themeColors.dim(' Tier (T) '))
351
+ }
352
+
353
+ // 📖 Provider filter block — D key cycles through providers
354
+ if (originFilterMode > 0) {
355
+ const originKeys = [null, ...Object.keys(sources)]
356
+ const activeOriginKey = originKeys[originFilterMode]
357
+ const activeOriginName = activeOriginKey ? sources[activeOriginKey]?.name ?? activeOriginKey : null
358
+ if (activeOriginName) {
359
+ const normName = normalizeOriginLabel(activeOriginName, activeOriginKey)
360
+ const providerRgb = PROVIDER_COLOR[activeOriginKey] || [255, 255, 255]
361
+ filterParts.push(themeColors.badge(` Provider (${normName}) `, providerRgb, [255, 255, 255]))
362
+ }
363
+ } else {
364
+ filterParts.push(themeColors.dim(' Provider (D) '))
365
+ }
366
+
367
+ // 📖 Verdict filter block — V key cycles through verdicts
368
+ if (verdictFilterMode > 0) {
369
+ const verdictLabel = VERDICT_CYCLE[verdictFilterMode]
370
+ const verdictColors = {
371
+ 'Perfect': themeColors.success,
372
+ 'Normal': themeColors.metricGood,
373
+ 'Slow': (t) => chalk.bold.rgb(...getTierRgb('A-'))(t),
374
+ 'Spiky': (t) => chalk.bold.rgb(...getTierRgb('A+'))(t),
375
+ 'Very Slow': (t) => chalk.bold.rgb(...getTierRgb('B+'))(t),
376
+ 'Overloaded': (t) => chalk.bold.rgb(...getTierRgb('B'))(t),
377
+ 'Unstable': themeColors.errorBold,
378
+ 'Not Active': themeColors.dim,
379
+ 'Pending': themeColors.dim,
380
+ }
381
+ const vc = verdictColors[verdictLabel] || themeColors.accent
382
+ filterParts.push(themeColors.badge(` Verdict (${verdictLabel}) `, [20, 20, 20], vc === themeColors.dim ? [130, 130, 130] : [255, 255, 255]))
383
+ } else {
384
+ filterParts.push(themeColors.dim(' Verdict (V) '))
385
+ }
386
+
387
+ // 📖 Health filter block — H key cycles through health states
388
+ if (healthFilterMode > 0) {
389
+ const healthLabel = HEALTH_CYCLE[healthFilterMode]
390
+ const healthDisplay = healthLabel === 'auth_error' ? 'Auth Err' : healthLabel === 'noauth' ? 'No Key' : healthLabel.charAt(0).toUpperCase() + healthLabel.slice(1)
391
+ const healthBg = healthLabel === 'up' ? [52, 120, 88] : healthLabel === 'timeout' ? [180, 130, 0] : healthLabel === 'down' ? [120, 40, 40] : [60, 60, 60]
392
+ filterParts.push(themeColors.badge(` Health (${healthDisplay}) `, healthBg, [255, 255, 255]))
393
+ } else {
394
+ filterParts.push(themeColors.dim(' Health (H) '))
395
+ }
396
+
397
+ lines.push(filterParts.join(blockSep))
398
+ }
399
+
324
400
  // 📖 Header row with sorting indicators
325
401
  // 📖 NOTE: padEnd on chalk strings counts ANSI codes, breaking alignment
326
402
  // 📖 Solution: build plain text first, then colorize
@@ -756,135 +832,128 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
756
832
  }
757
833
  }
758
834
 
759
- lines.push(
760
- ' ' + hotkey('F', ' Toggle Favorite') +
761
- themeColors.dim(` • `) +
762
- activeHotkey('Y', favoritesModeLabel, favoritesModeBg) +
763
- themeColors.dim(` • `) +
764
- (tierFilterMode > 0
765
- ? activeHotkey('T', ` Tier (${activeTierLabel})`, getTierRgb(activeTierLabel))
766
- : hotkey('T', ' Tier')) +
767
- themeColors.dim(` • `) +
768
- (originFilterMode > 0
769
- ? activeHotkey('D', ` Provider (${activeOriginLabel})`, PROVIDER_COLOR[[null, ...Object.keys(sources)][originFilterMode]] || [255, 255, 255])
770
- : hotkey('D', ' Provider')) +
771
- themeColors.dim(` • `) +
772
- (hideUnconfiguredModels ? activeHotkey('E', ' Show only configured models', configuredBadgeBg) : hotkey('E', ' Show only configured models')) +
773
- themeColors.dim(` • `) +
774
- hotkey('P', ' Settings') +
775
- themeColors.dim(` • `) +
776
- themeColors.dim('J/K Navigate') +
777
- themeColors.dim(` • `) +
778
- themeColors.dim('Ctrl+H Help')
779
- )
780
-
781
- // 📖 Line 2: command palette, recommend, feedback, theme
782
- {
783
- const cpText = ' CTRL+P ⚡️ Command Palette '
784
- const parts = [
785
- { text: ' ', key: null },
786
- { text: cpText, key: 'ctrl+p' },
787
- { text: ' • ', key: null },
788
- { text: 'Q Smart Recommend', key: 'q' },
789
- { text: ' • ', key: null },
790
- { text: 'G Theme', key: 'g' },
791
- { text: ' • ', key: null },
792
- { text: 'I Feedback, bugs & requests', key: 'i' },
793
- ]
794
- const footerRow2 = lines.length + 1
795
- let xPos = 1
796
- for (const part of parts) {
797
- const w = displayWidth(part.text)
798
- if (part.key) footerHotkeys.push({ key: part.key, row: footerRow2, xStart: xPos, xEnd: xPos + w - 1 })
799
- xPos += w
800
- }
801
- }
835
+ if (!footerHidden) {
836
+ // 📖 Full footer all hint lines hidden when footerHidden=true to maximize table space
837
+ lines.push(
838
+ ' ' + hotkey('F', ' Toggle Favorite') +
839
+ themeColors.dim(` • `) +
840
+ activeHotkey('Y', favoritesModeLabel, favoritesModeBg) +
841
+ themeColors.dim(` • `) +
842
+ (tierFilterMode > 0
843
+ ? activeHotkey('T', ` Tier (${activeTierLabel})`, getTierRgb(activeTierLabel))
844
+ : hotkey('T', ' Tier')) +
845
+ themeColors.dim(` • `) +
846
+ (originFilterMode > 0
847
+ ? activeHotkey('D', ` Provider (${activeOriginLabel})`, PROVIDER_COLOR[[null, ...Object.keys(sources)][originFilterMode]] || [255, 255, 255])
848
+ : hotkey('D', ' Provider')) +
849
+ themeColors.dim(` • `) +
850
+ (hideUnconfiguredModels ? activeHotkey('E', ' Show only configured models', configuredBadgeBg) : hotkey('E', ' Show only configured models')) +
851
+ themeColors.dim(` • `) +
852
+ hotkey('P', ' Settings') +
853
+ themeColors.dim(` • `) +
854
+ themeColors.dim('J/K Navigate') +
855
+ themeColors.dim(` • `) +
856
+ themeColors.dim('Ctrl+H Help')
857
+ )
802
858
 
803
- // 📖 Line 2: command palette (highlighted as new), recommend, feedback, and extended hints.
804
- // 📖 CTRL+P ⚡️ Command Palette uses neon-green-on-dark-green background to highlight the feature.
805
- const paletteLabel = chalk.bgRgb(0, 60, 0).rgb(57, 255, 20).bold(' CTRL+P ⚡️ Command Palette ')
806
- lines.push(
807
- ' ' + paletteLabel + themeColors.dim(` • `) +
808
- hotkey('Q', ' Smart Recommend') + themeColors.dim(` • `) +
809
- hotkey('G', ' Theme') + themeColors.dim(``) +
810
- hotkey('I', ' Feedback, bugs & requests')
811
- )
812
- // 📖 Proxy status is now shown via the badge in line 2 above — no need for a dedicated line
813
- const footerLine =
814
- themeColors.footerLove(' Made with 💖 & by \x1b]8;;https://github.com/vava-nessa\x1b\\vava-nessa\x1b]8;;\x1b\\') +
815
- themeColors.dim(' • ') +
816
- '⭐ ' +
817
- themeColors.link('\x1b]8;;https://github.com/vava-nessa/free-coding-models\x1b\\Star on GitHub\x1b]8;;\x1b\\') +
818
- themeColors.dim(' • ') +
819
- '🤝 ' +
820
- themeColors.warning('\x1b]8;;https://github.com/vava-nessa/free-coding-models/graphs/contributors\x1b\\Contributors\x1b]8;;\x1b\\') +
821
- themeColors.dim(' • ') +
822
- '☕ ' +
823
- themeColors.footerCoffee('\x1b]8;;https://buymeacoffee.com/vavanessadev\x1b\\Buy me a coffee\x1b]8;;\x1b\\')
824
- lines.push(footerLine)
825
-
826
- if (versionStatus.isOutdated) {
827
- const updateMsg = ` 🚀⬆️ UPDATE AVAILABLE — v${LOCAL_VERSION} → v${versionStatus.latestVersion} • Click here or press Shift+U to update 🚀⬆️ `
828
- const paddedBanner = terminalCols > 0
829
- ? updateMsg + ' '.repeat(Math.max(0, terminalCols - displayWidth(updateMsg)))
830
- : updateMsg
831
- const fluoGreenBanner = chalk.bgRgb(57, 255, 20).rgb(0, 0, 0).bold(paddedBanner)
832
- const updateBannerRow = lines.length + 1
833
- _lastLayout.updateBannerRow = updateBannerRow
834
- footerHotkeys.push({ key: 'update-click', row: updateBannerRow, xStart: 1, xEnd: Math.max(terminalCols, displayWidth(updateMsg)) })
835
- lines.push(fluoGreenBanner)
836
- } else {
837
- _lastLayout.updateBannerRow = 0
838
- }
859
+ // 📖 Line 2: command palette, recommend, feedback, theme
860
+ {
861
+ const cpText = ' CTRL+P ⚡️ Command Palette '
862
+ const parts = [
863
+ { text: ' ', key: null },
864
+ { text: cpText, key: 'ctrl+p' },
865
+ { text: ' • ', key: null },
866
+ { text: 'Q Smart Recommend', key: 'q' },
867
+ { text: ' • ', key: null },
868
+ { text: 'G Theme', key: 'g' },
869
+ { text: ' • ', key: null },
870
+ { text: 'I Feedback, bugs & requests', key: 'i' },
871
+ ]
872
+ const footerRow2 = lines.length + 1
873
+ let xPos = 1
874
+ for (const part of parts) {
875
+ const w = displayWidth(part.text)
876
+ if (part.key) footerHotkeys.push({ key: part.key, row: footerRow2, xStart: xPos, xEnd: xPos + w - 1 })
877
+ xPos += w
878
+ }
879
+ }
839
880
 
840
- // 📖 Final footer line: changelog + optional active text-filter badge + exit hint.
841
- let filterBadge = ''
842
- if (hasCustomFilter) {
843
- const normalizedFilter = customTextFilter.trim().replace(/\s+/g, ' ')
844
- const filterPrefix = 'X Disable filter: "'
845
- const filterSuffix = '"'
846
- const separatorPlain = ''
847
- const baseFooterPlain = ' N Changelog' + separatorPlain + 'Ctrl+C Exit'
848
- const baseBadgeWidth = displayWidth(` ${filterPrefix}${filterSuffix} `)
849
- const availableFilterWidth = terminalCols > 0
850
- ? Math.max(8, terminalCols - displayWidth(baseFooterPlain) - displayWidth(separatorPlain) - baseBadgeWidth)
851
- : normalizedFilter.length
852
- const visibleFilter = normalizedFilter.length > availableFilterWidth
853
- ? `${normalizedFilter.slice(0, Math.max(3, availableFilterWidth - 3))}...`
854
- : normalizedFilter
855
- filterBadge = chalk.bgYellow.black.bold(` ${filterPrefix}${visibleFilter}${filterSuffix} `)
856
- }
881
+ // 📖 Line 2: command palette (highlighted as new), recommend, feedback, and extended hints.
882
+ // 📖 CTRL+P ⚡️ Command Palette uses neon-green-on-dark-green background to highlight the feature.
883
+ const paletteLabel = chalk.bgRgb(0, 60, 0).rgb(57, 255, 20).bold(' CTRL+P ⚡️ Command Palette ')
884
+ lines.push(
885
+ ' ' + paletteLabel + themeColors.dim(` • `) +
886
+ hotkey('Q', ' Smart Recommend') + themeColors.dim(` • `) +
887
+ hotkey('G', ' Theme') + themeColors.dim(``) +
888
+ hotkey('I', ' Feedback, bugs & requests')
889
+ )
890
+ // 📖 Proxy status is now shown via the badge in line 2 above — no need for a dedicated line
891
+ const footerLine =
892
+ themeColors.footerLove(' Made with 💖 & ☕ by \x1b]8;;https://github.com/vava-nessa\x1b\\vava-nessa\x1b]8;;\x1b\\') +
893
+ themeColors.dim(' • ') +
894
+ '⭐ ' +
895
+ themeColors.link('\x1b]8;;https://github.com/vava-nessa/free-coding-models\x1b\\Star on GitHub\x1b]8;;\x1b\\') +
896
+ themeColors.dim(' • ') +
897
+ '🤝 ' +
898
+ themeColors.warning('\x1b]8;;https://github.com/vava-nessa/free-coding-models/graphs/contributors\x1b\\Contributors\x1b]8;;\x1b\\') +
899
+ themeColors.dim(' • ') +
900
+ '☕ ' +
901
+ themeColors.footerCoffee('\x1b]8;;https://buymeacoffee.com/vavanessadev\x1b\\Buy me a coffee\x1b]8;;\x1b\\')
902
+ lines.push(footerLine)
903
+
904
+ if (versionStatus.isOutdated) {
905
+ const updateMsg = ` 🚀⬆️ UPDATE AVAILABLE — v${LOCAL_VERSION} → v${versionStatus.latestVersion} • Click here or press Shift+U to update 🚀⬆️ `
906
+ const paddedBanner = terminalCols > 0
907
+ ? updateMsg + ' '.repeat(Math.max(0, terminalCols - displayWidth(updateMsg)))
908
+ : updateMsg
909
+ const fluoGreenBanner = chalk.bgRgb(57, 255, 20).rgb(0, 0, 0).bold(paddedBanner)
910
+ const updateBannerRow = lines.length + 1
911
+ _lastLayout.updateBannerRow = updateBannerRow
912
+ footerHotkeys.push({ key: 'update-click', row: updateBannerRow, xStart: 1, xEnd: Math.max(terminalCols, displayWidth(updateMsg)) })
913
+ lines.push(fluoGreenBanner)
914
+ } else {
915
+ _lastLayout.updateBannerRow = 0
916
+ }
857
917
 
858
- // 📖 Mouse support: track last footer line hotkey zones
859
- {
860
- const lastFooterRow = lines.length + 1 // 📖 1-based terminal row (line about to be pushed)
861
- const parts = [
862
- { text: ' ', key: null },
863
- { text: 'N Changelog', key: 'n' },
864
- ]
918
+ // 📖 Final footer line: changelog + optional active text-filter badge + exit hint.
919
+ let filterBadge = ''
865
920
  if (hasCustomFilter) {
866
- parts.push({ text: ' • ', key: null })
867
- // 📖 X key clears filter — compute width from rendered badge text
868
- const badgePlain = `X Disable filter: "${customTextFilter.trim().replace(/\s+/g, ' ')}"`
869
- parts.push({ text: ` ${badgePlain} `, key: 'x' })
870
- }
871
- let xPos = 1
872
- for (const part of parts) {
873
- const w = displayWidth(part.text)
874
- if (part.key) footerHotkeys.push({ key: part.key, row: lastFooterRow, xStart: xPos, xEnd: xPos + w - 1 })
875
- xPos += w
921
+ const normalizedFilter = customTextFilter.trim().replace(/\s+/g, ' ')
922
+ const filterPrefix = 'X Disable filter: "'
923
+ const filterSuffix = '"'
924
+ const separatorPlain = ''
925
+ const baseFooterPlain = ' N Changelog' + separatorPlain + 'Ctrl+C Exit'
926
+ const baseBadgeWidth = displayWidth(` ${filterPrefix}${filterSuffix} `)
927
+ const availableFilterWidth = terminalCols > 0
928
+ ? Math.max(8, terminalCols - displayWidth(baseFooterPlain) - displayWidth(separatorPlain) - baseBadgeWidth)
929
+ : normalizedFilter.length
930
+ const visibleFilter = normalizedFilter.length > availableFilterWidth
931
+ ? `${normalizedFilter.slice(0, Math.max(3, availableFilterWidth - 3))}...`
932
+ : normalizedFilter
933
+ filterBadge = chalk.bgYellow.black.bold(` ${filterPrefix}${visibleFilter}${filterSuffix} `)
876
934
  }
877
- }
878
935
 
879
- _lastLayout.footerHotkeys = footerHotkeys
936
+ // 📖 Mouse support: track last footer line hotkey zones
937
+ {
938
+ const lastFooterRow = lines.length + 1 // 📖 1-based terminal row (line about to be pushed)
939
+ const parts = [
940
+ { text: ' ', key: null },
941
+ { text: 'N Changelog', key: 'n' },
942
+ ]
943
+ if (hasCustomFilter) {
944
+ parts.push({ text: ' • ', key: null })
945
+ // 📖 X key clears filter — compute width from rendered badge text
946
+ const badgePlain = `X Disable filter: "${customTextFilter.trim().replace(/\s+/g, ' ')}"`
947
+ parts.push({ text: ` ${badgePlain} `, key: 'x' })
948
+ }
949
+ let xPos = 1
950
+ for (const part of parts) {
951
+ const w = displayWidth(part.text)
952
+ if (part.key) footerHotkeys.push({ key: part.key, row: lastFooterRow, xStart: xPos, xEnd: xPos + w - 1 })
953
+ xPos += w
954
+ }
955
+ }
880
956
 
881
- if (footerHidden) {
882
- // 📖 Collapsed footer: single line with toggle hint
883
- lines.push(
884
- ' ' + themeColors.hotkey('Alt+W') + themeColors.dim(' Toggle Footer') +
885
- themeColors.dim(' • Ctrl+C Exit')
886
- )
887
- } else {
888
957
  const releaseLabel = lastReleaseDate
889
958
  ? chalk.rgb(255, 182, 193)(`Last release: ${lastReleaseDate}`)
890
959
  : ''
@@ -906,14 +975,27 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
906
975
  themeColors.dim(' → ') +
907
976
  themeColors.footerDiscord('https://discord.gg/ZTNFHvvCkU')
908
977
  )
978
+ } else {
979
+ // 📖 Collapsed footer: single line with toggle hint
980
+ lines.push(
981
+ ' ' + themeColors.hotkey('Ctrl+O') + themeColors.dim(' Toggle Footer') +
982
+ themeColors.dim(' • Ctrl+C Exit')
983
+ )
909
984
  }
910
985
 
986
+ _lastLayout.footerHotkeys = footerHotkeys
987
+
911
988
  // 📖 Append \x1b[K (erase to EOL) to each line so leftover chars from previous
912
- // 📖 frames are cleared. Then pad with blank cleared lines to fill the terminal,
913
- // 📖 preventing stale content from lingering at the bottom after resize.
989
+ // 📖 frames are cleared. \x1b[J (erase from cursor to end of screen) clears any
990
+ // 📖 stale content below when footer is hidden.
914
991
  const EL = '\x1b[K'
915
992
  const cleared = lines.map(l => l + EL)
916
- const remaining = terminalRows > 0 ? Math.max(0, terminalRows - cleared.length) : 0
917
- for (let i = 0; i < remaining; i++) cleared.push(EL)
993
+ if (footerHidden) {
994
+ // 📖 When footer is hidden, \x1b[J erases stale footer content below the cursor
995
+ cleared.push('\x1b[J')
996
+ } else {
997
+ const remaining = terminalRows > 0 ? Math.max(0, terminalRows - cleared.length) : 0
998
+ for (let i = 0; i < remaining; i++) cleared.push(EL)
999
+ }
918
1000
  return cleared.join('\n')
919
1001
  }
package/src/shell-env.js CHANGED
@@ -41,6 +41,7 @@ import { join } from 'node:path'
41
41
  import * as readline from 'node:readline'
42
42
  import chalk from 'chalk'
43
43
  import { ENV_VAR_NAMES } from './provider-metadata.js'
44
+ import { saveConfig } from './config.js'
44
45
 
45
46
  // 📖 Unique marker used to identify the source line we inject into shell rc files.
46
47
  // 📖 This allows idempotent add/remove without relying on exact path matching.
@@ -361,6 +362,8 @@ export async function promptShellEnvMigration(config) {
361
362
  render()
362
363
 
363
364
  readline.emitKeypressEvents(process.stdin)
365
+ // 📖 Ensure stdin is flowing — a prior prompt may have paused it
366
+ process.stdin.resume()
364
367
  if (process.stdin.isTTY) process.stdin.setRawMode(true)
365
368
 
366
369
  const onKey = (_str, key) => {
@@ -28,6 +28,7 @@
28
28
  export const TOOL_METADATA = {
29
29
  opencode: { label: 'OpenCode CLI', emoji: '📦', flag: '--opencode', color: [110, 214, 255] },
30
30
  'opencode-desktop': { label: 'OpenCode Desktop', emoji: '📦', flag: '--opencode-desktop', color: [149, 205, 255] },
31
+ 'opencode-web': { label: 'OpenCode Web', emoji: '📦', flag: '--opencode-web', color: [180, 220, 255] },
31
32
  openclaw: { label: 'OpenClaw', emoji: '🦞', flag: '--openclaw', color: [255, 129, 129] },
32
33
  crush: { label: 'Crush', emoji: '💘', flag: '--crush', color: [255, 168, 209] },
33
34
  goose: { label: 'Goose', emoji: '🪿', flag: '--goose', color: [132, 235, 168] },
@@ -49,7 +50,7 @@ export const TOOL_METADATA = {
49
50
  // 📖 OpenCode CLI + Desktop are merged into a single 📦 slot since they share compatibility.
50
51
  // 📖 Each slot maps to one or more toolKeys for compatibility checking.
51
52
  export const COMPAT_COLUMN_SLOTS = [
52
- { emoji: '📦', toolKeys: ['opencode', 'opencode-desktop'], color: [110, 214, 255] },
53
+ { emoji: '📦', toolKeys: ['opencode', 'opencode-desktop', 'opencode-web'], color: [110, 214, 255] },
53
54
  { emoji: '🦞', toolKeys: ['openclaw'], color: [255, 129, 129] },
54
55
  { emoji: '💘', toolKeys: ['crush'], color: [255, 168, 209] },
55
56
  { emoji: '🪿', toolKeys: ['goose'], color: [132, 235, 168] },
@@ -72,6 +73,7 @@ export const TOOL_MODE_ORDER = [
72
73
  'pi',
73
74
  'jcode',
74
75
  'opencode-desktop',
76
+ 'opencode-web',
75
77
  'openclaw',
76
78
  'crush',
77
79
  'goose',
@@ -100,7 +102,7 @@ export function getToolModeOrder() {
100
102
  const REGULAR_TOOLS = Object.keys(TOOL_METADATA).filter(k => !TOOL_METADATA[k].cliOnly)
101
103
 
102
104
  // 📖 Zen-only tools: OpenCode Zen models can ONLY run on OpenCode CLI / OpenCode Desktop.
103
- const ZEN_COMPATIBLE_TOOLS = ['opencode', 'opencode-desktop']
105
+ const ZEN_COMPATIBLE_TOOLS = ['opencode', 'opencode-desktop', 'opencode-web']
104
106
 
105
107
  /**
106
108
  * 📖 Returns the list of tool keys a model is compatible with.
package/src/updater.js CHANGED
@@ -394,6 +394,8 @@ export async function promptUpdateNotification(latestVersion) {
394
394
  render()
395
395
 
396
396
  readline.emitKeypressEvents(process.stdin)
397
+ // 📖 Ensure stdin is flowing — the shell-env prompt may have paused it
398
+ process.stdin.resume()
397
399
  if (process.stdin.isTTY) process.stdin.setRawMode(true)
398
400
 
399
401
  const onKey = (_str, key) => {
package/src/utils.js CHANGED
@@ -387,13 +387,13 @@ export function findBestModel(results) {
387
387
  //
388
388
  // 📖 Argument types:
389
389
  // - API key: first positional arg that does not look like a CLI flag (e.g., "nvapi-xxx")
390
- // - Boolean flags: --best, --fiable, --opencode, --opencode-desktop, --openclaw,
390
+ // - Boolean flags: --best, --fiable, --opencode, --opencode-desktop, --opencode-web, --openclaw,
391
391
  // --aider, --crush, --goose, --qwen,
392
392
  // --openhands, --amp, --pi, --no-telemetry, --json, --help/-h (case-insensitive)
393
393
  // - Value flag: --tier <letter> (the next non-flag arg is the tier value)
394
394
  //
395
395
  // Returns:
396
- // { apiKey, bestMode, fiableMode, openCodeMode, openCodeDesktopMode, openClawMode,
396
+ // { apiKey, bestMode, fiableMode, openCodeMode, openCodeDesktopMode, openCodeWebMode, openClawMode,
397
397
  // aiderMode, crushMode, gooseMode, qwenMode, openHandsMode, ampMode,
398
398
  // piMode, jcodeMode, noTelemetry, jsonMode, helpMode, tierFilter }
399
399
  //
@@ -446,6 +446,7 @@ export function parseArgs(argv) {
446
446
  const fiableMode = flags.includes('--fiable')
447
447
  const openCodeMode = flags.includes('--opencode')
448
448
  const openCodeDesktopMode = flags.includes('--opencode-desktop')
449
+ const openCodeWebMode = flags.includes('--opencode-web')
449
450
  const openClawMode = flags.includes('--openclaw')
450
451
  const aiderMode = flags.includes('--aider')
451
452
  const crushMode = flags.includes('--crush')
@@ -492,6 +493,7 @@ export function parseArgs(argv) {
492
493
  fiableMode,
493
494
  openCodeMode,
494
495
  openCodeDesktopMode,
496
+ openCodeWebMode,
495
497
  openClawMode,
496
498
  aiderMode,
497
499
  crushMode,