diff --git a/internal/app/lsp.go b/internal/app/lsp.go index 214f104b879..fdbb05f7c1e 100644 --- a/internal/app/lsp.go +++ b/internal/app/lsp.go @@ -2,9 +2,8 @@ package app import ( "context" - "time" - "log/slog" + "time" "github.com/sst/opencode/internal/config" "github.com/sst/opencode/internal/logging" diff --git a/internal/completions/files-folders.go b/internal/completions/files-folders.go index a405b34cd7a..c48fbbd27c9 100644 --- a/internal/completions/files-folders.go +++ b/internal/completions/files-folders.go @@ -184,7 +184,7 @@ func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.C return items, nil } -func NewFileAndFolderContextGroup() dialog.CompletionProvider { +func NewFileAndFolderCompletionProvider() dialog.CompletionProvider { return &filesAndFoldersContextGroup{ prefix: "file", } diff --git a/internal/completions/lsp.go b/internal/completions/lsp.go new file mode 100644 index 00000000000..b9800379378 --- /dev/null +++ b/internal/completions/lsp.go @@ -0,0 +1,49 @@ +package completions + +import ( + "context" + + "github.com/sst/opencode/internal/llm/tools" + "github.com/sst/opencode/internal/lsp" + "github.com/sst/opencode/internal/tui/components/dialog" +) + +type lspCompletionProvider struct { + prefix string + lspClients map[string]*lsp.Client +} + +func (cg *lspCompletionProvider) GetId() string { + return cg.prefix +} + +func (cg *lspCompletionProvider) GetEntry() dialog.CompletionItemI { + return dialog.NewCompletionItem(dialog.CompletionItem{ + Title: "Lsp Symbols", + Value: "lsp", + }) +} + +func (cg *lspCompletionProvider) GetChildEntries(query string) ([]dialog.CompletionItemI, error) { + items := make([]dialog.CompletionItemI, 0, 1) + + symbols := tools.GetWorkspaceSymbols(context.Background(), query, cg.lspClients) + + for _, symbol := range symbols { + item := dialog.NewCompletionItem(dialog.CompletionItem{ + Title: "Test symbol", + Value: symbol, + }) + + items = append(items, item) + } + + return items, nil +} + +func NewLspCompleitonProvider(lspClients map[string]*lsp.Client) dialog.CompletionProvider { + return &lspCompletionProvider{ + prefix: "lsp", + lspClients: lspClients, + } +} diff --git a/internal/llm/tools/lsp_workspace_symbols.go b/internal/llm/tools/lsp_workspace_symbols.go index 24ca577ea72..7a1c0134cdb 100644 --- a/internal/llm/tools/lsp_workspace_symbols.go +++ b/internal/llm/tools/lsp_workspace_symbols.go @@ -81,12 +81,18 @@ func (b *workspaceSymbolsTool) Run(ctx context.Context, call ToolCall) (ToolResp return NewTextResponse("\nLSP clients are still initializing. Workspace symbols lookup will be available once they're ready.\n"), nil } - output := getWorkspaceSymbols(ctx, params.Query, lsps) + output := GetWorkspaceSymbolsString(ctx, params.Query, lsps) return NewTextResponse(output), nil } -func getWorkspaceSymbols(ctx context.Context, query string, lsps map[string]*lsp.Client) string { +func GetWorkspaceSymbolsString(ctx context.Context, query string, lsps map[string]*lsp.Client) string { + symbols := GetWorkspaceSymbols(ctx, query, lsps) + + return strings.Join(symbols, "\n") +} + +func GetWorkspaceSymbols(ctx context.Context, query string, lsps map[string]*lsp.Client) []string { var results []string for lspName, client := range lsps { @@ -117,10 +123,10 @@ func getWorkspaceSymbols(ctx context.Context, query string, lsps map[string]*lsp } if len(results) == 0 { - return fmt.Sprintf("No symbols found matching query '%s'.", query) + return []string{fmt.Sprintf("No symbols found matching query '%s'.", query)} } - return strings.Join(results, "\n") + return results } func processWorkspaceSymbolResult(result protocol.Or_Result_workspace_symbol) []SymbolInfo { @@ -159,4 +165,5 @@ func processWorkspaceSymbolResult(result protocol.Or_Result_workspace_symbol) [] func formatWorkspaceLocation(location protocol.Location) string { path := strings.TrimPrefix(string(location.URI), "file://") return fmt.Sprintf("%s:%d:%d", path, location.Range.Start.Line+1, location.Range.Start.Character+1) -} \ No newline at end of file +} + diff --git a/internal/tui/components/dialog/complete.go b/internal/tui/components/dialog/complete.go index 57193d00ce2..2d33fa2fe33 100644 --- a/internal/tui/components/dialog/complete.go +++ b/internal/tui/components/dialog/complete.go @@ -1,10 +1,16 @@ package dialog import ( + "context" + "fmt" + "strings" + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textarea" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/sst/opencode/internal/lsp" + "github.com/sst/opencode/internal/lsp/protocol" "github.com/sst/opencode/internal/status" utilComponents "github.com/sst/opencode/internal/tui/components/util" "github.com/sst/opencode/internal/tui/layout" @@ -83,12 +89,13 @@ type CompletionDialog interface { } type completionDialogCmp struct { - query string - completionProvider CompletionProvider - width int - height int - pseudoSearchTextArea textarea.Model - listView utilComponents.SimpleList[CompletionItemI] + query string + completionProviders []CompletionProvider + selectedCompletionProvider int + width int + height int + pseudoSearchTextArea textarea.Model + listView utilComponents.SimpleList[CompletionItemI] } type completionDialogKeyMap struct { @@ -126,9 +133,17 @@ func (c *completionDialogCmp) complete(item CompletionItemI) tea.Cmd { } func (c *completionDialogCmp) close() tea.Cmd { - c.listView.SetItems([]CompletionItemI{}) c.pseudoSearchTextArea.Reset() c.pseudoSearchTextArea.Blur() + c.selectedCompletionProvider = -1 + + items := make([]CompletionItemI, 0) + + for _, provider := range c.completionProviders { + items = append(items, provider.GetEntry()) + } + + c.listView.SetItems(items) return util.CmdHandler(CompletionDialogCloseMsg{}) } @@ -149,16 +164,37 @@ func (c *completionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { query = c.pseudoSearchTextArea.Value() if query != "" { query = query[1:] - } - if query != c.query { - items, err := c.completionProvider.GetChildEntries(query) - if err != nil { - status.Error(err.Error()) + if query != c.query { + completionsItems := make([]CompletionItemI, 0) + if query != "" { + for _, provider := range c.completionProviders { + items, err := provider.GetChildEntries(query) + if err != nil { + status.Error(err.Error()) + } + completionsItems = append(completionsItems, items...) + } + } else { + if c.selectedCompletionProvider == -1 { + for _, provider := range c.completionProviders { + items := provider.GetEntry() + completionsItems = append(completionsItems, items) + } + } else { + provider := c.completionProviders[c.selectedCompletionProvider] + items, err := provider.GetChildEntries(query) + if err != nil { + status.Error(err.Error()) + } + + completionsItems = append(completionsItems, items...) + } + } + c.listView.SetItems(completionsItems) + c.query = query } - c.listView.SetItems(items) - c.query = query } u, cmd := c.listView.Update(msg) @@ -174,7 +210,22 @@ func (c *completionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return c, nil } - cmd := c.complete(item) + var cmd tea.Cmd = nil + + if c.selectedCompletionProvider == -1 { + c.selectedCompletionProvider = i + + provider := c.completionProviders[i] + + items, err := provider.GetChildEntries("") + if err != nil { + status.Error(err.Error()) + } + + c.listView.SetItems(items) + } else { + cmd = c.complete(item) + } return c, cmd case key.Matches(msg, completionDialogKeys.Cancel): @@ -186,12 +237,6 @@ func (c *completionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return c, tea.Batch(cmds...) } else { - items, err := c.completionProvider.GetChildEntries("") - if err != nil { - status.Error(err.Error()) - } - - c.listView.SetItems(items) c.pseudoSearchTextArea.SetValue(msg.String()) return c, c.pseudoSearchTextArea.Focus() } @@ -239,12 +284,13 @@ func (c *completionDialogCmp) BindingKeys() []key.Binding { return layout.KeyMapToSlice(completionDialogKeys) } -func NewCompletionDialogCmp(completionProvider CompletionProvider) CompletionDialog { +func NewCompletionDialogCmp(completionProvider []CompletionProvider) CompletionDialog { ti := textarea.New() - items, err := completionProvider.GetChildEntries("") - if err != nil { - status.Error(err.Error()) + items := make([]CompletionItemI, 0) + + for _, provider := range completionProvider { + items = append(items, provider.GetEntry()) } li := utilComponents.NewSimpleList( @@ -255,9 +301,126 @@ func NewCompletionDialogCmp(completionProvider CompletionProvider) CompletionDia ) return &completionDialogCmp{ - query: "", - completionProvider: completionProvider, - pseudoSearchTextArea: ti, - listView: li, + query: "", + completionProviders: completionProvider, + selectedCompletionProvider: -1, + pseudoSearchTextArea: ti, + listView: li, } } + +func getDocumentSymbols(ctx context.Context, filePath string, lsps map[string]*lsp.Client) string { + var results []string + + for lspName, client := range lsps { + // Create document symbol params + uri := fmt.Sprintf("file://%s", filePath) + symbolParams := protocol.DocumentSymbolParams{ + TextDocument: protocol.TextDocumentIdentifier{ + URI: protocol.DocumentUri(uri), + }, + } + + // Get document symbols + symbolResult, err := client.DocumentSymbol(ctx, symbolParams) + if err != nil { + results = append(results, fmt.Sprintf("Error from %s: %s", lspName, err)) + continue + } + + // Process the symbol result + symbols := processDocumentSymbolResult(symbolResult) + if len(symbols) == 0 { + results = append(results, fmt.Sprintf("No symbols found by %s", lspName)) + continue + } + + // Format the symbols + results = append(results, fmt.Sprintf("Symbols found by %s:", lspName)) + for _, symbol := range symbols { + results = append(results, formatSymbol(symbol, 1)) + } + } + + if len(results) == 0 { + return "No symbols found in the specified file." + } + + return strings.Join(results, "\n") +} + +func processDocumentSymbolResult(result protocol.Or_Result_textDocument_documentSymbol) []SymbolInfo { + var symbols []SymbolInfo + + switch v := result.Value.(type) { + case []protocol.SymbolInformation: + for _, si := range v { + symbols = append(symbols, SymbolInfo{ + Name: si.Name, + Kind: symbolKindToString(si.Kind), + Location: locationToString(si.Location), + Children: nil, + }) + } + case []protocol.DocumentSymbol: + for _, ds := range v { + symbols = append(symbols, documentSymbolToSymbolInfo(ds)) + } + } + + return symbols +} + +// SymbolInfo represents a symbol in a document +type SymbolInfo struct { + Name string + Kind string + Location string + Children []SymbolInfo +} + +func documentSymbolToSymbolInfo(symbol protocol.DocumentSymbol) SymbolInfo { + info := SymbolInfo{ + Name: symbol.Name, + Kind: symbolKindToString(symbol.Kind), + Location: fmt.Sprintf("Line %d-%d", + symbol.Range.Start.Line+1, + symbol.Range.End.Line+1), + Children: []SymbolInfo{}, + } + + for _, child := range symbol.Children { + info.Children = append(info.Children, documentSymbolToSymbolInfo(child)) + } + + return info +} + +func locationToString(location protocol.Location) string { + return fmt.Sprintf("Line %d-%d", + location.Range.Start.Line+1, + location.Range.End.Line+1) +} + +func symbolKindToString(kind protocol.SymbolKind) string { + if kindStr, ok := protocol.TableKindMap[kind]; ok { + return kindStr + } + return "Unknown" +} + +func formatSymbol(symbol SymbolInfo, level int) string { + indent := strings.Repeat(" ", level) + result := fmt.Sprintf("%s- %s (%s) %s", indent, symbol.Name, symbol.Kind, symbol.Location) + + var childResults []string + for _, child := range symbol.Children { + childResults = append(childResults, formatSymbol(child, level+1)) + } + + if len(childResults) > 0 { + return result + "\n" + strings.Join(childResults, "\n") + } + + return result +} diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go index 1b31c838c3b..b2ae17aa486 100644 --- a/internal/tui/page/chat.go +++ b/internal/tui/page/chat.go @@ -52,8 +52,8 @@ var keyMap = ChatKeyMap{ key.WithHelp("ctrl+h", "toggle tools"), ), ShowCompletionDialog: key.NewBinding( - key.WithKeys("/"), - key.WithHelp("/", "Complete"), + key.WithKeys("@"), + key.WithHelp("@", "Complete"), ), } @@ -240,8 +240,14 @@ func (p *chatPage) BindingKeys() []key.Binding { } func NewChatPage(app *app.App) tea.Model { - cg := completions.NewFileAndFolderContextGroup() - completionDialog := dialog.NewCompletionDialogCmp(cg) + filesCg := completions.NewFileAndFolderCompletionProvider() + lspCp := completions.NewLspCompleitonProvider(app.LSPClients) + + completionProviders := make([]dialog.CompletionProvider, 0) + + completionProviders = append(completionProviders, filesCg, lspCp) + + completionDialog := dialog.NewCompletionDialogCmp(completionProviders) messagesContainer := layout.NewContainer( chat.NewMessagesCmp(app), layout.WithPadding(1, 1, 0, 1),