summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-08-19 22:11:22 +0300
committerPaul Buetow <paul@buetow.org>2025-08-19 22:11:22 +0300
commit8fa3c76907c3e99e75c5828d9b5642646c81205c (patch)
tree75cf350ab4c01000603f7843c6427bc10710c68c
parent9f59e7acd647f9adc0903e9c9655c04495f13a53 (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.md2
-rw-r--r--config.json.example2
-rw-r--r--internal/lsp/completion_throttle_test.go38
-rw-r--r--internal/lsp/handlers.go58
-rw-r--r--internal/lsp/server.go4
5 files changed, 84 insertions, 20 deletions
diff --git a/README.md b/README.md
index e9c53c5..fe70232 100644
--- a/README.md
+++ b/README.md
@@ -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...)
}