| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314 |
- package dialog
- import (
- "log/slog"
- "sort"
- "strings"
- "github.com/charmbracelet/bubbles/v2/key"
- "github.com/charmbracelet/bubbles/v2/textarea"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/lithammer/fuzzysearch/fuzzy"
- "github.com/muesli/reflow/truncate"
- "github.com/sst/opencode/internal/completions"
- "github.com/sst/opencode/internal/components/list"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/util"
- )
- type CompletionSelectedMsg struct {
- Item completions.CompletionSuggestion
- SearchString string
- }
- type CompletionDialogCompleteItemMsg struct {
- Value string
- }
- type CompletionDialogCloseMsg struct{}
- type CompletionDialog interface {
- tea.Model
- tea.ViewModel
- SetWidth(width int)
- IsEmpty() bool
- }
- type completionDialogComponent struct {
- query string
- providers []completions.CompletionProvider
- width int
- height int
- pseudoSearchTextArea textarea.Model
- list list.List[completions.CompletionSuggestion]
- trigger string
- }
- type completionDialogKeyMap struct {
- Complete key.Binding
- Cancel key.Binding
- }
- var completionDialogKeys = completionDialogKeyMap{
- Complete: key.NewBinding(
- key.WithKeys("tab", "enter", "right"),
- ),
- Cancel: key.NewBinding(
- key.WithKeys("space", " ", "esc", "backspace", "ctrl+h", "ctrl+c"),
- ),
- }
- func (c *completionDialogComponent) Init() tea.Cmd {
- return nil
- }
- func (c *completionDialogComponent) getAllCompletions(query string) tea.Cmd {
- return func() tea.Msg {
- // Collect results from all providers and preserve provider order
- type providerItems struct {
- idx int
- items []completions.CompletionSuggestion
- }
- itemsByProvider := make([]providerItems, 0, len(c.providers))
- providersWithResults := 0
- for idx, provider := range c.providers {
- items, err := provider.GetChildEntries(query)
- if err != nil {
- slog.Error(
- "Failed to get completion items",
- "provider",
- provider.GetId(),
- "error",
- err,
- )
- continue
- }
- if len(items) > 0 {
- providersWithResults++
- itemsByProvider = append(itemsByProvider, providerItems{idx: idx, items: items})
- }
- }
- // If there's a query, fuzzy-rank within each provider, then concatenate by provider order
- if query != "" && providersWithResults > 0 {
- t := theme.CurrentTheme()
- baseStyle := styles.NewStyle().Background(t.BackgroundElement())
- // Ensure stable provider order just in case
- sort.SliceStable(
- itemsByProvider,
- func(i, j int) bool { return itemsByProvider[i].idx < itemsByProvider[j].idx },
- )
- final := make([]completions.CompletionSuggestion, 0)
- for _, entry := range itemsByProvider {
- // Build display values for fuzzy matching within this provider
- displayValues := make([]string, len(entry.items))
- for i, item := range entry.items {
- displayValues[i] = item.Display(baseStyle)
- }
- matches := fuzzy.RankFindFold(query, displayValues)
- sort.Sort(matches)
- // Reorder items for this provider based on fuzzy ranking
- ranked := make([]completions.CompletionSuggestion, 0, len(matches))
- for _, m := range matches {
- ranked = append(ranked, entry.items[m.OriginalIndex])
- }
- final = append(final, ranked...)
- }
- return final
- }
- // No query or no results: just concatenate in provider order
- all := make([]completions.CompletionSuggestion, 0)
- for _, entry := range itemsByProvider {
- all = append(all, entry.items...)
- }
- return all
- }
- }
- func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
- switch msg := msg.(type) {
- case []completions.CompletionSuggestion:
- c.list.SetItems(msg)
- case tea.KeyMsg:
- if c.pseudoSearchTextArea.Focused() {
- if !key.Matches(msg, completionDialogKeys.Complete) {
- var cmd tea.Cmd
- c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg)
- cmds = append(cmds, cmd)
- fullValue := c.pseudoSearchTextArea.Value()
- query := strings.TrimPrefix(fullValue, c.trigger)
- if query != c.query {
- c.query = query
- cmds = append(cmds, c.getAllCompletions(query))
- }
- u, cmd := c.list.Update(msg)
- c.list = u.(list.List[completions.CompletionSuggestion])
- cmds = append(cmds, cmd)
- }
- switch {
- case key.Matches(msg, completionDialogKeys.Complete):
- item, i := c.list.GetSelectedItem()
- if i == -1 {
- return c, nil
- }
- return c, c.complete(item)
- case key.Matches(msg, completionDialogKeys.Cancel):
- value := c.pseudoSearchTextArea.Value()
- width := lipgloss.Width(value)
- triggerWidth := lipgloss.Width(c.trigger)
- if msg.String() == "space" || msg.String() == " " {
- item, i := c.list.GetSelectedItem()
- if i > -1 {
- return c, c.complete(item)
- }
- // If no exact match, close the dialog
- return c, c.close()
- }
- // Only close on backspace when there are no characters left, unless we're back to just the trigger
- if (msg.String() != "backspace" && msg.String() != "ctrl+h") || (width <= triggerWidth && value != c.trigger) {
- return c, c.close()
- }
- }
- return c, tea.Batch(cmds...)
- } else {
- cmds = append(cmds, c.getAllCompletions(""))
- cmds = append(cmds, c.pseudoSearchTextArea.Focus())
- return c, tea.Batch(cmds...)
- }
- }
- return c, tea.Batch(cmds...)
- }
- func (c *completionDialogComponent) View() string {
- t := theme.CurrentTheme()
- c.list.SetMaxWidth(c.width)
- return styles.NewStyle().
- Padding(0, 1).
- Foreground(t.Text()).
- Background(t.BackgroundElement()).
- BorderStyle(lipgloss.ThickBorder()).
- BorderLeft(true).
- BorderRight(true).
- BorderForeground(t.Border()).
- BorderBackground(t.Background()).
- Width(c.width).
- Render(c.list.View())
- }
- func (c *completionDialogComponent) SetWidth(width int) {
- c.width = width
- }
- func (c *completionDialogComponent) IsEmpty() bool {
- return c.list.IsEmpty()
- }
- func (c *completionDialogComponent) complete(item completions.CompletionSuggestion) tea.Cmd {
- value := c.pseudoSearchTextArea.Value()
- return tea.Batch(
- util.CmdHandler(CompletionSelectedMsg{
- SearchString: value,
- Item: item,
- }),
- c.close(),
- )
- }
- func (c *completionDialogComponent) close() tea.Cmd {
- c.pseudoSearchTextArea.Reset()
- c.pseudoSearchTextArea.Blur()
- return util.CmdHandler(CompletionDialogCloseMsg{})
- }
- func NewCompletionDialogComponent(
- trigger string,
- providers ...completions.CompletionProvider,
- ) CompletionDialog {
- ti := textarea.New()
- ti.SetValue(trigger)
- // Use a generic empty message if we have multiple providers
- emptyMessage := "no matching items"
- if len(providers) == 1 {
- emptyMessage = providers[0].GetEmptyMessage()
- }
- // Define render function for completion suggestions
- renderFunc := func(item completions.CompletionSuggestion, selected bool, width int, baseStyle styles.Style) string {
- t := theme.CurrentTheme()
- style := baseStyle
- if selected {
- style = style.Background(t.BackgroundElement()).Foreground(t.Primary())
- } else {
- style = style.Background(t.BackgroundElement()).Foreground(t.Text())
- }
- // The item.Display string already has any inline colors from the provider
- truncatedStr := truncate.String(item.Display(style), uint(width-4))
- return style.Width(width - 4).Render(truncatedStr)
- }
- // Define selectable function - all completion suggestions are selectable
- selectableFunc := func(item completions.CompletionSuggestion) bool {
- return true
- }
- li := list.NewListComponent(
- list.WithItems([]completions.CompletionSuggestion{}),
- list.WithMaxVisibleHeight[completions.CompletionSuggestion](7),
- list.WithFallbackMessage[completions.CompletionSuggestion](emptyMessage),
- list.WithAlphaNumericKeys[completions.CompletionSuggestion](false),
- list.WithRenderFunc(renderFunc),
- list.WithSelectableFunc(selectableFunc),
- )
- c := &completionDialogComponent{
- query: "",
- providers: providers,
- pseudoSearchTextArea: ti,
- list: li,
- trigger: trigger,
- }
- // Load initial items from all providers
- go func() {
- allItems := make([]completions.CompletionSuggestion, 0)
- for _, provider := range providers {
- items, err := provider.GetChildEntries("")
- if err != nil {
- slog.Error(
- "Failed to get completion items",
- "provider",
- provider.GetId(),
- "error",
- err,
- )
- continue
- }
- allItems = append(allItems, items...)
- }
- li.SetItems(allItems)
- }()
- return c
- }
|