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 | |
| parent | 085c0e4e2b8518d740f75372367a44d19b22f90e (diff) | |
| download | opencode-85dbfeb3147cefa597938a315f0848a0d978640b.tar.gz opencode-85dbfeb3147cefa597938a315f0848a0d978640b.zip | |
feat(tui): @symbol attachments
Diffstat (limited to 'packages/tui')
| -rw-r--r-- | packages/tui/internal/completions/commands.go | 23 | ||||
| -rw-r--r-- | packages/tui/internal/completions/files.go (renamed from packages/tui/internal/completions/files-folders.go) | 26 | ||||
| -rw-r--r-- | packages/tui/internal/completions/symbols.go | 118 | ||||
| -rw-r--r-- | packages/tui/internal/components/chat/editor.go | 41 | ||||
| -rw-r--r-- | packages/tui/internal/components/dialog/complete.go | 174 | ||||
| -rw-r--r-- | packages/tui/internal/tui/tui.go | 15 |
6 files changed, 301 insertions, 96 deletions
diff --git a/packages/tui/internal/completions/commands.go b/packages/tui/internal/completions/commands.go index 3a5dc3bb1..bb48d540c 100644 --- a/packages/tui/internal/completions/commands.go +++ b/packages/tui/internal/completions/commands.go @@ -29,17 +29,26 @@ func (c *CommandCompletionProvider) GetEmptyMessage() string { return "no matching commands" } -func getCommandCompletionItem(cmd commands.Command, space int, t theme.Theme) dialog.CompletionItemI { +func (c *CommandCompletionProvider) getCommandCompletionItem( + cmd commands.Command, + space int, + t theme.Theme, +) dialog.CompletionItemI { spacer := strings.Repeat(" ", space) - title := " /" + cmd.PrimaryTrigger() + styles.NewStyle().Foreground(t.TextMuted()).Render(spacer+cmd.Description) + title := " /" + cmd.PrimaryTrigger() + styles.NewStyle(). + Foreground(t.TextMuted()). + Render(spacer+cmd.Description) value := string(cmd.Name) return dialog.NewCompletionItem(dialog.CompletionItem{ - Title: title, - Value: value, + Title: title, + Value: value, + ProviderID: c.GetId(), }) } -func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.CompletionItemI, error) { +func (c *CommandCompletionProvider) GetChildEntries( + query string, +) ([]dialog.CompletionItemI, error) { t := theme.CurrentTheme() commands := c.app.Commands @@ -60,7 +69,7 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp continue } space := space - lipgloss.Width(cmd.PrimaryTrigger()) - items = append(items, getCommandCompletionItem(cmd, space, t)) + items = append(items, c.getCommandCompletionItem(cmd, space, t)) } return items, nil } @@ -77,7 +86,7 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp // Add all triggers as searchable options for _, trigger := range cmd.Trigger { commandNames = append(commandNames, trigger) - commandMap[trigger] = getCommandCompletionItem(cmd, space, t) + commandMap[trigger] = c.getCommandCompletionItem(cmd, space, t) } } diff --git a/packages/tui/internal/completions/files-folders.go b/packages/tui/internal/completions/files.go index 55e0c1a1b..861762db8 100644 --- a/packages/tui/internal/completions/files-folders.go +++ b/packages/tui/internal/completions/files.go @@ -14,20 +14,20 @@ import ( "github.com/sst/opencode/internal/theme" ) -type filesAndFoldersContextGroup struct { +type filesContextGroup struct { app *app.App gitFiles []dialog.CompletionItemI } -func (cg *filesAndFoldersContextGroup) GetId() string { +func (cg *filesContextGroup) GetId() string { return "files" } -func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string { +func (cg *filesContextGroup) GetEmptyMessage() string { return "no matching files" } -func (cg *filesAndFoldersContextGroup) getGitFiles() []dialog.CompletionItemI { +func (cg *filesContextGroup) getGitFiles() []dialog.CompletionItemI { t := theme.CurrentTheme() items := make([]dialog.CompletionItemI, 0) base := styles.NewStyle().Background(t.BackgroundElement()) @@ -50,8 +50,10 @@ func (cg *filesAndFoldersContextGroup) getGitFiles() []dialog.CompletionItemI { title += red(" -" + strconv.Itoa(int(file.Removed))) } item := dialog.NewCompletionItem(dialog.CompletionItem{ - Title: title, - Value: file.Path, + Title: title, + Value: file.Path, + ProviderID: cg.GetId(), + Raw: file, }) items = append(items, item) } @@ -60,7 +62,7 @@ func (cg *filesAndFoldersContextGroup) getGitFiles() []dialog.CompletionItemI { return items } -func (cg *filesAndFoldersContextGroup) GetChildEntries( +func (cg *filesContextGroup) GetChildEntries( query string, ) ([]dialog.CompletionItemI, error) { items := make([]dialog.CompletionItemI, 0) @@ -94,8 +96,10 @@ func (cg *filesAndFoldersContextGroup) GetChildEntries( } if !exists { item := dialog.NewCompletionItem(dialog.CompletionItem{ - Title: file, - Value: file, + Title: file, + Value: file, + ProviderID: cg.GetId(), + Raw: file, }) items = append(items, item) } @@ -104,8 +108,8 @@ func (cg *filesAndFoldersContextGroup) GetChildEntries( return items, nil } -func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider { - cg := &filesAndFoldersContextGroup{ +func NewFileContextGroup(app *app.App) dialog.CompletionProvider { + cg := &filesContextGroup{ app: app, } go func() { diff --git a/packages/tui/internal/completions/symbols.go b/packages/tui/internal/completions/symbols.go new file mode 100644 index 000000000..fea1b7117 --- /dev/null +++ b/packages/tui/internal/completions/symbols.go @@ -0,0 +1,118 @@ +package completions + +import ( + "context" + "fmt" + "log/slog" + "strings" + + "github.com/sst/opencode-sdk-go" + "github.com/sst/opencode/internal/app" + "github.com/sst/opencode/internal/components/dialog" + "github.com/sst/opencode/internal/styles" + "github.com/sst/opencode/internal/theme" +) + +type symbolsContextGroup struct { + app *app.App +} + +func (cg *symbolsContextGroup) GetId() string { + return "symbols" +} + +func (cg *symbolsContextGroup) GetEmptyMessage() string { + return "no matching symbols" +} + +type SymbolKind int + +const ( + SymbolKindFile SymbolKind = 1 + SymbolKindModule SymbolKind = 2 + SymbolKindNamespace SymbolKind = 3 + SymbolKindPackage SymbolKind = 4 + SymbolKindClass SymbolKind = 5 + SymbolKindMethod SymbolKind = 6 + SymbolKindProperty SymbolKind = 7 + SymbolKindField SymbolKind = 8 + SymbolKindConstructor SymbolKind = 9 + SymbolKindEnum SymbolKind = 10 + SymbolKindInterface SymbolKind = 11 + SymbolKindFunction SymbolKind = 12 + SymbolKindVariable SymbolKind = 13 + SymbolKindConstant SymbolKind = 14 + SymbolKindString SymbolKind = 15 + SymbolKindNumber SymbolKind = 16 + SymbolKindBoolean SymbolKind = 17 + SymbolKindArray SymbolKind = 18 + SymbolKindObject SymbolKind = 19 + SymbolKindKey SymbolKind = 20 + SymbolKindNull SymbolKind = 21 + SymbolKindEnumMember SymbolKind = 22 + SymbolKindStruct SymbolKind = 23 + SymbolKindEvent SymbolKind = 24 + SymbolKindOperator SymbolKind = 25 + SymbolKindTypeParameter SymbolKind = 26 +) + +func (cg *symbolsContextGroup) GetChildEntries( + query string, +) ([]dialog.CompletionItemI, error) { + items := make([]dialog.CompletionItemI, 0) + + query = strings.TrimSpace(query) + if query == "" { + return items, nil + } + + symbols, err := cg.app.Client.Find.Symbols( + context.Background(), + opencode.FindSymbolsParams{Query: opencode.F(query)}, + ) + if err != nil { + slog.Error("Failed to get symbol completion items", "error", err) + return items, err + } + if symbols == nil { + return items, nil + } + + t := theme.CurrentTheme() + baseStyle := styles.NewStyle().Background(t.BackgroundElement()) + base := baseStyle.Render + muted := baseStyle.Foreground(t.TextMuted()).Render + + for _, sym := range *symbols { + parts := strings.Split(sym.Name, ".") + lastPart := parts[len(parts)-1] + title := base(lastPart) + + uriParts := strings.Split(sym.Location.Uri, "/") + lastTwoParts := uriParts[len(uriParts)-2:] + joined := strings.Join(lastTwoParts, "/") + title += muted(fmt.Sprintf(" %s", joined)) + + start := int(sym.Location.Range.Start.Line) + end := int(sym.Location.Range.End.Line) + title += muted(fmt.Sprintf(":L%d-%d", start, end)) + + value := fmt.Sprintf("%s?start=%d&end=%d", sym.Location.Uri, start, end) + + item := dialog.NewCompletionItem(dialog.CompletionItem{ + Title: title, + Value: value, + ProviderID: cg.GetId(), + Raw: sym, + }) + items = append(items, item) + } + + return items, nil +} + +func NewSymbolsContextGroup(app *app.App) dialog.CompletionProvider { + return &symbolsContextGroup{ + app: app, + } +} 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 } diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index 73dd01186..38b61efab 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -65,6 +65,7 @@ type appModel struct { completions dialog.CompletionDialog commandProvider dialog.CompletionProvider fileProvider dialog.CompletionProvider + symbolsProvider dialog.CompletionProvider showCompletionDialog bool fileCompletionActive bool leaderBinding *key.Binding @@ -202,7 +203,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) // Set command provider for command completion - a.completions = dialog.NewCompletionDialogComponent(a.commandProvider, "/") + a.completions = dialog.NewCompletionDialogComponent("/", a.commandProvider) updated, cmd = a.completions.Update(msg) a.completions = updated.(dialog.CompletionDialog) cmds = append(cmds, cmd) @@ -220,8 +221,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.editor = updated.(chat.EditorComponent) cmds = append(cmds, cmd) - // Set file provider for file completion - a.completions = dialog.NewCompletionDialogComponent(a.fileProvider, "@") + // Set both file and symbols providers for @ completion + a.completions = dialog.NewCompletionDialogComponent("@", a.fileProvider, a.symbolsProvider) updated, cmd = a.completions.Update(msg) a.completions = updated.(dialog.CompletionDialog) cmds = append(cmds, cmd) @@ -922,7 +923,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) a.modal = themeDialog case commands.FileListCommand: a.editor.Blur() - provider := completions.NewFileAndFolderContextGroup(a.app) + provider := completions.NewFileContextGroup(a.app) findDialog := dialog.NewFindDialog(provider) findDialog.SetWidth(layout.Current.Container.Width - 8) a.modal = findDialog @@ -1030,11 +1031,12 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) func NewModel(app *app.App) tea.Model { commandProvider := completions.NewCommandCompletionProvider(app) - fileProvider := completions.NewFileAndFolderContextGroup(app) + fileProvider := completions.NewFileContextGroup(app) + symbolsProvider := completions.NewSymbolsContextGroup(app) messages := chat.NewMessagesComponent(app) editor := chat.NewEditorComponent(app) - completions := dialog.NewCompletionDialogComponent(commandProvider, "/") + completions := dialog.NewCompletionDialogComponent("/", commandProvider) var leaderBinding *key.Binding if app.Config.Keybinds.Leader != "" { @@ -1050,6 +1052,7 @@ func NewModel(app *app.App) tea.Model { completions: completions, commandProvider: commandProvider, fileProvider: fileProvider, + symbolsProvider: symbolsProvider, leaderBinding: leaderBinding, isLeaderSequence: false, showCompletionDialog: false, |
