diff --git a/.opencode.json b/.opencode.json index c4d1547a0c62aad24a470af1d503c225a5b5955b..75e357de711e3a49ea37519f9cd91f21bba8a25f 100644 --- a/.opencode.json +++ b/.opencode.json @@ -4,5 +4,8 @@ "gopls": { "command": "gopls" } + }, + "tui": { + "theme": "opencode-dark" } } diff --git a/cspell.json b/cspell.json index b7dbd552ca81fc12eeb287709e68c150a3b8f6f7..9881e74f5d62a4b87631a2fd1ce372e2ebee804c 100644 --- a/cspell.json +++ b/cspell.json @@ -1 +1 @@ -{"flagWords":[],"version":"0.2","words":["opencode","charmbracelet","lipgloss","bubbletea"],"language":"en"} \ No newline at end of file +{"flagWords":[],"language":"en","words":["opencode","charmbracelet","lipgloss","bubbletea","textinput","Focusable"],"version":"0.2"} \ No newline at end of file diff --git a/go.mod b/go.mod index 30cf44c417849d7902bc7092a47f5bd925759fc5..52ab603e5f4a0158e0ac2dec3ddfc1cf5f8214ca 100644 --- a/go.mod +++ b/go.mod @@ -32,6 +32,8 @@ require ( github.com/stretchr/testify v1.10.0 ) +require github.com/sahilm/fuzzy v0.1.1 + require ( cloud.google.com/go v0.116.0 // indirect cloud.google.com/go/auth v0.13.0 // indirect diff --git a/go.sum b/go.sum index 2cf341113db9f55d2127cbdba61c679cc4bdfe8d..eb7738075c88558f623578bd0bcfae89480bb1e8 100644 --- a/go.sum +++ b/go.sum @@ -199,6 +199,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y= github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= diff --git a/internal/tui/components/chat/list.go b/internal/tui/components/chat/list.go index 732e1d51d231720a3af4bd505d799c8ff6e23ea7..6fe7b96663bf29d495ac5806f5ffc049c1f1a4bd 100644 --- a/internal/tui/components/chat/list.go +++ b/internal/tui/components/chat/list.go @@ -45,7 +45,7 @@ type messageListCmp struct { // NewMessagesListCmp creates a new message list component with custom keybindings // and reverse ordering (newest messages at bottom). func NewMessagesListCmp(app *app.App) MessageListCmp { - defaultKeymaps := list.DefaultKeymap() + defaultKeymaps := list.DefaultKeyMap() defaultKeymaps.Up.SetEnabled(false) defaultKeymaps.Down.SetEnabled(false) defaultKeymaps.NDown = key.NewBinding( diff --git a/internal/tui/components/core/list/keys.go b/internal/tui/components/core/list/keys.go index 4e534fed54d8112649bd785f112b29ce796ec394..23035c4030542b6a157a3dd08448ea4271d095d6 100644 --- a/internal/tui/components/core/list/keys.go +++ b/internal/tui/components/core/list/keys.go @@ -18,7 +18,7 @@ type KeyMap struct { End key.Binding } -func DefaultKeymap() KeyMap { +func DefaultKeyMap() KeyMap { return KeyMap{ Down: key.NewBinding( key.WithKeys("down", "ctrl+j", "ctrl+n"), @@ -45,10 +45,10 @@ func DefaultKeymap() KeyMap { key.WithKeys("ctrl+u"), ), Home: key.NewBinding( - key.WithKeys("g", "home"), + key.WithKeys("ctrl+g", "home"), ), End: key.NewBinding( - key.WithKeys("shift+g", "end"), + key.WithKeys("ctrl+shift+g", "end"), ), } } diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go index 1dd00a01b30a890dd123a670e8ceb4cf4277a3fd..235e9ee92d50fc071379464f3a2bfb3b437af13d 100644 --- a/internal/tui/components/core/list/list.go +++ b/internal/tui/components/core/list/list.go @@ -2,16 +2,21 @@ package list import ( "slices" + "sort" "strings" "github.com/charmbracelet/bubbles/v2/help" "github.com/charmbracelet/bubbles/v2/key" "github.com/charmbracelet/bubbles/v2/spinner" + "github.com/charmbracelet/bubbles/v2/textinput" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" "github.com/opencode-ai/opencode/internal/tui/components/anim" "github.com/opencode-ai/opencode/internal/tui/layout" + "github.com/opencode-ai/opencode/internal/tui/styles" + "github.com/opencode-ai/opencode/internal/tui/theme" "github.com/opencode-ai/opencode/internal/tui/util" + "github.com/sahilm/fuzzy" ) // Constants for special index values and defaults @@ -43,6 +48,18 @@ type HasAnim interface { Spinning() bool // Returns true if the item is currently animating } +// HasFilterValue interface allows items to provide a filter value for searching. +type HasFilterValue interface { + util.Model + FilterValue() string // Returns a string value used for filtering/searching +} + +// HasMatchIndexes interface allows items to set matched character indexes. +type HasMatchIndexes interface { + util.Model + MatchIndexes([]int) // Sets the indexes of matched characters in the item's content +} + // renderedItem represents a cached rendered item with its position and content. type renderedItem struct { lines []string // The rendered lines of text for this item @@ -105,10 +122,15 @@ type model struct { renderState *renderState // Rendering cache and state selectionState selectionState // Item selection state help help.Model // Help system for keyboard shortcuts - keymap KeyMap // Key bindings for navigation - items []util.Model // The actual list items + keyMap KeyMap // Key bindings for navigation + allItems []util.Model // The actual list items gapSize int // Number of empty lines between items padding []int // Padding around the list content + + filterable bool // Whether items can be filtered + filteredItems []util.Model // Filtered items based on current search + input textinput.Model // Input field for filtering items + currentSearch string // Current search term for filtering } // listOptions is a function type for configuring list options. @@ -117,7 +139,7 @@ type listOptions func(*model) // WithKeyMap sets custom key bindings for the list. func WithKeyMap(k KeyMap) listOptions { return func(m *model) { - m.keymap = k + m.keyMap = k } } @@ -147,7 +169,15 @@ func WithPadding(padding ...int) listOptions { // WithItems sets the initial items for the list. func WithItems(items []util.Model) listOptions { return func(m *model) { - m.items = items + m.allItems = items + m.filteredItems = items // Initially, all items are visible + } +} + +// WithFilterable enables filtering of items based on their FilterValue. +func WithFilterable(filterable bool) listOptions { + return func(m *model) { + m.filterable = filterable } } @@ -157,8 +187,9 @@ func WithItems(items []util.Model) listOptions { func New(opts ...listOptions) ListModel { m := &model{ help: help.New(), - keymap: DefaultKeymap(), - items: []util.Model{}, + keyMap: DefaultKeyMap(), + allItems: []util.Model{}, + filteredItems: []util.Model{}, renderState: newRenderState(), gapSize: DefaultGapSize, padding: []int{}, @@ -167,13 +198,25 @@ func New(opts ...listOptions) ListModel { for _, opt := range opts { opt(m) } + + if m.filterable { + ti := textinput.New() + ti.Placeholder = "Type to filter..." + ti.SetVirtualCursor(false) + ti.Focus() + m.input = ti + + // disable j,k movements + m.keyMap.NDown.SetEnabled(false) + m.keyMap.NUp.SetEnabled(false) + } return m } // Init initializes the list component and sets up the initial items. // This is called automatically by the Bubble Tea framework. func (m *model) Init() tea.Cmd { - return m.SetItems(m.items) + return m.SetItems(m.filteredItems) } // 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) { case anim.ColorCycleMsg, anim.StepCharsMsg, spinner.TickMsg: return m.handleAnimationMsg(msg) } - - if m.selectionState.isValidIndex(len(m.items)) { + if m.selectionState.isValidIndex(len(m.filteredItems)) { return m.updateSelectedItem(msg) } return m, nil } +// View renders the list to a string for display. +// Returns empty string if the list has no dimensions. +// Triggers re-rendering if needed before returning content. +func (m *model) View() tea.View { + if m.viewState.height == 0 || m.viewState.width == 0 { + return tea.NewView("") // No content to display + } + if m.renderState.needsRerender { + m.renderVisible() + } + + content := lipgloss.NewStyle(). + Padding(m.padding...). + Height(m.viewState.height). + Render(m.viewState.content) + + if m.filterable { + content = lipgloss.JoinVertical( + lipgloss.Left, + m.inputStyle().Render(m.input.View()), + content, + ) + } + view := tea.NewView(content) + if m.filterable { + view.SetCursor(m.input.Cursor()) + } + return view +} + // handleKeyPress processes keyboard input for list navigation. // Supports scrolling, item selection, and navigation to top/bottom. func (m *model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { switch { - case key.Matches(msg, m.keymap.Down) || key.Matches(msg, m.keymap.NDown): + case key.Matches(msg, m.keyMap.Down) || key.Matches(msg, m.keyMap.NDown): m.scrollDown(1) - case key.Matches(msg, m.keymap.Up) || key.Matches(msg, m.keymap.NUp): + case key.Matches(msg, m.keyMap.Up) || key.Matches(msg, m.keyMap.NUp): m.scrollUp(1) - case key.Matches(msg, m.keymap.DownOneItem): + case key.Matches(msg, m.keyMap.DownOneItem): return m, m.selectNextItem() - case key.Matches(msg, m.keymap.UpOneItem): + case key.Matches(msg, m.keyMap.UpOneItem): return m, m.selectPreviousItem() - case key.Matches(msg, m.keymap.HalfPageDown): + case key.Matches(msg, m.keyMap.HalfPageDown): m.scrollDown(m.listHeight() / 2) - case key.Matches(msg, m.keymap.HalfPageUp): + case key.Matches(msg, m.keyMap.HalfPageUp): m.scrollUp(m.listHeight() / 2) - case key.Matches(msg, m.keymap.Home): + case key.Matches(msg, m.keyMap.Home): return m, m.goToTop() - case key.Matches(msg, m.keymap.End): + case key.Matches(msg, m.keyMap.End): return m, m.goToBottom() + default: + if !m.filterable { + return m, nil // Ignore other keys if not filterable + } + var cmds []tea.Cmd + u, cmd := m.input.Update(msg) + m.input = u + cmds = append(cmds, cmd) + if m.currentSearch != m.input.Value() { + cmd = m.filter(m.input.Value()) + cmds = append(cmds, cmd) + } + m.currentSearch = m.input.Value() + return m, tea.Batch(cmds...) + } return m, nil } @@ -222,7 +309,7 @@ func (m *model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { // Only items implementing HasAnim and currently spinning receive these messages. func (m *model) handleAnimationMsg(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd - for inx, item := range m.items { + for inx, item := range m.filteredItems { if i, ok := item.(HasAnim); ok && i.Spinning() { updated, cmd := i.Update(msg) cmds = append(cmds, cmd) @@ -238,7 +325,7 @@ func (m *model) handleAnimationMsg(msg tea.Msg) (tea.Model, tea.Cmd) { // This allows the selected item to handle its own input and state changes. func (m *model) updateSelectedItem(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd - u, cmd := m.items[m.selectionState.selectedIndex].Update(msg) + u, cmd := m.filteredItems[m.selectionState.selectedIndex].Update(msg) cmds = append(cmds, cmd) if updated, ok := u.(util.Model); ok { m.UpdateItem(m.selectionState.selectedIndex, updated) @@ -266,27 +353,9 @@ func (m *model) scrollUp(amount int) { } } -// View renders the list to a string for display. -// Returns empty string if the list has no dimensions. -// Triggers re-rendering if needed before returning content. -func (m *model) View() tea.View { - if m.viewState.height == 0 || m.viewState.width == 0 { - return tea.NewView("") // No content to display - } - if m.renderState.needsRerender { - m.renderVisible() - } - return tea.NewView( - lipgloss.NewStyle(). - Padding(m.padding...). - Height(m.viewState.height). - Render(m.viewState.content), - ) -} - // Items returns a copy of all items in the list. func (m *model) Items() []util.Model { - return m.items + return m.filteredItems } // renderVisible determines which rendering strategy to use and triggers rendering. @@ -306,12 +375,12 @@ func (m *model) renderVisibleForward() { model: m, start: 0, cutoff: m.viewState.offset + m.listHeight(), - items: m.items, + items: m.filteredItems, realIdx: m.renderState.lastIndex, } if m.renderState.lastIndex > NotRendered { - renderer.items = m.items[m.renderState.lastIndex+1:] + renderer.items = m.filteredItems[m.renderState.lastIndex+1:] renderer.start = len(m.renderState.lines) } @@ -326,16 +395,16 @@ func (m *model) renderVisibleReverse() { model: m, start: 0, cutoff: m.viewState.offset + m.listHeight(), - items: m.items, + items: m.filteredItems, realIdx: m.renderState.lastIndex, } if m.renderState.lastIndex > NotRendered { - renderer.items = m.items[:m.renderState.lastIndex] + renderer.items = m.filteredItems[:m.renderState.lastIndex] renderer.start = len(m.renderState.lines) } else { - m.renderState.lastIndex = len(m.items) - renderer.realIdx = len(m.items) + m.renderState.lastIndex = len(m.filteredItems) + renderer.realIdx = len(m.filteredItems) } renderer.render() @@ -389,7 +458,7 @@ func (r *forwardRenderer) render() { } itemLines := r.getOrRenderItem(item) - if r.realIdx == len(r.model.items)-1 { + if r.realIdx == len(r.model.filteredItems)-1 { r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight()) } @@ -485,7 +554,7 @@ func (m *model) selectPreviousItem() tea.Cmd { // selectNextItem moves selection to the next item in the list. // Handles focus management and ensures the selected item remains visible. func (m *model) selectNextItem() tea.Cmd { - if m.selectionState.selectedIndex >= len(m.items)-1 || m.selectionState.selectedIndex < 0 { + if m.selectionState.selectedIndex >= len(m.filteredItems)-1 || m.selectionState.selectedIndex < 0 { return nil } @@ -543,7 +612,7 @@ func (m *model) ensureVisibleForward(cachedItem renderedItem) { // Handles both large items (taller than viewport) and normal items. func (m *model) ensureVisibleReverse(cachedItem renderedItem) { if cachedItem.height >= m.listHeight() { - if m.selectionState.selectedIndex < len(m.items)-1 { + if m.selectionState.selectedIndex < len(m.filteredItems)-1 { changeNeeded := m.viewState.offset - (cachedItem.start + cachedItem.height - m.listHeight()) m.decreaseOffset(changeNeeded) } else { @@ -567,7 +636,7 @@ func (m *model) ensureVisibleReverse(cachedItem renderedItem) { func (m *model) goToBottom() tea.Cmd { cmds := []tea.Cmd{m.blurSelected()} m.viewState.reverse = true - m.selectionState.selectedIndex = len(m.items) - 1 + m.selectionState.selectedIndex = len(m.filteredItems) - 1 cmds = append(cmds, m.focusSelected()) m.ResetView() return tea.Batch(cmds...) @@ -578,7 +647,7 @@ func (m *model) goToBottom() tea.Cmd { func (m *model) goToTop() tea.Cmd { cmds := []tea.Cmd{m.blurSelected()} m.viewState.reverse = false - if len(m.items) > 0 { + if len(m.filteredItems) > 0 { m.selectionState.selectedIndex = 0 } cmds = append(cmds, m.focusSelected()) @@ -596,10 +665,10 @@ func (m *model) ResetView() { // focusSelected gives focus to the currently selected item if it supports focus. // Triggers a re-render of the item to show its focused state. func (m *model) focusSelected() tea.Cmd { - if !m.selectionState.isValidIndex(len(m.items)) { + if !m.selectionState.isValidIndex(len(m.filteredItems)) { return nil } - if i, ok := m.items[m.selectionState.selectedIndex].(layout.Focusable); ok { + if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok { cmd := i.Focus() m.rerenderItem(m.selectionState.selectedIndex) return cmd @@ -610,10 +679,10 @@ func (m *model) focusSelected() tea.Cmd { // blurSelected removes focus from the currently selected item if it supports focus. // Triggers a re-render of the item to show its unfocused state. func (m *model) blurSelected() tea.Cmd { - if !m.selectionState.isValidIndex(len(m.items)) { + if !m.selectionState.isValidIndex(len(m.filteredItems)) { return nil } - if i, ok := m.items[m.selectionState.selectedIndex].(layout.Focusable); ok { + if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok { cmd := i.Blur() m.rerenderItem(m.selectionState.selectedIndex) return cmd @@ -625,7 +694,7 @@ func (m *model) blurSelected() tea.Cmd { // This is called when an item's state changes (e.g., focus/blur) and needs to be re-displayed. // It efficiently updates only the changed item and adjusts positions of subsequent items if needed. func (m *model) rerenderItem(inx int) { - if inx < 0 || inx >= len(m.items) || len(m.renderState.lines) == 0 { + if inx < 0 || inx >= len(m.filteredItems) || len(m.renderState.lines) == 0 { return } @@ -634,7 +703,7 @@ func (m *model) rerenderItem(inx int) { return } - rerenderedLines := m.getItemLines(m.items[inx]) + rerenderedLines := m.getItemLines(m.filteredItems[inx]) if slices.Equal(cachedItem.lines, rerenderedLines) { return } @@ -687,7 +756,7 @@ func (m *model) updateItemPositions(inx int, cachedItem renderedItem, newHeight return } - if inx == len(m.items)-1 { + if inx == len(m.filteredItems)-1 { m.renderState.finalHeight = max(0, cachedItem.start+newHeight-m.listHeight()) } @@ -701,7 +770,7 @@ func (m *model) updateItemPositions(inx int, cachedItem renderedItem, newHeight // updatePositionsForward updates positions for items after the changed item in forward mode. func (m *model) updatePositionsForward(inx int, currentStart int) { - for i := inx + 1; i < len(m.items); i++ { + for i := inx + 1; i < len(m.filteredItems); i++ { if existing, ok := m.renderState.items[i]; ok { existing.start = currentStart currentStart += existing.height @@ -766,12 +835,12 @@ func (m *model) decreaseOffset(n int) { // UpdateItem replaces an item at the specified index with a new item. // Handles focus management and triggers re-rendering as needed. func (m *model) UpdateItem(inx int, item util.Model) { - if inx < 0 || inx >= len(m.items) { + if inx < 0 || inx >= len(m.filteredItems) { return } - m.items[inx] = item + m.filteredItems[inx] = item if m.selectionState.selectedIndex == inx { - if i, ok := m.items[m.selectionState.selectedIndex].(layout.Focusable); ok { + if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok { i.Focus() } } @@ -788,6 +857,10 @@ func (m *model) GetSize() (int, int) { // SetSize updates the list dimensions and triggers a complete re-render. // Also updates the size of all items that support sizing. func (m *model) SetSize(width int, height int) tea.Cmd { + if m.filterable { + height -= 2 // adjust for input field height and border + } + if m.viewState.width == width && m.viewState.height == height { return nil } @@ -797,11 +870,14 @@ func (m *model) SetSize(width int, height int) tea.Cmd { } m.viewState.width = width m.ResetView() + if m.filterable { + m.input.SetWidth(m.getItemWidth() - 3) + } return m.setAllItemsSize() } -// getItemSize calculates the available width for items, accounting for padding. -func (m *model) getItemSize() int { +// getItemWidth calculates the available width for items, accounting for padding. +func (m *model) getItemWidth() int { width := m.viewState.width switch len(m.padding) { case 1: @@ -816,11 +892,11 @@ func (m *model) getItemSize() int { // setItemSize updates the size of a specific item if it supports sizing. func (m *model) setItemSize(inx int) tea.Cmd { - if inx < 0 || inx >= len(m.items) { + if inx < 0 || inx >= len(m.filteredItems) { return nil } - if i, ok := m.items[inx].(layout.Sizeable); ok { - return i.SetSize(m.getItemSize(), 0) + if i, ok := m.filteredItems[inx].(layout.Sizeable); ok { + return i.SetSize(m.getItemWidth(), 0) } return nil } @@ -828,7 +904,7 @@ func (m *model) setItemSize(inx int) tea.Cmd { // setAllItemsSize updates the size of all items that support sizing. func (m *model) setAllItemsSize() tea.Cmd { var cmds []tea.Cmd - for i := range m.items { + for i := range m.filteredItems { if cmd := m.setItemSize(i); cmd != nil { cmds = append(cmds, cmd) } @@ -856,8 +932,9 @@ func (m *model) AppendItem(item util.Model) tea.Cmd { cmds := []tea.Cmd{ item.Init(), } - m.items = append(m.items, item) - cmds = append(cmds, m.setItemSize(len(m.items)-1)) + m.allItems = append(m.allItems, item) + m.filteredItems = m.allItems + cmds = append(cmds, m.setItemSize(len(m.filteredItems)-1)) cmds = append(cmds, m.goToBottom()) m.renderState.needsRerender = true return tea.Batch(cmds...) @@ -866,11 +943,12 @@ func (m *model) AppendItem(item util.Model) tea.Cmd { // DeleteItem removes an item at the specified index. // Adjusts selection if necessary and triggers a complete re-render. func (m *model) DeleteItem(i int) { - if i < 0 || i >= len(m.items) { + if i < 0 || i >= len(m.filteredItems) { return } - m.items = slices.Delete(m.items, i, i+1) + m.allItems = slices.Delete(m.allItems, i, i+1) delete(m.renderState.items, i) + m.filteredItems = m.allItems if m.selectionState.selectedIndex == i && m.selectionState.selectedIndex > 0 { m.selectionState.selectedIndex-- @@ -886,7 +964,8 @@ func (m *model) DeleteItem(i int) { // Adjusts cached positions and selection index, then switches to forward mode. func (m *model) PrependItem(item util.Model) tea.Cmd { cmds := []tea.Cmd{item.Init()} - m.items = append([]util.Model{item}, m.items...) + m.allItems = append([]util.Model{item}, m.allItems...) + m.filteredItems = m.allItems // Shift all cached item indices by 1 newItems := make(map[int]renderedItem, len(m.renderState.items)) @@ -917,16 +996,78 @@ func (m *model) setReverse(reverse bool) { // SetItems replaces all items in the list with a new set. // Initializes all items, sets their sizes, and establishes initial selection. func (m *model) SetItems(items []util.Model) tea.Cmd { - m.items = items + m.allItems = items + m.filteredItems = items cmds := []tea.Cmd{m.setAllItemsSize()} - for _, item := range m.items { + for _, item := range m.filteredItems { cmds = append(cmds, item.Init()) } - if len(m.items) > 0 { + if len(m.filteredItems) > 0 { + if m.viewState.reverse { + m.selectionState.selectedIndex = len(m.filteredItems) - 1 + } else { + m.selectionState.selectedIndex = 0 + } + if cmd := m.focusSelected(); cmd != nil { + cmds = append(cmds, cmd) + } + } else { + m.selectionState.selectedIndex = NoSelection + } + + m.ResetView() + return tea.Batch(cmds...) +} + +func (c *model) inputStyle() lipgloss.Style { + t := theme.CurrentTheme() + return styles.BaseStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(t.TextMuted()). + BorderBackground(t.Background()). + BorderBottom(true) +} + +func (m *model) filter(search string) tea.Cmd { + var cmds []tea.Cmd + search = strings.TrimSpace(search) + search = strings.ToLower(search) + for _, item := range m.allItems { + if i, ok := item.(layout.Focusable); ok { + cmds = append(cmds, i.Blur()) + } + if i, ok := item.(HasMatchIndexes); ok { + i.MatchIndexes(make([]int, 0)) + } + } + if search == "" { + cmds = append(cmds, m.SetItems(m.allItems)) // Reset to all items if search is empty + return tea.Batch(cmds...) + } + words := make([]string, 0, len(m.allItems)) + for _, cmd := range m.allItems { + if f, ok := cmd.(HasFilterValue); ok { + words = append(words, strings.ToLower(f.FilterValue())) + } else { + words = append(words, strings.ToLower("")) + } + } + matches := fuzzy.Find(search, words) + sort.Sort(matches) + filteredItems := make([]util.Model, 0, len(matches)) + for _, match := range matches { + item := m.allItems[match.Index] + if i, ok := item.(HasMatchIndexes); ok { + i.MatchIndexes(match.MatchedIndexes) + } + filteredItems = append(filteredItems, item) + } + m.filteredItems = filteredItems + if len(filteredItems) > 0 { if m.viewState.reverse { - m.selectionState.selectedIndex = len(m.items) - 1 + m.selectionState.selectedIndex = len(filteredItems) - 1 } else { m.selectionState.selectedIndex = 0 } diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go index 7a34fdd511bee2ade344ae44ca3652e0cecbe2c3..41b21064e13a938246fb733d0ff24a4bcdca3a40 100644 --- a/internal/tui/components/dialogs/commands/commands.go +++ b/internal/tui/components/dialogs/commands/commands.go @@ -1,11 +1,10 @@ package commands import ( - "github.com/charmbracelet/bubbles/v2/textinput" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" - "github.com/opencode-ai/opencode/internal/logging" + "github.com/opencode-ai/opencode/internal/tui/components/chat" "github.com/opencode-ai/opencode/internal/tui/components/core/list" "github.com/opencode-ai/opencode/internal/tui/components/dialogs" "github.com/opencode-ai/opencode/internal/tui/styles" @@ -15,6 +14,8 @@ import ( const ( id dialogs.DialogID = "commands" + + defaultWidth int = 60 ) // Command represents a command that can be executed @@ -36,37 +37,31 @@ type commandDialogCmp struct { wHeight int // Height of the terminal window commandList list.ListModel - input textinput.Model - oldCursor tea.Cursor } func NewCommandDialog() CommandsDialog { - ti := textinput.New() - ti.Placeholder = "Type a command or search..." - ti.SetVirtualCursor(false) - ti.Focus() - ti.SetWidth(60 - 7) - commandList := list.New() + commandList := list.New(list.WithFilterable(true)) + return &commandDialogCmp{ commandList: commandList, - width: 60, - input: ti, + width: defaultWidth, } } func (c *commandDialogCmp) Init() tea.Cmd { - logging.Info("Initializing commands dialog") commands, err := LoadCustomCommands() if err != nil { return util.ReportError(err) } - logging.Info("Commands loaded", "count", len(commands)) + + commands = append(commands, c.defaultCommands()...) commandItems := make([]util.Model, 0, len(commands)) for _, cmd := range commands { commandItems = append(commandItems, NewCommandItem(cmd)) } + c.commandList.SetItems(commandItems) return c.commandList.Init() } @@ -76,43 +71,40 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: c.wWidth = msg.Width c.wHeight = msg.Height - return c, c.commandList.SetSize(60, min(len(c.commandList.Items())*2, c.wHeight/2)) + return c, c.commandList.SetSize(c.listWidth(), c.listHeight()) } - u, cmd := c.input.Update(msg) - c.input = u + u, cmd := c.commandList.Update(msg) + c.commandList = u.(list.ListModel) return c, cmd } func (c *commandDialogCmp) View() tea.View { - content := lipgloss.JoinVertical( - lipgloss.Left, - c.inputStyle().Render(c.input.View()), - c.commandList.View().String(), - ) - - v := tea.NewView(c.style().Render(content)) - v.SetCursor(c.getCursor()) + listView := c.commandList.View() + v := tea.NewView(c.style().Render(listView.String())) + if listView.Cursor() != nil { + c := c.moveCursor(listView.Cursor()) + v.SetCursor(c) + } return v } -func (c *commandDialogCmp) getCursor() *tea.Cursor { - cursor := c.input.Cursor() +func (c *commandDialogCmp) listWidth() int { + return defaultWidth - 4 // 4 for padding +} + +func (c *commandDialogCmp) listHeight() int { + listHeigh := len(c.commandList.Items()) + 2 // height based on items + 2 for the input + return min(listHeigh, c.wHeight/2) +} + +func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { offset := 10 + 1 cursor.Y += offset _, col := c.Position() - cursor.X = c.input.Cursor().X + col + 2 + cursor.X = cursor.X + col + 2 return cursor } -func (c *commandDialogCmp) inputStyle() lipgloss.Style { - t := theme.CurrentTheme() - return styles.BaseStyle(). - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(t.TextMuted()). - BorderBackground(t.Background()). - BorderBottom(true) -} - func (c *commandDialogCmp) style() lipgloss.Style { t := theme.CurrentTheme() return styles.BaseStyle(). @@ -130,6 +122,41 @@ func (q *commandDialogCmp) Position() (int, int) { return row, col } +func (c *commandDialogCmp) defaultCommands() []Command { + return []Command{ + { + ID: "init", + Title: "Initialize Project", + Description: "Create/Update the OpenCode.md memory file", + Handler: func(cmd Command) tea.Cmd { + prompt := `Please analyze this codebase and create a OpenCode.md file containing: + 1. Build/lint/test commands - especially for running a single test + 2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc. + + 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. + If there's already a opencode.md, improve it. + If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.` + return tea.Batch( + util.CmdHandler(chat.SendMsg{ + Text: prompt, + }), + ) + }, + }, + { + ID: "compact", + Title: "Compact Session", + Description: "Summarize the current session and create a new one with the summary", + Handler: func(cmd Command) tea.Cmd { + return func() tea.Msg { + // TODO: implement compact message + return "" + } + }, + }, + } +} + func (c *commandDialogCmp) ID() dialogs.DialogID { return id } diff --git a/internal/tui/components/dialogs/commands/item.go b/internal/tui/components/dialogs/commands/item.go index 36c00199da323abc7038478079f353c1c2279820..5cdeae2112fd5d310587982b2a89ff82d7c2146b 100644 --- a/internal/tui/components/dialogs/commands/item.go +++ b/internal/tui/components/dialogs/commands/item.go @@ -2,23 +2,32 @@ package commands import ( tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/x/ansi" "github.com/opencode-ai/opencode/internal/tui/layout" + "github.com/opencode-ai/opencode/internal/tui/styles" + "github.com/opencode-ai/opencode/internal/tui/theme" "github.com/opencode-ai/opencode/internal/tui/util" + "github.com/rivo/uniseg" ) type CommandItem interface { util.Model layout.Focusable + layout.Sizeable } type commandItem struct { - command Command - focus bool + width int + command Command + focus bool + matchIndexes []int } func NewCommandItem(command Command) CommandItem { return &commandItem{ - command: command, + command: command, + matchIndexes: make([]int, 0), } } @@ -34,7 +43,30 @@ func (c *commandItem) Update(tea.Msg) (tea.Model, tea.Cmd) { // View implements CommandItem. func (c *commandItem) View() tea.View { - return tea.NewView(c.command.Title) + t := theme.CurrentTheme() + + baseStyle := styles.BaseStyle() + titleStyle := baseStyle.Width(c.width).Foreground(t.Text()) + titleMatchStyle := baseStyle.Foreground(t.Text()).Underline(true) + + if c.focus { + titleStyle = titleStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true) + titleMatchStyle = titleMatchStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true) + } + var ranges []lipgloss.Range + truncatedTitle := ansi.Truncate(c.command.Title, c.width-2, "…") + text := titleStyle.Padding(0, 1).Render(truncatedTitle) + if len(c.matchIndexes) > 0 { + for _, rng := range matchedRanges(c.matchIndexes) { + // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes. + // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions. + // so we need to adjust it here: + start, stop := bytePosToVisibleCharPos(text, rng) + ranges = append(ranges, lipgloss.NewRange(start+1, stop+2, titleMatchStyle)) + } + text = lipgloss.StyleRanges(text, ranges...) + } + return tea.NewView(text) } // Blur implements CommandItem. @@ -53,3 +85,66 @@ func (c *commandItem) Focus() tea.Cmd { func (c *commandItem) IsFocused() bool { return c.focus } + +// GetSize implements CommandItem. +func (c *commandItem) GetSize() (int, int) { + return c.width, 2 +} + +// SetSize implements CommandItem. +func (c *commandItem) SetSize(width int, height int) tea.Cmd { + c.width = width + return nil +} + +func (c *commandItem) FilterValue() string { + return c.command.Title +} + +func (c *commandItem) MatchIndexes(indexes []int) { + c.matchIndexes = indexes +} + +func matchedRanges(in []int) [][2]int { + if len(in) == 0 { + return [][2]int{} + } + current := [2]int{in[0], in[0]} + if len(in) == 1 { + return [][2]int{current} + } + var out [][2]int + for i := 1; i < len(in); i++ { + if in[i] == current[1]+1 { + current[1] = in[i] + } else { + out = append(out, current) + current = [2]int{in[i], in[i]} + } + } + out = append(out, current) + return out +} + +func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) { + bytePos, byteStart, byteStop := 0, rng[0], rng[1] + pos, start, stop := 0, 0, 0 + gr := uniseg.NewGraphemes(str) + for byteStart > bytePos { + if !gr.Next() { + break + } + bytePos += len(gr.Str()) + pos += max(1, gr.Width()) + } + start = pos + for byteStop > bytePos { + if !gr.Next() { + break + } + bytePos += len(gr.Str()) + pos += max(1, gr.Width()) + } + stop = pos + return start, stop +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index f3fffd9a383d4916698d8275885ed9a43e8b0665..e2dabdd777b464d16b84eeaf159e6ce5e685768a 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -103,9 +103,6 @@ func (a appModel) Init() tea.Cmd { // } // return dialog.ShowInitDialogMsg{Show: shouldShow} // }) - - t := theme.CurrentTheme() - cmds = append(cmds, tea.SetBackgroundColor(t.Background())) return tea.Batch(cmds...) }