@@ -14,7 +14,6 @@ import (
1414 "github.com/opencode-ai/opencode/internal/tui/components/anim"
1515 "github.com/opencode-ai/opencode/internal/tui/layout"
1616 "github.com/opencode-ai/opencode/internal/tui/styles"
17- "github.com/opencode-ai/opencode/internal/tui/theme"
1817 "github.com/opencode-ai/opencode/internal/tui/util"
1918 "github.com/sahilm/fuzzy"
2019)
@@ -60,6 +59,13 @@ type HasMatchIndexes interface {
6059 MatchIndexes ([]int ) // Sets the indexes of matched characters in the item's content
6160}
6261
62+ // SectionHeader interface identifies items that are section headers.
63+ // Section headers are rendered differently and are skipped during navigation.
64+ type SectionHeader interface {
65+ util.Model
66+ IsSectionHeader () bool // Returns true if this item is a section header
67+ }
68+
6369// renderedItem represents a cached rendered item with its position and content.
6470type renderedItem struct {
6571 lines []string // The rendered lines of text for this item
@@ -539,32 +545,87 @@ func (r *reverseRenderer) renderItemLines(item util.Model) []string {
539545
540546// selectPreviousItem moves selection to the previous item in the list.
541547// Handles focus management and ensures the selected item remains visible.
548+ // Skips section headers during navigation.
542549func (m * model ) selectPreviousItem () tea.Cmd {
543550 if m .selectionState .selectedIndex <= 0 {
544551 return nil
545552 }
546553
547554 cmds := []tea.Cmd {m .blurSelected ()}
548555 m .selectionState .selectedIndex --
556+
557+ // Skip section headers
558+ for m .selectionState .selectedIndex >= 0 && m .isSectionHeader (m .selectionState .selectedIndex ) {
559+ m .selectionState .selectedIndex --
560+ }
561+
562+ // If we went past the beginning, stay at the first non-header item
563+ if m .selectionState .selectedIndex < 0 {
564+ m .selectionState .selectedIndex = m .findFirstSelectableItem ()
565+ }
566+
549567 cmds = append (cmds , m .focusSelected ())
550568 m .ensureSelectedItemVisible ()
551569 return tea .Batch (cmds ... )
552570}
553571
554572// selectNextItem moves selection to the next item in the list.
555573// Handles focus management and ensures the selected item remains visible.
574+ // Skips section headers during navigation.
556575func (m * model ) selectNextItem () tea.Cmd {
557576 if m .selectionState .selectedIndex >= len (m .filteredItems )- 1 || m .selectionState .selectedIndex < 0 {
558577 return nil
559578 }
560579
561580 cmds := []tea.Cmd {m .blurSelected ()}
562581 m .selectionState .selectedIndex ++
582+
583+ // Skip section headers
584+ for m .selectionState .selectedIndex < len (m .filteredItems ) && m .isSectionHeader (m .selectionState .selectedIndex ) {
585+ m .selectionState .selectedIndex ++
586+ }
587+
588+ // If we went past the end, stay at the last non-header item
589+ if m .selectionState .selectedIndex >= len (m .filteredItems ) {
590+ m .selectionState .selectedIndex = m .findLastSelectableItem ()
591+ }
592+
563593 cmds = append (cmds , m .focusSelected ())
564594 m .ensureSelectedItemVisible ()
565595 return tea .Batch (cmds ... )
566596}
567597
598+ // isSectionHeader checks if the item at the given index is a section header.
599+ func (m * model ) isSectionHeader (index int ) bool {
600+ if index < 0 || index >= len (m .filteredItems ) {
601+ return false
602+ }
603+ if header , ok := m .filteredItems [index ].(SectionHeader ); ok {
604+ return header .IsSectionHeader ()
605+ }
606+ return false
607+ }
608+
609+ // findFirstSelectableItem finds the first item that is not a section header.
610+ func (m * model ) findFirstSelectableItem () int {
611+ for i := 0 ; i < len (m .filteredItems ); i ++ {
612+ if ! m .isSectionHeader (i ) {
613+ return i
614+ }
615+ }
616+ return NoSelection
617+ }
618+
619+ // findLastSelectableItem finds the last item that is not a section header.
620+ func (m * model ) findLastSelectableItem () int {
621+ for i := len (m .filteredItems ) - 1 ; i >= 0 ; i -- {
622+ if ! m .isSectionHeader (i ) {
623+ return i
624+ }
625+ }
626+ return NoSelection
627+ }
628+
568629// ensureSelectedItemVisible scrolls the list to make the selected item visible.
569630// Uses different strategies for forward and reverse rendering modes.
570631func (m * model ) ensureSelectedItemVisible () {
@@ -631,25 +692,25 @@ func (m *model) ensureVisibleReverse(cachedItem renderedItem) {
631692 }
632693}
633694
634- // goToBottom switches to reverse mode and selects the last item.
695+ // goToBottom switches to reverse mode and selects the last selectable item.
635696// Commonly used for chat-like interfaces where new content appears at the bottom.
697+ // Skips section headers when selecting the last item.
636698func (m * model ) goToBottom () tea.Cmd {
637699 cmds := []tea.Cmd {m .blurSelected ()}
638700 m .viewState .reverse = true
639- m .selectionState .selectedIndex = len ( m . filteredItems ) - 1
701+ m .selectionState .selectedIndex = m . findLastSelectableItem ()
640702 cmds = append (cmds , m .focusSelected ())
641703 m .ResetView ()
642704 return tea .Batch (cmds ... )
643705}
644706
645- // goToTop switches to forward mode and selects the first item.
707+ // goToTop switches to forward mode and selects the first selectable item.
646708// Standard behavior for most list interfaces.
709+ // Skips section headers when selecting the first item.
647710func (m * model ) goToTop () tea.Cmd {
648711 cmds := []tea.Cmd {m .blurSelected ()}
649712 m .viewState .reverse = false
650- if len (m .filteredItems ) > 0 {
651- m .selectionState .selectedIndex = 0
652- }
713+ m .selectionState .selectedIndex = m .findFirstSelectableItem ()
653714 cmds = append (cmds , m .focusSelected ())
654715 m .ResetView ()
655716 return tea .Batch (cmds ... )
@@ -715,8 +776,12 @@ func (m *model) rerenderItem(inx int) {
715776}
716777
717778// getItemLines converts an item to its rendered lines, including any gap spacing.
779+ // Handles section headers with special styling.
718780func (m * model ) getItemLines (item util.Model ) []string {
719- itemLines := strings .Split (item .View ().String (), "\n " )
781+ var itemLines []string
782+
783+ itemLines = strings .Split (item .View ().String (), "\n " )
784+
720785 if m .gapSize > 0 {
721786 gap := make ([]string , m .gapSize )
722787 itemLines = append (itemLines , gap ... )
@@ -995,6 +1060,7 @@ func (m *model) setReverse(reverse bool) {
9951060
9961061// SetItems replaces all items in the list with a new set.
9971062// Initializes all items, sets their sizes, and establishes initial selection.
1063+ // Ensures the initial selection skips section headers.
9981064func (m * model ) SetItems (items []util.Model ) tea.Cmd {
9991065 m .allItems = items
10001066 m .filteredItems = items
@@ -1006,9 +1072,9 @@ func (m *model) SetItems(items []util.Model) tea.Cmd {
10061072
10071073 if len (m .filteredItems ) > 0 {
10081074 if m .viewState .reverse {
1009- m .selectionState .selectedIndex = len ( m . filteredItems ) - 1
1075+ m .selectionState .selectedIndex = m . findLastSelectableItem ()
10101076 } else {
1011- m .selectionState .selectedIndex = 0
1077+ m .selectionState .selectedIndex = m . findFirstSelectableItem ()
10121078 }
10131079 if cmd := m .focusSelected (); cmd != nil {
10141080 cmds = append (cmds , cmd )
@@ -1022,18 +1088,75 @@ func (m *model) SetItems(items []util.Model) tea.Cmd {
10221088}
10231089
10241090func (c * model ) inputStyle () lipgloss.Style {
1025- t := theme .CurrentTheme ()
1026- return styles .BaseStyle ().
1027- BorderStyle (lipgloss .NormalBorder ()).
1028- BorderForeground (t .TextMuted ()).
1029- BorderBackground (t .Background ()).
1030- BorderBottom (true )
1091+ return styles .BaseStyle ()
1092+ }
1093+
1094+ // section represents a group of items under a section header.
1095+ type section struct {
1096+ header SectionHeader
1097+ items []util.Model
1098+ }
1099+
1100+ // parseSections parses the flat item list into sections.
1101+ func (m * model ) parseSections () []section {
1102+ var sections []section
1103+ var currentSection * section
1104+
1105+ for _ , item := range m .allItems {
1106+ if header , ok := item .(SectionHeader ); ok && header .IsSectionHeader () {
1107+ // Start a new section
1108+ if currentSection != nil {
1109+ sections = append (sections , * currentSection )
1110+ }
1111+ currentSection = & section {
1112+ header : header ,
1113+ items : []util.Model {},
1114+ }
1115+ } else if currentSection != nil {
1116+ // Add item to current section
1117+ currentSection .items = append (currentSection .items , item )
1118+ } else {
1119+ // Item without a section header - create an implicit section
1120+ if len (sections ) == 0 || sections [len (sections )- 1 ].header != nil {
1121+ sections = append (sections , section {
1122+ header : nil ,
1123+ items : []util.Model {item },
1124+ })
1125+ } else {
1126+ // Add to the last implicit section
1127+ sections [len (sections )- 1 ].items = append (sections [len (sections )- 1 ].items , item )
1128+ }
1129+ }
1130+ }
1131+
1132+ // Don't forget the last section
1133+ if currentSection != nil {
1134+ sections = append (sections , * currentSection )
1135+ }
1136+
1137+ return sections
1138+ }
1139+
1140+ // flattenSections converts sections back to a flat list.
1141+ func (m * model ) flattenSections (sections []section ) []util.Model {
1142+ var result []util.Model
1143+
1144+ for _ , sect := range sections {
1145+ if sect .header != nil {
1146+ result = append (result , sect .header )
1147+ }
1148+ result = append (result , sect .items ... )
1149+ }
1150+
1151+ return result
10311152}
10321153
10331154func (m * model ) filter (search string ) tea.Cmd {
10341155 var cmds []tea.Cmd
10351156 search = strings .TrimSpace (search )
10361157 search = strings .ToLower (search )
1158+
1159+ // Clear focus and match indexes from all items
10371160 for _ , item := range m .allItems {
10381161 if i , ok := item .(layout.Focusable ); ok {
10391162 cmds = append (cmds , i .Blur ())
@@ -1042,34 +1165,32 @@ func (m *model) filter(search string) tea.Cmd {
10421165 i .MatchIndexes (make ([]int , 0 ))
10431166 }
10441167 }
1168+
10451169 if search == "" {
1046- cmds = append (cmds , m .SetItems (m .allItems )) // Reset to all items if search is empty
1170+ cmds = append (cmds , m .SetItems (m .allItems ))
10471171 return tea .Batch (cmds ... )
10481172 }
1049- words := make ([]string , 0 , len (m .allItems ))
1050- for _ , cmd := range m .allItems {
1051- if f , ok := cmd .(HasFilterValue ); ok {
1052- words = append (words , strings .ToLower (f .FilterValue ()))
1053- } else {
1054- words = append (words , strings .ToLower ("" ))
1055- }
1056- }
1057- matches := fuzzy .Find (search , words )
1058- sort .Sort (matches )
1059- filteredItems := make ([]util.Model , 0 , len (matches ))
1060- for _ , match := range matches {
1061- item := m .allItems [match .Index ]
1062- if i , ok := item .(HasMatchIndexes ); ok {
1063- i .MatchIndexes (match .MatchedIndexes )
1173+
1174+ // Parse items into sections
1175+ sections := m .parseSections ()
1176+ var filteredSections []section
1177+
1178+ for _ , sect := range sections {
1179+ filteredSection := m .filterSection (sect , search )
1180+ if filteredSection != nil {
1181+ filteredSections = append (filteredSections , * filteredSection )
10641182 }
1065- filteredItems = append (filteredItems , item )
10661183 }
1067- m .filteredItems = filteredItems
1068- if len (filteredItems ) > 0 {
1184+
1185+ // Rebuild flat list from filtered sections
1186+ m .filteredItems = m .flattenSections (filteredSections )
1187+
1188+ // Set initial selection
1189+ if len (m .filteredItems ) > 0 {
10691190 if m .viewState .reverse {
1070- m .selectionState .selectedIndex = len ( filteredItems ) - 1
1191+ m .selectionState .selectedIndex = m . findLastSelectableItem ()
10711192 } else {
1072- m .selectionState .selectedIndex = 0
1193+ m .selectionState .selectedIndex = m . findFirstSelectableItem ()
10731194 }
10741195 if cmd := m .focusSelected (); cmd != nil {
10751196 cmds = append (cmds , cmd )
@@ -1081,3 +1202,59 @@ func (m *model) filter(search string) tea.Cmd {
10811202 m .ResetView ()
10821203 return tea .Batch (cmds ... )
10831204}
1205+
1206+ // filterSection filters items within a section and returns the section if it has matches.
1207+ func (m * model ) filterSection (sect section , search string ) * section {
1208+ var matchedItems []util.Model
1209+ var hasHeaderMatch bool
1210+
1211+ // Check if section header itself matches
1212+ if sect .header != nil {
1213+ headerText := strings .ToLower (sect .header .View ().String ())
1214+ if strings .Contains (headerText , search ) {
1215+ hasHeaderMatch = true
1216+ // If header matches, include all items in the section
1217+ matchedItems = sect .items
1218+ }
1219+ }
1220+
1221+ // If header didn't match, filter items within the section
1222+ if ! hasHeaderMatch && len (sect .items ) > 0 {
1223+ // Create words array for items in this section
1224+ words := make ([]string , len (sect .items ))
1225+ for i , item := range sect .items {
1226+ if f , ok := item .(HasFilterValue ); ok {
1227+ words [i ] = strings .ToLower (f .FilterValue ())
1228+ } else {
1229+ words [i ] = ""
1230+ }
1231+ }
1232+
1233+ // Find matches within this section
1234+ matches := fuzzy .Find (search , words )
1235+
1236+ // Sort matches by score but preserve relative order for equal scores
1237+ sort .SliceStable (matches , func (i , j int ) bool {
1238+ return matches [i ].Score > matches [j ].Score
1239+ })
1240+
1241+ // Build matched items list
1242+ for _ , match := range matches {
1243+ item := sect .items [match .Index ]
1244+ if i , ok := item .(HasMatchIndexes ); ok {
1245+ i .MatchIndexes (match .MatchedIndexes )
1246+ }
1247+ matchedItems = append (matchedItems , item )
1248+ }
1249+ }
1250+
1251+ // Return section only if it has matches
1252+ if len (matchedItems ) > 0 {
1253+ return & section {
1254+ header : sect .header ,
1255+ items : matchedItems ,
1256+ }
1257+ }
1258+
1259+ return nil
1260+ }
0 commit comments