on main help sign in
lsp improvements
2b5a33eKujtim Hoxha
internal/app/app.go+2−1
@@ -49,7 +49,8 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) {
49 LSPClients: make(map[string]*lsp.Client),
49 LSPClients: make(map[string]*lsp.Client),
50 }
50 }
51
51
52+ // Initialize LSP clients in the background
52− app.initLSPClients(ctx)
53+ go app.initLSPClients(ctx)
53
54
54 var err error
55 var err error
55 app.CoderAgent, err = agent.NewAgent(
56 app.CoderAgent, err = agent.NewAgent(
internal/app/lsp.go+25−4
@@ -15,24 +15,28 @@ func (app *App) initLSPClients(ctx context.Context) {
15
15
16 // Initialize LSP clients
16 // Initialize LSP clients
17 for name, clientConfig := range cfg.LSP {
17 for name, clientConfig := range cfg.LSP {
18+ // Start each client initialization in its own goroutine
18− app.createAndStartLSPClient(ctx, name, clientConfig.Command, clientConfig.Args...)
19+ go app.createAndStartLSPClient(ctx, name, clientConfig.Command, clientConfig.Args...)
19 }
20 }
21+ logging.Info("LSP clients initialization started in background")
20}
22}
21
23
22// createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher
24// createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher
23func (app *App) createAndStartLSPClient(ctx context.Context, name string, command string, args ...string) {
25func (app *App) createAndStartLSPClient(ctx context.Context, name string, command string, args ...string) {
24 // Create a specific context for initialization with a timeout
26 // Create a specific context for initialization with a timeout
25−
27+ logging.Info("Creating LSP client", "name", name, "command", command, "args", args)
28+
26 // Create the LSP client
29 // Create the LSP client
27 lspClient, err := lsp.NewClient(ctx, command, args...)
30 lspClient, err := lsp.NewClient(ctx, command, args...)
28 if err != nil {
31 if err != nil {
29 logging.Error("Failed to create LSP client for", name, err)
32 logging.Error("Failed to create LSP client for", name, err)
30 return
33 return
31−
32 }
34 }
33
35
36+ // Create a longer timeout for initialization (some servers take time to start)
34− initCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
37+ initCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
35 defer cancel()
38 defer cancel()
39+
36 // Initialize with the initialization context
40 // Initialize with the initialization context
37 _, err = lspClient.InitializeLSPClient(initCtx, config.WorkingDirectory())
41 _, err = lspClient.InitializeLSPClient(initCtx, config.WorkingDirectory())
38 if err != nil {
42 if err != nil {
@@ -42,8 +46,25 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman
42 return
46 return
43 }
47 }
44
48
49+ // Wait for the server to be ready
50+ if err := lspClient.WaitForServerReady(initCtx); err != nil {
51+ logging.Error("Server failed to become ready", "name", name, "error", err)
52+ // We'll continue anyway, as some functionality might still work
53+ lspClient.SetServerState(lsp.StateError)
54+ } else {
55+ logging.Info("LSP server is ready", "name", name)
56+ lspClient.SetServerState(lsp.StateReady)
57+ }
58+
59+ logging.Info("LSP client initialized", "name", name)
60+
45 // Create a child context that can be canceled when the app is shutting down
61 // Create a child context that can be canceled when the app is shutting down
46 watchCtx, cancelFunc := context.WithCancel(ctx)
62 watchCtx, cancelFunc := context.WithCancel(ctx)
63+
64+ // Create a context with the server name for better identification
65+ watchCtx = context.WithValue(watchCtx, "serverName", name)
66+
67+ // Create the workspace watcher
47 workspaceWatcher := watcher.NewWorkspaceWatcher(lspClient)
68 workspaceWatcher := watcher.NewWorkspaceWatcher(lspClient)
48
69
49 // Store the cancel function to be called during cleanup
70 // Store the cancel function to be called during cleanup
internal/config/config.go+6−6
@@ -209,17 +209,17 @@ func setProviderDefaults() {
209 // Google Gemini configuration
209 // Google Gemini configuration
210 if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
210 if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
211 viper.SetDefault("providers.gemini.apiKey", apiKey)
211 viper.SetDefault("providers.gemini.apiKey", apiKey)
212− viper.SetDefault("agents.coder.model", models.GRMINI20Flash)
212+ viper.SetDefault("agents.coder.model", models.Gemini25)
213− viper.SetDefault("agents.task.model", models.GRMINI20Flash)
213+ viper.SetDefault("agents.task.model", models.Gemini25Flash)
214− viper.SetDefault("agents.title.model", models.GRMINI20Flash)
214+ viper.SetDefault("agents.title.model", models.Gemini25Flash)
215 }
215 }
216
216
217 // OpenAI configuration
217 // OpenAI configuration
218 if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
218 if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
219 viper.SetDefault("providers.openai.apiKey", apiKey)
219 viper.SetDefault("providers.openai.apiKey", apiKey)
220− viper.SetDefault("agents.coder.model", models.GPT4o)
220+ viper.SetDefault("agents.coder.model", models.GPT41)
221− viper.SetDefault("agents.task.model", models.GPT4o)
221+ viper.SetDefault("agents.task.model", models.GPT41Mini)
222− viper.SetDefault("agents.title.model", models.GPT4o)
222+ viper.SetDefault("agents.title.model", models.GPT41Mini)
223
223
224 }
224 }
225
225
internal/llm/models/gemini.gonew file+63
@@ -0,0 +1,63 @@
1+package models
2+
3+const (
4+ ProviderGemini ModelProvider = "gemini"
5+
6+ // Models
7+ Gemini25Flash ModelID = "gemini-2.5-flash"
8+ Gemini25 ModelID = "gemini-2.5"
9+ Gemini20Flash ModelID = "gemini-2.0-flash"
10+ Gemini20FlashLite ModelID = "gemini-2.0-flash-lite"
11+)
12+
13+var GeminiModels = map[ModelID]Model{
14+ Gemini25Flash: {
15+ ID: Gemini25Flash,
16+ Name: "Gemini 2.5 Flash",
17+ Provider: ProviderGemini,
18+ APIModel: "gemini-2.5-flash-preview-04-17",
19+ CostPer1MIn: 0.15,
20+ CostPer1MInCached: 0,
21+ CostPer1MOutCached: 0,
22+ CostPer1MOut: 0.60,
23+ ContextWindow: 1000000,
24+ DefaultMaxTokens: 50000,
25+ },
26+ Gemini25: {
27+ ID: Gemini25,
28+ Name: "Gemini 2.5 Pro",
29+ Provider: ProviderGemini,
30+ APIModel: "gemini-2.5-pro-preview-03-25",
31+ CostPer1MIn: 1.25,
32+ CostPer1MInCached: 0,
33+ CostPer1MOutCached: 0,
34+ CostPer1MOut: 10,
35+ ContextWindow: 1000000,
36+ DefaultMaxTokens: 50000,
37+ },
38+
39+ Gemini20Flash: {
40+ ID: Gemini20Flash,
41+ Name: "Gemini 2.0 Flash",
42+ Provider: ProviderGemini,
43+ APIModel: "gemini-2.0-flash",
44+ CostPer1MIn: 0.10,
45+ CostPer1MInCached: 0,
46+ CostPer1MOutCached: 0,
47+ CostPer1MOut: 0.40,
48+ ContextWindow: 1000000,
49+ DefaultMaxTokens: 6000,
50+ },
51+ Gemini20FlashLite: {
52+ ID: Gemini20FlashLite,
53+ Name: "Gemini 2.0 Flash Lite",
54+ Provider: ProviderGemini,
55+ APIModel: "gemini-2.0-flash-lite",
56+ CostPer1MIn: 0.05,
57+ CostPer1MInCached: 0,
58+ CostPer1MOutCached: 0,
59+ CostPer1MOut: 0.30,
60+ ContextWindow: 1000000,
61+ DefaultMaxTokens: 6000,
62+ },
63+}
internal/llm/models/models.go+1−4
@@ -23,9 +23,6 @@ type Model struct {
23
23
24// Model IDs
24// Model IDs
25const ( // GEMINI
25const ( // GEMINI
26− GEMINI25 ModelID = "gemini-2.5"
27− GRMINI20Flash ModelID = "gemini-2.0-flash"
28−
29 // GROQ
26 // GROQ
30 QWENQwq ModelID = "qwen-qwq"
27 QWENQwq ModelID = "qwen-qwq"
31
28
@@ -35,7 +32,6 @@ const ( // GEMINI
35
32
36const (
33const (
37 ProviderBedrock ModelProvider = "bedrock"
34 ProviderBedrock ModelProvider = "bedrock"
38− ProviderGemini ModelProvider = "gemini"
39 ProviderGROQ ModelProvider = "groq"
35 ProviderGROQ ModelProvider = "groq"
40
36
41 // ForTests
37 // ForTests
@@ -95,4 +91,5 @@ var SupportedModels = map[ModelID]Model{
95func init() {
91func init() {
96 maps.Copy(SupportedModels, AnthropicModels)
92 maps.Copy(SupportedModels, AnthropicModels)
97 maps.Copy(SupportedModels, OpenAIModels)
93 maps.Copy(SupportedModels, OpenAIModels)
94+ maps.Copy(SupportedModels, GeminiModels)
98}
95}
internal/llm/prompt/coder.go+2
@@ -68,6 +68,7 @@ You MUST adhere to the following criteria when executing the task:
68 - Do NOT show the full contents of large files you have already written, unless the user explicitly asks for them.
68 - Do NOT show the full contents of large files you have already written, unless the user explicitly asks for them.
69- When doing things with paths, always use use the full path, if the working directory is /abc/xyz and you want to edit the file abc.go in the working dir refer to it as /abc/xyz/abc.go.
69- When doing things with paths, always use use the full path, if the working directory is /abc/xyz and you want to edit the file abc.go in the working dir refer to it as /abc/xyz/abc.go.
70- If you send a path not including the working dir, the working dir will be prepended to it.
70- If you send a path not including the working dir, the working dir will be prepended to it.
71+- Remember the user does not see the full output of tools
71`
72`
72
73
73const baseAnthropicCoderPrompt = `You are OpenCode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
74const baseAnthropicCoderPrompt = `You are OpenCode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
@@ -162,6 +163,7 @@ NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTAN
162# Tool usage policy
163# Tool usage policy
163- When doing file search, prefer to use the Agent tool in order to reduce context usage.
164- When doing file search, prefer to use the Agent tool in order to reduce context usage.
164- If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in the same function_calls block.
165- If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in the same function_calls block.
166+- IMPORTANT: The user does not see the full output of the tool responses, so if you need the output of the tool for the response make sure to summarize it for the user.
165
167
166You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.`
168You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.`
167
169
internal/llm/provider/gemini.go−1
@@ -567,4 +567,3 @@ func contains(s string, substrs ...string) bool {
567 }
567 }
568 return false
568 return false
569}
569}
570−
internal/llm/tools/grep.go+58−33
@@ -10,6 +10,7 @@ import (
10 "path/filepath"
10 "path/filepath"
11 "regexp"
11 "regexp"
12 "sort"
12 "sort"
13+ "strconv"
13 "strings"
14 "strings"
14 "time"
15 "time"
15
16
@@ -24,8 +25,10 @@ type GrepParams struct {
24}
25}
25
26
26type grepMatch struct {
27type grepMatch struct {
27− path string
28+ path string
28− modTime time.Time
29+ modTime time.Time
30+ lineNum int
31+ lineText string
29}
32}
30
33
31type GrepResponseMetadata struct {
34type GrepResponseMetadata struct {
@@ -147,13 +150,26 @@ func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
147 if len(matches) == 0 {
150 if len(matches) == 0 {
148 output = "No files found"
151 output = "No files found"
149 } else {
152 } else {
150− output = fmt.Sprintf("Found %d file%s\n%s",
153+ output = fmt.Sprintf("Found %d matches\n", len(matches))
151− len(matches),
154+
152− pluralize(len(matches)),
155+ currentFile := ""
153− strings.Join(matches, "\n"))
156+ for _, match := range matches {
157+ if currentFile != match.path {
158+ if currentFile != "" {
159+ output += "\n"
160+ }
161+ currentFile = match.path
162+ output += fmt.Sprintf("%s:\n", match.path)
163+ }
164+ if match.lineNum > 0 {
165+ output += fmt.Sprintf(" Line %d: %s\n", match.lineNum, match.lineText)
166+ } else {
167+ output += fmt.Sprintf(" %s\n", match.path)
168+ }
169+ }
154
170
155 if truncated {
171 if truncated {
156− output += "\n\n(Results are truncated. Consider using a more specific path or pattern.)"
172+ output += "\n(Results are truncated. Consider using a more specific path or pattern.)"
157 }
173 }
158 }
174 }
159
175
@@ -166,14 +182,7 @@ func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
166 ), nil
182 ), nil
167}
183}
168
184
169−func pluralize(count int) string {
170− if count == 1 {
171− return ""
172− }
173− return "s"
174−}
175−
176−func searchFiles(pattern, rootPath, include string, limit int) ([]string, bool, error) {
185+func searchFiles(pattern, rootPath, include string, limit int) ([]grepMatch, bool, error) {
177 matches, err := searchWithRipgrep(pattern, rootPath, include)
186 matches, err := searchWithRipgrep(pattern, rootPath, include)
178 if err != nil {
187 if err != nil {
179 matches, err = searchFilesWithRegex(pattern, rootPath, include)
188 matches, err = searchFilesWithRegex(pattern, rootPath, include)
@@ -191,12 +200,7 @@ func searchFiles(pattern, rootPath, include string, limit int) ([]string, bool,
191 matches = matches[:limit]
200 matches = matches[:limit]
192 }
201 }
193
202
194− results := make([]string, len(matches))
195− for i, m := range matches {
196− results[i] = m.path
197− }
198−
199− return results, truncated, nil
203+ return matches, truncated, nil
200}
204}
201
205
202func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) {
206func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) {
@@ -205,7 +209,8 @@ func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) {
205 return nil, fmt.Errorf("ripgrep not found: %w", err)
209 return nil, fmt.Errorf("ripgrep not found: %w", err)
206 }
210 }
207
211
212+ // Use -n to show line numbers and include the matched line
208− args := []string{"-l", pattern}
213+ args := []string{"-n", pattern}
209 if include != "" {
214 if include != "" {
210 args = append(args, "--glob", include)
215 args = append(args, "--glob", include)
211 }
216 }
@@ -228,14 +233,29 @@ func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) {
228 continue
233 continue
229 }
234 }
230
235
236+ // Parse ripgrep output format: file:line:content
237+ parts := strings.SplitN(line, ":", 3)
238+ if len(parts) < 3 {
239+ continue
240+ }
241+
242+ filePath := parts[0]
243+ lineNum, err := strconv.Atoi(parts[1])
244+ if err != nil {
245+ continue
246+ }
247+ lineText := parts[2]
248+
231− fileInfo, err := os.Stat(line)
249+ fileInfo, err := os.Stat(filePath)
232 if err != nil {
250 if err != nil {
233 continue // Skip files we can't access
251 continue // Skip files we can't access
234 }
252 }
235
253
236 matches = append(matches, grepMatch{
254 matches = append(matches, grepMatch{
237− path: line,
255+ path: filePath,
238− modTime: fileInfo.ModTime(),
256+ modTime: fileInfo.ModTime(),
257+ lineNum: lineNum,
258+ lineText: lineText,
239 })
259 })
240 }
260 }
241
261
@@ -276,15 +296,17 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
276 return nil
296 return nil
277 }
297 }
278
298
279− match, err := fileContainsPattern(path, regex)
299+ match, lineNum, lineText, err := fileContainsPattern(path, regex)
280 if err != nil {
300 if err != nil {
281 return nil // Skip files we can't read
301 return nil // Skip files we can't read
282 }
302 }
283
303
284 if match {
304 if match {
285 matches = append(matches, grepMatch{
305 matches = append(matches, grepMatch{
286− path: path,
306+ path: path,
287− modTime: info.ModTime(),
307+ modTime: info.ModTime(),
308+ lineNum: lineNum,
309+ lineText: lineText,
288 })
310 })
289
311
290 if len(matches) >= 200 {
312 if len(matches) >= 200 {
@@ -301,21 +323,24 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
301 return matches, nil
323 return matches, nil
302}
324}
303
325
304−func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, error) {
326+func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, string, error) {
305 file, err := os.Open(filePath)
327 file, err := os.Open(filePath)
306 if err != nil {
328 if err != nil {
307− return false, err
329+ return false, 0, "", err
308 }
330 }
309 defer file.Close()
331 defer file.Close()
310
332
311 scanner := bufio.NewScanner(file)
333 scanner := bufio.NewScanner(file)
334+ lineNum := 0
312 for scanner.Scan() {
335 for scanner.Scan() {
336+ lineNum++
337+ line := scanner.Text()
313− if pattern.MatchString(scanner.Text()) {
338+ if pattern.MatchString(line) {
314− return true, nil
339+ return true, lineNum, line, nil
315 }
340 }
316 }
341 }
317
342
318− return false, scanner.Err()
343+ return false, 0, "", scanner.Err()
319}
344}
320
345
321func globToRegex(glob string) string {
346func globToRegex(glob string) string {
internal/lsp/client.go+351−13
@@ -8,6 +8,7 @@ import (
8 "io"
8 "io"
9 "os"
9 "os"
10 "os/exec"
10 "os/exec"
11+ "path/filepath"
11 "strings"
12 "strings"
12 "sync"
13 "sync"
13 "sync/atomic"
14 "sync/atomic"
@@ -46,6 +47,9 @@ type Client struct {
46 // Files are currently opened by the LSP
47 // Files are currently opened by the LSP
47 openFiles map[string]*OpenFileInfo
48 openFiles map[string]*OpenFileInfo
48 openFilesMu sync.RWMutex
49 openFilesMu sync.RWMutex
50+
51+ // Server state
52+ serverState atomic.Value
49}
53}
50
54
51func NewClient(ctx context.Context, command string, args ...string) (*Client, error) {
55func NewClient(ctx context.Context, command string, args ...string) (*Client, error) {
@@ -80,6 +84,9 @@ func NewClient(ctx context.Context, command string, args ...string) (*Client, er
80 openFiles: make(map[string]*OpenFileInfo),
84 openFiles: make(map[string]*OpenFileInfo),
81 }
85 }
82
86
87+ // Initialize server state
88+ client.serverState.Store(StateStarting)
89+
83 // Start the LSP server process
90 // Start the LSP server process
84 if err := cmd.Start(); err != nil {
91 if err := cmd.Start(); err != nil {
85 return nil, fmt.Errorf("failed to start LSP server: %w", err)
92 return nil, fmt.Errorf("failed to start LSP server: %w", err)
@@ -220,16 +227,6 @@ func (c *Client) InitializeLSPClient(ctx context.Context, workspaceDir string) (
220 return nil, fmt.Errorf("initialization failed: %w", err)
227 return nil, fmt.Errorf("initialization failed: %w", err)
221 }
228 }
222
229
223− // LSP sepecific Initialization
224− path := strings.ToLower(c.Cmd.Path)
225− switch {
226− case strings.Contains(path, "typescript-language-server"):
227− // err := initializeTypescriptLanguageServer(ctx, c, workspaceDir)
228− // if err != nil {
229− // return nil, err
230− // }
231− }
232−
233 return &result, nil
230 return &result, nil
234}
231}
235
232
@@ -273,10 +270,314 @@ const (
273 StateError
270 StateError
274)
271)
275
272
273+// GetServerState returns the current state of the LSP server
274+func (c *Client) GetServerState() ServerState {
275+ if val := c.serverState.Load(); val != nil {
276+ return val.(ServerState)
277+ }
278+ return StateStarting
279+}
280+
281+// SetServerState sets the current state of the LSP server
282+func (c *Client) SetServerState(state ServerState) {
283+ c.serverState.Store(state)
284+}
285+
286+// WaitForServerReady waits for the server to be ready by polling the server
287+// with a simple request until it responds successfully or times out
276func (c *Client) WaitForServerReady(ctx context.Context) error {
288func (c *Client) WaitForServerReady(ctx context.Context) error {
277− // TODO: wait for specific messages or poll workspace/symbol
289+ cnf := config.Get()
278− time.Sleep(time.Second * 1)
290+
279− return nil
291+ // Set initial state
292+ c.SetServerState(StateStarting)
293+
294+ // Create a context with timeout
295+ ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
296+ defer cancel()
297+
298+ // Try to ping the server with a simple request
299+ ticker := time.NewTicker(500 * time.Millisecond)
300+ defer ticker.Stop()
301+
302+ if cnf.DebugLSP {
303+ logging.Debug("Waiting for LSP server to be ready...")
304+ }
305+
306+ // Determine server type for specialized initialization
307+ serverType := c.detectServerType()
308+
309+ // For TypeScript-like servers, we need to open some key files first
310+ if serverType == ServerTypeTypeScript {
311+ if cnf.DebugLSP {
312+ logging.Debug("TypeScript-like server detected, opening key configuration files")
313+ }
314+ c.openKeyConfigFiles(ctx)
315+ }
316+
317+ for {
318+ select {
319+ case <-ctx.Done():
320+ c.SetServerState(StateError)
321+ return fmt.Errorf("timeout waiting for LSP server to be ready")
322+ case <-ticker.C:
323+ // Try a ping method appropriate for this server type
324+ err := c.pingServerByType(ctx, serverType)
325+ if err == nil {
326+ // Server responded successfully
327+ c.SetServerState(StateReady)
328+ if cnf.DebugLSP {
329+ logging.Debug("LSP server is ready")
330+ }
331+ return nil
332+ } else {
333+ logging.Debug("LSP server not ready yet", "error", err, "serverType", serverType)
334+ }
335+
336+ if cnf.DebugLSP {
337+ logging.Debug("LSP server not ready yet", "error", err, "serverType", serverType)
338+ }
339+ }
340+ }
341+}
342+
343+// ServerType represents the type of LSP server
344+type ServerType int
345+
346+const (
347+ ServerTypeUnknown ServerType = iota
348+ ServerTypeGo
349+ ServerTypeTypeScript
350+ ServerTypeRust
351+ ServerTypePython
352+ ServerTypeGeneric
353+)
354+
355+// detectServerType tries to determine what type of LSP server we're dealing with
356+func (c *Client) detectServerType() ServerType {
357+ if c.Cmd == nil {
358+ return ServerTypeUnknown
359+ }
360+
361+ cmdPath := strings.ToLower(c.Cmd.Path)
362+
363+ switch {
364+ case strings.Contains(cmdPath, "gopls"):
365+ return ServerTypeGo
366+ case strings.Contains(cmdPath, "typescript") || strings.Contains(cmdPath, "vtsls") || strings.Contains(cmdPath, "tsserver"):
367+ return ServerTypeTypeScript
368+ case strings.Contains(cmdPath, "rust-analyzer"):
369+ return ServerTypeRust
370+ case strings.Contains(cmdPath, "pyright") || strings.Contains(cmdPath, "pylsp") || strings.Contains(cmdPath, "python"):
371+ return ServerTypePython
372+ default:
373+ return ServerTypeGeneric
374+ }
375+}
376+
377+// openKeyConfigFiles opens important configuration files that help initialize the server
378+func (c *Client) openKeyConfigFiles(ctx context.Context) {
379+ workDir := config.WorkingDirectory()
380+ serverType := c.detectServerType()
381+
382+ var filesToOpen []string
383+
384+ switch serverType {
385+ case ServerTypeTypeScript:
386+ // TypeScript servers need these config files to properly initialize
387+ filesToOpen = []string{
388+ filepath.Join(workDir, "tsconfig.json"),
389+ filepath.Join(workDir, "package.json"),
390+ filepath.Join(workDir, "jsconfig.json"),
391+ }
392+
393+ // Also find and open a few TypeScript files to help the server initialize
394+ c.openTypeScriptFiles(ctx, workDir)
395+ case ServerTypeGo:
396+ filesToOpen = []string{
397+ filepath.Join(workDir, "go.mod"),
398+ filepath.Join(workDir, "go.sum"),
399+ }
400+ case ServerTypeRust:
401+ filesToOpen = []string{
402+ filepath.Join(workDir, "Cargo.toml"),
403+ filepath.Join(workDir, "Cargo.lock"),
404+ }
405+ }
406+
407+ // Try to open each file, ignoring errors if they don't exist
408+ for _, file := range filesToOpen {
409+ if _, err := os.Stat(file); err == nil {
410+ // File exists, try to open it
411+ if err := c.OpenFile(ctx, file); err != nil {
412+ logging.Debug("Failed to open key config file", "file", file, "error", err)
413+ } else {
414+ logging.Debug("Opened key config file for initialization", "file", file)
415+ }
416+ }
417+ }
418+}
419+
420+// pingServerByType sends a ping request appropriate for the server type
421+func (c *Client) pingServerByType(ctx context.Context, serverType ServerType) error {
422+ switch serverType {
423+ case ServerTypeTypeScript:
424+ // For TypeScript, try a document symbol request on an open file
425+ return c.pingTypeScriptServer(ctx)
426+ case ServerTypeGo:
427+ // For Go, workspace/symbol works well
428+ return c.pingWithWorkspaceSymbol(ctx)
429+ case ServerTypeRust:
430+ // For Rust, workspace/symbol works well
431+ return c.pingWithWorkspaceSymbol(ctx)
432+ default:
433+ // Default ping method
434+ return c.pingWithWorkspaceSymbol(ctx)
435+ }
436+}
437+
438+// pingTypeScriptServer tries to ping a TypeScript server with appropriate methods
439+func (c *Client) pingTypeScriptServer(ctx context.Context) error {
440+ // First try workspace/symbol which works for many servers
441+ if err := c.pingWithWorkspaceSymbol(ctx); err == nil {
442+ return nil
443+ }
444+
445+ // If that fails, try to find an open file and request document symbols
446+ c.openFilesMu.RLock()
447+ defer c.openFilesMu.RUnlock()
448+
449+ // If we have any open files, try to get document symbols for one
450+ for uri := range c.openFiles {
451+ filePath := strings.TrimPrefix(uri, "file://")
452+ if strings.HasSuffix(filePath, ".ts") || strings.HasSuffix(filePath, ".js") ||
453+ strings.HasSuffix(filePath, ".tsx") || strings.HasSuffix(filePath, ".jsx") {
454+ var symbols []protocol.DocumentSymbol
455+ err := c.Call(ctx, "textDocument/documentSymbol", protocol.DocumentSymbolParams{
456+ TextDocument: protocol.TextDocumentIdentifier{
457+ URI: protocol.DocumentUri(uri),
458+ },
459+ }, &symbols)
460+ if err == nil {
461+ return nil
462+ }
463+ }
464+ }
465+
466+ // If we have no open TypeScript files, try to find and open one
467+ workDir := config.WorkingDirectory()
468+ err := filepath.WalkDir(workDir, func(path string, d os.DirEntry, err error) error {
469+ if err != nil {
470+ return err
471+ }
472+
473+ // Skip directories and non-TypeScript files
474+ if d.IsDir() {
475+ return nil
476+ }
477+
478+ ext := filepath.Ext(path)
479+ if ext == ".ts" || ext == ".js" || ext == ".tsx" || ext == ".jsx" {
480+ // Found a TypeScript file, try to open it
481+ if err := c.OpenFile(ctx, path); err == nil {
482+ // Successfully opened, stop walking
483+ return filepath.SkipAll
484+ }
485+ }
486+
487+ return nil
488+ })
489+ if err != nil {
490+ logging.Debug("Error walking directory for TypeScript files", "error", err)
491+ }
492+
493+ // Final fallback - just try a generic capability
494+ return c.pingWithServerCapabilities(ctx)
495+}
496+
497+// openTypeScriptFiles finds and opens TypeScript files to help initialize the server
498+func (c *Client) openTypeScriptFiles(ctx context.Context, workDir string) {
499+ cnf := config.Get()
500+ filesOpened := 0
501+ maxFilesToOpen := 5 // Limit to a reasonable number of files
502+
503+ // Find and open TypeScript files
504+ err := filepath.WalkDir(workDir, func(path string, d os.DirEntry, err error) error {
505+ if err != nil {
506+ return err
507+ }
508+
509+ // Skip directories and non-TypeScript files
510+ if d.IsDir() {
511+ // Skip common directories to avoid wasting time
512+ if shouldSkipDir(path) {
513+ return filepath.SkipDir
514+ }
515+ return nil
516+ }
517+
518+ // Check if we've opened enough files
519+ if filesOpened >= maxFilesToOpen {
520+ return filepath.SkipAll
521+ }
522+
523+ // Check file extension
524+ ext := filepath.Ext(path)
525+ if ext == ".ts" || ext == ".tsx" || ext == ".js" || ext == ".jsx" {
526+ // Try to open the file
527+ if err := c.OpenFile(ctx, path); err == nil {
528+ filesOpened++
529+ if cnf.DebugLSP {
530+ logging.Debug("Opened TypeScript file for initialization", "file", path)
531+ }
532+ }
533+ }
534+
535+ return nil
536+ })
537+
538+ if err != nil && cnf.DebugLSP {
539+ logging.Debug("Error walking directory for TypeScript files", "error", err)
540+ }
541+
542+ if cnf.DebugLSP {
543+ logging.Debug("Opened TypeScript files for initialization", "count", filesOpened)
544+ }
545+}
546+
547+// shouldSkipDir returns true if the directory should be skipped during file search
548+func shouldSkipDir(path string) bool {
549+ dirName := filepath.Base(path)
550+
551+ // Skip hidden directories
552+ if strings.HasPrefix(dirName, ".") {
553+ return true
554+ }
555+
556+ // Skip common directories that won't contain relevant source files
557+ skipDirs := map[string]bool{
558+ "node_modules": true,
559+ "dist": true,
560+ "build": true,
561+ "coverage": true,
562+ "vendor": true,
563+ "target": true,
564+ }
565+
566+ return skipDirs[dirName]
567+}
568+
569+// pingWithWorkspaceSymbol tries a workspace/symbol request
570+func (c *Client) pingWithWorkspaceSymbol(ctx context.Context) error {
571+ var result []protocol.SymbolInformation
572+ return c.Call(ctx, "workspace/symbol", protocol.WorkspaceSymbolParams{
573+ Query: "",
574+ }, &result)
575+}
576+
577+// pingWithServerCapabilities tries to get server capabilities
578+func (c *Client) pingWithServerCapabilities(ctx context.Context) error {
579+ // This is a very lightweight request that should work for most servers
580+ return c.Notify(ctx, "$/cancelRequest", struct{ ID int }{ID: -1})
280}
581}
281
582
282type OpenFileInfo struct {
583type OpenFileInfo struct {
@@ -435,6 +736,43 @@ func (c *Client) GetFileDiagnostics(uri protocol.DocumentUri) []protocol.Diagnos
435 return c.diagnostics[uri]
736 return c.diagnostics[uri]
436}
737}
437
738
739+// GetDiagnostics returns all diagnostics for all files
438func (c *Client) GetDiagnostics() map[protocol.DocumentUri][]protocol.Diagnostic {
740func (c *Client) GetDiagnostics() map[protocol.DocumentUri][]protocol.Diagnostic {
439 return c.diagnostics
741 return c.diagnostics
440}
742}
743+
744+// OpenFileOnDemand opens a file only if it's not already open
745+// This is used for lazy-loading files when they're actually needed
746+func (c *Client) OpenFileOnDemand(ctx context.Context, filepath string) error {
747+ // Check if the file is already open
748+ if c.IsFileOpen(filepath) {
749+ return nil
750+ }
751+
752+ // Open the file
753+ return c.OpenFile(ctx, filepath)
754+}
755+
756+// GetDiagnosticsForFile ensures a file is open and returns its diagnostics
757+// This is useful for on-demand diagnostics when using lazy loading
758+func (c *Client) GetDiagnosticsForFile(ctx context.Context, filepath string) ([]protocol.Diagnostic, error) {
759+ uri := fmt.Sprintf("file://%s", filepath)
760+ documentUri := protocol.DocumentUri(uri)
761+
762+ // Make sure the file is open
763+ if !c.IsFileOpen(filepath) {
764+ if err := c.OpenFile(ctx, filepath); err != nil {
765+ return nil, fmt.Errorf("failed to open file for diagnostics: %w", err)
766+ }
767+
768+ // Give the LSP server a moment to process the file
769+ time.Sleep(100 * time.Millisecond)
770+ }
771+
772+ // Get diagnostics
773+ c.diagnosticsMu.RLock()
774+ diagnostics := c.diagnostics[documentUri]
775+ c.diagnosticsMu.RUnlock()
776+
777+ return diagnostics, nil
778+}
internal/lsp/watcher/watcher.go+377−51
@@ -9,6 +9,7 @@ import (
9 "sync"
9 "sync"
10 "time"
10 "time"
11
11
12+ "github.com/bmatcuk/doublestar/v4"
12 "github.com/fsnotify/fsnotify"
13 "github.com/fsnotify/fsnotify"
13 "github.com/kujtimiihoxha/opencode/internal/config"
14 "github.com/kujtimiihoxha/opencode/internal/config"
14 "github.com/kujtimiihoxha/opencode/internal/logging"
15 "github.com/kujtimiihoxha/opencode/internal/logging"
@@ -43,6 +44,8 @@ func NewWorkspaceWatcher(client *lsp.Client) *WorkspaceWatcher {
43// AddRegistrations adds file watchers to track
44// AddRegistrations adds file watchers to track
44func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) {
45func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) {
45 cnf := config.Get()
46 cnf := config.Get()
47+
48+ logging.Debug("Adding file watcher registrations")
46 w.registrationMu.Lock()
49 w.registrationMu.Lock()
47 defer w.registrationMu.Unlock()
50 defer w.registrationMu.Unlock()
48
51
@@ -55,7 +58,6 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
55 "id", id,
58 "id", id,
56 "watchers", len(watchers),
59 "watchers", len(watchers),
57 "total", len(w.registrations),
60 "total", len(w.registrations),
58− "watchers", watchers,
59 )
61 )
60
62
61 for i, watcher := range watchers {
63 for i, watcher := range watchers {
@@ -88,66 +90,217 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
88 }
90 }
89
91
90 logging.Debug("WatchKind", "kind", watchKind)
92 logging.Debug("WatchKind", "kind", watchKind)
91−
92− // Test match against some example paths
93− testPaths := []string{
94− "/Users/phil/dev/mcp-language-server/internal/watcher/watcher.go",
95− "/Users/phil/dev/mcp-language-server/go.mod",
96− }
97−
98− for _, testPath := range testPaths {
99− isMatch := w.matchesPattern(testPath, watcher.GlobPattern)
100− logging.Debug("Test path", "path", testPath, "matches", isMatch)
101− }
102 }
93 }
103 }
94 }
104
95
105− // Find and open all existing files that match the newly registered patterns
96+ // Determine server type for specialized handling
106− // TODO: not all language servers require this, but typescript does. Make this configurable
97+ serverName := getServerNameFromContext(ctx)
98+ logging.Debug("Server type detected", "serverName", serverName)
99+
100+ // Check if this server has sent file watchers
101+ hasFileWatchers := len(watchers) > 0
102+
103+ // For servers that need file preloading, we'll use a smart approach
104+ if shouldPreloadFiles(serverName) || !hasFileWatchers {
107− go func() {
105+ go func() {
108− startTime := time.Now()
106+ startTime := time.Now()
109− filesOpened := 0
107+ filesOpened := 0
110−
108+
111− err := filepath.WalkDir(w.workspacePath, func(path string, d os.DirEntry, err error) error {
109+ // Determine max files to open based on server type
112− if err != nil {
110+ maxFilesToOpen := 50 // Default conservative limit
113− return err
111+
112+ switch serverName {
113+ case "typescript", "typescript-language-server", "tsserver", "vtsls":
114+ // TypeScript servers benefit from seeing more files
115+ maxFilesToOpen = 100
116+ case "java", "jdtls":
117+ // Java servers need to see many files for project model
118+ maxFilesToOpen = 200
119+ }
120+
121+ // First, open high-priority files
122+ highPriorityFilesOpened := w.openHighPriorityFiles(ctx, serverName)
123+ filesOpened += highPriorityFilesOpened
124+
125+ if cnf.DebugLSP {
126+ logging.Debug("Opened high-priority files",
127+ "count", highPriorityFilesOpened,
128+ "serverName", serverName)
114 }
129 }
130+
131+ // If we've already opened enough high-priority files, we might not need more
132+ if filesOpened >= maxFilesToOpen {
133+ if cnf.DebugLSP {
134+ logging.Debug("Reached file limit with high-priority files",
135+ "filesOpened", filesOpened,
136+ "maxFiles", maxFilesToOpen)
137+ }
138+ return
139+ }
140+
141+ // For the remaining slots, walk the directory and open matching files
142+
143+ err := filepath.WalkDir(w.workspacePath, func(path string, d os.DirEntry, err error) error {
144+ if err != nil {
145+ return err
146+ }
115
147
116− // Skip directories that should be excluded
148+ // Skip directories that should be excluded
117− if d.IsDir() {
149+ if d.IsDir() {
118− if path != w.workspacePath && shouldExcludeDir(path) {
150+ if path != w.workspacePath && shouldExcludeDir(path) {
119− if cnf.DebugLSP {
151+ if cnf.DebugLSP {
120− logging.Debug("Skipping excluded directory", "path", path)
152+ logging.Debug("Skipping excluded directory", "path", path)
153+ }
154+ return filepath.SkipDir
155+ }
156+ } else {
157+ // Process files, but limit the total number
158+ if filesOpened < maxFilesToOpen {
159+ // Only process if it's not already open (high-priority files were opened earlier)
160+ if !w.client.IsFileOpen(path) {
161+ w.openMatchingFile(ctx, path)
162+ filesOpened++
163+
164+ // Add a small delay after every 10 files to prevent overwhelming the server
165+ if filesOpened%10 == 0 {
166+ time.Sleep(50 * time.Millisecond)
167+ }
168+ }
169+ } else {
170+ // We've reached our limit, stop walking
171+ return filepath.SkipAll
121 }
172 }
122− return filepath.SkipDir
123 }
173 }
124− } else {
125− // Process files
126− w.openMatchingFile(ctx, path)
127− filesOpened++
128
174
129− // Add a small delay after every 100 files to prevent overwhelming the server
175+ return nil
130− if filesOpened%100 == 0 {
176+ })
131− time.Sleep(10 * time.Millisecond)
177+
132− }
178+ elapsedTime := time.Since(startTime)
179+ if cnf.DebugLSP {
180+ logging.Debug("Limited workspace scan complete",
181+ "filesOpened", filesOpened,
182+ "maxFiles", maxFilesToOpen,
183+ "elapsedTime", elapsedTime.Seconds(),
184+ "workspacePath", w.workspacePath,
185+ )
133 }
186 }
134
187
135− return nil
188+ if err != nil && cnf.DebugLSP {
189+ logging.Debug("Error scanning workspace for files to open", "error", err)
190+ }
136− })
191+ }()
192+ } else if cnf.DebugLSP {
193+ logging.Debug("Using on-demand file loading for server", "server", serverName)
194+ }
195+}
137
196
138− elapsedTime := time.Since(startTime)
197+// openHighPriorityFiles opens important files for the server type
139− if cnf.DebugLSP {
198+// Returns the number of files opened
140− logging.Debug("Workspace scan complete",
199+func (w *WorkspaceWatcher) openHighPriorityFiles(ctx context.Context, serverName string) int {
141− "filesOpened", filesOpened,
200+ cnf := config.Get()
142− "elapsedTime", elapsedTime.Seconds(),
201+ filesOpened := 0
143− "workspacePath", w.workspacePath,
202+
144− )
203+ // Define patterns for high-priority files based on server type
204+ var patterns []string
205+
206+ switch serverName {
207+ case "typescript", "typescript-language-server", "tsserver", "vtsls":
208+ patterns = []string{
209+ "**/tsconfig.json",
210+ "**/package.json",
211+ "**/jsconfig.json",
212+ "**/index.ts",
213+ "**/index.js",
214+ "**/main.ts",
215+ "**/main.js",
145 }
216 }
146−
217+ case "gopls":
147− if err != nil && cnf.DebugLSP {
218+ patterns = []string{
148− logging.Debug("Error scanning workspace for files to open", "error", err)
219+ "**/go.mod",
220+ "**/go.sum",
221+ "**/main.go",
222+ }
223+ case "rust-analyzer":
224+ patterns = []string{
225+ "**/Cargo.toml",
226+ "**/Cargo.lock",
227+ "**/src/lib.rs",
228+ "**/src/main.rs",
229+ }
230+ case "python", "pyright", "pylsp":
231+ patterns = []string{
232+ "**/pyproject.toml",
233+ "**/setup.py",
234+ "**/requirements.txt",
235+ "**/__init__.py",
236+ "**/__main__.py",
237+ }
238+ case "clangd":
239+ patterns = []string{
240+ "**/CMakeLists.txt",
241+ "**/Makefile",
242+ "**/compile_commands.json",
243+ }
244+ case "java", "jdtls":
245+ patterns = []string{
246+ "**/pom.xml",
247+ "**/build.gradle",
248+ "**/src/main/java/**/*.java",
149 }
249 }
250+ default:
251+ // For unknown servers, use common configuration files
252+ patterns = []string{
253+ "**/package.json",
254+ "**/Makefile",
255+ "**/CMakeLists.txt",
256+ "**/.editorconfig",
150− }()
257+ }
258+ }
259+
260+ // For each pattern, find and open matching files
261+ for _, pattern := range patterns {
262+ // Use doublestar.Glob to find files matching the pattern (supports ** patterns)
263+ matches, err := doublestar.Glob(os.DirFS(w.workspacePath), pattern)
264+ if err != nil {
265+ if cnf.DebugLSP {
266+ logging.Debug("Error finding high-priority files", "pattern", pattern, "error", err)
267+ }
268+ continue
269+ }
270+
271+ for _, match := range matches {
272+ // Convert relative path to absolute
273+ fullPath := filepath.Join(w.workspacePath, match)
274+
275+ // Skip directories and excluded files
276+ info, err := os.Stat(fullPath)
277+ if err != nil || info.IsDir() || shouldExcludeFile(fullPath) {
278+ continue
279+ }
280+
281+ // Open the file
282+ if err := w.client.OpenFile(ctx, fullPath); err != nil {
283+ if cnf.DebugLSP {
284+ logging.Debug("Error opening high-priority file", "path", fullPath, "error", err)
285+ }
286+ } else {
287+ filesOpened++
288+ if cnf.DebugLSP {
289+ logging.Debug("Opened high-priority file", "path", fullPath)
290+ }
291+ }
292+
293+ // Add a small delay to prevent overwhelming the server
294+ time.Sleep(20 * time.Millisecond)
295+
296+ // Limit the number of files opened per pattern
297+ if filesOpened >= 5 && (serverName != "java" && serverName != "jdtls") {
298+ break
299+ }
300+ }
301+ }
302+
303+ return filesOpened
151}
304}
152
305
153// WatchWorkspace sets up file watching for a workspace
306// WatchWorkspace sets up file watching for a workspace
@@ -155,6 +308,18 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
155 cnf := config.Get()
308 cnf := config.Get()
156 w.workspacePath = workspacePath
309 w.workspacePath = workspacePath
157
310
311+ // Store the watcher in the context for later use
312+ ctx = context.WithValue(ctx, "workspaceWatcher", w)
313+
314+ // If the server name isn't already in the context, try to detect it
315+ if _, ok := ctx.Value("serverName").(string); !ok {
316+ serverName := getServerNameFromContext(ctx)
317+ ctx = context.WithValue(ctx, "serverName", serverName)
318+ }
319+
320+ serverName := getServerNameFromContext(ctx)
321+ logging.Debug("Starting workspace watcher", "workspacePath", workspacePath, "serverName", serverName)
322+
158 // Register handler for file watcher registrations from the server
323 // Register handler for file watcher registrations from the server
159 lsp.RegisterFileWatchHandler(func(id string, watchers []protocol.FileSystemWatcher) {
324 lsp.RegisterFileWatchHandler(func(id string, watchers []protocol.FileSystemWatcher) {
160 w.AddRegistrations(ctx, id, watchers)
325 w.AddRegistrations(ctx, id, watchers)
@@ -510,6 +675,57 @@ func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, chan
510 return w.client.DidChangeWatchedFiles(ctx, params)
675 return w.client.DidChangeWatchedFiles(ctx, params)
511}
676}
512
677
678+// getServerNameFromContext extracts the server name from the context
679+// This is a best-effort function that tries to identify which LSP server we're dealing with
680+func getServerNameFromContext(ctx context.Context) string {
681+ // First check if the server name is directly stored in the context
682+ if serverName, ok := ctx.Value("serverName").(string); ok && serverName != "" {
683+ return strings.ToLower(serverName)
684+ }
685+
686+ // Otherwise, try to extract server name from the client command path
687+ if w, ok := ctx.Value("workspaceWatcher").(*WorkspaceWatcher); ok && w != nil && w.client != nil && w.client.Cmd != nil {
688+ path := strings.ToLower(w.client.Cmd.Path)
689+
690+ // Extract server name from path
691+ if strings.Contains(path, "typescript") || strings.Contains(path, "tsserver") || strings.Contains(path, "vtsls") {
692+ return "typescript"
693+ } else if strings.Contains(path, "gopls") {
694+ return "gopls"
695+ } else if strings.Contains(path, "rust-analyzer") {
696+ return "rust-analyzer"
697+ } else if strings.Contains(path, "pyright") || strings.Contains(path, "pylsp") || strings.Contains(path, "python") {
698+ return "python"
699+ } else if strings.Contains(path, "clangd") {
700+ return "clangd"
701+ } else if strings.Contains(path, "jdtls") || strings.Contains(path, "java") {
702+ return "java"
703+ }
704+
705+ // Return the base name as fallback
706+ return filepath.Base(path)
707+ }
708+
709+ return "unknown"
710+}
711+
712+// shouldPreloadFiles determines if we should preload files for a specific language server
713+// Some servers work better with preloaded files, others don't need it
714+func shouldPreloadFiles(serverName string) bool {
715+ // TypeScript/JavaScript servers typically need some files preloaded
716+ // to properly resolve imports and provide intellisense
717+ switch serverName {
718+ case "typescript", "typescript-language-server", "tsserver", "vtsls":
719+ return true
720+ case "java", "jdtls":
721+ // Java servers often need to see source files to build the project model
722+ return true
723+ default:
724+ // For most servers, we'll use lazy loading by default
725+ return false
726+ }
727+}
728+
513// Common patterns for directories and files to exclude
729// Common patterns for directories and files to exclude
514// TODO: make configurable
730// TODO: make configurable
515var (
731var (
@@ -647,9 +863,119 @@ func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
647
863
648 // Check if this path should be watched according to server registrations
864 // Check if this path should be watched according to server registrations
649 if watched, _ := w.isPathWatched(path); watched {
865 if watched, _ := w.isPathWatched(path); watched {
866+ // Get server name for specialized handling
867+ serverName := getServerNameFromContext(ctx)
868+
869+ // Check if the file is a high-priority file that should be opened immediately
870+ // This helps with project initialization for certain language servers
871+ if isHighPriorityFile(path, serverName) {
872+ if cnf.DebugLSP {
873+ logging.Debug("Opening high-priority file", "path", path, "serverName", serverName)
874+ }
875+ if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP {
876+ logging.Error("Error opening high-priority file", "path", path, "error", err)
877+ }
878+ return
879+ }
880+
881+ // For non-high-priority files, we'll use different strategies based on server type
882+ if shouldPreloadFiles(serverName) {
883+ // For servers that benefit from preloading, open files but with limits
884+
885+ // Check file size - for preloading we're more conservative
886+ if info.Size() > (1 * 1024 * 1024) { // 1MB limit for preloaded files
887+ if cnf.DebugLSP {
888+ logging.Debug("Skipping large file for preloading", "path", path, "size", info.Size())
889+ }
890+ return
891+ }
892+
893+ // Check file extension for common source files
894+ ext := strings.ToLower(filepath.Ext(path))
895+
896+ // Only preload source files for the specific language
897+ shouldOpen := false
898+
899+ switch serverName {
900+ case "typescript", "typescript-language-server", "tsserver", "vtsls":
901+ shouldOpen = ext == ".ts" || ext == ".js" || ext == ".tsx" || ext == ".jsx"
902+ case "gopls":
903+ shouldOpen = ext == ".go"
904+ case "rust-analyzer":
905+ shouldOpen = ext == ".rs"
906+ case "python", "pyright", "pylsp":
907+ shouldOpen = ext == ".py"
908+ case "clangd":
909+ shouldOpen = ext == ".c" || ext == ".cpp" || ext == ".h" || ext == ".hpp"
910+ case "java", "jdtls":
911+ shouldOpen = ext == ".java"
912+ default:
913+ // For unknown servers, be conservative
914+ shouldOpen = false
915+ }
916+
917+ if shouldOpen {
650− // Don't need to check if it's already open - the client.OpenFile handles that
918+ // Don't need to check if it's already open - the client.OpenFile handles that
651− if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP {
919+ if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP {
652− logging.Error("Error opening file", "path", path, "error", err)
920+ logging.Error("Error opening file", "path", path, "error", err)
921+ }
922+ }
653 }
923 }
654 }
924 }
655}
925}
926+
927+// isHighPriorityFile determines if a file should be opened immediately
928+// regardless of the preloading strategy
929+func isHighPriorityFile(path string, serverName string) bool {
930+ fileName := filepath.Base(path)
931+ ext := filepath.Ext(path)
932+
933+ switch serverName {
934+ case "typescript", "typescript-language-server", "tsserver", "vtsls":
935+ // For TypeScript, we want to open configuration files immediately
936+ return fileName == "tsconfig.json" ||
937+ fileName == "package.json" ||
938+ fileName == "jsconfig.json" ||
939+ // Also open main entry points
940+ fileName == "index.ts" ||
941+ fileName == "index.js" ||
942+ fileName == "main.ts" ||
943+ fileName == "main.js"
944+ case "gopls":
945+ // For Go, we want to open go.mod files immediately
946+ return fileName == "go.mod" ||
947+ fileName == "go.sum" ||
948+ // Also open main.go files
949+ fileName == "main.go"
950+ case "rust-analyzer":
951+ // For Rust, we want to open Cargo.toml files immediately
952+ return fileName == "Cargo.toml" ||
953+ fileName == "Cargo.lock" ||
954+ // Also open lib.rs and main.rs
955+ fileName == "lib.rs" ||
956+ fileName == "main.rs"
957+ case "python", "pyright", "pylsp":
958+ // For Python, open key project files
959+ return fileName == "pyproject.toml" ||
960+ fileName == "setup.py" ||
961+ fileName == "requirements.txt" ||
962+ fileName == "__init__.py" ||
963+ fileName == "__main__.py"
964+ case "clangd":
965+ // For C/C++, open key project files
966+ return fileName == "CMakeLists.txt" ||
967+ fileName == "Makefile" ||
968+ fileName == "compile_commands.json"
969+ case "java", "jdtls":
970+ // For Java, open key project files
971+ return fileName == "pom.xml" ||
972+ fileName == "build.gradle" ||
973+ ext == ".java" // Java servers often need to see source files
974+ }
975+
976+ // For unknown servers, prioritize common configuration files
977+ return fileName == "package.json" ||
978+ fileName == "Makefile" ||
979+ fileName == "CMakeLists.txt" ||
980+ fileName == ".editorconfig"
981+}
internal/tui/components/chat/chat.go+10−2
@@ -2,6 +2,7 @@ package chat
2
2
3import (
3import (
4 "fmt"
4 "fmt"
5+ "sort"
5
6
6 "github.com/charmbracelet/lipgloss"
7 "github.com/charmbracelet/lipgloss"
7 "github.com/charmbracelet/x/ansi"
8 "github.com/charmbracelet/x/ansi"
@@ -28,8 +29,16 @@ func lspsConfigured(width int) string {
28
29
29 lsps := styles.BaseStyle.Width(width).Foreground(styles.PrimaryColor).Bold(true).Render(title)
30 lsps := styles.BaseStyle.Width(width).Foreground(styles.PrimaryColor).Bold(true).Render(title)
30
31
32+ // Get LSP names and sort them for consistent ordering
33+ var lspNames []string
34+ for name := range cfg.LSP {
35+ lspNames = append(lspNames, name)
36+ }
37+ sort.Strings(lspNames)
38+
31 var lspViews []string
39 var lspViews []string
40+ for _, name := range lspNames {
32− for name, lsp := range cfg.LSP {
41+ lsp := cfg.LSP[name]
33 lspName := styles.BaseStyle.Foreground(styles.Forground).Render(
42 lspName := styles.BaseStyle.Foreground(styles.Forground).Render(
34 fmt.Sprintf("• %s", name),
43 fmt.Sprintf("• %s", name),
35 )
44 )
@@ -49,7 +58,6 @@ func lspsConfigured(width int) string {
49 ),
58 ),
50 ),
59 ),
51 )
60 )
52−
53 }
61 }
54 return styles.BaseStyle.
62 return styles.BaseStyle.
55 Width(width).
63 Width(width).
internal/tui/components/chat/list.go+1−8
@@ -376,14 +376,7 @@ func (m *messagesCmp) working() string {
376 if hasToolsWithoutResponse(m.messages) {
376 if hasToolsWithoutResponse(m.messages) {
377 task = "Waiting for tool response..."
377 task = "Waiting for tool response..."
378 } else if !lastMessage.IsFinished() {
378 } else if !lastMessage.IsFinished() {
379− lastUpdate := lastMessage.UpdatedAt
380− currentTime := time.Now().Unix()
381− if lastMessage.Content().String() != "" && lastUpdate != 0 && currentTime-lastUpdate > 5 {
382− task = "Building tool call..."
383− } else if lastMessage.Content().String() == "" {
384− task = "Generating..."
379+ task = "Generating..."
385− }
386− task = ""
387 }
380 }
388 if task != "" {
381 if task != "" {
389 text += styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Bold(true).Render(
382 text += styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Bold(true).Render(
internal/tui/components/chat/message.go+5−1
@@ -151,7 +151,11 @@ func renderAssistantMessage(
151 ))
151 ))
152 }
152 }
153 }
153 }
154− if content != "" {
154+ if content != "" || (finished && finishData.Reason == message.FinishReasonEndTurn) {
155+ if content == "" {
156+ content = "*Finished without output*"
157+ }
158+
155 content = renderMessage(content, false, msg.ID == focusedUIMessageId, width, info...)
159 content = renderMessage(content, false, msg.ID == focusedUIMessageId, width, info...)
156 messages = append(messages, uiMessage{
160 messages = append(messages, uiMessage{
157 ID: msg.ID,
161 ID: msg.ID,
internal/tui/components/core/status.go+17
@@ -138,6 +138,23 @@ func (m statusCmp) View() string {
138}
138}
139
139
140func (m *statusCmp) projectDiagnostics() string {
140func (m *statusCmp) projectDiagnostics() string {
141+ // Check if any LSP server is still initializing
142+ initializing := false
143+ for _, client := range m.lspClients {
144+ if client.GetServerState() == lsp.StateStarting {
145+ initializing = true
146+ break
147+ }
148+ }
149+
150+ // If any server is initializing, show that status
151+ if initializing {
152+ return lipgloss.NewStyle().
153+ Background(styles.BackgroundDarker).
154+ Foreground(styles.Peach).
155+ Render(fmt.Sprintf("%s Initializing LSP...", styles.SpinnerIcon))
156+ }
157+
141 errorDiagnostics := []protocol.Diagnostic{}
158 errorDiagnostics := []protocol.Diagnostic{}
142 warnDiagnostics := []protocol.Diagnostic{}
159 warnDiagnostics := []protocol.Diagnostic{}
143 hintDiagnostics := []protocol.Diagnostic{}
160 hintDiagnostics := []protocol.Diagnostic{}
internal/tui/styles/icons.go+3−2
@@ -6,7 +6,8 @@ const (
6 CheckIcon string = "✓"
6 CheckIcon string = "✓"
7 ErrorIcon string = "✖"
7 ErrorIcon string = "✖"
8 WarningIcon string = "⚠"
8 WarningIcon string = "⚠"
9− InfoIcon string = ""
9+ InfoIcon string = ""
10 HintIcon string = "i"
10 HintIcon string = "i"
11 SpinnerIcon string = "..."
11 SpinnerIcon string = "..."
12+ LoadingIcon string = "⟳"
12−)
13+)