free-coding-models 0.3.21 β†’ 0.3.23

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.
@@ -31,7 +31,7 @@ import { loadChangelog } from './changelog-loader.js'
31
31
  import { loadConfig, replaceConfigContents } from './config.js'
32
32
  import { cleanupLegacyProxyArtifacts } from './legacy-proxy-cleanup.js'
33
33
  import { cycleThemeSetting, detectActiveTheme } from './theme.js'
34
- import { buildCommandPaletteEntries, filterCommandPaletteEntries } from './command-palette.js'
34
+ import { buildCommandPaletteTree, flattenCommandTree, filterCommandPaletteEntries } from './command-palette.js'
35
35
  import { WIDTH_WARNING_MIN_COLS } from './constants.js'
36
36
 
37
37
  // πŸ“– Some providers need an explicit probe model because the first catalog entry
@@ -519,7 +519,9 @@ export function createKeyHandler(ctx) {
519
519
  // πŸ“– Shared table refresh helper so command-palette and hotkeys keep identical behavior.
520
520
  function refreshVisibleSorted({ resetCursor = true } = {}) {
521
521
  const visible = state.results.filter(r => !r.hidden)
522
- state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
522
+ state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection, {
523
+ pinFavorites: state.favoritesPinnedAndSticky,
524
+ })
523
525
  if (resetCursor) {
524
526
  state.cursor = 0
525
527
  state.scrollOffset = 0
@@ -600,15 +602,52 @@ export function createKeyHandler(ctx) {
600
602
  const modeOrder = getToolModeOrder()
601
603
  const currentIndex = modeOrder.indexOf(state.mode)
602
604
  const nextIndex = (currentIndex + 1) % modeOrder.length
603
- state.mode = modeOrder[nextIndex]
605
+ setToolMode(modeOrder[nextIndex])
606
+ }
607
+
608
+ // πŸ“– Keep tool-mode changes centralized so keyboard shortcuts and command palette
609
+ // πŸ“– both persist to config exactly the same way.
610
+ function setToolMode(nextMode) {
611
+ if (!getToolModeOrder().includes(nextMode)) return
612
+ state.mode = nextMode
604
613
  if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
605
614
  state.config.settings.preferredToolMode = state.mode
606
615
  saveConfig(state.config)
607
616
  }
608
617
 
618
+ // πŸ“– Favorites display mode:
619
+ // πŸ“– - true => favorites stay pinned + always visible (legacy behavior)
620
+ // πŸ“– - false => favorites are just starred rows and obey normal sort/filter rules
621
+ function setFavoritesDisplayMode(nextPinned, { preserveSelection = true } = {}) {
622
+ const normalizedNextPinned = nextPinned !== false
623
+ if (state.favoritesPinnedAndSticky === normalizedNextPinned) return
624
+
625
+ const selected = preserveSelection ? state.visibleSorted[state.cursor] : null
626
+ const selectedKey = selected ? toFavoriteKey(selected.providerKey, selected.modelId) : null
627
+
628
+ state.favoritesPinnedAndSticky = normalizedNextPinned
629
+ if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
630
+ state.config.settings.favoritesPinnedAndSticky = state.favoritesPinnedAndSticky
631
+ saveConfig(state.config)
632
+
633
+ applyTierFilter()
634
+ refreshVisibleSorted({ resetCursor: false })
635
+
636
+ if (selectedKey) {
637
+ const selectedIdx = state.visibleSorted.findIndex((row) => toFavoriteKey(row.providerKey, row.modelId) === selectedKey)
638
+ if (selectedIdx >= 0) state.cursor = selectedIdx
639
+ adjustScrollOffset(state)
640
+ }
641
+ }
642
+
643
+ function toggleFavoritesDisplayMode() {
644
+ setFavoritesDisplayMode(!state.favoritesPinnedAndSticky)
645
+ }
646
+
609
647
  function resetViewSettings() {
610
648
  state.tierFilterMode = 0
611
649
  state.originFilterMode = 0
650
+ state.customTextFilter = null // πŸ“– Clear ephemeral text filter on view reset
612
651
  state.sortColumn = 'avg'
613
652
  state.sortDirection = 'asc'
614
653
  if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
@@ -630,7 +669,7 @@ export function createKeyHandler(ctx) {
630
669
  applyTierFilter()
631
670
  refreshVisibleSorted({ resetCursor: false })
632
671
 
633
- if (wasFavorite) {
672
+ if (wasFavorite && state.favoritesPinnedAndSticky) {
634
673
  state.cursor = 0
635
674
  state.scrollOffset = 0
636
675
  return
@@ -653,8 +692,50 @@ export function createKeyHandler(ctx) {
653
692
  }
654
693
 
655
694
  function refreshCommandPaletteResults() {
656
- const commands = buildCommandPaletteEntries()
657
- state.commandPaletteResults = filterCommandPaletteEntries(commands, state.commandPaletteQuery)
695
+ const query = (state.commandPaletteQuery || '').trim()
696
+ const tree = buildCommandPaletteTree(state.results || [])
697
+ // πŸ“– Keep collapsed view clean when query is empty, but search across the
698
+ // πŸ“– full tree when users type so hidden submenu commands still appear.
699
+ let flat
700
+ if (query.length > 0) {
701
+ const expandedIds = new Set()
702
+ const collectExpandedIds = (nodes) => {
703
+ for (const node of nodes || []) {
704
+ if (Array.isArray(node.children) && node.children.length > 0) {
705
+ expandedIds.add(node.id)
706
+ collectExpandedIds(node.children)
707
+ }
708
+ }
709
+ }
710
+ collectExpandedIds(tree)
711
+ flat = flattenCommandTree(tree, expandedIds)
712
+ } else {
713
+ flat = flattenCommandTree(tree, state.commandPaletteExpandedIds)
714
+ }
715
+ state.commandPaletteResults = filterCommandPaletteEntries(flat, query)
716
+
717
+ if (query.length > 0) {
718
+ state.commandPaletteResults.unshift({
719
+ id: 'filter-custom-text-apply',
720
+ label: `πŸ” Apply text filter: ${query}`,
721
+ type: 'command',
722
+ depth: 0,
723
+ hasChildren: false,
724
+ isExpanded: false,
725
+ filterQuery: query,
726
+ })
727
+ } else if (state.customTextFilter) {
728
+ state.commandPaletteResults.unshift({
729
+ id: 'filter-custom-text-remove',
730
+ label: `❌ Remove custom filter: ${state.customTextFilter}`,
731
+ type: 'command',
732
+ depth: 0,
733
+ hasChildren: false,
734
+ isExpanded: false,
735
+ filterQuery: null,
736
+ })
737
+ }
738
+
658
739
  if (state.commandPaletteCursor >= state.commandPaletteResults.length) {
659
740
  state.commandPaletteCursor = Math.max(0, state.commandPaletteResults.length - 1)
660
741
  }
@@ -681,8 +762,59 @@ export function createKeyHandler(ctx) {
681
762
  function executeCommandPaletteEntry(entry) {
682
763
  if (!entry?.id) return
683
764
 
765
+ if (entry.id.startsWith('action-set-ping-') && entry.pingMode) {
766
+ setPingMode(entry.pingMode, 'manual')
767
+ return
768
+ }
769
+
770
+ if (entry.id.startsWith('action-set-tool-') && entry.toolMode) {
771
+ setToolMode(entry.toolMode)
772
+ return
773
+ }
774
+
775
+ if (entry.id.startsWith('action-favorites-mode-') && typeof entry.favoritesPinned === 'boolean') {
776
+ setFavoritesDisplayMode(entry.favoritesPinned)
777
+ return
778
+ }
779
+
684
780
  if (entry.id.startsWith('filter-tier-')) {
685
- setTierFilterFromCommand(entry.tierValue ?? null)
781
+ setTierFilterFromCommand(entry.tier ?? null)
782
+ return
783
+ }
784
+
785
+ if (entry.id.startsWith('filter-provider-') && entry.id !== 'filter-provider-cycle') {
786
+ if (entry.providerKey === null || entry.providerKey === undefined) {
787
+ state.originFilterMode = 0 // All
788
+ } else {
789
+ state.originFilterMode = ORIGIN_CYCLE.findIndex(key => key === entry.providerKey) + 1
790
+ if (state.originFilterMode <= 0) state.originFilterMode = 0
791
+ }
792
+ applyTierFilter()
793
+ refreshVisibleSorted({ resetCursor: true })
794
+ persistUiSettings()
795
+ return
796
+ }
797
+
798
+ if (entry.id.startsWith('filter-model-')) {
799
+ if (entry.modelId && entry.providerKey) {
800
+ state.customTextFilter = `${entry.providerKey}/${entry.modelId}`
801
+ applyTierFilter()
802
+ refreshVisibleSorted({ resetCursor: true })
803
+ }
804
+ return
805
+ }
806
+
807
+ // πŸ“– Custom text filter β€” apply or remove the free-text filter from the command palette.
808
+ if (entry.id === 'filter-custom-text-apply') {
809
+ state.customTextFilter = entry.filterQuery || null
810
+ applyTierFilter()
811
+ refreshVisibleSorted({ resetCursor: true })
812
+ return
813
+ }
814
+ if (entry.id === 'filter-custom-text-remove') {
815
+ state.customTextFilter = null
816
+ applyTierFilter()
817
+ refreshVisibleSorted({ resetCursor: true })
686
818
  return
687
819
  }
688
820
 
@@ -731,6 +863,7 @@ export function createKeyHandler(ctx) {
731
863
  return
732
864
  }
733
865
  case 'action-toggle-favorite': return toggleFavoriteOnSelectedRow()
866
+ case 'action-toggle-favorite-mode': return toggleFavoritesDisplayMode()
734
867
  case 'action-reset-view': return resetViewSettings()
735
868
  default:
736
869
  return
@@ -758,6 +891,7 @@ export function createKeyHandler(ctx) {
758
891
  if (key.ctrl && key.name === 'c') { exit(0); return }
759
892
 
760
893
  const pageStep = Math.max(1, (state.terminalRows || 1) - 10)
894
+ const selected = state.commandPaletteResults[state.commandPaletteCursor]
761
895
 
762
896
  if (key.name === 'escape') {
763
897
  closeCommandPalette()
@@ -775,6 +909,23 @@ export function createKeyHandler(ctx) {
775
909
  state.commandPaletteCursor = state.commandPaletteCursor < count - 1 ? state.commandPaletteCursor + 1 : 0
776
910
  return
777
911
  }
912
+ if (key.name === 'left') {
913
+ if (selected?.hasChildren && selected.isExpanded) {
914
+ state.commandPaletteExpandedIds.delete(selected.id)
915
+ refreshCommandPaletteResults()
916
+ }
917
+ return
918
+ }
919
+ if (key.name === 'right') {
920
+ if (selected?.hasChildren && !selected.isExpanded) {
921
+ state.commandPaletteExpandedIds.add(selected.id)
922
+ refreshCommandPaletteResults()
923
+ } else if (selected?.type === 'command') {
924
+ closeCommandPalette()
925
+ executeCommandPaletteEntry(selected)
926
+ }
927
+ return
928
+ }
778
929
  if (key.name === 'pageup') {
779
930
  state.commandPaletteCursor = Math.max(0, state.commandPaletteCursor - pageStep)
780
931
  return
@@ -800,9 +951,17 @@ export function createKeyHandler(ctx) {
800
951
  return
801
952
  }
802
953
  if (key.name === 'return') {
803
- const selectedCommand = state.commandPaletteResults[state.commandPaletteCursor]
804
- closeCommandPalette()
805
- executeCommandPaletteEntry(selectedCommand)
954
+ if (selected?.hasChildren) {
955
+ if (selected.isExpanded) {
956
+ state.commandPaletteExpandedIds.delete(selected.id)
957
+ } else {
958
+ state.commandPaletteExpandedIds.add(selected.id)
959
+ }
960
+ refreshCommandPaletteResults()
961
+ } else {
962
+ closeCommandPalette()
963
+ executeCommandPaletteEntry(selected)
964
+ }
806
965
  return
807
966
  }
808
967
  if (str && str.length === 1 && !key.ctrl && !key.meta) {
@@ -1354,7 +1513,8 @@ export function createKeyHandler(ctx) {
1354
1513
  const providerKeys = Object.keys(sources)
1355
1514
  const updateRowIdx = providerKeys.length
1356
1515
  const themeRowIdx = updateRowIdx + 1
1357
- const cleanupLegacyProxyRowIdx = themeRowIdx + 1
1516
+ const favoritesModeRowIdx = themeRowIdx + 1
1517
+ const cleanupLegacyProxyRowIdx = favoritesModeRowIdx + 1
1358
1518
  const changelogViewRowIdx = cleanupLegacyProxyRowIdx + 1
1359
1519
  // πŸ“– Profile system removed - API keys now persist permanently across all sessions
1360
1520
  const maxRowIdx = changelogViewRowIdx
@@ -1435,10 +1595,7 @@ export function createKeyHandler(ctx) {
1435
1595
  setResults(nextResults)
1436
1596
  syncFavoriteFlags(state.results, state.config)
1437
1597
  applyTierFilter()
1438
- const visible = state.results.filter(r => !r.hidden)
1439
- state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
1440
- if (state.cursor >= state.visibleSorted.length) state.cursor = Math.max(0, state.visibleSorted.length - 1)
1441
- adjustScrollOffset(state)
1598
+ refreshVisibleSorted({ resetCursor: false })
1442
1599
  // πŸ“– Re-ping all models that were 'noauth' (got 401 without key) but now have a key
1443
1600
  // πŸ“– This makes the TUI react immediately when a user adds an API key in settings
1444
1601
  const pingModel = getPingModel?.()
@@ -1503,6 +1660,11 @@ export function createKeyHandler(ctx) {
1503
1660
  return
1504
1661
  }
1505
1662
 
1663
+ if (state.settingsCursor === favoritesModeRowIdx) {
1664
+ toggleFavoritesDisplayMode()
1665
+ return
1666
+ }
1667
+
1506
1668
  if (state.settingsCursor === cleanupLegacyProxyRowIdx) {
1507
1669
  runLegacyProxyCleanup()
1508
1670
  return
@@ -1541,6 +1703,10 @@ export function createKeyHandler(ctx) {
1541
1703
  cycleGlobalTheme()
1542
1704
  return
1543
1705
  }
1706
+ if (state.settingsCursor === favoritesModeRowIdx) {
1707
+ toggleFavoritesDisplayMode()
1708
+ return
1709
+ }
1544
1710
  // πŸ“– Profile system removed - API keys now persist permanently across all sessions
1545
1711
 
1546
1712
  // πŸ“– Toggle enabled/disabled for selected provider
@@ -1556,6 +1722,7 @@ export function createKeyHandler(ctx) {
1556
1722
  if (
1557
1723
  state.settingsCursor === updateRowIdx
1558
1724
  || state.settingsCursor === themeRowIdx
1725
+ || state.settingsCursor === favoritesModeRowIdx
1559
1726
  || state.settingsCursor === cleanupLegacyProxyRowIdx
1560
1727
  || state.settingsCursor === changelogViewRowIdx
1561
1728
  ) return
@@ -1573,6 +1740,12 @@ export function createKeyHandler(ctx) {
1573
1740
  return
1574
1741
  }
1575
1742
 
1743
+ // πŸ“– Y toggles favorites display mode directly from Settings.
1744
+ if (key.name === 'y') {
1745
+ toggleFavoritesDisplayMode()
1746
+ return
1747
+ }
1748
+
1576
1749
  // πŸ“– Profile system removed - API keys now persist permanently across all sessions
1577
1750
 
1578
1751
  if (key.ctrl && key.name === 'c') { exit(0); return }
@@ -1619,9 +1792,18 @@ export function createKeyHandler(ctx) {
1619
1792
  return
1620
1793
  }
1621
1794
 
1622
- // πŸ“– Y key: open Install Endpoints flow for configured providers.
1623
- if (key.name === 'y') {
1624
- openInstallEndpointsOverlay()
1795
+ // πŸ“– Y key toggles favorites display mode (pinned+sticky vs normal rows).
1796
+ if (key.name === 'y' && !key.ctrl && !key.meta) {
1797
+ toggleFavoritesDisplayMode()
1798
+ return
1799
+ }
1800
+
1801
+ // πŸ“– X clears the active free-text filter set from the command palette.
1802
+ if (key.name === 'x' && !key.ctrl && !key.meta) {
1803
+ if (!state.customTextFilter) return
1804
+ state.customTextFilter = null
1805
+ applyTierFilter()
1806
+ refreshVisibleSorted({ resetCursor: true })
1625
1807
  return
1626
1808
  }
1627
1809
 
@@ -1636,7 +1818,8 @@ export function createKeyHandler(ctx) {
1636
1818
  }
1637
1819
 
1638
1820
  // πŸ“– Sorting keys: R=rank, O=origin, M=model, L=latest ping, A=avg ping, S=SWE-bench, C=context, H=health, V=verdict, B=stability, U=uptime, G=usage
1639
- // πŸ“– T is reserved for tier filter cycling. Y now opens the install-endpoints flow.
1821
+ // πŸ“– T is reserved for tier filter cycling. Y toggles favorites display mode.
1822
+ // πŸ“– X clears the active custom text filter.
1640
1823
  // πŸ“– D is now reserved for provider filter cycling
1641
1824
  // πŸ“– Shift+R is reserved for reset view settings
1642
1825
  const sortKeys = {
@@ -1679,10 +1862,7 @@ export function createKeyHandler(ctx) {
1679
1862
  state.config.settings.hideUnconfiguredModels = state.hideUnconfiguredModels
1680
1863
  saveConfig(state.config)
1681
1864
  applyTierFilter()
1682
- const visible = state.results.filter(r => !r.hidden)
1683
- state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
1684
- state.cursor = 0
1685
- state.scrollOffset = 0
1865
+ refreshVisibleSorted({ resetCursor: true })
1686
1866
  return
1687
1867
  }
1688
1868
 
@@ -1691,10 +1871,7 @@ export function createKeyHandler(ctx) {
1691
1871
  state.tierFilterMode = (state.tierFilterMode + 1) % TIER_CYCLE.length
1692
1872
  applyTierFilter()
1693
1873
  // πŸ“– Recompute visible sorted list and reset cursor to avoid stale index into new filtered set
1694
- const visible = state.results.filter(r => !r.hidden)
1695
- state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
1696
- state.cursor = 0
1697
- state.scrollOffset = 0
1874
+ refreshVisibleSorted({ resetCursor: true })
1698
1875
  persistUiSettings()
1699
1876
  return
1700
1877
  }
@@ -1704,10 +1881,7 @@ export function createKeyHandler(ctx) {
1704
1881
  state.originFilterMode = (state.originFilterMode + 1) % ORIGIN_CYCLE.length
1705
1882
  applyTierFilter()
1706
1883
  // πŸ“– Recompute visible sorted list and reset cursor to avoid stale index into new filtered set
1707
- const visible = state.results.filter(r => !r.hidden)
1708
- state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
1709
- state.cursor = 0
1710
- state.scrollOffset = 0
1884
+ refreshVisibleSorted({ resetCursor: true })
1711
1885
  persistUiSettings()
1712
1886
  return
1713
1887
  }
package/src/overlays.js CHANGED
@@ -94,7 +94,8 @@ export function createOverlayRenderers(state, deps) {
94
94
  const providerKeys = Object.keys(sources)
95
95
  const updateRowIdx = providerKeys.length
96
96
  const themeRowIdx = updateRowIdx + 1
97
- const cleanupLegacyProxyRowIdx = themeRowIdx + 1
97
+ const favoritesModeRowIdx = themeRowIdx + 1
98
+ const cleanupLegacyProxyRowIdx = favoritesModeRowIdx + 1
98
99
  const changelogViewRowIdx = cleanupLegacyProxyRowIdx + 1
99
100
  const EL = '\x1b[K'
100
101
  const lines = []
@@ -224,6 +225,16 @@ export function createOverlayRenderers(state, deps) {
224
225
  const themeRow = `${bullet(state.settingsCursor === themeRowIdx)}${themeColors.textBold('Global Theme').padEnd(44)} ${themeStatusColor(themeStatus)}`
225
226
  cursorLineByRow[themeRowIdx] = lines.length
226
227
  lines.push(state.settingsCursor === themeRowIdx ? themeColors.bgCursor(themeRow) : themeRow)
228
+
229
+ // πŸ“– Favorites mode row mirrors Y-key behavior from the main table.
230
+ const favoritesModeEnabled = state.favoritesPinnedAndSticky === true
231
+ const favoritesModeStatus = favoritesModeEnabled
232
+ ? themeColors.warningBold('Pinned + always visible')
233
+ : themeColors.info('Normal rows (filter/sort)')
234
+ const favoritesModeRow = `${bullet(state.settingsCursor === favoritesModeRowIdx)}${themeColors.textBold('Favorites Display Mode').padEnd(44)} ${favoritesModeStatus}`
235
+ cursorLineByRow[favoritesModeRowIdx] = lines.length
236
+ lines.push(state.settingsCursor === favoritesModeRowIdx ? themeColors.bgCursorSettingsList(favoritesModeRow) : favoritesModeRow)
237
+
227
238
  if (updateState === 'error' && state.settingsUpdateError) {
228
239
  lines.push(themeColors.error(` ${state.settingsUpdateError}`))
229
240
  }
@@ -244,7 +255,7 @@ export function createOverlayRenderers(state, deps) {
244
255
  if (state.settingsEditMode) {
245
256
  lines.push(themeColors.dim(' Type API key β€’ Enter Save β€’ Esc Cancel'))
246
257
  } else {
247
- lines.push(themeColors.dim(' ↑↓ Navigate β€’ Enter Edit/Run/Cycle β€’ + Add key β€’ - Remove key β€’ Space Toggle/Cycle β€’ T Test key β€’ U Updates β€’ G Global theme β€’ Esc Close'))
258
+ lines.push(themeColors.dim(' ↑↓ Navigate β€’ Enter Edit/Run/Cycle β€’ + Add key β€’ - Remove key β€’ Space Toggle/Cycle β€’ T Test key β€’ U Updates β€’ G Global theme β€’ Y Favorites mode β€’ Esc Close'))
248
259
  }
249
260
  // πŸ“– Show sync/restore status message if set
250
261
  if (state.settingsSyncStatus) {
@@ -283,7 +294,7 @@ export function createOverlayRenderers(state, deps) {
283
294
 
284
295
  // ─── Install Endpoints overlay renderer ───────────────────────────────────
285
296
  // πŸ“– renderInstallEndpoints drives the provider β†’ tool β†’ scope β†’ model flow
286
- // πŸ“– behind the `Y` hotkey. It deliberately reuses the same overlay viewport
297
+ // πŸ“– opened from Settings/Command Palette. It deliberately reuses the same overlay viewport
287
298
  // πŸ“– helpers as Settings so long provider/model lists stay navigable.
288
299
  function renderInstallEndpoints() {
289
300
  const EL = '\x1b[K'
@@ -528,18 +539,16 @@ export function createOverlayRenderers(state, deps) {
528
539
 
529
540
  // ─── Command palette renderer ──────────────────────────────────────────────
530
541
  // πŸ“– renderCommandPalette draws a centered floating modal over the live table.
531
- // πŸ“– It returns cursor-positioned ANSI rows instead of replacing the full screen,
532
- // πŸ“– so ping updates continue to animate in the background behind the palette.
542
+ // πŸ“– Supports hierarchical categories with expand/collapse and rich colors.
533
543
  function renderCommandPalette() {
534
544
  const terminalRows = state.terminalRows || 24
535
545
  const terminalCols = state.terminalCols || 80
536
- const panelWidth = Math.max(44, Math.min(96, terminalCols - 8))
537
- const panelInnerWidth = Math.max(28, panelWidth - 4)
546
+ const panelWidth = Math.max(52, Math.min(100, terminalCols - 8))
547
+ const panelInnerWidth = Math.max(32, panelWidth - 4)
538
548
  const panelPad = 2
539
549
  const panelOuterWidth = panelWidth + (panelPad * 2)
540
- const footerRowCount = 2
541
- const headerRowCount = 3
542
- const bodyRows = Math.max(6, Math.min(16, terminalRows - 12))
550
+ const headerRowCount = 4
551
+ const bodyRows = Math.max(8, Math.min(18, terminalRows - 12))
543
552
 
544
553
  const truncatePlain = (text, width) => {
545
554
  if (width <= 1) return ''
@@ -559,30 +568,70 @@ export function createOverlayRenderers(state, deps) {
559
568
  }
560
569
 
561
570
  const allResults = Array.isArray(state.commandPaletteResults) ? state.commandPaletteResults.slice(0, 80) : []
562
- const groupedLines = []
571
+ const panelLines = []
563
572
  const cursorLineByRow = {}
564
- let category = null
565
573
 
566
574
  if (allResults.length === 0) {
567
- groupedLines.push(themeColors.dim(' No command found. Try a broader query.'))
575
+ panelLines.push(themeColors.dim(' No commands found. Try a different search.'))
568
576
  } else {
569
577
  for (let idx = 0; idx < allResults.length; idx++) {
570
578
  const entry = allResults[idx]
571
- if (entry.category !== category) {
572
- category = entry.category
573
- groupedLines.push(themeColors.textBold(` ${category}`))
579
+ const isCursor = idx === state.commandPaletteCursor
580
+
581
+ const indent = ' '.repeat(entry.depth || 0)
582
+ const expandIndicator = entry.hasChildren
583
+ ? (entry.isExpanded ? themeColors.infoBold('β–Ό') : themeColors.dim('β–Ά'))
584
+ : themeColors.dim('β€’')
585
+
586
+ // πŸ“– Only use icon from entry, label should NOT include emoji
587
+ const iconPrefix = entry.icon ? `${entry.icon} ` : ''
588
+ const plainLabel = truncatePlain(entry.label, panelInnerWidth - indent.length - iconPrefix.length - 4)
589
+ const label = entry.matchPositions ? highlightMatch(plainLabel, entry.matchPositions) : plainLabel
590
+
591
+ let rowLine
592
+ if (entry.type === 'category') {
593
+ rowLine = `${indent}${expandIndicator} ${iconPrefix}${themeColors.headerBold(label)}`
594
+ } else if (entry.type === 'subcategory') {
595
+ rowLine = `${indent}${expandIndicator} ${iconPrefix}${themeColors.textBold(label)}`
596
+ } else if (entry.type === 'page') {
597
+ // πŸ“– Pages are at root level with icon + label + shortcut + description
598
+ const shortcut = entry.shortcut ? themeColors.dim(` (${entry.shortcut})`) : ''
599
+ const description = entry.description ? themeColors.dim(` β€” ${entry.description}`) : ''
600
+ rowLine = `${expandIndicator} ${iconPrefix}${themeColors.textBold(label)}${shortcut}${description}`
601
+ } else if (entry.type === 'action') {
602
+ // πŸ“– Actions are at root level with icon + label + shortcut + description
603
+ const shortcut = entry.shortcut ? themeColors.dim(` (${entry.shortcut})`) : ''
604
+ const description = entry.description ? themeColors.dim(` β€” ${entry.description}`) : ''
605
+ rowLine = `${expandIndicator} ${iconPrefix}${themeColors.textBold(label)}${shortcut}${description}`
606
+ } else {
607
+ // πŸ“– Regular commands in submenus
608
+ const shortcut = entry.shortcut ? themeColors.dim(` (${entry.shortcut})`) : ''
609
+ const description = entry.description ? themeColors.dim(` β€” ${entry.description}`) : ''
610
+ // πŸ“– Color tiers and providers
611
+ let coloredLabel = label
612
+ let prefixWithIcon = iconPrefix
613
+
614
+ if (entry.providerKey && !entry.icon) {
615
+ // πŸ“– Model filter: add provider icon
616
+ const providerIcon = '🏒'
617
+ prefixWithIcon = `${providerIcon} `
618
+ coloredLabel = themeColors.provider(entry.providerKey, label, { bold: false })
619
+ } else if (entry.tier) {
620
+ coloredLabel = themeColors.tier(entry.tier, label)
621
+ } else if (entry.providerKey) {
622
+ coloredLabel = themeColors.provider(entry.providerKey, label, { bold: false })
623
+ }
624
+
625
+ rowLine = `${indent} ${expandIndicator} ${prefixWithIcon}${coloredLabel}${shortcut}${description}`
574
626
  }
575
627
 
576
- const isCursor = idx === state.commandPaletteCursor
577
- const pointer = isCursor ? themeColors.accentBold(' ❯ ') : themeColors.dim(' ')
578
- const shortcutText = entry.shortcut ? themeColors.dim(entry.shortcut) : ''
579
- const shortcutWidth = entry.shortcut ? Math.min(16, displayWidth(entry.shortcut)) : 0
580
- const labelMax = Math.max(12, panelInnerWidth - 8 - shortcutWidth)
581
- const plainLabel = truncatePlain(entry.label, labelMax)
582
- const label = highlightMatch(plainLabel, entry.matchPositions)
583
- const row = `${pointer}${padEndDisplay(label, labelMax)}${entry.shortcut ? ` ${shortcutText}` : ''}`
584
- cursorLineByRow[idx] = groupedLines.length
585
- groupedLines.push(isCursor ? themeColors.bgCursor(row) : row)
628
+ cursorLineByRow[idx] = panelLines.length
629
+
630
+ if (isCursor) {
631
+ panelLines.push(themeColors.bgCursor(rowLine))
632
+ } else {
633
+ panelLines.push(rowLine)
634
+ }
586
635
  }
587
636
  }
588
637
 
@@ -590,44 +639,43 @@ export function createOverlayRenderers(state, deps) {
590
639
  state.commandPaletteScrollOffset = keepOverlayTargetVisible(
591
640
  state.commandPaletteScrollOffset,
592
641
  targetLine,
593
- groupedLines.length,
642
+ panelLines.length,
594
643
  bodyRows
595
644
  )
596
- const { visible, offset } = sliceOverlayLines(groupedLines, state.commandPaletteScrollOffset, bodyRows)
645
+ const { visible, offset } = sliceOverlayLines(panelLines, state.commandPaletteScrollOffset, bodyRows)
597
646
  state.commandPaletteScrollOffset = offset
598
647
 
599
648
  const query = state.commandPaletteQuery || ''
600
649
  const queryWithCursor = query.length > 0
601
- ? themeColors.textBold(`${query}▏`)
602
- : themeColors.dim('type a command…') + themeColors.accentBold('▏')
650
+ ? `${query}${themeColors.accentBold('▏')}`
651
+ : themeColors.accentBold('▏') + themeColors.dim(' Search commands…')
603
652
 
604
- const panelLines = []
605
- const title = themeColors.textBold('Command Palette')
653
+ const headerLines = []
654
+ const title = themeColors.headerBold('⚑️ Command Palette')
606
655
  const titleLeft = ` ${title}`
607
- const titleRight = themeColors.dim('Esc close')
608
- const titleWidth = Math.max(1, panelInnerWidth - 1 - displayWidth('Esc close'))
609
- panelLines.push(`${padEndDisplay(titleLeft, titleWidth)} ${titleRight}`)
610
- panelLines.push(` ${padEndDisplay(`> ${queryWithCursor}`, panelInnerWidth)}`)
611
- panelLines.push(themeColors.dim(` ${'-'.repeat(Math.max(1, panelInnerWidth))}`))
612
-
613
- for (const line of visible) {
614
- panelLines.push(` ${padEndDisplay(line, panelInnerWidth)}`)
615
- }
656
+ const titleRight = themeColors.dim('Esc')
657
+ const titleWidth = Math.max(1, panelInnerWidth - 1 - displayWidth('Esc'))
658
+ headerLines.push(`${padEndDisplay(titleLeft, titleWidth)} ${titleRight}`)
659
+ headerLines.push(` ${padEndDisplay(`> ${queryWithCursor}`, panelInnerWidth)}`)
660
+ headerLines.push(themeColors.dim(` ${'─'.repeat(Math.max(1, panelInnerWidth))}`))
661
+
662
+ const footerLines = [
663
+ themeColors.dim(` ${'─'.repeat(Math.max(1, panelInnerWidth))}`),
664
+ ` ${padEndDisplay(themeColors.dim('↡ Select β€’ ← β†’ Expand'), panelInnerWidth)}`,
665
+ ` ${padEndDisplay(themeColors.dim('↑↓ Navigate β€’ Type search'), panelInnerWidth)}`,
666
+ ]
616
667
 
617
- // πŸ“– Keep panel body stable by filling with blank rows when result list is short.
618
- while (panelLines.length < bodyRows + headerRowCount) {
619
- panelLines.push(` ${' '.repeat(panelInnerWidth)}`)
668
+ const allPanelLines = [...headerLines, ...visible, ...footerLines]
669
+
670
+ while (allPanelLines.length < bodyRows + headerRowCount + 3) {
671
+ allPanelLines.splice(headerLines.length + visible.length, 0, ` ${' '.repeat(panelInnerWidth)}`)
620
672
  }
621
673
 
622
- panelLines.push(themeColors.dim(` ${'-'.repeat(Math.max(1, panelInnerWidth))}`))
623
- panelLines.push(` ${padEndDisplay(themeColors.dim('↑↓ navigate β€’ Enter run β€’ Type to search'), panelInnerWidth)}`)
624
- panelLines.push(` ${padEndDisplay(themeColors.dim('PgUp/PgDn β€’ Home/End'), panelInnerWidth)}`)
625
-
626
674
  const blankPaddedLine = ' '.repeat(panelOuterWidth)
627
675
  const paddedPanelLines = [
628
676
  blankPaddedLine,
629
677
  blankPaddedLine,
630
- ...panelLines.map((line) => `${' '.repeat(panelPad)}${padEndDisplay(line, panelWidth)}${' '.repeat(panelPad)}`),
678
+ ...allPanelLines.map((line) => `${' '.repeat(panelPad)}${padEndDisplay(line, panelWidth)}${' '.repeat(panelPad)}`),
631
679
  blankPaddedLine,
632
680
  blankPaddedLine,
633
681
  ]
@@ -641,8 +689,6 @@ export function createOverlayRenderers(state, deps) {
641
689
  return themeColors.overlayBgCommandPalette(padded)
642
690
  })
643
691
 
644
- // πŸ“– Absolute cursor positioning overlays the palette on top of the existing table.
645
- // πŸ“– The next frame starts with ALT_HOME, so this remains stable without manual cleanup.
646
692
  return tintedLines
647
693
  .map((line, idx) => `\x1b[${top + idx};${left}H${line}`)
648
694
  .join('')
@@ -678,8 +724,8 @@ export function createOverlayRenderers(state, deps) {
678
724
  lines.push(` ${label('CTX')} Context window size (128k, 200k, 256k, 1m, etc.) ${hint('Sort:')} ${key('C')}`)
679
725
  lines.push(` ${hint('Bigger context = the model can read more of your codebase at once without forgetting.')}`)
680
726
  lines.push('')
681
- lines.push(` ${label('Model')} Model name (⭐ = favorited, pinned at top) ${hint('Sort:')} ${key('M')} ${hint('Favorite:')} ${key('F')}`)
682
- lines.push(` ${hint('Star the ones you like β€” they stay pinned at the top across restarts.')}`)
727
+ lines.push(` ${label('Model')} Model name (⭐ = favorited) ${hint('Sort:')} ${key('M')} ${hint('Favorite:')} ${key('F')}`)
728
+ lines.push(` ${hint('Star the ones you like. Press Y to switch between pinned mode and normal filter/sort mode.')}`)
683
729
  lines.push('')
684
730
  lines.push(` ${label('Provider')} Provider source (NIM, Groq, Cerebras, etc.) ${hint('Sort:')} ${key('O')} ${hint('Cycle:')} ${key('D')}`)
685
731
  lines.push(` ${hint('Same model on different providers can have very different speed and uptime.')}`)
@@ -716,11 +762,12 @@ export function createOverlayRenderers(state, deps) {
716
762
  lines.push('')
717
763
  lines.push(` ${heading('Controls')}`)
718
764
  lines.push(` ${key('W')} Toggle ping mode ${hint('(speed 2s β†’ normal 10s β†’ slow 30s β†’ forced 4s)')}`)
719
- lines.push(` ${key('Ctrl+P')} Open command palette ${hint('(search and run actions quickly)')}`)
765
+ lines.push(` ${key('Ctrl+P')} Open ⚑️ command palette ${hint('(search and run actions quickly)')}`)
720
766
  lines.push(` ${key('E')} Toggle configured models only ${hint('(enabled by default)')}`)
721
767
  lines.push(` ${key('Z')} Cycle tool mode ${hint('(OpenCode β†’ Desktop β†’ OpenClaw β†’ Crush β†’ Goose β†’ Pi β†’ Aider β†’ Qwen β†’ OpenHands β†’ Amp)')}`)
722
- lines.push(` ${key('F')} Toggle favorite on selected row ${hint('(⭐ pinned at top, persisted)')}`)
723
- lines.push(` ${key('Y')} Install endpoints ${hint('(provider catalog β†’ compatible tools, direct provider only)')}`)
768
+ lines.push(` ${key('F')} Toggle favorite on selected row ${hint('(⭐ persisted across sessions)')}`)
769
+ lines.push(` ${key('Y')} Toggle favorites mode ${hint('(Pinned + always visible ↔ Normal filter/sort behavior)')}`)
770
+ lines.push(` ${key('X')} Clear active text filter ${hint('(remove custom query applied from ⚑️ Command Palette)')}`)
724
771
  lines.push(` ${key('Q')} Smart Recommend ${hint('(🎯 find the best model for your task β€” questionnaire + live analysis)')}`)
725
772
  lines.push(` ${key('G')} Cycle theme ${hint('(auto β†’ dark β†’ light)')}`)
726
773
  lines.push(` ${themeColors.errorBold('I')} Feedback, bugs & requests ${hint('(πŸ“ send anonymous feedback, bug reports, or feature requests)')}`)
@@ -736,7 +783,8 @@ export function createOverlayRenderers(state, deps) {
736
783
  lines.push(` ${key('PgUp/PgDn')} Jump by page`)
737
784
  lines.push(` ${key('Home/End')} Jump first/last row`)
738
785
  lines.push(` ${key('Enter')} Edit key / run selected maintenance action`)
739
- lines.push(` ${key('Space')} Toggle provider enable/disable`)
786
+ lines.push(` ${key('Space')} Toggle selected row option (provider/theme/favorites)`)
787
+ lines.push(` ${key('Y')} Toggle favorites mode (global)`)
740
788
  lines.push(` ${key('T')} Test selected provider key`)
741
789
  lines.push(` ${key('U')} Check updates manually`)
742
790
  lines.push(` ${key('G')} Cycle theme globally`)