Skip to content

Commit c8f8d67

Browse files
committed
feat: codeAction tool
1 parent 182e32e commit c8f8d67

File tree

2 files changed

+351
-0
lines changed

2 files changed

+351
-0
lines changed

internal/llm/agent/tools.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ func PrimaryAgentTools(
3737
tools.NewReferencesTool(lspClients),
3838
tools.NewDocSymbolsTool(lspClients),
3939
tools.NewWorkspaceSymbolsTool(lspClients),
40+
tools.NewCodeActionTool(lspClients),
4041
NewAgentTool(sessions, messages, lspClients),
4142
}, mcpTools...,
4243
)
Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
package tools
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"strings"
8+
9+
"github.com/sst/opencode/internal/lsp"
10+
"github.com/sst/opencode/internal/lsp/protocol"
11+
"github.com/sst/opencode/internal/lsp/util"
12+
)
13+
14+
type CodeActionParams struct {
15+
FilePath string `json:"file_path"`
16+
Line int `json:"line"`
17+
Column int `json:"column"`
18+
EndLine int `json:"end_line,omitempty"`
19+
EndColumn int `json:"end_column,omitempty"`
20+
ActionID int `json:"action_id,omitempty"`
21+
LspName string `json:"lsp_name,omitempty"`
22+
}
23+
24+
type codeActionTool struct {
25+
lspClients map[string]*lsp.Client
26+
}
27+
28+
const (
29+
CodeActionToolName = "codeAction"
30+
codeActionDescription = `Get available code actions at a specific position or range in a file.
31+
WHEN TO USE THIS TOOL:
32+
- Use when you need to find available fixes or refactorings for code issues
33+
- Helpful for resolving errors, warnings, or improving code quality
34+
- Great for discovering automated code transformations
35+
36+
HOW TO USE:
37+
- Provide the path to the file containing the code
38+
- Specify the line number (1-based) where the action should be applied
39+
- Specify the column number (1-based) where the action should be applied
40+
- Optionally specify end_line and end_column to define a range
41+
- Results show available code actions with their titles and kinds
42+
43+
TO EXECUTE A CODE ACTION:
44+
- After getting the list of available actions, call the tool again with the same parameters
45+
- Add action_id parameter with the number of the action you want to execute (e.g., 1 for the first action)
46+
- Add lsp_name parameter with the name of the LSP server that provided the action
47+
48+
FEATURES:
49+
- Finds quick fixes for errors and warnings
50+
- Discovers available refactorings
51+
- Shows code organization actions
52+
- Returns detailed information about each action
53+
- Can execute selected code actions
54+
55+
LIMITATIONS:
56+
- Requires a functioning LSP server for the file type
57+
- May not work for all code issues depending on LSP capabilities
58+
- Results depend on the accuracy of the LSP server
59+
60+
TIPS:
61+
- Use in conjunction with Diagnostics tool to find issues that can be fixed
62+
- First call without action_id to see available actions, then call again with action_id to execute
63+
`
64+
)
65+
66+
func NewCodeActionTool(lspClients map[string]*lsp.Client) BaseTool {
67+
return &codeActionTool{
68+
lspClients,
69+
}
70+
}
71+
72+
func (b *codeActionTool) Info() ToolInfo {
73+
return ToolInfo{
74+
Name: CodeActionToolName,
75+
Description: codeActionDescription,
76+
Parameters: map[string]any{
77+
"file_path": map[string]any{
78+
"type": "string",
79+
"description": "The path to the file containing the code",
80+
},
81+
"line": map[string]any{
82+
"type": "integer",
83+
"description": "The line number (1-based) where the action should be applied",
84+
},
85+
"column": map[string]any{
86+
"type": "integer",
87+
"description": "The column number (1-based) where the action should be applied",
88+
},
89+
"end_line": map[string]any{
90+
"type": "integer",
91+
"description": "The ending line number (1-based) for a range (optional)",
92+
},
93+
"end_column": map[string]any{
94+
"type": "integer",
95+
"description": "The ending column number (1-based) for a range (optional)",
96+
},
97+
"action_id": map[string]any{
98+
"type": "integer",
99+
"description": "The ID of the code action to execute (optional)",
100+
},
101+
"lsp_name": map[string]any{
102+
"type": "string",
103+
"description": "The name of the LSP server that provided the action (optional)",
104+
},
105+
},
106+
Required: []string{"file_path", "line", "column"},
107+
}
108+
}
109+
110+
func (b *codeActionTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
111+
var params CodeActionParams
112+
if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
113+
return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
114+
}
115+
116+
lsps := b.lspClients
117+
118+
if len(lsps) == 0 {
119+
return NewTextResponse("\nLSP clients are still initializing. Code actions will be available once they're ready.\n"), nil
120+
}
121+
122+
// Ensure file is open in LSP
123+
notifyLspOpenFile(ctx, params.FilePath, lsps)
124+
125+
// Convert 1-based line/column to 0-based for LSP protocol
126+
line := max(0, params.Line-1)
127+
column := max(0, params.Column-1)
128+
129+
// Handle optional end line/column
130+
endLine := line
131+
endColumn := column
132+
if params.EndLine > 0 {
133+
endLine = max(0, params.EndLine-1)
134+
}
135+
if params.EndColumn > 0 {
136+
endColumn = max(0, params.EndColumn-1)
137+
}
138+
139+
// Check if we're executing a specific action
140+
if params.ActionID > 0 && params.LspName != "" {
141+
return executeCodeAction(ctx, params.FilePath, line, column, endLine, endColumn, params.ActionID, params.LspName, lsps)
142+
}
143+
144+
// Otherwise, just list available actions
145+
output := getCodeActions(ctx, params.FilePath, line, column, endLine, endColumn, lsps)
146+
return NewTextResponse(output), nil
147+
}
148+
149+
func getCodeActions(ctx context.Context, filePath string, line, column, endLine, endColumn int, lsps map[string]*lsp.Client) string {
150+
var results []string
151+
152+
for lspName, client := range lsps {
153+
// Create code action params
154+
uri := fmt.Sprintf("file://%s", filePath)
155+
codeActionParams := protocol.CodeActionParams{
156+
TextDocument: protocol.TextDocumentIdentifier{
157+
URI: protocol.DocumentUri(uri),
158+
},
159+
Range: protocol.Range{
160+
Start: protocol.Position{
161+
Line: uint32(line),
162+
Character: uint32(column),
163+
},
164+
End: protocol.Position{
165+
Line: uint32(endLine),
166+
Character: uint32(endColumn),
167+
},
168+
},
169+
Context: protocol.CodeActionContext{
170+
// Request all kinds of code actions
171+
Only: []protocol.CodeActionKind{
172+
protocol.QuickFix,
173+
protocol.Refactor,
174+
protocol.RefactorExtract,
175+
protocol.RefactorInline,
176+
protocol.RefactorRewrite,
177+
protocol.Source,
178+
protocol.SourceOrganizeImports,
179+
protocol.SourceFixAll,
180+
},
181+
},
182+
}
183+
184+
// Get code actions
185+
codeActions, err := client.CodeAction(ctx, codeActionParams)
186+
if err != nil {
187+
results = append(results, fmt.Sprintf("Error from %s: %s", lspName, err))
188+
continue
189+
}
190+
191+
if len(codeActions) == 0 {
192+
results = append(results, fmt.Sprintf("No code actions found by %s", lspName))
193+
continue
194+
}
195+
196+
// Format the code actions
197+
results = append(results, fmt.Sprintf("Code actions found by %s:", lspName))
198+
for i, action := range codeActions {
199+
actionInfo := formatCodeAction(action, i+1)
200+
results = append(results, actionInfo)
201+
}
202+
}
203+
204+
if len(results) == 0 {
205+
return "No code actions found at the specified position."
206+
}
207+
208+
return strings.Join(results, "\n")
209+
}
210+
211+
func formatCodeAction(action protocol.Or_Result_textDocument_codeAction_Item0_Elem, index int) string {
212+
switch v := action.Value.(type) {
213+
case protocol.CodeAction:
214+
kind := "Unknown"
215+
if v.Kind != "" {
216+
kind = string(v.Kind)
217+
}
218+
219+
var details []string
220+
221+
// Add edit information if available
222+
if v.Edit != nil {
223+
numChanges := 0
224+
if v.Edit.Changes != nil {
225+
numChanges = len(v.Edit.Changes)
226+
}
227+
if v.Edit.DocumentChanges != nil {
228+
numChanges = len(v.Edit.DocumentChanges)
229+
}
230+
details = append(details, fmt.Sprintf("Edits: %d changes", numChanges))
231+
}
232+
233+
// Add command information if available
234+
if v.Command != nil {
235+
details = append(details, fmt.Sprintf("Command: %s", v.Command.Title))
236+
}
237+
238+
// Add diagnostics information if available
239+
if v.Diagnostics != nil && len(v.Diagnostics) > 0 {
240+
details = append(details, fmt.Sprintf("Fixes: %d diagnostics", len(v.Diagnostics)))
241+
}
242+
243+
detailsStr := ""
244+
if len(details) > 0 {
245+
detailsStr = " (" + strings.Join(details, ", ") + ")"
246+
}
247+
248+
return fmt.Sprintf(" %d. %s [%s]%s", index, v.Title, kind, detailsStr)
249+
250+
case protocol.Command:
251+
return fmt.Sprintf(" %d. %s [Command]", index, v.Title)
252+
}
253+
254+
return fmt.Sprintf(" %d. Unknown code action type", index)
255+
}
256+
257+
func executeCodeAction(ctx context.Context, filePath string, line, column, endLine, endColumn, actionID int, lspName string, lsps map[string]*lsp.Client) (ToolResponse, error) {
258+
client, ok := lsps[lspName]
259+
if !ok {
260+
return NewTextErrorResponse(fmt.Sprintf("LSP server '%s' not found", lspName)), nil
261+
}
262+
263+
// Create code action params
264+
uri := fmt.Sprintf("file://%s", filePath)
265+
codeActionParams := protocol.CodeActionParams{
266+
TextDocument: protocol.TextDocumentIdentifier{
267+
URI: protocol.DocumentUri(uri),
268+
},
269+
Range: protocol.Range{
270+
Start: protocol.Position{
271+
Line: uint32(line),
272+
Character: uint32(column),
273+
},
274+
End: protocol.Position{
275+
Line: uint32(endLine),
276+
Character: uint32(endColumn),
277+
},
278+
},
279+
Context: protocol.CodeActionContext{
280+
// Request all kinds of code actions
281+
Only: []protocol.CodeActionKind{
282+
protocol.QuickFix,
283+
protocol.Refactor,
284+
protocol.RefactorExtract,
285+
protocol.RefactorInline,
286+
protocol.RefactorRewrite,
287+
protocol.Source,
288+
protocol.SourceOrganizeImports,
289+
protocol.SourceFixAll,
290+
},
291+
},
292+
}
293+
294+
// Get code actions
295+
codeActions, err := client.CodeAction(ctx, codeActionParams)
296+
if err != nil {
297+
return NewTextErrorResponse(fmt.Sprintf("Error getting code actions: %s", err)), nil
298+
}
299+
300+
if len(codeActions) == 0 {
301+
return NewTextErrorResponse("No code actions found"), nil
302+
}
303+
304+
// Check if the requested action ID is valid
305+
if actionID < 1 || actionID > len(codeActions) {
306+
return NewTextErrorResponse(fmt.Sprintf("Invalid action ID: %d. Available actions: 1-%d", actionID, len(codeActions))), nil
307+
}
308+
309+
// Get the selected action (adjust for 0-based index)
310+
selectedAction := codeActions[actionID-1]
311+
312+
// Execute the action based on its type
313+
switch v := selectedAction.Value.(type) {
314+
case protocol.CodeAction:
315+
// Apply workspace edit if available
316+
if v.Edit != nil {
317+
err := util.ApplyWorkspaceEdit(*v.Edit)
318+
if err != nil {
319+
return NewTextErrorResponse(fmt.Sprintf("Error applying edit: %s", err)), nil
320+
}
321+
}
322+
323+
// Execute command if available
324+
if v.Command != nil {
325+
_, err := client.ExecuteCommand(ctx, protocol.ExecuteCommandParams{
326+
Command: v.Command.Command,
327+
Arguments: v.Command.Arguments,
328+
})
329+
if err != nil {
330+
return NewTextErrorResponse(fmt.Sprintf("Error executing command: %s", err)), nil
331+
}
332+
}
333+
334+
return NewTextResponse(fmt.Sprintf("Successfully executed code action: %s", v.Title)), nil
335+
336+
case protocol.Command:
337+
// Execute the command
338+
_, err := client.ExecuteCommand(ctx, protocol.ExecuteCommandParams{
339+
Command: v.Command,
340+
Arguments: v.Arguments,
341+
})
342+
if err != nil {
343+
return NewTextErrorResponse(fmt.Sprintf("Error executing command: %s", err)), nil
344+
}
345+
346+
return NewTextResponse(fmt.Sprintf("Successfully executed command: %s", v.Title)), nil
347+
}
348+
349+
return NewTextErrorResponse("Unknown code action type"), nil
350+
}

0 commit comments

Comments
 (0)