on main help sign in
chore: grouped list
dcf069eKujtim Hoxha
internal/tui/components/chat/splash/splash.go+15−11
@@ -14,12 +14,11 @@ import (
14 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/config"
15 "github.com/charmbracelet/crush/internal/llm/prompt"
15 "github.com/charmbracelet/crush/internal/llm/prompt"
16 "github.com/charmbracelet/crush/internal/tui/components/chat"
16 "github.com/charmbracelet/crush/internal/tui/components/chat"
17− "github.com/charmbracelet/crush/internal/tui/components/completions"
18 "github.com/charmbracelet/crush/internal/tui/components/core"
17 "github.com/charmbracelet/crush/internal/tui/components/core"
19 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
18 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
20− "github.com/charmbracelet/crush/internal/tui/components/core/list"
21 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
19 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
22 "github.com/charmbracelet/crush/internal/tui/components/logo"
20 "github.com/charmbracelet/crush/internal/tui/components/logo"
21+ "github.com/charmbracelet/crush/internal/tui/exp/list"
23 "github.com/charmbracelet/crush/internal/tui/styles"
22 "github.com/charmbracelet/crush/internal/tui/styles"
24 "github.com/charmbracelet/crush/internal/tui/util"
23 "github.com/charmbracelet/crush/internal/tui/util"
25 "github.com/charmbracelet/crush/internal/version"
24 "github.com/charmbracelet/crush/internal/version"
@@ -86,9 +85,7 @@ func New() Splash {
86 listKeyMap.DownOneItem = keyMap.Next
85 listKeyMap.DownOneItem = keyMap.Next
87 listKeyMap.UpOneItem = keyMap.Previous
86 listKeyMap.UpOneItem = keyMap.Previous
88
87
89− t := styles.CurrentTheme()
90− inputStyle := t.S().Base.Padding(0, 1, 0, 1)
91− modelList := models.NewModelListComponent(listKeyMap, inputStyle, "Find your fave")
88+ modelList := models.NewModelListComponent(listKeyMap, "Find your fave", false)
92 apiKeyInput := models.NewAPIKeyInput()
89 apiKeyInput := models.NewAPIKeyInput()
93
90
94 return &splashCmp{
91 return &splashCmp{
@@ -195,17 +192,18 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
195 return s, s.saveAPIKeyAndContinue(s.apiKeyValue)
192 return s, s.saveAPIKeyAndContinue(s.apiKeyValue)
196 }
193 }
197 if s.isOnboarding && !s.needsAPIKey {
194 if s.isOnboarding && !s.needsAPIKey {
198− modelInx := s.modelList.SelectedIndex()
195+ selectedItem := s.modelList.SelectedModel()
199− items := s.modelList.Items()
196+ if selectedItem == nil {
200− selectedItem := items[modelInx].(completions.CompletionItem).Value().(models.ModelOption)
197+ return s, nil
198+ }
201 if s.isProviderConfigured(string(selectedItem.Provider.ID)) {
199 if s.isProviderConfigured(string(selectedItem.Provider.ID)) {
202− cmd := s.setPreferredModel(selectedItem)
200+ cmd := s.setPreferredModel(*selectedItem)
203 s.isOnboarding = false
201 s.isOnboarding = false
204 return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
202 return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
205 } else {
203 } else {
206 // Provider not configured, show API key input
204 // Provider not configured, show API key input
207 s.needsAPIKey = true
205 s.needsAPIKey = true
208− s.selectedModel = &selectedItem
206+ s.selectedModel = selectedItem
209 s.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
207 s.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
210 return s, nil
208 return s, nil
211 }
209 }
@@ -264,6 +262,9 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
264 return s, nil
262 return s, nil
265 }
263 }
266 case key.Matches(msg, s.keyMap.Yes):
264 case key.Matches(msg, s.keyMap.Yes):
265+ if s.isOnboarding {
266+ return s, nil
267+ }
267 if s.needsAPIKey {
268 if s.needsAPIKey {
268 u, cmd := s.apiKeyInput.Update(msg)
269 u, cmd := s.apiKeyInput.Update(msg)
269 s.apiKeyInput = u.(*models.APIKeyInput)
270 s.apiKeyInput = u.(*models.APIKeyInput)
@@ -274,6 +275,9 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
274 return s, s.initializeProject()
275 return s, s.initializeProject()
275 }
276 }
276 case key.Matches(msg, s.keyMap.No):
277 case key.Matches(msg, s.keyMap.No):
278+ if s.isOnboarding {
279+ return s, nil
280+ }
277 if s.needsAPIKey {
281 if s.needsAPIKey {
278 u, cmd := s.apiKeyInput.Update(msg)
282 u, cmd := s.apiKeyInput.Update(msg)
279 s.apiKeyInput = u.(*models.APIKeyInput)
283 s.apiKeyInput = u.(*models.APIKeyInput)
@@ -605,7 +609,7 @@ func (s *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
605 cursor.Y += offset
609 cursor.Y += offset
606 cursor.X = cursor.X + 1
610 cursor.X = cursor.X + 1
607 } else if s.isOnboarding {
611 } else if s.isOnboarding {
608− offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 3
612+ offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 2
609 cursor.Y += offset
613 cursor.Y += offset
610 cursor.X = cursor.X + 1
614 cursor.X = cursor.X + 1
611 }
615 }
internal/tui/components/dialogs/commands/commands.go+38−29
@@ -10,10 +10,9 @@ import (
10 "github.com/charmbracelet/crush/internal/config"
10 "github.com/charmbracelet/crush/internal/config"
11 "github.com/charmbracelet/crush/internal/llm/prompt"
11 "github.com/charmbracelet/crush/internal/llm/prompt"
12 "github.com/charmbracelet/crush/internal/tui/components/chat"
12 "github.com/charmbracelet/crush/internal/tui/components/chat"
13− "github.com/charmbracelet/crush/internal/tui/components/completions"
14 "github.com/charmbracelet/crush/internal/tui/components/core"
13 "github.com/charmbracelet/crush/internal/tui/components/core"
15− "github.com/charmbracelet/crush/internal/tui/components/core/list"
16 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
14 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
15+ "github.com/charmbracelet/crush/internal/tui/exp/list"
17 "github.com/charmbracelet/crush/internal/tui/styles"
16 "github.com/charmbracelet/crush/internal/tui/styles"
18 "github.com/charmbracelet/crush/internal/tui/util"
17 "github.com/charmbracelet/crush/internal/tui/util"
19)
18)
@@ -29,6 +28,8 @@ const (
29 UserCommands
28 UserCommands
30)
29)
31
30
31+type listModel = list.FilterableList[list.CompletionItem[Command]]
32+
32// Command represents a command that can be executed
33// Command represents a command that can be executed
33type Command struct {
34type Command struct {
34 ID string
35 ID string
@@ -48,7 +49,7 @@ type commandDialogCmp struct {
48 wWidth int // Width of the terminal window
49 wWidth int // Width of the terminal window
49 wHeight int // Height of the terminal window
50 wHeight int // Height of the terminal window
50
51
51− commandList list.ListModel
52+ commandList listModel
52 keyMap CommandsDialogKeyMap
53 keyMap CommandsDialogKeyMap
53 help help.Model
54 help help.Model
54 commandType int // SystemCommands or UserCommands
55 commandType int // SystemCommands or UserCommands
@@ -67,24 +68,23 @@ type (
67)
68)
68
69
69func NewCommandDialog(sessionID string) CommandsDialog {
70func NewCommandDialog(sessionID string) CommandsDialog {
70− listKeyMap := list.DefaultKeyMap()
71 keyMap := DefaultCommandsDialogKeyMap()
71 keyMap := DefaultCommandsDialogKeyMap()
72−
72+ listKeyMap := list.DefaultKeyMap()
73 listKeyMap.Down.SetEnabled(false)
73 listKeyMap.Down.SetEnabled(false)
74 listKeyMap.Up.SetEnabled(false)
74 listKeyMap.Up.SetEnabled(false)
75− listKeyMap.HalfPageDown.SetEnabled(false)
76− listKeyMap.HalfPageUp.SetEnabled(false)
77− listKeyMap.Home.SetEnabled(false)
78− listKeyMap.End.SetEnabled(false)
79−
80 listKeyMap.DownOneItem = keyMap.Next
75 listKeyMap.DownOneItem = keyMap.Next
81 listKeyMap.UpOneItem = keyMap.Previous
76 listKeyMap.UpOneItem = keyMap.Previous
82
77
83 t := styles.CurrentTheme()
78 t := styles.CurrentTheme()
79+ inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
84− commandList := list.New(
80+ commandList := list.NewFilterableList(
85− list.WithFilterable(true),
81+ []list.CompletionItem[Command]{},
82+ list.WithFilterInputStyle(inputStyle),
83+ list.WithFilterListOptions(
86− list.WithKeyMap(listKeyMap),
84+ list.WithKeyMap(listKeyMap),
87− list.WithWrapNavigation(true),
85+ list.WithWrapNavigation(),
86+ list.WithResizeByList(),
87+ ),
88 )
88 )
89 help := help.New()
89 help := help.New()
90 help.Styles = t.S().Help
90 help.Styles = t.S().Help
@@ -103,10 +103,8 @@ func (c *commandDialogCmp) Init() tea.Cmd {
103 if err != nil {
103 if err != nil {
104 return util.ReportError(err)
104 return util.ReportError(err)
105 }
105 }
106−
107 c.userCommands = commands
106 c.userCommands = commands
108− c.SetCommandType(c.commandType)
107+ return c.SetCommandType(c.commandType)
109− return c.commandList.Init()
110}
108}
111
109
112func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
110func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -114,22 +112,23 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
114 case tea.WindowSizeMsg:
112 case tea.WindowSizeMsg:
115 c.wWidth = msg.Width
113 c.wWidth = msg.Width
116 c.wHeight = msg.Height
114 c.wHeight = msg.Height
117− c.SetCommandType(c.commandType)
118 return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
115 return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
119 case tea.KeyPressMsg:
116 case tea.KeyPressMsg:
120 switch {
117 switch {
121 case key.Matches(msg, c.keyMap.Select):
118 case key.Matches(msg, c.keyMap.Select):
122− selectedItemInx := c.commandList.SelectedIndex()
119+ selectedItem := c.commandList.SelectedItem()
123− if selectedItemInx == list.NoSelection {
120+ if selectedItem == nil {
124 return c, nil // No item selected, do nothing
121 return c, nil // No item selected, do nothing
125 }
122 }
126− items := c.commandList.Items()
123+ command := (*selectedItem).Value()
127− selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(Command)
128 return c, tea.Sequence(
124 return c, tea.Sequence(
129 util.CmdHandler(dialogs.CloseDialogMsg{}),
125 util.CmdHandler(dialogs.CloseDialogMsg{}),
130− selectedItem.Handler(selectedItem),
126+ command.Handler(command),
131 )
127 )
132 case key.Matches(msg, c.keyMap.Tab):
128 case key.Matches(msg, c.keyMap.Tab):
129+ if len(c.userCommands) == 0 {
130+ return c, nil
131+ }
133 // Toggle command type between System and User commands
132 // Toggle command type between System and User commands
134 if c.commandType == SystemCommands {
133 if c.commandType == SystemCommands {
135 return c, c.SetCommandType(UserCommands)
134 return c, c.SetCommandType(UserCommands)
@@ -140,7 +139,7 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
140 return c, util.CmdHandler(dialogs.CloseDialogMsg{})
139 return c, util.CmdHandler(dialogs.CloseDialogMsg{})
141 default:
140 default:
142 u, cmd := c.commandList.Update(msg)
141 u, cmd := c.commandList.Update(msg)
143− c.commandList = u.(list.ListModel)
142+ c.commandList = u.(listModel)
144 return c, cmd
143 return c, cmd
145 }
144 }
146 }
145 }
@@ -151,9 +150,14 @@ func (c *commandDialogCmp) View() string {
151 t := styles.CurrentTheme()
150 t := styles.CurrentTheme()
152 listView := c.commandList
151 listView := c.commandList
153 radio := c.commandTypeRadio()
152 radio := c.commandTypeRadio()
153+
154+ header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5) + " " + radio)
155+ if len(c.userCommands) == 0 {
156+ header = t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-4))
157+ }
154 content := lipgloss.JoinVertical(
158 content := lipgloss.JoinVertical(
155 lipgloss.Left,
159 lipgloss.Left,
156− t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5)+" "+radio),
160+ header,
157 listView.View(),
161 listView.View(),
158 "",
162 "",
159 t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
163 t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
@@ -197,13 +201,18 @@ func (c *commandDialogCmp) SetCommandType(commandType int) tea.Cmd {
197 commands = c.userCommands
201 commands = c.userCommands
198 }
202 }
199
203
200− commandItems := []util.Model{}
204+ commandItems := []list.CompletionItem[Command]{}
201 for _, cmd := range commands {
205 for _, cmd := range commands {
202− opts := []completions.CompletionOption{}
206+ opts := []list.CompletionItemOption{
207+ list.WithCompletionID(cmd.ID),
208+ }
203 if cmd.Shortcut != "" {
209 if cmd.Shortcut != "" {
204− opts = append(opts, completions.WithShortcut(cmd.Shortcut))
210+ opts = append(
211+ opts,
212+ list.WithCompletionShortcut(cmd.Shortcut),
213+ )
205 }
214 }
206− commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd, opts...))
215+ commandItems = append(commandItems, list.NewCompletionItem(cmd.Title, cmd, opts...))
207 }
216 }
208 return c.commandList.SetItems(commandItems)
217 return c.commandList.SetItems(commandItems)
209}
218}
internal/tui/components/dialogs/models/list.go+73−32
@@ -7,27 +7,36 @@ import (
7 tea "github.com/charmbracelet/bubbletea/v2"
7 tea "github.com/charmbracelet/bubbletea/v2"
8 "github.com/charmbracelet/catwalk/pkg/catwalk"
8 "github.com/charmbracelet/catwalk/pkg/catwalk"
9 "github.com/charmbracelet/crush/internal/config"
9 "github.com/charmbracelet/crush/internal/config"
10− "github.com/charmbracelet/crush/internal/tui/components/completions"
10+ "github.com/charmbracelet/crush/internal/tui/exp/list"
11− "github.com/charmbracelet/crush/internal/tui/components/core/list"
12− "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
13 "github.com/charmbracelet/crush/internal/tui/styles"
11 "github.com/charmbracelet/crush/internal/tui/styles"
14 "github.com/charmbracelet/crush/internal/tui/util"
12 "github.com/charmbracelet/crush/internal/tui/util"
15− "github.com/charmbracelet/lipgloss/v2"
16)
13)
17
14
15+type listModel = list.FilterableGroupList[list.CompletionItem[ModelOption]]
16+
18type ModelListComponent struct {
17type ModelListComponent struct {
19− list list.ListModel
18+ list listModel
20 modelType int
19 modelType int
21 providers []catwalk.Provider
20 providers []catwalk.Provider
22}
21}
23
22
24−func NewModelListComponent(keyMap list.KeyMap, inputStyle lipgloss.Style, inputPlaceholder string) *ModelListComponent {
23+func NewModelListComponent(keyMap list.KeyMap, inputPlaceholder string, shouldResize bool) *ModelListComponent {
25− modelList := list.New(
24+ t := styles.CurrentTheme()
26− list.WithFilterable(true),
25+ inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
26+ options := []list.ListOption{
27 list.WithKeyMap(keyMap),
27 list.WithKeyMap(keyMap),
28− list.WithInputStyle(inputStyle),
28+ list.WithWrapNavigation(),
29+ }
30+ if shouldResize {
31+ options = append(options, list.WithResizeByList())
32+ }
33+ modelList := list.NewFilterableGroupedList(
34+ []list.Group[list.CompletionItem[ModelOption]]{},
35+ list.WithFilterInputStyle(inputStyle),
29 list.WithFilterPlaceholder(inputPlaceholder),
36 list.WithFilterPlaceholder(inputPlaceholder),
30− list.WithWrapNavigation(true),
37+ list.WithFilterListOptions(
38+ options...,
39+ ),
31 )
40 )
32
41
33 return &ModelListComponent{
42 return &ModelListComponent{
@@ -51,7 +60,7 @@ func (m *ModelListComponent) Init() tea.Cmd {
51
60
52func (m *ModelListComponent) Update(msg tea.Msg) (*ModelListComponent, tea.Cmd) {
61func (m *ModelListComponent) Update(msg tea.Msg) (*ModelListComponent, tea.Cmd) {
53 u, cmd := m.list.Update(msg)
62 u, cmd := m.list.Update(msg)
54− m.list = u.(list.ListModel)
63+ m.list = u.(listModel)
55 return m, cmd
64 return m, cmd
56}
65}
57
66
@@ -67,21 +76,23 @@ func (m *ModelListComponent) SetSize(width, height int) tea.Cmd {
67 return m.list.SetSize(width, height)
76 return m.list.SetSize(width, height)
68}
77}
69
78
70−func (m *ModelListComponent) Items() []util.Model {
79+func (m *ModelListComponent) SelectedModel() *ModelOption {
71− return m.list.Items()
80+ s := m.list.SelectedItem()
81+ if s == nil {
82+ return nil
72−}
83+ }
73−
84+ sv := *s
74−func (m *ModelListComponent) SelectedIndex() int {
85+ model := sv.Value()
75− return m.list.SelectedIndex()
86+ return &model
76}
87}
77
88
78func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
89func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
79 t := styles.CurrentTheme()
90 t := styles.CurrentTheme()
80 m.modelType = modelType
91 m.modelType = modelType
81
92
82− modelItems := []util.Model{}
93+ var groups []list.Group[list.CompletionItem[ModelOption]]
83 // first none section
94 // first none section
84− selectIndex := 1
95+ selectedItemID := ""
85
96
86 cfg := config.Get()
97 cfg := config.Get()
87 var currentModel config.SelectedModel
98 var currentModel config.SelectedModel
@@ -140,18 +151,28 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
140 if name == "" {
151 if name == "" {
141 name = string(configProvider.ID)
152 name = string(configProvider.ID)
142 }
153 }
143− section := commands.NewItemSection(name)
154+ section := list.NewItemSection(name)
144 section.SetInfo(configured)
155 section.SetInfo(configured)
145− modelItems = append(modelItems, section)
156+ group := list.Group[list.CompletionItem[ModelOption]]{
157+ Section: section,
158+ }
146 for _, model := range configProvider.Models {
159 for _, model := range configProvider.Models {
147− modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{
160+ item := list.NewCompletionItem(model.Model, ModelOption{
148 Provider: configProvider,
161 Provider: configProvider,
149 Model: model,
162 Model: model,
163+ },
164+ list.WithCompletionID(
165+ fmt.Sprintf("%s:%s", providerConfig.ID, model.ID),
166+ ),
150− }))
167+ )
168+
169+ group.Items = append(group.Items, item)
151 if model.ID == currentModel.Model && string(configProvider.ID) == currentModel.Provider {
170 if model.ID == currentModel.Model && string(configProvider.ID) == currentModel.Provider {
152− selectIndex = len(modelItems) - 1 // Set the selected index to the current model
171+ selectedItemID = item.ID()
153 }
172 }
154 }
173 }
174+ groups = append(groups, group)
175+
155 addedProviders[providerID] = true
176 addedProviders[providerID] = true
156 }
177 }
157 }
178 }
@@ -173,23 +194,43 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
173 name = string(provider.ID)
194 name = string(provider.ID)
174 }
195 }
175
196
176− section := commands.NewItemSection(name)
197+ section := list.NewItemSection(name)
177− if _, ok := cfg.Providers.Get(string(provider.ID)); ok {
198+ if _, ok := cfg.Providers[string(provider.ID)]; ok {
178 section.SetInfo(configured)
199 section.SetInfo(configured)
179 }
200 }
180− modelItems = append(modelItems, section)
201+ group := list.Group[list.CompletionItem[ModelOption]]{
202+ Section: section,
203+ }
181 for _, model := range provider.Models {
204 for _, model := range provider.Models {
182− modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{
205+ item := list.NewCompletionItem(model.Model, ModelOption{
183 Provider: provider,
206 Provider: provider,
184 Model: model,
207 Model: model,
185− }))
208+ },
209+ list.WithCompletionID(
210+ fmt.Sprintf("%s:%s", provider.ID, model.ID),
211+ ),
212+ )
213+ group.Items = append(group.Items, item)
186 if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider {
214 if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider {
187− selectIndex = len(modelItems) - 1 // Set the selected index to the current model
215+ selectedItemID = item.ID()
188 }
216 }
189 }
217 }
218+ groups = append(groups, group)
219+ }
220+
221+ var cmds []tea.Cmd
222+
223+ cmd := m.list.SetGroups(groups)
224+
225+ if cmd != nil {
226+ cmds = append(cmds, cmd)
227+ }
228+ cmd = m.list.SetSelected(selectedItemID)
229+ if cmd != nil {
230+ cmds = append(cmds, cmd)
190 }
231 }
191
232
192− return tea.Sequence(m.list.SetItems(modelItems), m.list.SetSelected(selectIndex))
233+ return tea.Sequence(cmds...)
193}
234}
194
235
195// GetModelType returns the current model type
236// GetModelType returns the current model type
@@ -198,7 +239,7 @@ func (m *ModelListComponent) GetModelType() int {
198}
239}
199
240
200func (m *ModelListComponent) SetInputPlaceholder(placeholder string) {
241func (m *ModelListComponent) SetInputPlaceholder(placeholder string) {
201− m.list.SetFilterPlaceholder(placeholder)
242+ m.list.SetInputPlaceholder(placeholder)
202}
243}
203
244
204func (m *ModelListComponent) SetProviders(providers []catwalk.Provider) {
245func (m *ModelListComponent) SetProviders(providers []catwalk.Provider) {
internal/tui/components/dialogs/models/models.go+7−21
@@ -10,10 +10,9 @@ import (
10 tea "github.com/charmbracelet/bubbletea/v2"
10 tea "github.com/charmbracelet/bubbletea/v2"
11 "github.com/charmbracelet/catwalk/pkg/catwalk"
11 "github.com/charmbracelet/catwalk/pkg/catwalk"
12 "github.com/charmbracelet/crush/internal/config"
12 "github.com/charmbracelet/crush/internal/config"
13− "github.com/charmbracelet/crush/internal/tui/components/completions"
14 "github.com/charmbracelet/crush/internal/tui/components/core"
13 "github.com/charmbracelet/crush/internal/tui/components/core"
15− "github.com/charmbracelet/crush/internal/tui/components/core/list"
16 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
14 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
15+ "github.com/charmbracelet/crush/internal/tui/exp/list"
17 "github.com/charmbracelet/crush/internal/tui/styles"
16 "github.com/charmbracelet/crush/internal/tui/styles"
18 "github.com/charmbracelet/crush/internal/tui/util"
17 "github.com/charmbracelet/crush/internal/tui/util"
19 "github.com/charmbracelet/lipgloss/v2"
18 "github.com/charmbracelet/lipgloss/v2"
@@ -71,22 +70,16 @@ type modelDialogCmp struct {
71}
70}
72
71
73func NewModelDialogCmp() ModelDialog {
72func NewModelDialogCmp() ModelDialog {
74− listKeyMap := list.DefaultKeyMap()
75 keyMap := DefaultKeyMap()
73 keyMap := DefaultKeyMap()
76
74
75+ listKeyMap := list.DefaultKeyMap()
77 listKeyMap.Down.SetEnabled(false)
76 listKeyMap.Down.SetEnabled(false)
78 listKeyMap.Up.SetEnabled(false)
77 listKeyMap.Up.SetEnabled(false)
79− listKeyMap.HalfPageDown.SetEnabled(false)
80− listKeyMap.HalfPageUp.SetEnabled(false)
81− listKeyMap.Home.SetEnabled(false)
82− listKeyMap.End.SetEnabled(false)
83−
84 listKeyMap.DownOneItem = keyMap.Next
78 listKeyMap.DownOneItem = keyMap.Next
85 listKeyMap.UpOneItem = keyMap.Previous
79 listKeyMap.UpOneItem = keyMap.Previous
86
80
87 t := styles.CurrentTheme()
81 t := styles.CurrentTheme()
88− inputStyle := t.S().Base.Padding(0, 1, 0, 1)
89− modelList := NewModelListComponent(listKeyMap, inputStyle, "Choose a model for large, complex tasks")
82+ modelList := NewModelListComponent(listKeyMap, "Choose a model for large, complex tasks", true)
90 apiKeyInput := NewAPIKeyInput()
83 apiKeyInput := NewAPIKeyInput()
91 apiKeyInput.SetShowTitle(false)
84 apiKeyInput.SetShowTitle(false)
92 help := help.New()
85 help := help.New()
@@ -162,12 +155,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
162 )
155 )
163 }
156 }
164 // Normal model selection
157 // Normal model selection
165− selectedItemInx := m.modelList.SelectedIndex()
158+ selectedItem := m.modelList.SelectedModel()
166− if selectedItemInx == list.NoSelection {
167− return m, nil
168− }
169− items := m.modelList.Items()
170− selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(ModelOption)
171
159
172 var modelType config.SelectedModelType
160 var modelType config.SelectedModelType
173 if m.modelList.GetModelType() == LargeModelType {
161 if m.modelList.GetModelType() == LargeModelType {
@@ -191,7 +179,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
191 } else {
179 } else {
192 // Provider not configured, show API key input
180 // Provider not configured, show API key input
193 m.needsAPIKey = true
181 m.needsAPIKey = true
194− m.selectedModel = &selectedItem
182+ m.selectedModel = selectedItem
195 m.selectedModelType = modelType
183 m.selectedModelType = modelType
196 m.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
184 m.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
197 return m, nil
185 return m, nil
@@ -310,13 +298,11 @@ func (m *modelDialogCmp) style() lipgloss.Style {
310}
298}
311
299
312func (m *modelDialogCmp) listWidth() int {
300func (m *modelDialogCmp) listWidth() int {
313− return defaultWidth - 2 // 4 for padding
301+ return m.width - 2
314}
302}
315
303
316func (m *modelDialogCmp) listHeight() int {
304func (m *modelDialogCmp) listHeight() int {
317− items := m.modelList.Items()
318− listHeigh := len(items) + 2 + 4
319− return min(listHeigh, m.wHeight/2)
305+ return m.wHeight / 2
320}
306}
321
307
322func (m *modelDialogCmp) Position() (int, int) {
308func (m *modelDialogCmp) Position() (int, int) {
internal/tui/components/dialogs/sessions/sessions.go+1−1
@@ -47,7 +47,7 @@ func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionD
47 items := make([]list.CompletionItem[session.Session], len(sessions))
47 items := make([]list.CompletionItem[session.Session], len(sessions))
48 if len(sessions) > 0 {
48 if len(sessions) > 0 {
49 for i, session := range sessions {
49 for i, session := range sessions {
50− items[i] = list.NewCompletionItem(session.Title, session, list.WithID(session.ID))
50+ items[i] = list.NewCompletionItem(session.Title, session, list.WithCompletionID(session.ID))
51 }
51 }
52 }
52 }
53
53
internal/tui/exp/list/filterable.go+7−2
@@ -23,6 +23,7 @@ type FilterableList[T FilterableItem] interface {
23 List[T]
23 List[T]
24 Cursor() *tea.Cursor
24 Cursor() *tea.Cursor
25 SetInputWidth(int)
25 SetInputWidth(int)
26+ SetInputPlaceholder(string)
26}
27}
27
28
28type HasMatchIndexes interface {
29type HasMatchIndexes interface {
@@ -30,7 +31,7 @@ type HasMatchIndexes interface {
30}
31}
31
32
32type filterableOptions struct {
33type filterableOptions struct {
33− listOptions []listOption
34+ listOptions []ListOption
34 placeholder string
35 placeholder string
35 inputHidden bool
36 inputHidden bool
36 inputWidth int
37 inputWidth int
@@ -67,7 +68,7 @@ func WithFilterInputStyle(inputStyle lipgloss.Style) filterableListOption {
67 }
68 }
68}
69}
69
70
70−func WithFilterListOptions(opts ...listOption) filterableListOption {
71+func WithFilterListOptions(opts ...ListOption) filterableListOption {
71 return func(f *filterableOptions) {
72 return func(f *filterableOptions) {
72 f.listOptions = opts
73 f.listOptions = opts
73 }
74 }
@@ -295,3 +296,7 @@ func (f *filterableList[T]) IsFocused() bool {
295func (f *filterableList[T]) SetInputWidth(w int) {
296func (f *filterableList[T]) SetInputWidth(w int) {
296 f.inputWidth = w
297 f.inputWidth = w
297}
298}
299+
300+func (f *filterableList[T]) SetInputPlaceholder(ph string) {
301+ f.placeholder = ph
302+}
internal/tui/exp/list/filterable_group.gonew file+260
@@ -0,0 +1,260 @@
1+package list
2+
3+import (
4+ "regexp"
5+ "sort"
6+ "strings"
7+
8+ "github.com/charmbracelet/bubbles/v2/key"
9+ "github.com/charmbracelet/bubbles/v2/textinput"
10+ tea "github.com/charmbracelet/bubbletea/v2"
11+ "github.com/charmbracelet/crush/internal/tui/components/core/layout"
12+ "github.com/charmbracelet/crush/internal/tui/styles"
13+ "github.com/charmbracelet/lipgloss/v2"
14+ "github.com/sahilm/fuzzy"
15+)
16+
17+type FilterableGroupList[T FilterableItem] interface {
18+ GroupedList[T]
19+ Cursor() *tea.Cursor
20+ SetInputWidth(int)
21+ SetInputPlaceholder(string)
22+}
23+type filterableGroupList[T FilterableItem] struct {
24+ *groupedList[T]
25+ *filterableOptions
26+ width, height int
27+ groups []Group[T]
28+ // stores all available items
29+ input textinput.Model
30+ inputWidth int
31+ query string
32+}
33+
34+func NewFilterableGroupedList[T FilterableItem](items []Group[T], opts ...filterableListOption) FilterableGroupList[T] {
35+ t := styles.CurrentTheme()
36+
37+ f := &filterableGroupList[T]{
38+ filterableOptions: &filterableOptions{
39+ inputStyle: t.S().Base,
40+ placeholder: "Type to filter",
41+ },
42+ }
43+ for _, opt := range opts {
44+ opt(f.filterableOptions)
45+ }
46+ f.groupedList = NewGroupedList(items, f.listOptions...).(*groupedList[T])
47+
48+ f.updateKeyMaps()
49+
50+ if f.inputHidden {
51+ return f
52+ }
53+
54+ ti := textinput.New()
55+ ti.Placeholder = f.placeholder
56+ ti.SetVirtualCursor(false)
57+ ti.Focus()
58+ ti.SetStyles(t.S().TextInput)
59+ f.input = ti
60+ return f
61+}
62+
63+func (f *filterableGroupList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
64+ switch msg := msg.(type) {
65+ case tea.KeyPressMsg:
66+ switch {
67+ // handle movements
68+ case key.Matches(msg, f.keyMap.Down),
69+ key.Matches(msg, f.keyMap.Up),
70+ key.Matches(msg, f.keyMap.DownOneItem),
71+ key.Matches(msg, f.keyMap.UpOneItem),
72+ key.Matches(msg, f.keyMap.HalfPageDown),
73+ key.Matches(msg, f.keyMap.HalfPageUp),
74+ key.Matches(msg, f.keyMap.PageDown),
75+ key.Matches(msg, f.keyMap.PageUp),
76+ key.Matches(msg, f.keyMap.End),
77+ key.Matches(msg, f.keyMap.Home):
78+ u, cmd := f.groupedList.Update(msg)
79+ f.groupedList = u.(*groupedList[T])
80+ return f, cmd
81+ default:
82+ if !f.inputHidden {
83+ var cmds []tea.Cmd
84+ var cmd tea.Cmd
85+ f.input, cmd = f.input.Update(msg)
86+ cmds = append(cmds, cmd)
87+
88+ if f.query != f.input.Value() {
89+ cmd = f.Filter(f.input.Value())
90+ cmds = append(cmds, cmd)
91+ }
92+ f.query = f.input.Value()
93+ return f, tea.Batch(cmds...)
94+ }
95+ }
96+ }
97+ u, cmd := f.groupedList.Update(msg)
98+ f.groupedList = u.(*groupedList[T])
99+ return f, cmd
100+}
101+
102+func (f *filterableGroupList[T]) View() string {
103+ if f.inputHidden {
104+ return f.groupedList.View()
105+ }
106+
107+ return lipgloss.JoinVertical(
108+ lipgloss.Left,
109+ f.inputStyle.Render(f.input.View()),
110+ f.groupedList.View(),
111+ )
112+}
113+
114+// removes bindings that are used for search
115+func (f *filterableGroupList[T]) updateKeyMaps() {
116+ alphanumeric := regexp.MustCompile("^[a-zA-Z0-9]*$")
117+
118+ removeLettersAndNumbers := func(bindings []string) []string {
119+ var keep []string
120+ for _, b := range bindings {
121+ if len(b) != 1 {
122+ keep = append(keep, b)
123+ continue
124+ }
125+ if b == " " {
126+ continue
127+ }
128+ m := alphanumeric.MatchString(b)
129+ if !m {
130+ keep = append(keep, b)
131+ }
132+ }
133+ return keep
134+ }
135+
136+ updateBinding := func(binding key.Binding) key.Binding {
137+ newKeys := removeLettersAndNumbers(binding.Keys())
138+ if len(newKeys) == 0 {
139+ binding.SetEnabled(false)
140+ return binding
141+ }
142+ binding.SetKeys(newKeys...)
143+ return binding
144+ }
145+
146+ f.keyMap.Down = updateBinding(f.keyMap.Down)
147+ f.keyMap.Up = updateBinding(f.keyMap.Up)
148+ f.keyMap.DownOneItem = updateBinding(f.keyMap.DownOneItem)
149+ f.keyMap.UpOneItem = updateBinding(f.keyMap.UpOneItem)
150+ f.keyMap.HalfPageDown = updateBinding(f.keyMap.HalfPageDown)
151+ f.keyMap.HalfPageUp = updateBinding(f.keyMap.HalfPageUp)
152+ f.keyMap.PageDown = updateBinding(f.keyMap.PageDown)
153+ f.keyMap.PageUp = updateBinding(f.keyMap.PageUp)
154+ f.keyMap.End = updateBinding(f.keyMap.End)
155+ f.keyMap.Home = updateBinding(f.keyMap.Home)
156+}
157+
158+func (m *filterableGroupList[T]) GetSize() (int, int) {
159+ return m.width, m.height
160+}
161+
162+func (f *filterableGroupList[T]) SetSize(w, h int) tea.Cmd {
163+ f.width = w
164+ f.height = h
165+ if f.inputHidden {
166+ return f.groupedList.SetSize(w, h)
167+ }
168+ if f.inputWidth == 0 {
169+ f.input.SetWidth(w)
170+ } else {
171+ f.input.SetWidth(f.inputWidth)
172+ }
173+ return f.groupedList.SetSize(w, h-(f.inputHeight()))
174+}
175+
176+func (f *filterableGroupList[T]) inputHeight() int {
177+ return lipgloss.Height(f.inputStyle.Render(f.input.View()))
178+}
179+
180+func (f *filterableGroupList[T]) Filter(query string) tea.Cmd {
181+ var cmds []tea.Cmd
182+ for _, item := range f.items {
183+ if i, ok := any(item).(layout.Focusable); ok {
184+ cmds = append(cmds, i.Blur())
185+ }
186+ if i, ok := any(item).(HasMatchIndexes); ok {
187+ i.MatchIndexes(make([]int, 0))
188+ }
189+ }
190+
191+ f.selectedItem = ""
192+ if query == "" {
193+ return f.groupedList.SetGroups(f.groups)
194+ }
195+
196+ var newGroups []Group[T]
197+ for _, g := range f.groups {
198+ words := make([]string, len(g.Items))
199+ for i, item := range g.Items {
200+ words[i] = strings.ToLower(item.FilterValue())
201+ }
202+
203+ matches := fuzzy.Find(query, words)
204+
205+ sort.SliceStable(matches, func(i, j int) bool {
206+ return matches[i].Score > matches[j].Score
207+ })
208+
209+ var matchedItems []T
210+ for _, match := range matches {
211+ item := g.Items[match.Index]
212+ if i, ok := any(item).(HasMatchIndexes); ok {
213+ i.MatchIndexes(match.MatchedIndexes)
214+ }
215+ matchedItems = append(matchedItems, item)
216+ }
217+ if len(matchedItems) > 0 {
218+ newGroups = append(newGroups, Group[T]{
219+ Section: g.Section,
220+ Items: matchedItems,
221+ })
222+ }
223+ }
224+ cmds = append(cmds, f.groupedList.SetGroups(newGroups))
225+ return tea.Batch(cmds...)
226+}
227+
228+func (f *filterableGroupList[T]) SetGroups(groups []Group[T]) tea.Cmd {
229+ f.groups = groups
230+ return f.groupedList.SetGroups(groups)
231+}
232+
233+func (f *filterableGroupList[T]) Cursor() *tea.Cursor {
234+ if f.inputHidden {
235+ return nil
236+ }
237+ return f.input.Cursor()
238+}
239+
240+func (f *filterableGroupList[T]) Blur() tea.Cmd {
241+ f.input.Blur()
242+ return f.groupedList.Blur()
243+}
244+
245+func (f *filterableGroupList[T]) Focus() tea.Cmd {
246+ f.input.Focus()
247+ return f.groupedList.Focus()
248+}
249+
250+func (f *filterableGroupList[T]) IsFocused() bool {
251+ return f.groupedList.IsFocused()
252+}
253+
254+func (f *filterableGroupList[T]) SetInputWidth(w int) {
255+ f.inputWidth = w
256+}
257+
258+func (f *filterableGroupList[T]) SetInputPlaceholder(ph string) {
259+ f.placeholder = ph
260+}
internal/tui/exp/list/filterable_test.go+66−58
@@ -1,60 +1,68 @@
1package list
1package list
2
2
3−//
3+import (
4+ "fmt"
5+ "slices"
6+ "testing"
7+
8+ "github.com/charmbracelet/x/exp/golden"
9+ "github.com/stretchr/testify/assert"
10+)
11+
4−// func TestFilterableList(t *testing.T) {
12+func TestFilterableList(t *testing.T) {
5−// t.Parallel()
13+ t.Parallel()
6−// t.Run("should create simple filterable list", func(t *testing.T) {
14+ t.Run("should create simple filterable list", func(t *testing.T) {
7−// t.Parallel()
15+ t.Parallel()
8−// items := []FilterableItem{}
16+ items := []FilterableItem{}
9−// for i := range 5 {
17+ for i := range 5 {
10−// item := NewFilterableItem(fmt.Sprintf("Item %d", i))
18+ item := NewFilterableItem(fmt.Sprintf("Item %d", i))
11−// items = append(items, item)
19+ items = append(items, item)
12−// }
20+ }
13−// l := NewFilterableList(
21+ l := NewFilterableList(
14−// items,
22+ items,
15−// WithFilterListOptions(WithDirection(Forward)),
23+ WithFilterListOptions(WithDirectionForward()),
16−// ).(*filterableList[FilterableItem])
24+ ).(*filterableList[FilterableItem])
17−//
25+
18−// l.SetSize(100, 10)
26+ l.SetSize(100, 10)
19−// cmd := l.Init()
27+ cmd := l.Init()
20−// if cmd != nil {
28+ if cmd != nil {
21−// cmd()
29+ cmd()
22−// }
30+ }
23−//
31+
24−// assert.Equal(t, items[0].ID(), l.selectedItem)
32+ assert.Equal(t, items[0].ID(), l.selectedItem)
25−// golden.RequireEqual(t, []byte(l.View()))
33+ golden.RequireEqual(t, []byte(l.View()))
26−// })
34+ })
27−// }
35+}
28−//
36+
29−// func TestUpdateKeyMap(t *testing.T) {
37+func TestUpdateKeyMap(t *testing.T) {
30−// t.Parallel()
38+ t.Parallel()
31−// l := NewFilterableList(
39+ l := NewFilterableList(
32−// []FilterableItem{},
40+ []FilterableItem{},
33−// WithFilterListOptions(WithDirection(Forward)),
41+ WithFilterListOptions(WithDirectionForward()),
34−// ).(*filterableList[FilterableItem])
42+ ).(*filterableList[FilterableItem])
35−//
43+
36−// hasJ := slices.Contains(l.keyMap.Down.Keys(), "j")
44+ hasJ := slices.Contains(l.keyMap.Down.Keys(), "j")
37−// fmt.Println(l.keyMap.Down.Keys())
45+ fmt.Println(l.keyMap.Down.Keys())
38−// hasCtrlJ := slices.Contains(l.keyMap.Down.Keys(), "ctrl+j")
46+ hasCtrlJ := slices.Contains(l.keyMap.Down.Keys(), "ctrl+j")
39−//
47+
40−// hasUpperCaseK := slices.Contains(l.keyMap.UpOneItem.Keys(), "K")
48+ hasUpperCaseK := slices.Contains(l.keyMap.UpOneItem.Keys(), "K")
41−//
49+
42−// assert.False(t, l.keyMap.HalfPageDown.Enabled(), "should disable keys that are only letters")
50+ assert.False(t, l.keyMap.HalfPageDown.Enabled(), "should disable keys that are only letters")
43−// assert.False(t, hasJ, "should not contain j")
51+ assert.False(t, hasJ, "should not contain j")
44−// assert.False(t, hasUpperCaseK, "should also remove upper case K")
52+ assert.False(t, hasUpperCaseK, "should also remove upper case K")
45−// assert.True(t, hasCtrlJ, "should still have ctrl+j")
53+ assert.True(t, hasCtrlJ, "should still have ctrl+j")
46−// }
54+}
47−//
55+
48−// type filterableItem struct {
56+type filterableItem struct {
49−// *selectableItem
57+ *selectableItem
50−// }
58+}
51−//
59+
52−// func NewFilterableItem(content string) FilterableItem {
60+func NewFilterableItem(content string) FilterableItem {
53−// return &filterableItem{
61+ return &filterableItem{
54−// selectableItem: NewSelectableItem(content).(*selectableItem),
62+ selectableItem: NewSelectableItem(content).(*selectableItem),
55−// }
63+ }
56−// }
64+}
57−//
65+
58−// func (f *filterableItem) FilterValue() string {
66+func (f *filterableItem) FilterValue() string {
59−// return f.content
67+ return f.content
60−// }
68+}
internal/tui/exp/list/grouped.gonew file+99
@@ -0,0 +1,99 @@
1+package list
2+
3+import (
4+ tea "github.com/charmbracelet/bubbletea/v2"
5+ "github.com/charmbracelet/crush/internal/tui/components/core/layout"
6+ "github.com/charmbracelet/crush/internal/tui/util"
7+)
8+
9+type Group[T Item] struct {
10+ Section ItemSection
11+ Items []T
12+}
13+type GroupedList[T Item] interface {
14+ util.Model
15+ layout.Sizeable
16+ Items() []Item
17+ Groups() []Group[T]
18+ SetGroups([]Group[T]) tea.Cmd
19+ MoveUp(int) tea.Cmd
20+ MoveDown(int) tea.Cmd
21+ GoToTop() tea.Cmd
22+ GoToBottom() tea.Cmd
23+ SelectItemAbove() tea.Cmd
24+ SelectItemBelow() tea.Cmd
25+ SetSelected(string) tea.Cmd
26+ SelectedItem() *T
27+}
28+type groupedList[T Item] struct {
29+ *list[Item]
30+ groups []Group[T]
31+}
32+
33+func NewGroupedList[T Item](groups []Group[T], opts ...ListOption) GroupedList[T] {
34+ list := &list[Item]{
35+ confOptions: &confOptions{
36+ direction: DirectionForward,
37+ keyMap: DefaultKeyMap(),
38+ focused: true,
39+ },
40+ indexMap: make(map[string]int),
41+ renderedItems: map[string]renderedItem{},
42+ }
43+ for _, opt := range opts {
44+ opt(list.confOptions)
45+ }
46+
47+ return &groupedList[T]{
48+ list: list,
49+ }
50+}
51+
52+func (g *groupedList[T]) Init() tea.Cmd {
53+ g.convertItems()
54+ return g.render()
55+}
56+
57+func (l *groupedList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
58+ u, cmd := l.list.Update(msg)
59+ l.list = u.(*list[Item])
60+ return l, cmd
61+}
62+
63+func (g *groupedList[T]) SelectedItem() *T {
64+ item := g.list.SelectedItem()
65+ if item == nil {
66+ return nil
67+ }
68+ dRef := *item
69+ c, ok := any(dRef).(T)
70+ if !ok {
71+ return nil
72+ }
73+ return &c
74+}
75+
76+func (g *groupedList[T]) convertItems() {
77+ var items []Item
78+ for _, g := range g.groups {
79+ items = append(items, g.Section)
80+ for _, g := range g.Items {
81+ items = append(items, g)
82+ }
83+ }
84+ g.items = items
85+}
86+
87+func (g *groupedList[T]) SetGroups(groups []Group[T]) tea.Cmd {
88+ g.groups = groups
89+ g.convertItems()
90+ return g.SetItems(g.items)
91+}
92+
93+func (g *groupedList[T]) Groups() []Group[T] {
94+ return g.groups
95+}
96+
97+func (g *groupedList[T]) Items() []Item {
98+ return g.list.Items()
99+}
internal/tui/exp/list/items.go+83−6
@@ -4,6 +4,7 @@ import (
4 "image/color"
4 "image/color"
5
5
6 tea "github.com/charmbracelet/bubbletea/v2"
6 tea "github.com/charmbracelet/bubbletea/v2"
7+ "github.com/charmbracelet/crush/internal/tui/components/core"
7 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
8 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
8 "github.com/charmbracelet/crush/internal/tui/styles"
9 "github.com/charmbracelet/crush/internal/tui/styles"
9 "github.com/charmbracelet/lipgloss/v2"
10 "github.com/charmbracelet/lipgloss/v2"
@@ -12,6 +13,10 @@ import (
12 "github.com/rivo/uniseg"
13 "github.com/rivo/uniseg"
13)
14)
14
15
16+type Indexable interface {
17+ SetIndex(int)
18+}
19+
15type CompletionItem[T any] interface {
20type CompletionItem[T any] interface {
16 FilterableItem
21 FilterableItem
17 layout.Focusable
22 layout.Focusable
@@ -39,33 +44,33 @@ type options struct {
39 shortcut string
44 shortcut string
40}
45}
41
46
42−type completionOption func(*options)
47+type CompletionItemOption func(*options)
43
48
44−func WithBackgroundColor(c color.Color) completionOption {
49+func WithCompletionBackgroundColor(c color.Color) CompletionItemOption {
45 return func(cmp *options) {
50 return func(cmp *options) {
46 cmp.bgColor = c
51 cmp.bgColor = c
47 }
52 }
48}
53}
49
54
50−func WithMatchIndexes(indexes ...int) completionOption {
55+func WithCompletionMatchIndexes(indexes ...int) CompletionItemOption {
51 return func(cmp *options) {
56 return func(cmp *options) {
52 cmp.matchIndexes = indexes
57 cmp.matchIndexes = indexes
53 }
58 }
54}
59}
55
60
56−func WithShortcut(shortcut string) completionOption {
61+func WithCompletionShortcut(shortcut string) CompletionItemOption {
57 return func(cmp *options) {
62 return func(cmp *options) {
58 cmp.shortcut = shortcut
63 cmp.shortcut = shortcut
59 }
64 }
60}
65}
61
66
62−func WithID(id string) completionOption {
67+func WithCompletionID(id string) CompletionItemOption {
63 return func(cmp *options) {
68 return func(cmp *options) {
64 cmp.id = id
69 cmp.id = id
65 }
70 }
66}
71}
67
72
68−func NewCompletionItem[T any](text string, value T, opts ...completionOption) CompletionItem[T] {
73+func NewCompletionItem[T any](text string, value T, opts ...CompletionItemOption) CompletionItem[T] {
69 c := &completionItemCmp[T]{
74 c := &completionItemCmp[T]{
70 text: text,
75 text: text,
71 value: value,
76 value: value,
@@ -306,3 +311,75 @@ func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
306func (c *completionItemCmp[T]) ID() string {
311func (c *completionItemCmp[T]) ID() string {
307 return c.id
312 return c.id
308}
313}
314+
315+type ItemSection interface {
316+ Item
317+ layout.Sizeable
318+ Indexable
319+ SetInfo(info string)
320+}
321+type itemSectionModel struct {
322+ width int
323+ title string
324+ inx int
325+ info string
326+}
327+
328+// ID implements ItemSection.
329+func (m *itemSectionModel) ID() string {
330+ return uuid.NewString()
331+}
332+
333+func NewItemSection(title string) ItemSection {
334+ return &itemSectionModel{
335+ title: title,
336+ inx: -1,
337+ }
338+}
339+
340+func (m *itemSectionModel) Init() tea.Cmd {
341+ return nil
342+}
343+
344+func (m *itemSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
345+ return m, nil
346+}
347+
348+func (m *itemSectionModel) View() string {
349+ t := styles.CurrentTheme()
350+ title := ansi.Truncate(m.title, m.width-2, "…")
351+ style := t.S().Base.Padding(1, 1, 0, 1)
352+ if m.inx == 0 {
353+ style = style.Padding(0, 1, 0, 1)
354+ }
355+ title = t.S().Muted.Render(title)
356+ section := ""
357+ if m.info != "" {
358+ section = core.SectionWithInfo(title, m.width-2, m.info)
359+ } else {
360+ section = core.Section(title, m.width-2)
361+ }
362+
363+ return style.Render(section)
364+}
365+
366+func (m *itemSectionModel) GetSize() (int, int) {
367+ return m.width, 1
368+}
369+
370+func (m *itemSectionModel) SetSize(width int, height int) tea.Cmd {
371+ m.width = width
372+ return nil
373+}
374+
375+func (m *itemSectionModel) IsSectionHeader() bool {
376+ return true
377+}
378+
379+func (m *itemSectionModel) SetInfo(info string) {
380+ m.info = info
381+}
382+
383+func (m *itemSectionModel) SetIndex(inx int) {
384+ m.inx = inx
385+}
internal/tui/exp/list/list.go+78−41
@@ -8,6 +8,7 @@ import (
8 tea "github.com/charmbracelet/bubbletea/v2"
8 tea "github.com/charmbracelet/bubbletea/v2"
9 "github.com/charmbracelet/crush/internal/tui/components/anim"
9 "github.com/charmbracelet/crush/internal/tui/components/anim"
10 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
10 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
11+ "github.com/charmbracelet/crush/internal/tui/styles"
11 "github.com/charmbracelet/crush/internal/tui/util"
12 "github.com/charmbracelet/crush/internal/tui/util"
12 "github.com/charmbracelet/lipgloss/v2"
13 "github.com/charmbracelet/lipgloss/v2"
13)
14)
@@ -22,30 +23,29 @@ type HasAnim interface {
22 Item
23 Item
23 Spinning() bool
24 Spinning() bool
24}
25}
25−type (
26− renderedMsg struct{}
26+type renderedMsg struct{}
27+
27− List[T Item] interface {
28+type List[T Item] interface {
28− util.Model
29+ util.Model
29− layout.Sizeable
30+ layout.Sizeable
30− layout.Focusable
31+ layout.Focusable
31−
32+
32− // Just change state
33+ // Just change state
33− MoveUp(int) tea.Cmd
34+ MoveUp(int) tea.Cmd
34− MoveDown(int) tea.Cmd
35+ MoveDown(int) tea.Cmd
35− GoToTop() tea.Cmd
36+ GoToTop() tea.Cmd
36− GoToBottom() tea.Cmd
37+ GoToBottom() tea.Cmd
37− SelectItemAbove() tea.Cmd
38+ SelectItemAbove() tea.Cmd
38− SelectItemBelow() tea.Cmd
39+ SelectItemBelow() tea.Cmd
39− SetItems([]T) tea.Cmd
40+ SetItems([]T) tea.Cmd
40− SetSelected(string) tea.Cmd
41+ SetSelected(string) tea.Cmd
41− SelectedItem() *T
42+ SelectedItem() *T
42− Items() []T
43+ Items() []T
43− UpdateItem(string, T) tea.Cmd
44+ UpdateItem(string, T) tea.Cmd
44− DeleteItem(string) tea.Cmd
45+ DeleteItem(string) tea.Cmd
45− PrependItem(T) tea.Cmd
46+ PrependItem(T) tea.Cmd
46− AppendItem(T) tea.Cmd
47+ AppendItem(T) tea.Cmd
47− }
48+}
48−)
49
49
50type direction int
50type direction int
51
51
@@ -76,6 +76,7 @@ type confOptions struct {
76 direction direction
76 direction direction
77 selectedItem string
77 selectedItem string
78 focused bool
78 focused bool
79+ resize bool
79}
80}
80
81
81type list[T Item] struct {
82type list[T Item] struct {
@@ -93,10 +94,10 @@ type list[T Item] struct {
93 movingByItem bool
94 movingByItem bool
94}
95}
95
96
96−type listOption func(*confOptions)
97+type ListOption func(*confOptions)
97
98
98// WithSize sets the size of the list.
99// WithSize sets the size of the list.
99−func WithSize(width, height int) listOption {
100+func WithSize(width, height int) ListOption {
100 return func(l *confOptions) {
101 return func(l *confOptions) {
101 l.width = width
102 l.width = width
102 l.height = height
103 l.height = height
@@ -104,52 +105,58 @@ func WithSize(width, height int) listOption {
104}
105}
105
106
106// WithGap sets the gap between items in the list.
107// WithGap sets the gap between items in the list.
107−func WithGap(gap int) listOption {
108+func WithGap(gap int) ListOption {
108 return func(l *confOptions) {
109 return func(l *confOptions) {
109 l.gap = gap
110 l.gap = gap
110 }
111 }
111}
112}
112
113
113// WithDirectionForward sets the direction to forward
114// WithDirectionForward sets the direction to forward
114−func WithDirectionForward() listOption {
115+func WithDirectionForward() ListOption {
115 return func(l *confOptions) {
116 return func(l *confOptions) {
116 l.direction = DirectionForward
117 l.direction = DirectionForward
117 }
118 }
118}
119}
119
120
120// WithDirectionBackward sets the direction to forward
121// WithDirectionBackward sets the direction to forward
121−func WithDirectionBackward() listOption {
122+func WithDirectionBackward() ListOption {
122 return func(l *confOptions) {
123 return func(l *confOptions) {
123 l.direction = DirectionBackward
124 l.direction = DirectionBackward
124 }
125 }
125}
126}
126
127
127// WithSelectedItem sets the initially selected item in the list.
128// WithSelectedItem sets the initially selected item in the list.
128−func WithSelectedItem(id string) listOption {
129+func WithSelectedItem(id string) ListOption {
129 return func(l *confOptions) {
130 return func(l *confOptions) {
130 l.selectedItem = id
131 l.selectedItem = id
131 }
132 }
132}
133}
133
134
134−func WithKeyMap(keyMap KeyMap) listOption {
135+func WithKeyMap(keyMap KeyMap) ListOption {
135 return func(l *confOptions) {
136 return func(l *confOptions) {
136 l.keyMap = keyMap
137 l.keyMap = keyMap
137 }
138 }
138}
139}
139
140
140−func WithWrapNavigation() listOption {
141+func WithWrapNavigation() ListOption {
141 return func(l *confOptions) {
142 return func(l *confOptions) {
142 l.wrap = true
143 l.wrap = true
143 }
144 }
144}
145}
145
146
146−func WithFocus(focus bool) listOption {
147+func WithFocus(focus bool) ListOption {
147 return func(l *confOptions) {
148 return func(l *confOptions) {
148 l.focused = focus
149 l.focused = focus
149 }
150 }
150}
151}
151
152
153+func WithResizeByList() ListOption {
154+ return func(l *confOptions) {
155+ l.resize = true
156+ }
157+}
158+
152−func New[T Item](items []T, opts ...listOption) List[T] {
159+func New[T Item](items []T, opts ...ListOption) List[T] {
153 list := &list[T]{
160 list := &list[T]{
154 confOptions: &confOptions{
161 confOptions: &confOptions{
155 direction: DirectionForward,
162 direction: DirectionForward,
@@ -165,6 +172,9 @@ func New[T Item](items []T, opts ...listOption) List[T] {
165 }
172 }
166
173
167 for inx, item := range items {
174 for inx, item := range items {
175+ if i, ok := any(item).(Indexable); ok {
176+ i.SetIndex(inx)
177+ }
168 list.indexMap[item.ID()] = inx
178 list.indexMap[item.ID()] = inx
169 }
179 }
170 return list
180 return list
@@ -224,6 +234,7 @@ func (l *list[T]) View() string {
224 if l.height <= 0 || l.width <= 0 {
234 if l.height <= 0 || l.width <= 0 {
225 return ""
235 return ""
226 }
236 }
237+ t := styles.CurrentTheme()
227 view := l.rendered
238 view := l.rendered
228 lines := strings.Split(view, "\n")
239 lines := strings.Split(view, "\n")
229
240
@@ -231,7 +242,13 @@ func (l *list[T]) View() string {
231 viewStart := max(0, start)
242 viewStart := max(0, start)
232 viewEnd := min(len(lines), end+1)
243 viewEnd := min(len(lines), end+1)
233 lines = lines[viewStart:viewEnd]
244 lines = lines[viewStart:viewEnd]
245+ if l.resize {
234− return strings.Join(lines, "\n")
246+ return strings.Join(lines, "\n")
247+ }
248+ return t.S().Base.
249+ Height(l.height).
250+ Width(l.width).
251+ Render(strings.Join(lines, "\n"))
235}
252}
236
253
237func (l *list[T]) viewPosition() (int, int) {
254func (l *list[T]) viewPosition() (int, int) {
@@ -774,10 +791,26 @@ func (l *list[T]) SelectItemAbove() tea.Cmd {
774 // no item above
791 // no item above
775 return nil
792 return nil
776 }
793 }
794+ var cmds []tea.Cmd
795+ if newIndex == 1 {
796+ peakAboveIndex := l.firstSelectableItemAbove(newIndex)
797+ if peakAboveIndex == ItemNotFound {
798+ // this means there is a section above move to the top
799+ cmd := l.GoToTop()
800+ if cmd != nil {
801+ cmds = append(cmds, cmd)
802+ }
803+ }
804+
805+ }
777 item := l.items[newIndex]
806 item := l.items[newIndex]
778 l.selectedItem = item.ID()
807 l.selectedItem = item.ID()
779 l.movingByItem = true
808 l.movingByItem = true
780− return l.render()
809+ renderCmd := l.render()
810+ if renderCmd != nil {
811+ cmds = append(cmds, renderCmd)
812+ }
813+ return tea.Sequence(cmds...)
781}
814}
782
815
783// SelectItemBelow implements List.
816// SelectItemBelow implements List.
@@ -815,10 +848,13 @@ func (l *list[T]) SelectedItem() *T {
815func (l *list[T]) SetItems(items []T) tea.Cmd {
848func (l *list[T]) SetItems(items []T) tea.Cmd {
816 l.items = items
849 l.items = items
817 var cmds []tea.Cmd
850 var cmds []tea.Cmd
818− for _, item := range l.items {
851+ for inx, item := range l.items {
852+ if i, ok := any(item).(Indexable); ok {
853+ i.SetIndex(inx)
854+ }
819 cmds = append(cmds, item.Init())
855 cmds = append(cmds, item.Init())
820 }
856 }
821− cmds = append(cmds, l.reset())
857+ cmds = append(cmds, l.reset(""))
822 return tea.Batch(cmds...)
858 return tea.Batch(cmds...)
823}
859}
824
860
@@ -828,11 +864,11 @@ func (l *list[T]) SetSelected(id string) tea.Cmd {
828 return l.render()
864 return l.render()
829}
865}
830
866
831−func (l *list[T]) reset() tea.Cmd {
867+func (l *list[T]) reset(selectedItem string) tea.Cmd {
832 var cmds []tea.Cmd
868 var cmds []tea.Cmd
833 l.rendered = ""
869 l.rendered = ""
834 l.offset = 0
870 l.offset = 0
835− l.selectedItem = ""
871+ l.selectedItem = selectedItem
836 l.indexMap = make(map[string]int)
872 l.indexMap = make(map[string]int)
837 l.renderedItems = make(map[string]renderedItem)
873 l.renderedItems = make(map[string]renderedItem)
838 for inx, item := range l.items {
874 for inx, item := range l.items {
@@ -851,7 +887,8 @@ func (l *list[T]) SetSize(width int, height int) tea.Cmd {
851 l.width = width
887 l.width = width
852 l.height = height
888 l.height = height
853 if oldWidth != width {
889 if oldWidth != width {
854− return l.reset()
890+ cmd := l.reset(l.selectedItem)
891+ return cmd
855 }
892 }
856 return nil
893 return nil
857}
894}
internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.goldennew file+6
@@ -0,0 +1,6 @@
1+> Type to filter 
2+│Item 0
3+Item 1
4+Item 2
5+Item 3
6+Item 4
internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items.goldendeleted−10
@@ -1,10 +0,0 @@
1−│Item 0
2−Item 1
3−Item 2
4−Item 3
5−Item 4
6−Item 5
7−Item 6
8−Item 7
9−Item 8
10−Item 9
internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.goldendeleted−10
@@ -1,10 +0,0 @@
1−│Item 0
2−Item 1
3−Item 1
4−Item 2
5−Item 2
6−Item 2
7−Item 3
8−Item 3
9−Item 3
10−Item 3
internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.goldendeleted−10
@@ -1,10 +0,0 @@
1−│Item 29
2−│Item 29
3−│Item 29
4−│Item 29
5−│Item 29
6−│Item 29
7−│Item 29
8−│Item 29
9−│Item 29
10−│Item 29
internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_backwards.goldendeleted−10
@@ -1,10 +0,0 @@
1−Item 20
2−Item 21
3−Item 22
4−Item 23
5−Item 24
6−Item 25
7−Item 26
8−Item 27
9−Item 28
10−│Item 29
internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items.goldendeleted−5
@@ -1,5 +0,0 @@
1−│Item 0
2−Item 1
3−Item 2
4−Item 3
5−Item 4
internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items_backwards.goldendeleted−5
@@ -1,5 +0,0 @@
1−Item 0
2−Item 1
3−Item 2
4−Item 3
5−│Item 4
internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_botton.goldendeleted−10
@@ -1,10 +0,0 @@
1−Item 29
2−Item 29
3−Item 29
4−Item 29
5−Item 29
6−Item 29
7−Item 29
8−Item 29
9−Item 29
10−│Testing
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up.goldendeleted−10
@@ -1,10 +0,0 @@
1−Item 18
2−Item 19
3−Item 20
4−Item 21
5−Item 22
6−Item 23
7−Item 24
8−Item 25
9−Item 26
10−│Item 27
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased.goldendeleted−10
@@ -1,10 +0,0 @@
1−Item 18
2−Item 19
3−Item 20
4−Item 21
5−Item 22
6−Item 23
7−Item 24
8−Item 25
9−Item 26
10−│Item 27
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases.goldendeleted−10
@@ -1,10 +0,0 @@
1−Item 21
2−Item 22
3−Item 23
4−Item 24
5−Item 25
6−Item 26
7−Item 27
8−Item 28
9−│Item 29
10−Item 30
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased#01.goldendeleted−10
@@ -1,10 +0,0 @@
1−Item 18
2−Item 19
3−Item 20
4−Item 21
5−Item 22
6−Item 23
7−Item 24
8−Item 25
9−Item 26
10−│Item 27
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased.goldendeleted−10
@@ -1,10 +0,0 @@
1−Item 18
2−Item 19
3−Item 20
4−Item 21
5−Item 22
6−Item 23
7−Item 24
8−Item 25
9−Item 26
10−│Item 27