on main help sign in
wip command dialog
b787dc0Kujtim Hoxha
.opencode.json+3
@@ -4,5 +4,8 @@
4 "gopls": {
4 "gopls": {
5 "command": "gopls"
5 "command": "gopls"
6 }
6 }
7+ },
8+ "tui": {
9+ "theme": "opencode-dark"
7 }
10 }
8}
11}
cspell.json+1−1
@@ -1 +1 @@
1−{"flagWords":[],"version":"0.2","words":["opencode","charmbracelet","lipgloss","bubbletea"],"language":"en"}
1+{"flagWords":[],"language":"en","words":["opencode","charmbracelet","lipgloss","bubbletea","textinput","Focusable"],"version":"0.2"}
go.mod+2
@@ -32,6 +32,8 @@ require (
32 github.com/stretchr/testify v1.10.0
32 github.com/stretchr/testify v1.10.0
33)
33)
34
34
35+require github.com/sahilm/fuzzy v0.1.1
36+
35require (
37require (
36 cloud.google.com/go v0.116.0 // indirect
38 cloud.google.com/go v0.116.0 // indirect
37 cloud.google.com/go/auth v0.13.0 // indirect
39 cloud.google.com/go/auth v0.13.0 // indirect
go.sum+2
@@ -199,6 +199,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
199github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
199github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
200github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
200github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
201github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
201github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
202+github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
203+github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
202github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y=
204github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y=
203github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
205github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
204github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
206github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
internal/tui/components/chat/list.go+1−1
@@ -45,7 +45,7 @@ type messageListCmp struct {
45// NewMessagesListCmp creates a new message list component with custom keybindings
45// NewMessagesListCmp creates a new message list component with custom keybindings
46// and reverse ordering (newest messages at bottom).
46// and reverse ordering (newest messages at bottom).
47func NewMessagesListCmp(app *app.App) MessageListCmp {
47func NewMessagesListCmp(app *app.App) MessageListCmp {
48− defaultKeymaps := list.DefaultKeymap()
48+ defaultKeymaps := list.DefaultKeyMap()
49 defaultKeymaps.Up.SetEnabled(false)
49 defaultKeymaps.Up.SetEnabled(false)
50 defaultKeymaps.Down.SetEnabled(false)
50 defaultKeymaps.Down.SetEnabled(false)
51 defaultKeymaps.NDown = key.NewBinding(
51 defaultKeymaps.NDown = key.NewBinding(
internal/tui/components/core/list/keys.go+3−3
@@ -18,7 +18,7 @@ type KeyMap struct {
18 End key.Binding
18 End key.Binding
19}
19}
20
20
21−func DefaultKeymap() KeyMap {
21+func DefaultKeyMap() KeyMap {
22 return KeyMap{
22 return KeyMap{
23 Down: key.NewBinding(
23 Down: key.NewBinding(
24 key.WithKeys("down", "ctrl+j", "ctrl+n"),
24 key.WithKeys("down", "ctrl+j", "ctrl+n"),
@@ -45,10 +45,10 @@ func DefaultKeymap() KeyMap {
45 key.WithKeys("ctrl+u"),
45 key.WithKeys("ctrl+u"),
46 ),
46 ),
47 Home: key.NewBinding(
47 Home: key.NewBinding(
48− key.WithKeys("g", "home"),
48+ key.WithKeys("ctrl+g", "home"),
49 ),
49 ),
50 End: key.NewBinding(
50 End: key.NewBinding(
51− key.WithKeys("shift+g", "end"),
51+ key.WithKeys("ctrl+shift+g", "end"),
52 ),
52 ),
53 }
53 }
54}
54}
internal/tui/components/core/list/list.go+216−75
@@ -2,16 +2,21 @@ package list
2
2
3import (
3import (
4 "slices"
4 "slices"
5+ "sort"
5 "strings"
6 "strings"
6
7
7 "github.com/charmbracelet/bubbles/v2/help"
8 "github.com/charmbracelet/bubbles/v2/help"
8 "github.com/charmbracelet/bubbles/v2/key"
9 "github.com/charmbracelet/bubbles/v2/key"
9 "github.com/charmbracelet/bubbles/v2/spinner"
10 "github.com/charmbracelet/bubbles/v2/spinner"
11+ "github.com/charmbracelet/bubbles/v2/textinput"
10 tea "github.com/charmbracelet/bubbletea/v2"
12 tea "github.com/charmbracelet/bubbletea/v2"
11 "github.com/charmbracelet/lipgloss/v2"
13 "github.com/charmbracelet/lipgloss/v2"
12 "github.com/opencode-ai/opencode/internal/tui/components/anim"
14 "github.com/opencode-ai/opencode/internal/tui/components/anim"
13 "github.com/opencode-ai/opencode/internal/tui/layout"
15 "github.com/opencode-ai/opencode/internal/tui/layout"
16+ "github.com/opencode-ai/opencode/internal/tui/styles"
17+ "github.com/opencode-ai/opencode/internal/tui/theme"
14 "github.com/opencode-ai/opencode/internal/tui/util"
18 "github.com/opencode-ai/opencode/internal/tui/util"
19+ "github.com/sahilm/fuzzy"
15)
20)
16
21
17// Constants for special index values and defaults
22// Constants for special index values and defaults
@@ -43,6 +48,18 @@ type HasAnim interface {
43 Spinning() bool // Returns true if the item is currently animating
48 Spinning() bool // Returns true if the item is currently animating
44}
49}
45
50
51+// HasFilterValue interface allows items to provide a filter value for searching.
52+type HasFilterValue interface {
53+ util.Model
54+ FilterValue() string // Returns a string value used for filtering/searching
55+}
56+
57+// HasMatchIndexes interface allows items to set matched character indexes.
58+type HasMatchIndexes interface {
59+ util.Model
60+ MatchIndexes([]int) // Sets the indexes of matched characters in the item's content
61+}
62+
46// renderedItem represents a cached rendered item with its position and content.
63// renderedItem represents a cached rendered item with its position and content.
47type renderedItem struct {
64type renderedItem struct {
48 lines []string // The rendered lines of text for this item
65 lines []string // The rendered lines of text for this item
@@ -105,10 +122,15 @@ type model struct {
105 renderState *renderState // Rendering cache and state
122 renderState *renderState // Rendering cache and state
106 selectionState selectionState // Item selection state
123 selectionState selectionState // Item selection state
107 help help.Model // Help system for keyboard shortcuts
124 help help.Model // Help system for keyboard shortcuts
108− keymap KeyMap // Key bindings for navigation
125+ keyMap KeyMap // Key bindings for navigation
109− items []util.Model // The actual list items
126+ allItems []util.Model // The actual list items
110 gapSize int // Number of empty lines between items
127 gapSize int // Number of empty lines between items
111 padding []int // Padding around the list content
128 padding []int // Padding around the list content
129+
130+ filterable bool // Whether items can be filtered
131+ filteredItems []util.Model // Filtered items based on current search
132+ input textinput.Model // Input field for filtering items
133+ currentSearch string // Current search term for filtering
112}
134}
113
135
114// listOptions is a function type for configuring list options.
136// listOptions is a function type for configuring list options.
@@ -117,7 +139,7 @@ type listOptions func(*model)
117// WithKeyMap sets custom key bindings for the list.
139// WithKeyMap sets custom key bindings for the list.
118func WithKeyMap(k KeyMap) listOptions {
140func WithKeyMap(k KeyMap) listOptions {
119 return func(m *model) {
141 return func(m *model) {
120− m.keymap = k
142+ m.keyMap = k
121 }
143 }
122}
144}
123
145
@@ -147,7 +169,15 @@ func WithPadding(padding ...int) listOptions {
147// WithItems sets the initial items for the list.
169// WithItems sets the initial items for the list.
148func WithItems(items []util.Model) listOptions {
170func WithItems(items []util.Model) listOptions {
149 return func(m *model) {
171 return func(m *model) {
150− m.items = items
172+ m.allItems = items
173+ m.filteredItems = items // Initially, all items are visible
174+ }
175+}
176+
177+// WithFilterable enables filtering of items based on their FilterValue.
178+func WithFilterable(filterable bool) listOptions {
179+ return func(m *model) {
180+ m.filterable = filterable
151 }
181 }
152}
182}
153
183
@@ -157,8 +187,9 @@ func WithItems(items []util.Model) listOptions {
157func New(opts ...listOptions) ListModel {
187func New(opts ...listOptions) ListModel {
158 m := &model{
188 m := &model{
159 help: help.New(),
189 help: help.New(),
160− keymap: DefaultKeymap(),
190+ keyMap: DefaultKeyMap(),
191+ allItems: []util.Model{},
161− items: []util.Model{},
192+ filteredItems: []util.Model{},
162 renderState: newRenderState(),
193 renderState: newRenderState(),
163 gapSize: DefaultGapSize,
194 gapSize: DefaultGapSize,
164 padding: []int{},
195 padding: []int{},
@@ -167,13 +198,25 @@ func New(opts ...listOptions) ListModel {
167 for _, opt := range opts {
198 for _, opt := range opts {
168 opt(m)
199 opt(m)
169 }
200 }
201+
202+ if m.filterable {
203+ ti := textinput.New()
204+ ti.Placeholder = "Type to filter..."
205+ ti.SetVirtualCursor(false)
206+ ti.Focus()
207+ m.input = ti
208+
209+ // disable j,k movements
210+ m.keyMap.NDown.SetEnabled(false)
211+ m.keyMap.NUp.SetEnabled(false)
212+ }
170 return m
213 return m
171}
214}
172
215
173// Init initializes the list component and sets up the initial items.
216// Init initializes the list component and sets up the initial items.
174// This is called automatically by the Bubble Tea framework.
217// This is called automatically by the Bubble Tea framework.
175func (m *model) Init() tea.Cmd {
218func (m *model) Init() tea.Cmd {
176− return m.SetItems(m.items)
219+ return m.SetItems(m.filteredItems)
177}
220}
178
221
179// Update handles incoming messages and updates the list state accordingly.
222// Update handles incoming messages and updates the list state accordingly.
@@ -186,34 +229,78 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
186 case anim.ColorCycleMsg, anim.StepCharsMsg, spinner.TickMsg:
229 case anim.ColorCycleMsg, anim.StepCharsMsg, spinner.TickMsg:
187 return m.handleAnimationMsg(msg)
230 return m.handleAnimationMsg(msg)
188 }
231 }
189−
190− if m.selectionState.isValidIndex(len(m.items)) {
232+ if m.selectionState.isValidIndex(len(m.filteredItems)) {
191 return m.updateSelectedItem(msg)
233 return m.updateSelectedItem(msg)
192 }
234 }
193
235
194 return m, nil
236 return m, nil
195}
237}
196
238
239+// View renders the list to a string for display.
240+// Returns empty string if the list has no dimensions.
241+// Triggers re-rendering if needed before returning content.
242+func (m *model) View() tea.View {
243+ if m.viewState.height == 0 || m.viewState.width == 0 {
244+ return tea.NewView("") // No content to display
245+ }
246+ if m.renderState.needsRerender {
247+ m.renderVisible()
248+ }
249+
250+ content := lipgloss.NewStyle().
251+ Padding(m.padding...).
252+ Height(m.viewState.height).
253+ Render(m.viewState.content)
254+
255+ if m.filterable {
256+ content = lipgloss.JoinVertical(
257+ lipgloss.Left,
258+ m.inputStyle().Render(m.input.View()),
259+ content,
260+ )
261+ }
262+ view := tea.NewView(content)
263+ if m.filterable {
264+ view.SetCursor(m.input.Cursor())
265+ }
266+ return view
267+}
268+
197// handleKeyPress processes keyboard input for list navigation.
269// handleKeyPress processes keyboard input for list navigation.
198// Supports scrolling, item selection, and navigation to top/bottom.
270// Supports scrolling, item selection, and navigation to top/bottom.
199func (m *model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
271func (m *model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
200 switch {
272 switch {
201− case key.Matches(msg, m.keymap.Down) || key.Matches(msg, m.keymap.NDown):
273+ case key.Matches(msg, m.keyMap.Down) || key.Matches(msg, m.keyMap.NDown):
202 m.scrollDown(1)
274 m.scrollDown(1)
203− case key.Matches(msg, m.keymap.Up) || key.Matches(msg, m.keymap.NUp):
275+ case key.Matches(msg, m.keyMap.Up) || key.Matches(msg, m.keyMap.NUp):
204 m.scrollUp(1)
276 m.scrollUp(1)
205− case key.Matches(msg, m.keymap.DownOneItem):
277+ case key.Matches(msg, m.keyMap.DownOneItem):
206 return m, m.selectNextItem()
278 return m, m.selectNextItem()
207− case key.Matches(msg, m.keymap.UpOneItem):
279+ case key.Matches(msg, m.keyMap.UpOneItem):
208 return m, m.selectPreviousItem()
280 return m, m.selectPreviousItem()
209− case key.Matches(msg, m.keymap.HalfPageDown):
281+ case key.Matches(msg, m.keyMap.HalfPageDown):
210 m.scrollDown(m.listHeight() / 2)
282 m.scrollDown(m.listHeight() / 2)
211− case key.Matches(msg, m.keymap.HalfPageUp):
283+ case key.Matches(msg, m.keyMap.HalfPageUp):
212 m.scrollUp(m.listHeight() / 2)
284 m.scrollUp(m.listHeight() / 2)
213− case key.Matches(msg, m.keymap.Home):
285+ case key.Matches(msg, m.keyMap.Home):
214 return m, m.goToTop()
286 return m, m.goToTop()
215− case key.Matches(msg, m.keymap.End):
287+ case key.Matches(msg, m.keyMap.End):
216 return m, m.goToBottom()
288 return m, m.goToBottom()
289+ default:
290+ if !m.filterable {
291+ return m, nil // Ignore other keys if not filterable
292+ }
293+ var cmds []tea.Cmd
294+ u, cmd := m.input.Update(msg)
295+ m.input = u
296+ cmds = append(cmds, cmd)
297+ if m.currentSearch != m.input.Value() {
298+ cmd = m.filter(m.input.Value())
299+ cmds = append(cmds, cmd)
300+ }
301+ m.currentSearch = m.input.Value()
302+ return m, tea.Batch(cmds...)
303+
217 }
304 }
218 return m, nil
305 return m, nil
219}
306}
@@ -222,7 +309,7 @@ func (m *model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
222// Only items implementing HasAnim and currently spinning receive these messages.
309// Only items implementing HasAnim and currently spinning receive these messages.
223func (m *model) handleAnimationMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
310func (m *model) handleAnimationMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
224 var cmds []tea.Cmd
311 var cmds []tea.Cmd
225− for inx, item := range m.items {
312+ for inx, item := range m.filteredItems {
226 if i, ok := item.(HasAnim); ok && i.Spinning() {
313 if i, ok := item.(HasAnim); ok && i.Spinning() {
227 updated, cmd := i.Update(msg)
314 updated, cmd := i.Update(msg)
228 cmds = append(cmds, cmd)
315 cmds = append(cmds, cmd)
@@ -238,7 +325,7 @@ func (m *model) handleAnimationMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
238// This allows the selected item to handle its own input and state changes.
325// This allows the selected item to handle its own input and state changes.
239func (m *model) updateSelectedItem(msg tea.Msg) (tea.Model, tea.Cmd) {
326func (m *model) updateSelectedItem(msg tea.Msg) (tea.Model, tea.Cmd) {
240 var cmds []tea.Cmd
327 var cmds []tea.Cmd
241− u, cmd := m.items[m.selectionState.selectedIndex].Update(msg)
328+ u, cmd := m.filteredItems[m.selectionState.selectedIndex].Update(msg)
242 cmds = append(cmds, cmd)
329 cmds = append(cmds, cmd)
243 if updated, ok := u.(util.Model); ok {
330 if updated, ok := u.(util.Model); ok {
244 m.UpdateItem(m.selectionState.selectedIndex, updated)
331 m.UpdateItem(m.selectionState.selectedIndex, updated)
@@ -266,27 +353,9 @@ func (m *model) scrollUp(amount int) {
266 }
353 }
267}
354}
268
355
269−// View renders the list to a string for display.
270−// Returns empty string if the list has no dimensions.
271−// Triggers re-rendering if needed before returning content.
272−func (m *model) View() tea.View {
273− if m.viewState.height == 0 || m.viewState.width == 0 {
274− return tea.NewView("") // No content to display
275− }
276− if m.renderState.needsRerender {
277− m.renderVisible()
278− }
279− return tea.NewView(
280− lipgloss.NewStyle().
281− Padding(m.padding...).
282− Height(m.viewState.height).
283− Render(m.viewState.content),
284− )
285−}
286−
287// Items returns a copy of all items in the list.
356// Items returns a copy of all items in the list.
288func (m *model) Items() []util.Model {
357func (m *model) Items() []util.Model {
289− return m.items
358+ return m.filteredItems
290}
359}
291
360
292// renderVisible determines which rendering strategy to use and triggers rendering.
361// renderVisible determines which rendering strategy to use and triggers rendering.
@@ -306,12 +375,12 @@ func (m *model) renderVisibleForward() {
306 model: m,
375 model: m,
307 start: 0,
376 start: 0,
308 cutoff: m.viewState.offset + m.listHeight(),
377 cutoff: m.viewState.offset + m.listHeight(),
309− items: m.items,
378+ items: m.filteredItems,
310 realIdx: m.renderState.lastIndex,
379 realIdx: m.renderState.lastIndex,
311 }
380 }
312
381
313 if m.renderState.lastIndex > NotRendered {
382 if m.renderState.lastIndex > NotRendered {
314− renderer.items = m.items[m.renderState.lastIndex+1:]
383+ renderer.items = m.filteredItems[m.renderState.lastIndex+1:]
315 renderer.start = len(m.renderState.lines)
384 renderer.start = len(m.renderState.lines)
316 }
385 }
317
386
@@ -326,16 +395,16 @@ func (m *model) renderVisibleReverse() {
326 model: m,
395 model: m,
327 start: 0,
396 start: 0,
328 cutoff: m.viewState.offset + m.listHeight(),
397 cutoff: m.viewState.offset + m.listHeight(),
329− items: m.items,
398+ items: m.filteredItems,
330 realIdx: m.renderState.lastIndex,
399 realIdx: m.renderState.lastIndex,
331 }
400 }
332
401
333 if m.renderState.lastIndex > NotRendered {
402 if m.renderState.lastIndex > NotRendered {
334− renderer.items = m.items[:m.renderState.lastIndex]
403+ renderer.items = m.filteredItems[:m.renderState.lastIndex]
335 renderer.start = len(m.renderState.lines)
404 renderer.start = len(m.renderState.lines)
336 } else {
405 } else {
337− m.renderState.lastIndex = len(m.items)
406+ m.renderState.lastIndex = len(m.filteredItems)
338− renderer.realIdx = len(m.items)
407+ renderer.realIdx = len(m.filteredItems)
339 }
408 }
340
409
341 renderer.render()
410 renderer.render()
@@ -389,7 +458,7 @@ func (r *forwardRenderer) render() {
389 }
458 }
390
459
391 itemLines := r.getOrRenderItem(item)
460 itemLines := r.getOrRenderItem(item)
392− if r.realIdx == len(r.model.items)-1 {
461+ if r.realIdx == len(r.model.filteredItems)-1 {
393 r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight())
462 r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight())
394 }
463 }
395
464
@@ -485,7 +554,7 @@ func (m *model) selectPreviousItem() tea.Cmd {
485// selectNextItem moves selection to the next item in the list.
554// selectNextItem moves selection to the next item in the list.
486// Handles focus management and ensures the selected item remains visible.
555// Handles focus management and ensures the selected item remains visible.
487func (m *model) selectNextItem() tea.Cmd {
556func (m *model) selectNextItem() tea.Cmd {
488− if m.selectionState.selectedIndex >= len(m.items)-1 || m.selectionState.selectedIndex < 0 {
557+ if m.selectionState.selectedIndex >= len(m.filteredItems)-1 || m.selectionState.selectedIndex < 0 {
489 return nil
558 return nil
490 }
559 }
491
560
@@ -543,7 +612,7 @@ func (m *model) ensureVisibleForward(cachedItem renderedItem) {
543// Handles both large items (taller than viewport) and normal items.
612// Handles both large items (taller than viewport) and normal items.
544func (m *model) ensureVisibleReverse(cachedItem renderedItem) {
613func (m *model) ensureVisibleReverse(cachedItem renderedItem) {
545 if cachedItem.height >= m.listHeight() {
614 if cachedItem.height >= m.listHeight() {
546− if m.selectionState.selectedIndex < len(m.items)-1 {
615+ if m.selectionState.selectedIndex < len(m.filteredItems)-1 {
547 changeNeeded := m.viewState.offset - (cachedItem.start + cachedItem.height - m.listHeight())
616 changeNeeded := m.viewState.offset - (cachedItem.start + cachedItem.height - m.listHeight())
548 m.decreaseOffset(changeNeeded)
617 m.decreaseOffset(changeNeeded)
549 } else {
618 } else {
@@ -567,7 +636,7 @@ func (m *model) ensureVisibleReverse(cachedItem renderedItem) {
567func (m *model) goToBottom() tea.Cmd {
636func (m *model) goToBottom() tea.Cmd {
568 cmds := []tea.Cmd{m.blurSelected()}
637 cmds := []tea.Cmd{m.blurSelected()}
569 m.viewState.reverse = true
638 m.viewState.reverse = true
570− m.selectionState.selectedIndex = len(m.items) - 1
639+ m.selectionState.selectedIndex = len(m.filteredItems) - 1
571 cmds = append(cmds, m.focusSelected())
640 cmds = append(cmds, m.focusSelected())
572 m.ResetView()
641 m.ResetView()
573 return tea.Batch(cmds...)
642 return tea.Batch(cmds...)
@@ -578,7 +647,7 @@ func (m *model) goToBottom() tea.Cmd {
578func (m *model) goToTop() tea.Cmd {
647func (m *model) goToTop() tea.Cmd {
579 cmds := []tea.Cmd{m.blurSelected()}
648 cmds := []tea.Cmd{m.blurSelected()}
580 m.viewState.reverse = false
649 m.viewState.reverse = false
581− if len(m.items) > 0 {
650+ if len(m.filteredItems) > 0 {
582 m.selectionState.selectedIndex = 0
651 m.selectionState.selectedIndex = 0
583 }
652 }
584 cmds = append(cmds, m.focusSelected())
653 cmds = append(cmds, m.focusSelected())
@@ -596,10 +665,10 @@ func (m *model) ResetView() {
596// focusSelected gives focus to the currently selected item if it supports focus.
665// focusSelected gives focus to the currently selected item if it supports focus.
597// Triggers a re-render of the item to show its focused state.
666// Triggers a re-render of the item to show its focused state.
598func (m *model) focusSelected() tea.Cmd {
667func (m *model) focusSelected() tea.Cmd {
599− if !m.selectionState.isValidIndex(len(m.items)) {
668+ if !m.selectionState.isValidIndex(len(m.filteredItems)) {
600 return nil
669 return nil
601 }
670 }
602− if i, ok := m.items[m.selectionState.selectedIndex].(layout.Focusable); ok {
671+ if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok {
603 cmd := i.Focus()
672 cmd := i.Focus()
604 m.rerenderItem(m.selectionState.selectedIndex)
673 m.rerenderItem(m.selectionState.selectedIndex)
605 return cmd
674 return cmd
@@ -610,10 +679,10 @@ func (m *model) focusSelected() tea.Cmd {
610// blurSelected removes focus from the currently selected item if it supports focus.
679// blurSelected removes focus from the currently selected item if it supports focus.
611// Triggers a re-render of the item to show its unfocused state.
680// Triggers a re-render of the item to show its unfocused state.
612func (m *model) blurSelected() tea.Cmd {
681func (m *model) blurSelected() tea.Cmd {
613− if !m.selectionState.isValidIndex(len(m.items)) {
682+ if !m.selectionState.isValidIndex(len(m.filteredItems)) {
614 return nil
683 return nil
615 }
684 }
616− if i, ok := m.items[m.selectionState.selectedIndex].(layout.Focusable); ok {
685+ if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok {
617 cmd := i.Blur()
686 cmd := i.Blur()
618 m.rerenderItem(m.selectionState.selectedIndex)
687 m.rerenderItem(m.selectionState.selectedIndex)
619 return cmd
688 return cmd
@@ -625,7 +694,7 @@ func (m *model) blurSelected() tea.Cmd {
625// This is called when an item's state changes (e.g., focus/blur) and needs to be re-displayed.
694// This is called when an item's state changes (e.g., focus/blur) and needs to be re-displayed.
626// It efficiently updates only the changed item and adjusts positions of subsequent items if needed.
695// It efficiently updates only the changed item and adjusts positions of subsequent items if needed.
627func (m *model) rerenderItem(inx int) {
696func (m *model) rerenderItem(inx int) {
628− if inx < 0 || inx >= len(m.items) || len(m.renderState.lines) == 0 {
697+ if inx < 0 || inx >= len(m.filteredItems) || len(m.renderState.lines) == 0 {
629 return
698 return
630 }
699 }
631
700
@@ -634,7 +703,7 @@ func (m *model) rerenderItem(inx int) {
634 return
703 return
635 }
704 }
636
705
637− rerenderedLines := m.getItemLines(m.items[inx])
706+ rerenderedLines := m.getItemLines(m.filteredItems[inx])
638 if slices.Equal(cachedItem.lines, rerenderedLines) {
707 if slices.Equal(cachedItem.lines, rerenderedLines) {
639 return
708 return
640 }
709 }
@@ -687,7 +756,7 @@ func (m *model) updateItemPositions(inx int, cachedItem renderedItem, newHeight
687 return
756 return
688 }
757 }
689
758
690− if inx == len(m.items)-1 {
759+ if inx == len(m.filteredItems)-1 {
691 m.renderState.finalHeight = max(0, cachedItem.start+newHeight-m.listHeight())
760 m.renderState.finalHeight = max(0, cachedItem.start+newHeight-m.listHeight())
692 }
761 }
693
762
@@ -701,7 +770,7 @@ func (m *model) updateItemPositions(inx int, cachedItem renderedItem, newHeight
701
770
702// updatePositionsForward updates positions for items after the changed item in forward mode.
771// updatePositionsForward updates positions for items after the changed item in forward mode.
703func (m *model) updatePositionsForward(inx int, currentStart int) {
772func (m *model) updatePositionsForward(inx int, currentStart int) {
704− for i := inx + 1; i < len(m.items); i++ {
773+ for i := inx + 1; i < len(m.filteredItems); i++ {
705 if existing, ok := m.renderState.items[i]; ok {
774 if existing, ok := m.renderState.items[i]; ok {
706 existing.start = currentStart
775 existing.start = currentStart
707 currentStart += existing.height
776 currentStart += existing.height
@@ -766,12 +835,12 @@ func (m *model) decreaseOffset(n int) {
766// UpdateItem replaces an item at the specified index with a new item.
835// UpdateItem replaces an item at the specified index with a new item.
767// Handles focus management and triggers re-rendering as needed.
836// Handles focus management and triggers re-rendering as needed.
768func (m *model) UpdateItem(inx int, item util.Model) {
837func (m *model) UpdateItem(inx int, item util.Model) {
769− if inx < 0 || inx >= len(m.items) {
838+ if inx < 0 || inx >= len(m.filteredItems) {
770 return
839 return
771 }
840 }
772− m.items[inx] = item
841+ m.filteredItems[inx] = item
773 if m.selectionState.selectedIndex == inx {
842 if m.selectionState.selectedIndex == inx {
774− if i, ok := m.items[m.selectionState.selectedIndex].(layout.Focusable); ok {
843+ if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok {
775 i.Focus()
844 i.Focus()
776 }
845 }
777 }
846 }
@@ -788,6 +857,10 @@ func (m *model) GetSize() (int, int) {
788// SetSize updates the list dimensions and triggers a complete re-render.
857// SetSize updates the list dimensions and triggers a complete re-render.
789// Also updates the size of all items that support sizing.
858// Also updates the size of all items that support sizing.
790func (m *model) SetSize(width int, height int) tea.Cmd {
859func (m *model) SetSize(width int, height int) tea.Cmd {
860+ if m.filterable {
861+ height -= 2 // adjust for input field height and border
862+ }
863+
791 if m.viewState.width == width && m.viewState.height == height {
864 if m.viewState.width == width && m.viewState.height == height {
792 return nil
865 return nil
793 }
866 }
@@ -797,11 +870,14 @@ func (m *model) SetSize(width int, height int) tea.Cmd {
797 }
870 }
798 m.viewState.width = width
871 m.viewState.width = width
799 m.ResetView()
872 m.ResetView()
873+ if m.filterable {
874+ m.input.SetWidth(m.getItemWidth() - 3)
875+ }
800 return m.setAllItemsSize()
876 return m.setAllItemsSize()
801}
877}
802
878
803−// getItemSize calculates the available width for items, accounting for padding.
879+// getItemWidth calculates the available width for items, accounting for padding.
804−func (m *model) getItemSize() int {
880+func (m *model) getItemWidth() int {
805 width := m.viewState.width
881 width := m.viewState.width
806 switch len(m.padding) {
882 switch len(m.padding) {
807 case 1:
883 case 1:
@@ -816,11 +892,11 @@ func (m *model) getItemSize() int {
816
892
817// setItemSize updates the size of a specific item if it supports sizing.
893// setItemSize updates the size of a specific item if it supports sizing.
818func (m *model) setItemSize(inx int) tea.Cmd {
894func (m *model) setItemSize(inx int) tea.Cmd {
819− if inx < 0 || inx >= len(m.items) {
895+ if inx < 0 || inx >= len(m.filteredItems) {
820 return nil
896 return nil
821 }
897 }
822− if i, ok := m.items[inx].(layout.Sizeable); ok {
898+ if i, ok := m.filteredItems[inx].(layout.Sizeable); ok {
823− return i.SetSize(m.getItemSize(), 0)
899+ return i.SetSize(m.getItemWidth(), 0)
824 }
900 }
825 return nil
901 return nil
826}
902}
@@ -828,7 +904,7 @@ func (m *model) setItemSize(inx int) tea.Cmd {
828// setAllItemsSize updates the size of all items that support sizing.
904// setAllItemsSize updates the size of all items that support sizing.
829func (m *model) setAllItemsSize() tea.Cmd {
905func (m *model) setAllItemsSize() tea.Cmd {
830 var cmds []tea.Cmd
906 var cmds []tea.Cmd
831− for i := range m.items {
907+ for i := range m.filteredItems {
832 if cmd := m.setItemSize(i); cmd != nil {
908 if cmd := m.setItemSize(i); cmd != nil {
833 cmds = append(cmds, cmd)
909 cmds = append(cmds, cmd)
834 }
910 }
@@ -856,8 +932,9 @@ func (m *model) AppendItem(item util.Model) tea.Cmd {
856 cmds := []tea.Cmd{
932 cmds := []tea.Cmd{
857 item.Init(),
933 item.Init(),
858 }
934 }
859− m.items = append(m.items, item)
935+ m.allItems = append(m.allItems, item)
936+ m.filteredItems = m.allItems
860− cmds = append(cmds, m.setItemSize(len(m.items)-1))
937+ cmds = append(cmds, m.setItemSize(len(m.filteredItems)-1))
861 cmds = append(cmds, m.goToBottom())
938 cmds = append(cmds, m.goToBottom())
862 m.renderState.needsRerender = true
939 m.renderState.needsRerender = true
863 return tea.Batch(cmds...)
940 return tea.Batch(cmds...)
@@ -866,11 +943,12 @@ func (m *model) AppendItem(item util.Model) tea.Cmd {
866// DeleteItem removes an item at the specified index.
943// DeleteItem removes an item at the specified index.
867// Adjusts selection if necessary and triggers a complete re-render.
944// Adjusts selection if necessary and triggers a complete re-render.
868func (m *model) DeleteItem(i int) {
945func (m *model) DeleteItem(i int) {
869− if i < 0 || i >= len(m.items) {
946+ if i < 0 || i >= len(m.filteredItems) {
870 return
947 return
871 }
948 }
872− m.items = slices.Delete(m.items, i, i+1)
949+ m.allItems = slices.Delete(m.allItems, i, i+1)
873 delete(m.renderState.items, i)
950 delete(m.renderState.items, i)
951+ m.filteredItems = m.allItems
874
952
875 if m.selectionState.selectedIndex == i && m.selectionState.selectedIndex > 0 {
953 if m.selectionState.selectedIndex == i && m.selectionState.selectedIndex > 0 {
876 m.selectionState.selectedIndex--
954 m.selectionState.selectedIndex--
@@ -886,7 +964,8 @@ func (m *model) DeleteItem(i int) {
886// Adjusts cached positions and selection index, then switches to forward mode.
964// Adjusts cached positions and selection index, then switches to forward mode.
887func (m *model) PrependItem(item util.Model) tea.Cmd {
965func (m *model) PrependItem(item util.Model) tea.Cmd {
888 cmds := []tea.Cmd{item.Init()}
966 cmds := []tea.Cmd{item.Init()}
889− m.items = append([]util.Model{item}, m.items...)
967+ m.allItems = append([]util.Model{item}, m.allItems...)
968+ m.filteredItems = m.allItems
890
969
891 // Shift all cached item indices by 1
970 // Shift all cached item indices by 1
892 newItems := make(map[int]renderedItem, len(m.renderState.items))
971 newItems := make(map[int]renderedItem, len(m.renderState.items))
@@ -917,16 +996,78 @@ func (m *model) setReverse(reverse bool) {
917// SetItems replaces all items in the list with a new set.
996// SetItems replaces all items in the list with a new set.
918// Initializes all items, sets their sizes, and establishes initial selection.
997// Initializes all items, sets their sizes, and establishes initial selection.
919func (m *model) SetItems(items []util.Model) tea.Cmd {
998func (m *model) SetItems(items []util.Model) tea.Cmd {
920− m.items = items
999+ m.allItems = items
1000+ m.filteredItems = items
921 cmds := []tea.Cmd{m.setAllItemsSize()}
1001 cmds := []tea.Cmd{m.setAllItemsSize()}
922
1002
923− for _, item := range m.items {
1003+ for _, item := range m.filteredItems {
924 cmds = append(cmds, item.Init())
1004 cmds = append(cmds, item.Init())
925 }
1005 }
926
1006
927− if len(m.items) > 0 {
1007+ if len(m.filteredItems) > 0 {
1008+ if m.viewState.reverse {
1009+ m.selectionState.selectedIndex = len(m.filteredItems) - 1
1010+ } else {
1011+ m.selectionState.selectedIndex = 0
1012+ }
1013+ if cmd := m.focusSelected(); cmd != nil {
1014+ cmds = append(cmds, cmd)
1015+ }
1016+ } else {
1017+ m.selectionState.selectedIndex = NoSelection
1018+ }
1019+
1020+ m.ResetView()
1021+ return tea.Batch(cmds...)
1022+}
1023+
1024+func (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)
1031+}
1032+
1033+func (m *model) filter(search string) tea.Cmd {
1034+ var cmds []tea.Cmd
1035+ search = strings.TrimSpace(search)
1036+ search = strings.ToLower(search)
1037+ for _, item := range m.allItems {
1038+ if i, ok := item.(layout.Focusable); ok {
1039+ cmds = append(cmds, i.Blur())
1040+ }
1041+ if i, ok := item.(HasMatchIndexes); ok {
1042+ i.MatchIndexes(make([]int, 0))
1043+ }
1044+ }
1045+ if search == "" {
1046+ cmds = append(cmds, m.SetItems(m.allItems)) // Reset to all items if search is empty
1047+ return tea.Batch(cmds...)
1048+ }
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)
1064+ }
1065+ filteredItems = append(filteredItems, item)
1066+ }
1067+ m.filteredItems = filteredItems
1068+ if len(filteredItems) > 0 {
928 if m.viewState.reverse {
1069 if m.viewState.reverse {
929− m.selectionState.selectedIndex = len(m.items) - 1
1070+ m.selectionState.selectedIndex = len(filteredItems) - 1
930 } else {
1071 } else {
931 m.selectionState.selectedIndex = 0
1072 m.selectionState.selectedIndex = 0
932 }
1073 }
internal/tui/components/dialogs/commands/commands.go+64−37
@@ -1,11 +1,10 @@
1package commands
1package commands
2
2
3import (
3import (
4− "github.com/charmbracelet/bubbles/v2/textinput"
5 tea "github.com/charmbracelet/bubbletea/v2"
4 tea "github.com/charmbracelet/bubbletea/v2"
6 "github.com/charmbracelet/lipgloss/v2"
5 "github.com/charmbracelet/lipgloss/v2"
7
6
8− "github.com/opencode-ai/opencode/internal/logging"
7+ "github.com/opencode-ai/opencode/internal/tui/components/chat"
9 "github.com/opencode-ai/opencode/internal/tui/components/core/list"
8 "github.com/opencode-ai/opencode/internal/tui/components/core/list"
10 "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
9 "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
11 "github.com/opencode-ai/opencode/internal/tui/styles"
10 "github.com/opencode-ai/opencode/internal/tui/styles"
@@ -15,6 +14,8 @@ import (
15
14
16const (
15const (
17 id dialogs.DialogID = "commands"
16 id dialogs.DialogID = "commands"
17+
18+ defaultWidth int = 60
18)
19)
19
20
20// Command represents a command that can be executed
21// Command represents a command that can be executed
@@ -36,37 +37,31 @@ type commandDialogCmp struct {
36 wHeight int // Height of the terminal window
37 wHeight int // Height of the terminal window
37
38
38 commandList list.ListModel
39 commandList list.ListModel
39− input textinput.Model
40− oldCursor tea.Cursor
41}
40}
42
41
43func NewCommandDialog() CommandsDialog {
42func NewCommandDialog() CommandsDialog {
44− ti := textinput.New()
45− ti.Placeholder = "Type a command or search..."
46− ti.SetVirtualCursor(false)
47− ti.Focus()
48− ti.SetWidth(60 - 7)
49− commandList := list.New()
43+ commandList := list.New(list.WithFilterable(true))
44+
50 return &commandDialogCmp{
45 return &commandDialogCmp{
51 commandList: commandList,
46 commandList: commandList,
52− width: 60,
47+ width: defaultWidth,
53− input: ti,
54 }
48 }
55}
49}
56
50
57func (c *commandDialogCmp) Init() tea.Cmd {
51func (c *commandDialogCmp) Init() tea.Cmd {
58− logging.Info("Initializing commands dialog")
59 commands, err := LoadCustomCommands()
52 commands, err := LoadCustomCommands()
60 if err != nil {
53 if err != nil {
61 return util.ReportError(err)
54 return util.ReportError(err)
62 }
55 }
63− logging.Info("Commands loaded", "count", len(commands))
56+
57+ commands = append(commands, c.defaultCommands()...)
64
58
65 commandItems := make([]util.Model, 0, len(commands))
59 commandItems := make([]util.Model, 0, len(commands))
66
60
67 for _, cmd := range commands {
61 for _, cmd := range commands {
68 commandItems = append(commandItems, NewCommandItem(cmd))
62 commandItems = append(commandItems, NewCommandItem(cmd))
69 }
63 }
64+
70 c.commandList.SetItems(commandItems)
65 c.commandList.SetItems(commandItems)
71 return c.commandList.Init()
66 return c.commandList.Init()
72}
67}
@@ -76,43 +71,40 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
76 case tea.WindowSizeMsg:
71 case tea.WindowSizeMsg:
77 c.wWidth = msg.Width
72 c.wWidth = msg.Width
78 c.wHeight = msg.Height
73 c.wHeight = msg.Height
79− return c, c.commandList.SetSize(60, min(len(c.commandList.Items())*2, c.wHeight/2))
74+ return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
80 }
75 }
81− u, cmd := c.input.Update(msg)
76+ u, cmd := c.commandList.Update(msg)
82− c.input = u
77+ c.commandList = u.(list.ListModel)
83 return c, cmd
78 return c, cmd
84}
79}
85
80
86func (c *commandDialogCmp) View() tea.View {
81func (c *commandDialogCmp) View() tea.View {
87− content := lipgloss.JoinVertical(
88− lipgloss.Left,
89− c.inputStyle().Render(c.input.View()),
90− c.commandList.View().String(),
82+ listView := c.commandList.View()
91− )
92−
93− v := tea.NewView(c.style().Render(content))
83+ v := tea.NewView(c.style().Render(listView.String()))
84+ if listView.Cursor() != nil {
85+ c := c.moveCursor(listView.Cursor())
94− v.SetCursor(c.getCursor())
86+ v.SetCursor(c)
87+ }
95 return v
88 return v
96}
89}
97
90
98−func (c *commandDialogCmp) getCursor() *tea.Cursor {
91+func (c *commandDialogCmp) listWidth() int {
99− cursor := c.input.Cursor()
92+ return defaultWidth - 4 // 4 for padding
93+}
94+
95+func (c *commandDialogCmp) listHeight() int {
96+ listHeigh := len(c.commandList.Items()) + 2 // height based on items + 2 for the input
97+ return min(listHeigh, c.wHeight/2)
98+}
99+
100+func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
100 offset := 10 + 1
101 offset := 10 + 1
101 cursor.Y += offset
102 cursor.Y += offset
102 _, col := c.Position()
103 _, col := c.Position()
103− cursor.X = c.input.Cursor().X + col + 2
104+ cursor.X = cursor.X + col + 2
104 return cursor
105 return cursor
105}
106}
106
107
107−func (c *commandDialogCmp) inputStyle() lipgloss.Style {
108− t := theme.CurrentTheme()
109− return styles.BaseStyle().
110− BorderStyle(lipgloss.NormalBorder()).
111− BorderForeground(t.TextMuted()).
112− BorderBackground(t.Background()).
113− BorderBottom(true)
114−}
115−
116func (c *commandDialogCmp) style() lipgloss.Style {
108func (c *commandDialogCmp) style() lipgloss.Style {
117 t := theme.CurrentTheme()
109 t := theme.CurrentTheme()
118 return styles.BaseStyle().
110 return styles.BaseStyle().
@@ -130,6 +122,41 @@ func (q *commandDialogCmp) Position() (int, int) {
130 return row, col
122 return row, col
131}
123}
132
124
125+func (c *commandDialogCmp) defaultCommands() []Command {
126+ return []Command{
127+ {
128+ ID: "init",
129+ Title: "Initialize Project",
130+ Description: "Create/Update the OpenCode.md memory file",
131+ Handler: func(cmd Command) tea.Cmd {
132+ prompt := `Please analyze this codebase and create a OpenCode.md file containing:
133+ 1. Build/lint/test commands - especially for running a single test
134+ 2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
135+
136+ The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
137+ If there's already a opencode.md, improve it.
138+ If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
139+ return tea.Batch(
140+ util.CmdHandler(chat.SendMsg{
141+ Text: prompt,
142+ }),
143+ )
144+ },
145+ },
146+ {
147+ ID: "compact",
148+ Title: "Compact Session",
149+ Description: "Summarize the current session and create a new one with the summary",
150+ Handler: func(cmd Command) tea.Cmd {
151+ return func() tea.Msg {
152+ // TODO: implement compact message
153+ return ""
154+ }
155+ },
156+ },
157+ }
158+}
159+
133func (c *commandDialogCmp) ID() dialogs.DialogID {
160func (c *commandDialogCmp) ID() dialogs.DialogID {
134 return id
161 return id
135}
162}
internal/tui/components/dialogs/commands/item.go+99−4
@@ -2,23 +2,32 @@ package commands
2
2
3import (
3import (
4 tea "github.com/charmbracelet/bubbletea/v2"
4 tea "github.com/charmbracelet/bubbletea/v2"
5+ "github.com/charmbracelet/lipgloss/v2"
6+ "github.com/charmbracelet/x/ansi"
5 "github.com/opencode-ai/opencode/internal/tui/layout"
7 "github.com/opencode-ai/opencode/internal/tui/layout"
8+ "github.com/opencode-ai/opencode/internal/tui/styles"
9+ "github.com/opencode-ai/opencode/internal/tui/theme"
6 "github.com/opencode-ai/opencode/internal/tui/util"
10 "github.com/opencode-ai/opencode/internal/tui/util"
11+ "github.com/rivo/uniseg"
7)
12)
8
13
9type CommandItem interface {
14type CommandItem interface {
10 util.Model
15 util.Model
11 layout.Focusable
16 layout.Focusable
17+ layout.Sizeable
12}
18}
13
19
14type commandItem struct {
20type commandItem struct {
21+ width int
15− command Command
22+ command Command
16− focus bool
23+ focus bool
24+ matchIndexes []int
17}
25}
18
26
19func NewCommandItem(command Command) CommandItem {
27func NewCommandItem(command Command) CommandItem {
20 return &commandItem{
28 return &commandItem{
21− command: command,
29+ command: command,
30+ matchIndexes: make([]int, 0),
22 }
31 }
23}
32}
24
33
@@ -34,7 +43,30 @@ func (c *commandItem) Update(tea.Msg) (tea.Model, tea.Cmd) {
34
43
35// View implements CommandItem.
44// View implements CommandItem.
36func (c *commandItem) View() tea.View {
45func (c *commandItem) View() tea.View {
46+ t := theme.CurrentTheme()
47+
48+ baseStyle := styles.BaseStyle()
49+ titleStyle := baseStyle.Width(c.width).Foreground(t.Text())
50+ titleMatchStyle := baseStyle.Foreground(t.Text()).Underline(true)
51+
52+ if c.focus {
53+ titleStyle = titleStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true)
54+ titleMatchStyle = titleMatchStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true)
55+ }
56+ var ranges []lipgloss.Range
57+ truncatedTitle := ansi.Truncate(c.command.Title, c.width-2, "…")
58+ text := titleStyle.Padding(0, 1).Render(truncatedTitle)
59+ if len(c.matchIndexes) > 0 {
60+ for _, rng := range matchedRanges(c.matchIndexes) {
61+ // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes.
62+ // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions.
63+ // so we need to adjust it here:
64+ start, stop := bytePosToVisibleCharPos(text, rng)
65+ ranges = append(ranges, lipgloss.NewRange(start+1, stop+2, titleMatchStyle))
66+ }
67+ text = lipgloss.StyleRanges(text, ranges...)
68+ }
37− return tea.NewView(c.command.Title)
69+ return tea.NewView(text)
38}
70}
39
71
40// Blur implements CommandItem.
72// Blur implements CommandItem.
@@ -53,3 +85,66 @@ func (c *commandItem) Focus() tea.Cmd {
53func (c *commandItem) IsFocused() bool {
85func (c *commandItem) IsFocused() bool {
54 return c.focus
86 return c.focus
55}
87}
88+
89+// GetSize implements CommandItem.
90+func (c *commandItem) GetSize() (int, int) {
91+ return c.width, 2
92+}
93+
94+// SetSize implements CommandItem.
95+func (c *commandItem) SetSize(width int, height int) tea.Cmd {
96+ c.width = width
97+ return nil
98+}
99+
100+func (c *commandItem) FilterValue() string {
101+ return c.command.Title
102+}
103+
104+func (c *commandItem) MatchIndexes(indexes []int) {
105+ c.matchIndexes = indexes
106+}
107+
108+func matchedRanges(in []int) [][2]int {
109+ if len(in) == 0 {
110+ return [][2]int{}
111+ }
112+ current := [2]int{in[0], in[0]}
113+ if len(in) == 1 {
114+ return [][2]int{current}
115+ }
116+ var out [][2]int
117+ for i := 1; i < len(in); i++ {
118+ if in[i] == current[1]+1 {
119+ current[1] = in[i]
120+ } else {
121+ out = append(out, current)
122+ current = [2]int{in[i], in[i]}
123+ }
124+ }
125+ out = append(out, current)
126+ return out
127+}
128+
129+func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
130+ bytePos, byteStart, byteStop := 0, rng[0], rng[1]
131+ pos, start, stop := 0, 0, 0
132+ gr := uniseg.NewGraphemes(str)
133+ for byteStart > bytePos {
134+ if !gr.Next() {
135+ break
136+ }
137+ bytePos += len(gr.Str())
138+ pos += max(1, gr.Width())
139+ }
140+ start = pos
141+ for byteStop > bytePos {
142+ if !gr.Next() {
143+ break
144+ }
145+ bytePos += len(gr.Str())
146+ pos += max(1, gr.Width())
147+ }
148+ stop = pos
149+ return start, stop
150+}
internal/tui/tui.go−3
@@ -103,9 +103,6 @@ func (a appModel) Init() tea.Cmd {
103 // }
103 // }
104 // return dialog.ShowInitDialogMsg{Show: shouldShow}
104 // return dialog.ShowInitDialogMsg{Show: shouldShow}
105 // })
105 // })
106−
107− t := theme.CurrentTheme()
108− cmds = append(cmds, tea.SetBackgroundColor(t.Background()))
109 return tea.Batch(cmds...)
106 return tea.Batch(cmds...)
110}
107}
111
108