diff options
| author | Paul Buetow <paul@buetow.org> | 2025-08-19 22:11:22 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-08-19 22:11:22 +0300 |
| commit | 8fa3c76907c3e99e75c5828d9b5642646c81205c (patch) | |
| tree | 75cf350ab4c01000603f7843c6427bc10710c68c | |
| parent | 9f59e7acd647f9adc0903e9c9655c04495f13a53 (diff) | |
lsp: include space in trigger characters and allow space-triggered completions\n\n- Defaults now include space (" ") in trigger list\n- Prefix heuristic treats space as structural trigger (no min-prefix required)\n- README and config example updated\n- Tests: add coverage for space trigger
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | config.json.example | 2 | ||||
| -rw-r--r-- | internal/lsp/completion_throttle_test.go | 38 | ||||
| -rw-r--r-- | internal/lsp/handlers.go | 58 | ||||
| -rw-r--r-- | internal/lsp/server.go | 4 |
5 files changed, 84 insertions, 20 deletions
@@ -23,7 +23,7 @@ Hexai exposes a simple LLM provider interface. It supports OpenAI, GitHub Copilo "max_context_tokens": 4000, "log_preview_limit": 100, "no_disk_io": true, - "trigger_characters": [".", ":", "/", "_", ";", "?"], + "trigger_characters": [".", ":", "/", "_", " " ], "coding_temperature": 0.2, "provider": "ollama", "copilot_model": "gpt-4o-mini", diff --git a/config.json.example b/config.json.example index 909f3a8..a2e46d1 100644 --- a/config.json.example +++ b/config.json.example @@ -5,7 +5,7 @@ "max_context_tokens": 4000, "log_preview_limit": 100, "no_disk_io": true, - "trigger_characters": [".", ":", "/", "_"], + "trigger_characters": [".", ":", "/", "_", " "], "coding_temperature": 0.2, "provider": "openai", diff --git a/internal/lsp/completion_throttle_test.go b/internal/lsp/completion_throttle_test.go index f986562..9bd6e54 100644 --- a/internal/lsp/completion_throttle_test.go +++ b/internal/lsp/completion_throttle_test.go @@ -35,13 +35,13 @@ func TestDefaultTriggerChars_DoesNotIncludeSemicolonOrQuestion(t *testing.T) { } func TestTryLLMCompletion_BusySkipsConcurrent(t *testing.T) { - s := &Server{ maxTokens: 32 } + s := &Server{ maxTokens: 32, triggerChars: []string{".", ":", "/", "_"} } fake := &countingLLM{} s.llmClient = fake // Simulate another LLM request in flight s.llmBusy = true - p := CompletionParams{ Position: Position{ Line: 0, Character: 3 }, TextDocument: TextDocumentIdentifier{URI: "file://x.go"} } - items, ok := s.tryLLMCompletion(p, "", "foo", "", "", "", false, "") + p := CompletionParams{ Position: Position{ Line: 0, Character: 4 }, TextDocument: TextDocumentIdentifier{URI: "file://x.go"} } + items, ok := s.tryLLMCompletion(p, "", "foo.", "", "", "", false, "") if !ok { t.Fatalf("expected ok=true when busy guard skips") } @@ -54,32 +54,44 @@ func TestTryLLMCompletion_BusySkipsConcurrent(t *testing.T) { } func TestTryLLMCompletion_MinPrefixSkipsEarly(t *testing.T) { - s := &Server{ maxTokens: 32 } + s := &Server{ maxTokens: 32, triggerChars: []string{".", ":", "/", "_"} } fake := &countingLLM{} s.llmClient = fake - // Zero identifier characters before cursor - p := CompletionParams{ Position: Position{ Line: 0, Character: 0 }, TextDocument: TextDocumentIdentifier{URI: "file://x.go"} } - items, ok := s.tryLLMCompletion(p, "", "", "", "", "", false, "") + // No trigger character -> skip regardless of prefix + p := CompletionParams{ Position: Position{ Line: 0, Character: 1 }, TextDocument: TextDocumentIdentifier{URI: "file://x.go"} } + items, ok := s.tryLLMCompletion(p, "", "a", "", "", "", false, "") if !ok { t.Fatalf("expected ok=true when skipped by min-prefix heuristic") } if len(items) != 0 { - t.Fatalf("expected zero items when min-prefix not satisfied") + t.Fatalf("expected zero items when not triggered") } if fake.calls != 0 { - t.Fatalf("LLM Chat should not be called when min-prefix not met; calls=%d", fake.calls) + t.Fatalf("LLM Chat should not be called when not triggered; calls=%d", fake.calls) } } -func TestTryLLMCompletion_AllowsAfterTrailingSpace(t *testing.T) { - s := &Server{ maxTokens: 32 } +func TestTryLLMCompletion_RequiresTriggerChar(t *testing.T) { + s := &Server{ maxTokens: 32, triggerChars: []string{".", ":", "/", "_", " "} } + fake := &countingLLM{} + s.llmClient = fake + // With trigger character '.' directly before cursor -> allowed + items, ok := s.tryLLMCompletion(CompletionParams{ Position: Position{ Line: 0, Character: 1 }, TextDocument: TextDocumentIdentifier{URI: "file://x.go"} }, "", ".", "", "", "", false, "") + if !ok || len(items) == 0 || fake.calls == 0 { t.Fatalf("expected allowed with '.' trigger") } + // Without trigger -> skipped + fake.calls = 0 + items, ok = s.tryLLMCompletion(CompletionParams{ Position: Position{ Line: 0, Character: 1 }, TextDocument: TextDocumentIdentifier{URI: "file://y.go"} }, "", "a", "", "", "", false, "") + if !ok || len(items) != 0 || fake.calls != 0 { t.Fatalf("expected skip without trigger; ok=%v len=%d calls=%d", ok, len(items), fake.calls) } +} + +func TestTryLLMCompletion_AllowsSpaceTrigger(t *testing.T) { + s := &Server{ maxTokens: 32, triggerChars: []string{".", ":", "/", "_", " "} } fake := &countingLLM{} s.llmClient = fake line := "type Matrix " - // Cursor after trailing space p := CompletionParams{ Position: Position{ Line: 0, Character: len(line) }, TextDocument: TextDocumentIdentifier{URI: "file://x.go"} } items, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, "") if !ok || len(items) == 0 || fake.calls == 0 { - t.Fatalf("expected completion allowed after trailing space; ok=%v len=%d calls=%d", ok, len(items), fake.calls) + t.Fatalf("expected allowed with space trigger; ok=%v len=%d calls=%d", ok, len(items), fake.calls) } } diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go index b9922f9..5d2201f 100644 --- a/internal/lsp/handlers.go +++ b/internal/lsp/handlers.go @@ -448,8 +448,14 @@ func (s *Server) logCompletionContext(p CompletionParams, above, current, below, } func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, funcCtx, docStr string, hasExtra bool, extraText string) ([]CompletionItem, bool) { - ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) - defer cancel() + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + defer cancel() + + // Only invoke LLM when triggered by one of our trigger characters. + if !s.isTriggerEvent(p, current) { + logging.Logf("lsp ", "completion skip=no-trigger line=%d char=%d current=%q", p.Position.Line, p.Position.Character, trimLen(current)) + return []CompletionItem{}, true + } inParams := inParamList(current, p.Position.Character) // Heuristic 1: Require a minimal typed identifier prefix to avoid early triggers, @@ -464,7 +470,7 @@ func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, fun allowNoPrefix := false if idx > 0 { ch := current[idx-1] - if ch == '.' || ch == ':' || ch == '/' || ch == '_' { + if ch == '.' || ch == ':' || ch == '/' || ch == '_' || ch == ' ' { allowNoPrefix = true } } @@ -541,6 +547,52 @@ func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, fun return s.makeCompletionItems(cleaned, inParams, current, p, docStr), true } +// isTriggerEvent returns true when the completion request appears to be caused +// by typing one of our configured trigger characters. It checks the LSP +// CompletionContext if provided and also falls back to inspecting the character +// immediately to the left of the cursor. +func (s *Server) isTriggerEvent(p CompletionParams, current string) bool { + // 1) Inspect LSP completion context if present + if p.Context != nil { + var ctx struct{ + TriggerKind int `json:"triggerKind"` + TriggerCharacter string `json:"triggerCharacter,omitempty"` + } + if raw, ok := p.Context.(json.RawMessage); ok { + _ = json.Unmarshal(raw, &ctx) + } else { + b, _ := json.Marshal(p.Context) + _ = json.Unmarshal(b, &ctx) + } + // TriggerKind 2 is TriggerCharacter per LSP spec + if ctx.TriggerKind == 2 { + if ctx.TriggerCharacter != "" { + for _, c := range s.triggerChars { + if c == ctx.TriggerCharacter { + return true + } + } + return false + } + // No character provided but reported as TriggerCharacter; be conservative + return false + } + // For Invoked (1) or TriggerForIncomplete (3), require manual char check below + } + // 2) Fallback: check the character immediately prior to cursor + idx := p.Position.Character + if idx <= 0 || idx > len(current) { + return false + } + ch := string(current[idx-1]) + for _, c := range s.triggerChars { + if c == ch { + return true + } + } + return false +} + func (s *Server) makeCompletionItems(cleaned string, inParams bool, current string, p CompletionParams, docStr string) []CompletionItem { te, filter := computeTextEditAndFilter(cleaned, inParams, current, p) rm := s.collectPromptRemovalEdits(p.TextDocument.URI) diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 7773dd1..2c4daa9 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -81,8 +81,8 @@ func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) s.startTime = time.Now() s.llmClient = opts.Client if len(opts.TriggerCharacters) == 0 { - // Conservative defaults to reduce early triggers and API usage - s.triggerChars = []string{".", ":", "/", "_"} + // Defaults (explicit space included to allow post-identifier triggers) + s.triggerChars = []string{".", ":", "/", "_", " "} } else { s.triggerChars = append([]string{}, opts.TriggerCharacters...) } |
