diff options
| author | adamdottv <[email protected]> | 2025-07-10 05:51:47 -0500 |
|---|---|---|
| committer | adamdottv <[email protected]> | 2025-07-10 05:53:00 -0500 |
| commit | 85dbfeb3147cefa597938a315f0848a0d978640b (patch) | |
| tree | 050bc9dd4a8a1a4e84431ccd637bdfc383ba433e /packages/tui/internal/components | |
| parent | 085c0e4e2b8518d740f75372367a44d19b22f90e (diff) | |
| download | opencode-85dbfeb3147cefa597938a315f0848a0d978640b.tar.gz opencode-85dbfeb3147cefa597938a315f0848a0d978640b.zip | |
feat(tui): @symbol attachments
Diffstat (limited to 'packages/tui/internal/components')
| -rw-r--r-- | packages/tui/internal/components/chat/editor.go | 41 | ||||
| -rw-r--r-- | packages/tui/internal/components/dialog/complete.go | 174 |
2 files changed, 143 insertions, 72 deletions
diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index 4f6249722..444f5bef1 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -140,9 +140,9 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.spinner = createSpinner() return m, tea.Batch(m.spinner.Tick, m.textarea.Focus()) case dialog.CompletionSelectedMsg: - switch msg.ProviderID { + switch msg.Item.GetProviderID() { case "commands": - commandName := strings.TrimPrefix(msg.CompletionValue, "/") + commandName := strings.TrimPrefix(msg.Item.GetValue(), "/") updated, cmd := m.Clear() m = updated.(*editorComponent) cmds = append(cmds, cmd) @@ -152,7 +152,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { atIndex := m.textarea.LastRuneIndex('@') if atIndex == -1 { // Should not happen, but as a fallback, just insert. - m.textarea.InsertString(msg.CompletionValue + " ") + m.textarea.InsertString(msg.Item.GetValue() + " ") return m, nil } @@ -163,7 +163,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Now, insert the attachment at the position where the '@' was. // The cursor is now at `atIndex` after the replacement. - filePath := msg.CompletionValue + filePath := msg.Item.GetValue() extension := filepath.Ext(filePath) mediaType := "" switch extension { @@ -186,15 +186,32 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.textarea.InsertAttachment(attachment) m.textarea.InsertString(" ") return m, nil - default: - existingValue := m.textarea.Value() - lastSpaceIndex := strings.LastIndex(existingValue, " ") - if lastSpaceIndex == -1 { - m.textarea.SetValue(msg.CompletionValue + " ") - } else { - modifiedValue := existingValue[:lastSpaceIndex+1] + msg.CompletionValue - m.textarea.SetValue(modifiedValue + " ") + case "symbols": + atIndex := m.textarea.LastRuneIndex('@') + if atIndex == -1 { + // Should not happen, but as a fallback, just insert. + m.textarea.InsertString(msg.Item.GetValue() + " ") + return m, nil } + + cursorCol := m.textarea.CursorColumn() + m.textarea.ReplaceRange(atIndex, cursorCol, "") + + symbol := msg.Item.GetRaw().(opencode.Symbol) + parts := strings.Split(symbol.Name, ".") + lastPart := parts[len(parts)-1] + attachment := &textarea.Attachment{ + ID: uuid.NewString(), + Display: "@" + lastPart, + URL: msg.Item.GetValue(), + Filename: lastPart, + MediaType: "text/plain", + } + m.textarea.InsertAttachment(attachment) + m.textarea.InsertString(" ") + return m, nil + default: + slog.Debug("Unknown provider", "provider", msg.Item.GetProviderID()) return m, nil } } diff --git a/packages/tui/internal/components/dialog/complete.go b/packages/tui/internal/components/dialog/complete.go index 8173c8af1..0e8019a23 100644 --- a/packages/tui/internal/components/dialog/complete.go +++ b/packages/tui/internal/components/dialog/complete.go @@ -2,12 +2,15 @@ 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/components/list" "github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/theme" @@ -15,32 +18,35 @@ import ( ) type CompletionItem struct { - Title string - Value string + Title string + Value string + ProviderID string + Raw any } type CompletionItemI interface { list.ListItem GetValue() string DisplayValue() string + GetProviderID() string + GetRaw() any } func (ci *CompletionItem) Render(selected bool, width int) string { t := theme.CurrentTheme() baseStyle := styles.NewStyle().Foreground(t.Text()) + truncatedStr := truncate.String(string(ci.DisplayValue()), uint(width-4)) + itemStyle := baseStyle. Background(t.BackgroundElement()). - Width(width). Padding(0, 1) if selected { itemStyle = itemStyle.Foreground(t.Primary()) } - title := itemStyle.Render( - ci.DisplayValue(), - ) + title := itemStyle.Render(truncatedStr) return title } @@ -52,6 +58,14 @@ func (ci *CompletionItem) GetValue() string { return ci.Value } +func (ci *CompletionItem) GetProviderID() string { + return ci.ProviderID +} + +func (ci *CompletionItem) GetRaw() any { + return ci.Raw +} + func NewCompletionItem(completionItem CompletionItem) CompletionItemI { return &completionItem } @@ -63,9 +77,8 @@ type CompletionProvider interface { } type CompletionSelectedMsg struct { - SearchString string - CompletionValue string - ProviderID string + Item CompletionItemI + SearchString string } type CompletionDialogCompleteItemMsg struct { @@ -83,7 +96,7 @@ type CompletionDialog interface { type completionDialogComponent struct { query string - completionProvider CompletionProvider + providers []CompletionProvider width int height int pseudoSearchTextArea textarea.Model @@ -109,6 +122,52 @@ func (c *completionDialogComponent) Init() tea.Cmd { return nil } +func (c *completionDialogComponent) getAllCompletions(query string) tea.Cmd { + return func() tea.Msg { + allItems := make([]CompletionItemI, 0) + + // Collect results from all providers + for _, 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 + } + allItems = append(allItems, items...) + } + + // If there's a query, use fuzzy ranking to sort results + if query != "" && len(allItems) > 0 { + // Create a slice of display values for fuzzy matching + displayValues := make([]string, len(allItems)) + for i, item := range allItems { + displayValues[i] = item.DisplayValue() + } + + // Get fuzzy matches with ranking + matches := fuzzy.RankFindFold(query, displayValues) + + // Sort by score (best matches first) + sort.Sort(matches) + + // Reorder items based on fuzzy ranking + rankedItems := make([]CompletionItemI, 0, len(matches)) + for _, match := range matches { + rankedItems = append(rankedItems, allItems[match.OriginalIndex]) + } + + return rankedItems + } + + return allItems + } +} func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { @@ -126,14 +185,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if query != c.query { c.query = query - cmd = func() tea.Msg { - items, err := c.completionProvider.GetChildEntries(query) - if err != nil { - slog.Error("Failed to get completion items", "error", err) - } - return items - } - cmds = append(cmds, cmd) + cmds = append(cmds, c.getAllCompletions(query)) } u, cmd := c.list.Update(msg) @@ -149,23 +201,18 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return c, c.complete(item) case key.Matches(msg, completionDialogKeys.Cancel): - // Only close on backspace when there are no characters left, unless we're back to just the trigger value := c.pseudoSearchTextArea.Value() - if msg.String() != "backspace" || (len(value) <= len(c.trigger) && value != c.trigger) { + width := lipgloss.Width(value) + triggerWidth := lipgloss.Width(c.trigger) + // Only close on backspace when there are no characters left, unless we're back to just the trigger + if msg.String() != "backspace" || (width <= triggerWidth && value != c.trigger) { return c, c.close() } } return c, tea.Batch(cmds...) } else { - cmd := func() tea.Msg { - items, err := c.completionProvider.GetChildEntries("") - if err != nil { - slog.Error("Failed to get completion items", "error", err) - } - return items - } - cmds = append(cmds, cmd) + cmds = append(cmds, c.getAllCompletions("")) cmds = append(cmds, c.pseudoSearchTextArea.Focus()) return c, tea.Batch(cmds...) } @@ -177,19 +224,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (c *completionDialogComponent) View() string { t := theme.CurrentTheme() baseStyle := styles.NewStyle().Foreground(t.Text()) - - maxWidth := 40 - completions := c.list.GetItems() - - for _, cmd := range completions { - title := cmd.DisplayValue() - width := lipgloss.Width(title) - if width > maxWidth-4 { - maxWidth = width + 4 - } - } - - c.list.SetMaxWidth(maxWidth) + c.list.SetMaxWidth(c.width) return baseStyle. Padding(0, 0). @@ -213,12 +248,10 @@ func (c *completionDialogComponent) IsEmpty() bool { func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd { value := c.pseudoSearchTextArea.Value() - return tea.Batch( util.CmdHandler(CompletionSelectedMsg{ - SearchString: value, - CompletionValue: item.GetValue(), - ProviderID: c.completionProvider.GetId(), + SearchString: value, + Item: item, }), c.close(), ) @@ -230,32 +263,53 @@ func (c *completionDialogComponent) close() tea.Cmd { return util.CmdHandler(CompletionDialogCloseMsg{}) } -func NewCompletionDialogComponent(completionProvider CompletionProvider, trigger string) CompletionDialog { +func NewCompletionDialogComponent( + trigger string, + providers ...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() + } li := list.NewListComponent( []CompletionItemI{}, 7, - completionProvider.GetEmptyMessage(), + emptyMessage, false, ) - go func() { - items, err := completionProvider.GetChildEntries("") - if err != nil { - slog.Error("Failed to get completion items", "error", err) - } - li.SetItems(items) - }() - - // Initialize the textarea with the trigger character - ti.SetValue(trigger) - - return &completionDialogComponent{ + c := &completionDialogComponent{ query: "", - completionProvider: completionProvider, + providers: providers, pseudoSearchTextArea: ti, list: li, trigger: trigger, } + + // Load initial items from all providers + go func() { + allItems := make([]CompletionItemI, 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 } |
