on main help sign in
lsp improvements
commit2b5a33e476ae3c6b5c6345777d20792786836dda authorKujtim Hoxha <kujtimii.h@gmail.com> dateSat 19 Apr 2025, 15:15 (2 years ago) parentbf8cd3b
15 files changed+921−126
internal/app/app.go+2−1 internal/app/lsp.go+25−4 internal/config/config.go+6−6 internal/llm/models/gemini.go+63 internal/llm/models/models.go+1−4 internal/llm/prompt/coder.go+2 internal/llm/provider/gemini.go−1 internal/llm/tools/grep.go+58−33 internal/lsp/client.go+351−13 internal/lsp/watcher/watcher.go+377−51 internal/tui/components/chat/chat.go+10−2 internal/tui/components/chat/list.go+1−8 internal/tui/components/chat/message.go+5−1 internal/tui/components/core/status.go+17 internal/tui/styles/icons.go+3−2
internal/app/app.go+2−1
@@ -49,7 +49,8 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) {
4949 LSPClients: make(map[string]*lsp.Client),
5050 }
5151
52− app.initLSPClients(ctx)
52+ // Initialize LSP clients in the background
53+ go app.initLSPClients(ctx)
5354
5455 var err error
5556 app.CoderAgent, err = agent.NewAgent(
internal/app/lsp.go+25−4
@@ -15,24 +15,28 @@ func (app *App) initLSPClients(ctx context.Context) {
1515
1616 // Initialize LSP clients
1717 for name, clientConfig := range cfg.LSP {
18− app.createAndStartLSPClient(ctx, name, clientConfig.Command, clientConfig.Args...)
18+ // Start each client initialization in its own goroutine
19+ go app.createAndStartLSPClient(ctx, name, clientConfig.Command, clientConfig.Args...)
1920 }
21+ logging.Info("LSP clients initialization started in background")
2022}
2123
2224// createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher
2325func (app *App) createAndStartLSPClient(ctx context.Context, name string, command string, args ...string) {
2426 // Create a specific context for initialization with a timeout
25−
27+ logging.Info("Creating LSP client", "name", name, "command", command, "args", args)
28+
2629 // Create the LSP client
2730 lspClient, err := lsp.NewClient(ctx, command, args...)
2831 if err != nil {
2932 logging.Error("Failed to create LSP client for", name, err)
3033 return
31−
3234 }
3335
34− initCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
36+ // Create a longer timeout for initialization (some servers take time to start)
37+ initCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
3538 defer cancel()
39+
3640 // Initialize with the initialization context
3741 _, err = lspClient.InitializeLSPClient(initCtx, config.WorkingDirectory())
3842 if err != nil {
@@ -42,8 +46,25 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman
4246 return
4347 }
4448
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+
4561 // Create a child context that can be canceled when the app is shutting down
4662 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
4768 workspaceWatcher := watcher.NewWorkspaceWatcher(lspClient)
4869
4970 // Store the cancel function to be called during cleanup
internal/config/config.go+6−6
@@ -209,17 +209,17 @@ func setProviderDefaults() {
209209 // Google Gemini configuration
210210 if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
211211 viper.SetDefault("providers.gemini.apiKey", apiKey)
212− viper.SetDefault("agents.coder.model", models.GRMINI20Flash)
213− viper.SetDefault("agents.task.model", models.GRMINI20Flash)
214− viper.SetDefault("agents.title.model", models.GRMINI20Flash)
212+ viper.SetDefault("agents.coder.model", models.Gemini25)
213+ viper.SetDefault("agents.task.model", models.Gemini25Flash)
214+ viper.SetDefault("agents.title.model", models.Gemini25Flash)
215215 }
216216
217217 // OpenAI configuration
218218 if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
219219 viper.SetDefault("providers.openai.apiKey", apiKey)
220− viper.SetDefault("agents.coder.model", models.GPT4o)
221− viper.SetDefault("agents.task.model", models.GPT4o)
222− viper.SetDefault("agents.title.model", models.GPT4o)
220+ viper.SetDefault("agents.coder.model", models.GPT41)
221+ viper.SetDefault("agents.task.model", models.GPT41Mini)
222+ viper.SetDefault("agents.title.model", models.GPT41Mini)
223223
224224 }
225225
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 {
2323
2424// Model IDs
2525const ( // GEMINI
26− GEMINI25 ModelID = "gemini-2.5"
27− GRMINI20Flash ModelID = "gemini-2.0-flash"
28−
2926 // GROQ
3027 QWENQwq ModelID = "qwen-qwq"
3128
@@ -35,7 +32,6 @@ const ( // GEMINI
3532
3633const (
3734 ProviderBedrock ModelProvider = "bedrock"
38− ProviderGemini ModelProvider = "gemini"
3935 ProviderGROQ ModelProvider = "groq"
4036
4137 // ForTests
@@ -95,4 +91,5 @@ var SupportedModels = map[ModelID]Model{
9591func init() {
9692 maps.Copy(SupportedModels, AnthropicModels)
9793 maps.Copy(SupportedModels, OpenAIModels)
94+ maps.Copy(SupportedModels, GeminiModels)
9895}
internal/llm/prompt/coder.go+2
@@ -68,6 +68,7 @@ You MUST adhere to the following criteria when executing the task:
6868 - Do NOT show the full contents of large files you have already written, unless the user explicitly asks for them.
6969- 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.
7070- 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
7172`
7273
7374const 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
162163# Tool usage policy
163164- When doing file search, prefer to use the Agent tool in order to reduce context usage.
164165- 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.
165167
166168You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.`
167169
internal/llm/provider/gemini.go−1
@@ -567,4 +567,3 @@ func contains(s string, substrs ...string) bool {
567567 }
568568 return false
569569}
570−
internal/llm/tools/grep.go+58−33
@@ -10,6 +10,7 @@ import (
1010 "path/filepath"
1111 "regexp"
1212 "sort"
13+ "strconv"
1314 "strings"
1415 "time"
1516
@@ -24,8 +25,10 @@ type GrepParams struct {
2425}
2526
2627type grepMatch struct {
27− path string
28− modTime time.Time
28+ path string
29+ modTime time.Time
30+ lineNum int
31+ lineText string
2932}
3033
3134type GrepResponseMetadata struct {
@@ -147,13 +150,26 @@ func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
147150 if len(matches) == 0 {
148151 output = "No files found"
149152 } else {
150− output = fmt.Sprintf("Found %d file%s\n%s",
151− len(matches),
152− pluralize(len(matches)),
153− strings.Join(matches, "\n"))
153+ output = fmt.Sprintf("Found %d matches\n", len(matches))
154+
155+ currentFile := ""
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+ }
154170
155171 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.)"
157173 }
158174 }
159175
@@ -166,14 +182,7 @@ func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
166182 ), nil
167183}
168184
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) {
177186 matches, err := searchWithRipgrep(pattern, rootPath, include)
178187 if err != nil {
179188 matches, err = searchFilesWithRegex(pattern, rootPath, include)
@@ -191,12 +200,7 @@ func searchFiles(pattern, rootPath, include string, limit int) ([]string, bool,
191200 matches = matches[:limit]
192201 }
193202
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
200204}
201205
202206func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) {
@@ -205,7 +209,8 @@ func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) {
205209 return nil, fmt.Errorf("ripgrep not found: %w", err)
206210 }
207211
208− args := []string{"-l", pattern}
212+ // Use -n to show line numbers and include the matched line
213+ args := []string{"-n", pattern}
209214 if include != "" {
210215 args = append(args, "--glob", include)
211216 }
@@ -228,14 +233,29 @@ func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) {
228233 continue
229234 }
230235
231− fileInfo, err := os.Stat(line)
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+
249+ fileInfo, err := os.Stat(filePath)
232250 if err != nil {
233251 continue // Skip files we can't access
234252 }
235253
236254 matches = append(matches, grepMatch{
237− path: line,
238− modTime: fileInfo.ModTime(),
255+ path: filePath,
256+ modTime: fileInfo.ModTime(),
257+ lineNum: lineNum,
258+ lineText: lineText,
239259 })
240260 }
241261
@@ -276,15 +296,17 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
276296 return nil
277297 }
278298
279− match, err := fileContainsPattern(path, regex)
299+ match, lineNum, lineText, err := fileContainsPattern(path, regex)
280300 if err != nil {
281301 return nil // Skip files we can't read
282302 }
283303
284304 if match {
285305 matches = append(matches, grepMatch{
286− path: path,
287− modTime: info.ModTime(),
306+ path: path,
307+ modTime: info.ModTime(),
308+ lineNum: lineNum,
309+ lineText: lineText,
288310 })
289311
290312 if len(matches) >= 200 {
@@ -301,21 +323,24 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
301323 return matches, nil
302324}
303325
304−func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, error) {
326+func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, string, error) {
305327 file, err := os.Open(filePath)
306328 if err != nil {
307− return false, err
329+ return false, 0, "", err
308330 }
309331 defer file.Close()
310332
311333 scanner := bufio.NewScanner(file)
334+ lineNum := 0
312335 for scanner.Scan() {
313− if pattern.MatchString(scanner.Text()) {
314− return true, nil
336+ lineNum++
337+ line := scanner.Text()
338+ if pattern.MatchString(line) {
339+ return true, lineNum, line, nil
315340 }
316341 }
317342
318− return false, scanner.Err()
343+ return false, 0, "", scanner.Err()
319344}
320345
321346func globToRegex(glob string) string {
internal/lsp/client.go+351−13
@@ -8,6 +8,7 @@ import (
88 "io"
99 "os"
1010 "os/exec"
11+ "path/filepath"
1112 "strings"
1213 "sync"
1314 "sync/atomic"
@@ -46,6 +47,9 @@ type Client struct {
4647 // Files are currently opened by the LSP
4748 openFiles map[string]*OpenFileInfo
4849 openFilesMu sync.RWMutex
50+
51+ // Server state
52+ serverState atomic.Value
4953}
5054
5155func 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
8084 openFiles: make(map[string]*OpenFileInfo),
8185 }
8286
87+ // Initialize server state
88+ client.serverState.Store(StateStarting)
89+
8390 // Start the LSP server process
8491 if err := cmd.Start(); err != nil {
8592 return nil, fmt.Errorf("failed to start LSP server: %w", err)
@@ -220,16 +227,6 @@ func (c *Client) InitializeLSPClient(ctx context.Context, workspaceDir string) (
220227 return nil, fmt.Errorf("initialization failed: %w", err)
221228 }
222229
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−
233230 return &result, nil
234231}
235232
@@ -273,10 +270,314 @@ const (
273270 StateError
274271)
275272
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
276288func (c *Client) WaitForServerReady(ctx context.Context) error {
277− // TODO: wait for specific messages or poll workspace/symbol
278− time.Sleep(time.Second * 1)
279− return nil
289+ cnf := config.Get()
290+
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})
280581}
281582
282583type OpenFileInfo struct {
@@ -435,6 +736,43 @@ func (c *Client) GetFileDiagnostics(uri protocol.DocumentUri) []protocol.Diagnos
435736 return c.diagnostics[uri]
436737}
437738
739+// GetDiagnostics returns all diagnostics for all files
438740func (c *Client) GetDiagnostics() map[protocol.DocumentUri][]protocol.Diagnostic {
439741 return c.diagnostics
440742}
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 (
99 "sync"
1010 "time"
1111
12+ "github.com/bmatcuk/doublestar/v4"
1213 "github.com/fsnotify/fsnotify"
1314 "github.com/kujtimiihoxha/opencode/internal/config"
1415 "github.com/kujtimiihoxha/opencode/internal/logging"
@@ -43,6 +44,8 @@ func NewWorkspaceWatcher(client *lsp.Client) *WorkspaceWatcher {
4344// AddRegistrations adds file watchers to track
4445func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) {
4546 cnf := config.Get()
47+
48+ logging.Debug("Adding file watcher registrations")
4649 w.registrationMu.Lock()
4750 defer w.registrationMu.Unlock()
4851
@@ -55,7 +58,6 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
5558 "id", id,
5659 "watchers", len(watchers),
5760 "total", len(w.registrations),
58− "watchers", watchers,
5961 )
6062
6163 for i, watcher := range watchers {
@@ -88,66 +90,217 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
8890 }
8991
9092 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− }
10293 }
10394 }
10495
105− // Find and open all existing files that match the newly registered patterns
106− // TODO: not all language servers require this, but typescript does. Make this configurable
107− go func() {
108− startTime := time.Now()
109− filesOpened := 0
110−
111− err := filepath.WalkDir(w.workspacePath, func(path string, d os.DirEntry, err error) error {
112− if err != nil {
113− return err
96+ // Determine server type for specialized handling
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 {
105+ go func() {
106+ startTime := time.Now()
107+ filesOpened := 0
108+
109+ // Determine max files to open based on server type
110+ maxFilesToOpen := 50 // Default conservative limit
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)
114129 }
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+ }
115147
116− // Skip directories that should be excluded
117− if d.IsDir() {
118− if path != w.workspacePath && shouldExcludeDir(path) {
119− if cnf.DebugLSP {
120− logging.Debug("Skipping excluded directory", "path", path)
148+ // Skip directories that should be excluded
149+ if d.IsDir() {
150+ if path != w.workspacePath && shouldExcludeDir(path) {
151+ if cnf.DebugLSP {
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
121172 }
122− return filepath.SkipDir
123173 }
124− } else {
125− // Process files
126− w.openMatchingFile(ctx, path)
127− filesOpened++
128174
129− // Add a small delay after every 100 files to prevent overwhelming the server
130− if filesOpened%100 == 0 {
131− time.Sleep(10 * time.Millisecond)
132− }
175+ return nil
176+ })
177+
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+ )
133186 }
134187
135− return nil
136− })
188+ if err != nil && cnf.DebugLSP {
189+ logging.Debug("Error scanning workspace for files to open", "error", err)
190+ }
191+ }()
192+ } else if cnf.DebugLSP {
193+ logging.Debug("Using on-demand file loading for server", "server", serverName)
194+ }
195+}
137196
138− elapsedTime := time.Since(startTime)
139− if cnf.DebugLSP {
140− logging.Debug("Workspace scan complete",
141− "filesOpened", filesOpened,
142− "elapsedTime", elapsedTime.Seconds(),
143− "workspacePath", w.workspacePath,
144− )
197+// openHighPriorityFiles opens important files for the server type
198+// Returns the number of files opened
199+func (w *WorkspaceWatcher) openHighPriorityFiles(ctx context.Context, serverName string) int {
200+ cnf := config.Get()
201+ filesOpened := 0
202+
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",
145216 }
146−
147− if err != nil && cnf.DebugLSP {
148− logging.Debug("Error scanning workspace for files to open", "error", err)
217+ case "gopls":
218+ patterns = []string{
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",
149249 }
150− }()
250+ default:
251+ // For unknown servers, use common configuration files
252+ patterns = []string{
253+ "**/package.json",
254+ "**/Makefile",
255+ "**/CMakeLists.txt",
256+ "**/.editorconfig",
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
151304}
152305
153306// WatchWorkspace sets up file watching for a workspace
@@ -155,6 +308,18 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
155308 cnf := config.Get()
156309 w.workspacePath = workspacePath
157310
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+
158323 // Register handler for file watcher registrations from the server
159324 lsp.RegisterFileWatchHandler(func(id string, watchers []protocol.FileSystemWatcher) {
160325 w.AddRegistrations(ctx, id, watchers)
@@ -510,6 +675,57 @@ func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, chan
510675 return w.client.DidChangeWatchedFiles(ctx, params)
511676}
512677
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+
513729// Common patterns for directories and files to exclude
514730// TODO: make configurable
515731var (
@@ -647,9 +863,119 @@ func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
647863
648864 // Check if this path should be watched according to server registrations
649865 if watched, _ := w.isPathWatched(path); watched {
650− // 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 {
652− logging.Error("Error opening file", "path", path, "error", err)
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 {
918+ // Don't need to check if it's already open - the client.OpenFile handles that
919+ if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP {
920+ logging.Error("Error opening file", "path", path, "error", err)
921+ }
922+ }
653923 }
654924 }
655925}
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
22
33import (
44 "fmt"
5+ "sort"
56
67 "github.com/charmbracelet/lipgloss"
78 "github.com/charmbracelet/x/ansi"
@@ -28,8 +29,16 @@ func lspsConfigured(width int) string {
2829
2930 lsps := styles.BaseStyle.Width(width).Foreground(styles.PrimaryColor).Bold(true).Render(title)
3031
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+
3139 var lspViews []string
32− for name, lsp := range cfg.LSP {
40+ for _, name := range lspNames {
41+ lsp := cfg.LSP[name]
3342 lspName := styles.BaseStyle.Foreground(styles.Forground).Render(
3443 fmt.Sprintf("• %s", name),
3544 )
@@ -49,7 +58,6 @@ func lspsConfigured(width int) string {
4958 ),
5059 ),
5160 )
52−
5361 }
5462 return styles.BaseStyle.
5563 Width(width).
internal/tui/components/chat/list.go+1−8
@@ -376,14 +376,7 @@ func (m *messagesCmp) working() string {
376376 if hasToolsWithoutResponse(m.messages) {
377377 task = "Waiting for tool response..."
378378 } 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..."
385− }
386− task = ""
379+ task = "Generating..."
387380 }
388381 if task != "" {
389382 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(
151151 ))
152152 }
153153 }
154− if content != "" {
154+ if content != "" || (finished && finishData.Reason == message.FinishReasonEndTurn) {
155+ if content == "" {
156+ content = "*Finished without output*"
157+ }
158+
155159 content = renderMessage(content, false, msg.ID == focusedUIMessageId, width, info...)
156160 messages = append(messages, uiMessage{
157161 ID: msg.ID,
internal/tui/components/core/status.go+17
@@ -138,6 +138,23 @@ func (m statusCmp) View() string {
138138}
139139
140140func (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+
141158 errorDiagnostics := []protocol.Diagnostic{}
142159 warnDiagnostics := []protocol.Diagnostic{}
143160 hintDiagnostics := []protocol.Diagnostic{}
internal/tui/styles/icons.go+3−2
@@ -6,7 +6,8 @@ const (
66 CheckIcon string = "✓"
77 ErrorIcon string = "✖"
88 WarningIcon string = "⚠"
9− InfoIcon string = ""
9+ InfoIcon string = ""
1010 HintIcon string = "i"
1111 SpinnerIcon string = "..."
12−)
12+ LoadingIcon string = "⟳"
13+)