diff options
Diffstat (limited to 'docs/coverage.html')
| -rw-r--r-- | docs/coverage.html | 647 |
1 files changed, 375 insertions, 272 deletions
diff --git a/docs/coverage.html b/docs/coverage.html index df02a90..d940029 100644 --- a/docs/coverage.html +++ b/docs/coverage.html @@ -59,7 +59,7 @@ <option value="file1">codeberg.org/snonux/hexai/cmd/hexai/main.go (0.0%)</option> - <option value="file2">codeberg.org/snonux/hexai/internal/appconfig/config.go (94.6%)</option> + <option value="file2">codeberg.org/snonux/hexai/internal/appconfig/config.go (86.9%)</option> <option value="file3">codeberg.org/snonux/hexai/internal/hexaicli/run.go (91.4%)</option> @@ -83,21 +83,21 @@ <option value="file13">codeberg.org/snonux/hexai/internal/lsp/document.go (90.1%)</option> - <option value="file14">codeberg.org/snonux/hexai/internal/lsp/handlers.go (91.3%)</option> + <option value="file14">codeberg.org/snonux/hexai/internal/lsp/handlers.go (90.5%)</option> <option value="file15">codeberg.org/snonux/hexai/internal/lsp/handlers_codeaction.go (81.2%)</option> - <option value="file16">codeberg.org/snonux/hexai/internal/lsp/handlers_completion.go (85.1%)</option> + <option value="file16">codeberg.org/snonux/hexai/internal/lsp/handlers_completion.go (86.1%)</option> - <option value="file17">codeberg.org/snonux/hexai/internal/lsp/handlers_document.go (88.9%)</option> + <option value="file17">codeberg.org/snonux/hexai/internal/lsp/handlers_document.go (87.4%)</option> <option value="file18">codeberg.org/snonux/hexai/internal/lsp/handlers_execute.go (75.0%)</option> <option value="file19">codeberg.org/snonux/hexai/internal/lsp/handlers_init.go (55.6%)</option> - <option value="file20">codeberg.org/snonux/hexai/internal/lsp/handlers_utils.go (88.1%)</option> + <option value="file20">codeberg.org/snonux/hexai/internal/lsp/handlers_utils.go (88.2%)</option> - <option value="file21">codeberg.org/snonux/hexai/internal/lsp/server.go (68.8%)</option> + <option value="file21">codeberg.org/snonux/hexai/internal/lsp/server.go (77.9%)</option> <option value="file22">codeberg.org/snonux/hexai/internal/lsp/transport.go (71.4%)</option> @@ -216,6 +216,13 @@ type App struct { TriggerCharacters []string `json:"trigger_characters"` Provider string `json:"provider"` + // Inline prompt trigger characters (default: >text> and >>text>) + InlineOpen string `json:"inline_open"` + InlineClose string `json:"inline_close"` + // In-editor chat triggers (default: suffix ">" after one of [?, !, :, ;]) + ChatSuffix string `json:"chat_suffix"` + ChatPrefixes []string `json:"chat_prefixes"` + // Provider-specific options OpenAIBaseURL string `json:"openai_base_url"` OpenAIModel string `json:"openai_model"` @@ -249,12 +256,17 @@ func newDefaultConfig() App <span class="cov5" title="9">{ ManualInvokeMinPrefix: 0, CompletionDebounceMs: 200, CompletionThrottleMs: 0, + // Inline/chat trigger defaults + InlineOpen: ">", + InlineClose: ">", + ChatSuffix: ">", + ChatPrefixes: []string{"?", "!", ":", ";"}, } }</span> // Load reads configuration from a file and merges with defaults. // It respects the XDG Base Directory Specification. -func Load(logger *log.Logger) App <span class="cov5" title="8">{ +func Load(logger *log.Logger) App <span class="cov4" title="8">{ cfg := newDefaultConfig() if logger == nil </span><span class="cov3" title="3">{ return cfg // Return defaults if no logger is provided (e.g. in tests) @@ -331,12 +343,24 @@ func (a *App) mergeBasics(other *App) <span class="cov3" title="4">{ }</span> <span class="cov3" title="4">if other.CompletionDebounceMs > 0 </span><span class="cov3" title="3">{ a.CompletionDebounceMs = other.CompletionDebounceMs }</span> <span class="cov3" title="4">if other.CompletionThrottleMs > 0 </span><span class="cov3" title="3">{ a.CompletionThrottleMs = other.CompletionThrottleMs }</span> - <span class="cov3" title="4">if len(other.TriggerCharacters) > 0 </span><span class="cov3" title="3">{ - a.TriggerCharacters = slices.Clone(other.TriggerCharacters) - }</span> - <span class="cov3" title="4">if s := strings.TrimSpace(other.Provider); s != "" </span><span class="cov3" title="4">{ - a.Provider = s - }</span> + <span class="cov3" title="4">if len(other.TriggerCharacters) > 0 </span><span class="cov3" title="3">{ + a.TriggerCharacters = slices.Clone(other.TriggerCharacters) + }</span> + <span class="cov3" title="4">if s := strings.TrimSpace(other.InlineOpen); s != "" </span><span class="cov0" title="0">{ + a.InlineOpen = s + }</span> + <span class="cov3" title="4">if s := strings.TrimSpace(other.InlineClose); s != "" </span><span class="cov0" title="0">{ + a.InlineClose = s + }</span> + <span class="cov3" title="4">if s := strings.TrimSpace(other.ChatSuffix); s != "" </span><span class="cov0" title="0">{ + a.ChatSuffix = s + }</span> + <span class="cov3" title="4">if len(other.ChatPrefixes) > 0 </span><span class="cov0" title="0">{ + a.ChatPrefixes = slices.Clone(other.ChatPrefixes) + }</span> + <span class="cov3" title="4">if s := strings.TrimSpace(other.Provider); s != "" </span><span class="cov3" title="4">{ + a.Provider = s + }</span> } // mergeProviderFields merges per-provider configuration. @@ -393,7 +417,7 @@ func loadFromEnv(logger *log.Logger) *App <span class="cov4" title="5">{ var any bool // helpers - getenv := func(k string) string </span><span class="cov10" title="100">{ return strings.TrimSpace(os.Getenv(k)) }</span> + getenv := func(k string) string </span><span class="cov10" title="120">{ return strings.TrimSpace(os.Getenv(k)) }</span> <span class="cov4" title="5">parseInt := func(k string) (int, bool) </span><span class="cov7" title="35">{ v := getenv(k) if v == "" </span><span class="cov7" title="28">{ return 0, false }</span> @@ -449,6 +473,19 @@ func loadFromEnv(logger *log.Logger) *App <span class="cov4" title="5">{ } <span class="cov1" title="1">any = true</span> } + <span class="cov4" title="5">if s := getenv("HEXAI_INLINE_OPEN"); s != "" </span><span class="cov0" title="0">{ out.InlineOpen = s; any = true }</span> + <span class="cov4" title="5">if s := getenv("HEXAI_INLINE_CLOSE"); s != "" </span><span class="cov0" title="0">{ out.InlineClose = s; any = true }</span> + <span class="cov4" title="5">if s := getenv("HEXAI_CHAT_SUFFIX"); s != "" </span><span class="cov0" title="0">{ out.ChatSuffix = s; any = true }</span> + <span class="cov4" title="5">if s := getenv("HEXAI_CHAT_PREFIXES"); s != "" </span><span class="cov0" title="0">{ + parts := strings.Split(s, ",") + out.ChatPrefixes = nil + for _, p := range parts </span><span class="cov0" title="0">{ + if t := strings.TrimSpace(p); t != "" </span><span class="cov0" title="0">{ + out.ChatPrefixes = append(out.ChatPrefixes, t) + }</span> + } + <span class="cov0" title="0">any = true</span> + } <span class="cov4" title="5">if s := getenv("HEXAI_PROVIDER"); s != "" </span><span class="cov1" title="1">{ out.Provider = s; any = true }</span> @@ -737,6 +774,10 @@ func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client) ls ManualInvokeMinPrefix: cfg.ManualInvokeMinPrefix, CompletionDebounceMs: cfg.CompletionDebounceMs, CompletionThrottleMs: cfg.CompletionThrottleMs, + InlineOpen: cfg.InlineOpen, + InlineClose: cfg.InlineClose, + ChatSuffix: cfg.ChatSuffix, + ChatPrefixes: cfg.ChatPrefixes, } }</span> </pre> @@ -2088,9 +2129,9 @@ func (s *Server) handle(req Request) <span class="cov2" title="2">{ // Preference order on each line: strict ;text; marker (no inner spaces), then // a line comment (//, #, --). Returns the instruction string and the selection // text cleaned of the matched instruction marker or comment. -func instructionFromSelection(sel string) (string, string) <span class="cov4" title="3">{ +func instructionFromSelection(sel string) (string, string) <span class="cov3" title="3">{ lines := splitLines(sel) - for idx, line := range lines </span><span class="cov4" title="3">{ + for idx, line := range lines </span><span class="cov3" title="3">{ if instr, cleaned, ok := findFirstInstructionInLine(line); ok && strings.TrimSpace(instr) != "" </span><span class="cov1" title="1">{ lines[idx] = cleaned return instr, strings.Join(lines, "\n") @@ -2108,7 +2149,7 @@ func instructionFromSelection(sel string) (string, string) <span class="cov4" ti // - // text // - # text // - -- text -func findFirstInstructionInLine(line string) (instr string, cleaned string, ok bool) <span class="cov9" title="22">{ +func findFirstInstructionInLine(line string) (instr string, cleaned string, ok bool) <span class="cov8" title="22">{ type cand struct { start, end int text string @@ -2117,7 +2158,7 @@ func findFirstInstructionInLine(line string) (instr string, cleaned string, ok b if t, l, r, ok := findStrictSemicolonTag(line); ok </span><span class="cov5" title="6">{ cands = append(cands, cand{start: l, end: r, text: t}) }</span> - <span class="cov9" title="22">if i := strings.Index(line, "/*"); i >= 0 </span><span class="cov2" title="2">{ + <span class="cov8" title="22">if i := strings.Index(line, "/*"); i >= 0 </span><span class="cov2" title="2">{ if j := strings.Index(line[i+2:], "*/"); j >= 0 </span><span class="cov2" title="2">{ start := i end := i + 2 + j + 2 @@ -2125,7 +2166,7 @@ func findFirstInstructionInLine(line string) (instr string, cleaned string, ok b cands = append(cands, cand{start: start, end: end, text: text}) }</span> } - <span class="cov9" title="22">if i := strings.Index(line, "<!--"); i >= 0 </span><span class="cov2" title="2">{ + <span class="cov8" title="22">if i := strings.Index(line, "<!--"); i >= 0 </span><span class="cov2" title="2">{ if j := strings.Index(line[i+4:], "-->"); j >= 0 </span><span class="cov2" title="2">{ start := i end := i + 4 + j + 3 @@ -2133,16 +2174,16 @@ func findFirstInstructionInLine(line string) (instr string, cleaned string, ok b cands = append(cands, cand{start: start, end: end, text: text}) }</span> } - <span class="cov9" title="22">if i := strings.Index(line, "//"); i >= 0 </span><span class="cov4" title="4">{ + <span class="cov8" title="22">if i := strings.Index(line, "//"); i >= 0 </span><span class="cov4" title="4">{ cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])}) }</span> - <span class="cov9" title="22">if i := strings.Index(line, "#"); i >= 0 </span><span class="cov2" title="2">{ + <span class="cov8" title="22">if i := strings.Index(line, "#"); i >= 0 </span><span class="cov2" title="2">{ cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+1:])}) }</span> - <span class="cov9" title="22">if i := strings.Index(line, "--"); i >= 0 </span><span class="cov4" title="4">{ + <span class="cov8" title="22">if i := strings.Index(line, "--"); i >= 0 </span><span class="cov4" title="4">{ cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])}) }</span> - <span class="cov9" title="22">if len(cands) == 0 </span><span class="cov5" title="6">{ + <span class="cov8" title="22">if len(cands) == 0 </span><span class="cov5" title="6">{ return "", line, false }</span> // pick earliest start index @@ -2251,33 +2292,33 @@ func (s *Server) reply(id json.RawMessage, result any, err *RespError) <span cla // --- small completion cache (last ~10 entries) --- -func (s *Server) completionCacheKey(p CompletionParams, above, current, below, funcCtx string, inParams bool, hasExtra bool, extraText string) string <span class="cov7" title="11">{ +func (s *Server) completionCacheKey(p CompletionParams, above, current, below, funcCtx string, inParams bool, hasExtra bool, extraText string) string <span class="cov7" title="12">{ // Normalize left-of-cursor by trimming trailing spaces/tabs idx := p.Position.Character if idx > len(current) </span><span class="cov0" title="0">{ idx = len(current) }</span> - <span class="cov7" title="11">left := strings.TrimRight(current[:idx], " \t") + <span class="cov7" title="12">left := strings.TrimRight(current[:idx], " \t") right := "" if idx < len(current) </span><span class="cov0" title="0">{ right = current[idx:] }</span> - <span class="cov7" title="11">prov := "" + <span class="cov7" title="12">prov := "" model := "" - if s.llmClient != nil </span><span class="cov7" title="11">{ + if s.llmClient != nil </span><span class="cov7" title="12">{ prov = s.llmClient.Name() model = s.llmClient.DefaultModel() }</span> - <span class="cov7" title="11">temp := "" + <span class="cov7" title="12">temp := "" if s.codingTemperature != nil </span><span class="cov0" title="0">{ temp = fmt.Sprintf("%.3f", *s.codingTemperature) }</span> - <span class="cov7" title="11">extra := "" + <span class="cov7" title="12">extra := "" if hasExtra </span><span class="cov0" title="0">{ extra = strings.TrimSpace(extraText) }</span> // Compose a key from essential context parts - <span class="cov7" title="11">return strings.Join([]string{ + <span class="cov7" title="12">return strings.Join([]string{ "v1", // version for future-proofing prov, model, @@ -2294,11 +2335,11 @@ func (s *Server) completionCacheKey(p CompletionParams, above, current, below, f }, "\x1f")</span> // use unit separator to avoid collisions } -func (s *Server) completionCacheGet(key string) (string, bool) <span class="cov7" title="9">{ +func (s *Server) completionCacheGet(key string) (string, bool) <span class="cov6" title="10">{ s.mu.Lock() defer s.mu.Unlock() v, ok := s.compCache[key] - if !ok </span><span class="cov6" title="8">{ + if !ok </span><span class="cov6" title="9">{ return "", false }</span> // move to most-recent @@ -2306,13 +2347,13 @@ func (s *Server) completionCacheGet(key string) (string, bool) <span class="cov7 return v, true</span> } -func (s *Server) completionCachePut(key, value string) <span class="cov7" title="9">{ +func (s *Server) completionCachePut(key, value string) <span class="cov6" title="9">{ s.mu.Lock() defer s.mu.Unlock() if s.compCache == nil </span><span class="cov1" title="1">{ s.compCache = make(map[string]string) }</span> - <span class="cov7" title="9">if _, exists := s.compCache[key]; !exists </span><span class="cov7" title="9">{ + <span class="cov6" title="9">if _, exists := s.compCache[key]; !exists </span><span class="cov6" title="9">{ s.compCacheOrder = append(s.compCacheOrder, key) s.compCache[key] = value if len(s.compCacheOrder) > 10 </span><span class="cov0" title="0">{ @@ -2321,7 +2362,7 @@ func (s *Server) completionCachePut(key, value string) <span class="cov7" title= s.compCacheOrder = s.compCacheOrder[1:] delete(s.compCache, old) }</span> - <span class="cov7" title="9">return</span> + <span class="cov6" title="9">return</span> } // update existing and mark most-recent <span class="cov0" title="0">s.compCache[key] = value @@ -2348,25 +2389,26 @@ func (s *Server) compCacheTouchLocked(key string) <span class="cov1" title="1">{ // 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 <span class="cov9" title="21">{ +func (s *Server) isTriggerEvent(p CompletionParams, current string) bool <span class="cov8" title="21">{ // 1) Inspect LSP completion context if present if p.Context != nil </span><span class="cov6" title="8">{ var ctx struct { TriggerKind int `json:"triggerKind"` TriggerCharacter string `json:"triggerCharacter,omitempty"` } - if raw, ok := p.Context.(json.RawMessage); ok </span><span class="cov6" title="7">{ + if raw, ok := p.Context.(json.RawMessage); ok </span><span class="cov5" title="7">{ _ = json.Unmarshal(raw, &ctx) }</span> else<span class="cov1" title="1"> { b, _ := json.Marshal(p.Context) _ = json.Unmarshal(b, &ctx) }</span> - // If the line contains a bare ';;' (no ';;text;'), do not treat as a trigger source. - <span class="cov6" title="8">if strings.Contains(current, ";;") && !hasDoubleSemicolonTrigger(current) </span><span class="cov1" title="1">{ + // If configured and the line contains a bare double-open marker (e.g., '>>' with no '>>text>'), + // do not treat as a trigger source. + <span class="cov6" title="8">if s.inlineOpen != "" && strings.Contains(current, s.inlineOpen+s.inlineOpen) && !hasDoubleSemicolonTrigger(current) </span><span class="cov0" title="0">{ return false }</span> // TriggerKind 1 = Invoked (manual). Always allow manual invoke. - <span class="cov6" title="7">if ctx.TriggerKind == 1 </span><span class="cov5" title="5">{ + <span class="cov6" title="8">if ctx.TriggerKind == 1 </span><span class="cov5" title="6">{ return true }</span> // TriggerKind 2 is TriggerCharacter per LSP spec @@ -2385,32 +2427,32 @@ func (s *Server) isTriggerEvent(p CompletionParams, current string) bool <span c // For TriggerForIncomplete (3), require manual char check below } // 2) Fallback: check the character immediately prior to cursor - <span class="cov8" title="13">idx := p.Position.Character + <span class="cov7" title="13">idx := p.Position.Character if idx <= 0 || idx > len(current) </span><span class="cov0" title="0">{ return false }</span> - // Bare ';;' should not trigger via fallback char either - <span class="cov8" title="13">if strings.Contains(current, ";;") && !hasDoubleSemicolonTrigger(current) </span><span class="cov4" title="3">{ - return false - }</span> - <span class="cov7" title="10">ch := string(current[idx-1]) - for _, c := range s.triggerChars </span><span class="cov10" title="26">{ + // Bare double-open should not trigger via fallback char either (only when configured) + <span class="cov7" title="13">if s.inlineOpen != "" && strings.Contains(current, s.inlineOpen+s.inlineOpen) && !hasDoubleSemicolonTrigger(current) </span><span class="cov1" title="1">{ + return false + }</span> + <span class="cov7" title="12">ch := string(current[idx-1]) + for _, c := range s.triggerChars </span><span class="cov10" title="34">{ if c == ch </span><span class="cov5" title="5">{ return true }</span> } - <span class="cov5" title="5">return false</span> + <span class="cov5" title="7">return false</span> } -func (s *Server) makeCompletionItems(cleaned string, inParams bool, current string, p CompletionParams, docStr string) []CompletionItem <span class="cov7" title="10">{ +func (s *Server) makeCompletionItems(cleaned string, inParams bool, current string, p CompletionParams, docStr string) []CompletionItem <span class="cov6" title="10">{ te, filter := computeTextEditAndFilter(cleaned, inParams, current, p) rm := s.collectPromptRemovalEdits(p.TextDocument.URI) label := labelForCompletion(cleaned, filter) detail := "Hexai LLM completion" - if s.llmClient != nil </span><span class="cov7" title="10">{ + if s.llmClient != nil </span><span class="cov6" title="10">{ detail = "Hexai " + s.llmClient.Name() + ":" + s.llmClient.DefaultModel() }</span> - <span class="cov7" title="10">return []CompletionItem{{ + <span class="cov6" title="10">return []CompletionItem{{ Label: label, Kind: 1, Detail: detail, @@ -3082,15 +3124,15 @@ func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, fun defer cancel() inlinePrompt := lineHasInlinePrompt(current) - if !inlinePrompt && !s.isTriggerEvent(p, current) </span><span class="cov7" title="9">{ + if !inlinePrompt && !s.isTriggerEvent(p, current) </span><span class="cov7" title="8">{ logging.Logf("lsp ", "%scompletion skip=no-trigger line=%d char=%d current=%q%s", logging.AnsiYellow, p.Position.Line, p.Position.Character, trimLen(current), logging.AnsiBase) return []CompletionItem{}, true }</span> - <span class="cov7" title="9">if s.shouldSuppressForChatTriggerEOL(current, p) </span><span class="cov0" title="0">{ + <span class="cov8" title="10">if s.shouldSuppressForChatTriggerEOL(current, p) </span><span class="cov0" title="0">{ return []CompletionItem{}, true }</span> - <span class="cov7" title="9">inParams := inParamList(current, p.Position.Character) + <span class="cov8" title="10">inParams := inParamList(current, p.Position.Character) manualInvoke := parseManualInvoke(p.Context) // Cache fast-path @@ -3101,10 +3143,10 @@ func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, fun logging.AnsiGreen, logging.PreviewForLog(cleaned), logging.AnsiBase) return s.makeCompletionItems(cleaned, inParams, current, p, docStr), true }</span> - <span class="cov7" title="8">if (isBareDoubleSemicolon(current) || isBareDoubleSemicolon(below)) && !manualInvoke </span><span class="cov0" title="0">{ - logging.Logf("lsp ", "%scompletion skip=empty-double-semicolon line=%d char=%d current=%q%s", logging.AnsiYellow, p.Position.Line, p.Position.Character, trimLen(current), logging.AnsiBase) - return []CompletionItem{}, true - }</span> + <span class="cov7" title="9">if (isBareDoubleSemicolon(current) || isBareDoubleSemicolon(below)) </span><span class="cov1" title="1">{ + logging.Logf("lsp ", "%scompletion skip=empty-double-semicolon line=%d char=%d current=%q%s", logging.AnsiYellow, p.Position.Line, p.Position.Character, trimLen(current), logging.AnsiBase) + return []CompletionItem{}, true + }</span> <span class="cov7" title="8">if !inParams && !s.prefixHeuristicAllows(inlinePrompt, current, p, manualInvoke) </span><span class="cov0" title="0">{ logging.Logf("lsp ", "%scompletion skip=short-prefix line=%d char=%d current=%q%s", logging.AnsiYellow, p.Position.Line, p.Position.Character, trimLen(current), logging.AnsiBase) @@ -3153,32 +3195,37 @@ func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, fun } // parseManualInvoke inspects the LSP completion context and reports whether the user manually invoked completion. -func parseManualInvoke(ctx any) bool <span class="cov8" title="10">{ +func parseManualInvoke(ctx any) bool <span class="cov8" title="11">{ if ctx == nil </span><span class="cov6" title="5">{ return false }</span> - <span class="cov6" title="5">var c struct { + <span class="cov6" title="6">var c struct { TriggerKind int `json:"triggerKind"` } - if raw, ok := ctx.(json.RawMessage); ok </span><span class="cov6" title="5">{ + if raw, ok := ctx.(json.RawMessage); ok </span><span class="cov6" title="6">{ _ = json.Unmarshal(raw, &c) }</span> else<span class="cov0" title="0"> { b, _ := json.Marshal(ctx) _ = json.Unmarshal(b, &c) }</span> - <span class="cov6" title="5">return c.TriggerKind == 1</span> + <span class="cov6" title="6">return c.TriggerKind == 1</span> } // shouldSuppressForChatTriggerEOL returns true when a chat trigger like ">" follows ?, !, :, or ; at EOL. -func (s *Server) shouldSuppressForChatTriggerEOL(current string, p CompletionParams) bool <span class="cov8" title="11">{ - if t := strings.TrimRight(current, " \t"); len(t) >= 2 && t[len(t)-1] == '>' </span><span class="cov3" title="2">{ - prev := t[len(t)-2] - if prev == '?' || prev == '!' || prev == ':' || prev == ';' </span><span class="cov1" title="1">{ - logging.Logf("lsp ", "completion skip=chat-trigger-eol uri=%s line=%d", p.TextDocument.URI, p.Position.Line) - return true - }</span> +func (s *Server) shouldSuppressForChatTriggerEOL(current string, p CompletionParams) bool <span class="cov8" title="12">{ + t := strings.TrimRight(current, " \t") + if s.chatSuffix == "" </span><span class="cov6" title="5">{ return false }</span> + <span class="cov7" title="7">if strings.HasSuffix(t, s.chatSuffix) </span><span class="cov3" title="2">{ + if len(t) < len(s.chatSuffix)+1 </span><span class="cov0" title="0">{ return false }</span> + <span class="cov3" title="2">prev := string(t[len(t)-len(s.chatSuffix)-1]) + for _, pf := range s.chatPrefixes </span><span class="cov7" title="8">{ + if prev == pf </span><span class="cov1" title="1">{ + logging.Logf("lsp ", "completion skip=chat-trigger-eol uri=%s line=%d", p.TextDocument.URI, p.Position.Line) + return true + }</span> } - <span class="cov8" title="10">return false</span> + } + <span class="cov6" title="6">return false</span> } // prefixHeuristicAllows applies minimal prefix rules unless inlinePrompt or structural triggers apply. @@ -3383,6 +3430,11 @@ import ( "time" ) +// Package-level chat trigger vars for helpers without Server receiver. +// NewServer assigns these from configuration on startup. +var chatSuffixChar byte = '>' +var chatPrefixSingles = []string{"?", "!", ":", ";"} + func (s *Server) handleDidOpen(req Request) <span class="cov1" title="1">{ var p DidOpenTextDocumentParams if err := json.Unmarshal(req.Params, &p); err == nil </span><span class="cov1" title="1">{ @@ -3414,9 +3466,9 @@ func (s *Server) handleDidClose(req Request) <span class="cov1" title="1">{ // docBeforeAfter returns the full document text split at the given position. // The returned strings are the text before the cursor (inclusive of anything // left of the position) and the text after the cursor. -func (s *Server) docBeforeAfter(uri string, pos Position) (string, string) <span class="cov7" title="4">{ +func (s *Server) docBeforeAfter(uri string, pos Position) (string, string) <span class="cov6" title="4">{ d := s.getDocument(uri) - if d == nil </span><span class="cov6" title="3">{ + if d == nil </span><span class="cov5" title="3">{ return "", "" }</span> // Clamp indices @@ -3457,44 +3509,55 @@ func (s *Server) docBeforeAfter(uri string, pos Position) (string, string) <span // detectAndHandleChat scans the current document for any line that starts with // a new trigger pair (e.g., "?>" ",>" ":>" ";>") at EOL and inserts the LLM // reply below. -func (s *Server) detectAndHandleChat(uri string) <span class="cov6" title="3">{ +func (s *Server) detectAndHandleChat(uri string) <span class="cov5" title="3">{ if s.llmClient == nil </span><span class="cov1" title="1">{ return }</span> - <span class="cov4" title="2">d := s.getDocument(uri) + <span class="cov3" title="2">d := s.getDocument(uri) if d == nil || len(d.lines) == 0 </span><span class="cov0" title="0">{ return }</span> - <span class="cov4" title="2">for i, raw := range d.lines </span><span class="cov7" title="4">{ + <span class="cov3" title="2">for i, raw := range d.lines </span><span class="cov6" title="4">{ // Find last non-space character index j := len(raw) - 1 - for j >= 0 </span><span class="cov6" title="3">{ + for j >= 0 </span><span class="cov5" title="3">{ if raw[j] == ' ' || raw[j] == '\t' </span><span class="cov0" title="0">{ j-- continue</span> } - <span class="cov6" title="3">break</span> - } - <span class="cov7" title="4">if j < 1 </span><span class="cov1" title="1">{ - continue</span> - } // need at least two chars - <span class="cov6" title="3">pair := raw[j-1 : j+1] - isTrigger := pair == "?>" || pair == "!>" || pair == ":>" || pair == ";>" - if !isTrigger </span><span class="cov1" title="1">{ - continue</span> + <span class="cov5" title="3">break</span> } + <span class="cov6" title="4">if j < 0 </span><span class="cov1" title="1">{ + continue</span> + } + // Check suffix/prefix according to configuration + <span class="cov5" title="3">if s.chatSuffix == "" </span><span class="cov3" title="2">{ + continue</span> + } + // Last non-space must equal suffix + <span class="cov1" title="1">if string(raw[j]) != s.chatSuffix </span><span class="cov0" title="0">{ + continue</span> + } + // Require at least one char before suffix and that char must be in chatPrefixes + <span class="cov1" title="1">if j < 1 </span><span class="cov0" title="0">{ continue</span> } + <span class="cov1" title="1">prev := string(raw[j-1]) + isTrigger := false + for _, pfx := range s.chatPrefixes </span><span class="cov1" title="1">{ + if prev == pfx </span><span class="cov1" title="1">{ isTrigger = true; break</span> } + } + <span class="cov1" title="1">if !isTrigger </span><span class="cov0" title="0">{ continue</span> } // Avoid double-answering: if the next non-empty line starts with '>' we skip. - <span class="cov4" title="2">k := i + 1 - for k < len(d.lines) && strings.TrimSpace(d.lines[k]) == "" </span><span class="cov4" title="2">{ + <span class="cov1" title="1">k := i + 1 + for k < len(d.lines) && strings.TrimSpace(d.lines[k]) == "" </span><span class="cov3" title="2">{ k++ }</span> - <span class="cov4" title="2">if k < len(d.lines) && strings.HasPrefix(strings.TrimSpace(d.lines[k]), ">") </span><span class="cov1" title="1">{ + <span class="cov1" title="1">if k < len(d.lines) && strings.HasPrefix(strings.TrimSpace(d.lines[k]), ">") </span><span class="cov0" title="0">{ continue</span> } // Derive prompt by removing only the trailing '>' - <span class="cov1" title="1">removeCount := 1 + <span class="cov1" title="1">removeCount := len(s.chatSuffix) base := raw[:j+1-removeCount] - prompt := strings.TrimSpace(base) + prompt := strings.TrimSpace(base) if prompt == "" </span><span class="cov0" title="0">{ continue</span> } @@ -3549,80 +3612,85 @@ func (s *Server) applyChatEdits(uri string, lineIdx int, lastNonSpace int, remov // buildChatHistory walks upwards from the current line to collect the most recent // Q/A pairs in the in-editor transcript. Returns messages ending with current prompt. -func (s *Server) buildChatHistory(uri string, lineIdx int, currentPrompt string) []llm.Message <span class="cov4" title="2">{ +func (s *Server) buildChatHistory(uri string, lineIdx int, currentPrompt string) []llm.Message <span class="cov3" title="2">{ d := s.getDocument(uri) if d == nil </span><span class="cov0" title="0">{ return []llm.Message{{Role: "user", Content: currentPrompt}} }</span> - <span class="cov4" title="2">type pair struct{ q, a string } + <span class="cov3" title="2">type pair struct{ q, a string } pairs := []pair{} i := lineIdx - 1 - for i >= 0 && len(pairs) < 3 </span><span class="cov4" title="2">{ + for i >= 0 && len(pairs) < 3 </span><span class="cov3" title="2">{ for i >= 0 && strings.TrimSpace(d.lines[i]) == "" </span><span class="cov1" title="1">{ i-- }</span> - <span class="cov4" title="2">if i < 0 </span><span class="cov0" title="0">{ + <span class="cov3" title="2">if i < 0 </span><span class="cov0" title="0">{ break</span> } - <span class="cov4" title="2">if !strings.HasPrefix(strings.TrimSpace(d.lines[i]), ">") </span><span class="cov0" title="0">{ + <span class="cov3" title="2">if !strings.HasPrefix(strings.TrimSpace(d.lines[i]), ">") </span><span class="cov0" title="0">{ break</span> } - <span class="cov4" title="2">var replyLines []string - for i >= 0 </span><span class="cov7" title="4">{ + <span class="cov3" title="2">var replyLines []string + for i >= 0 </span><span class="cov6" title="4">{ line := strings.TrimSpace(d.lines[i]) - if strings.HasPrefix(line, ">") </span><span class="cov4" title="2">{ + if strings.HasPrefix(line, ">") </span><span class="cov3" title="2">{ replyLines = append([]string{strings.TrimSpace(strings.TrimPrefix(line, ">"))}, replyLines...) i-- continue</span> } - <span class="cov4" title="2">break</span> + <span class="cov3" title="2">break</span> } - <span class="cov4" title="2">for i >= 0 && strings.TrimSpace(d.lines[i]) == "" </span><span class="cov0" title="0">{ + <span class="cov3" title="2">for i >= 0 && strings.TrimSpace(d.lines[i]) == "" </span><span class="cov0" title="0">{ i-- }</span> - <span class="cov4" title="2">if i < 0 </span><span class="cov0" title="0">{ + <span class="cov3" title="2">if i < 0 </span><span class="cov0" title="0">{ break</span> } - <span class="cov4" title="2">q := strings.TrimSpace(d.lines[i]) + <span class="cov3" title="2">q := strings.TrimSpace(d.lines[i]) q = stripTrailingTrigger(q) pairs = append([]pair{{q: q, a: strings.Join(replyLines, "\n")}}, pairs...) i--</span> } - <span class="cov4" title="2">msgs := make([]llm.Message, 0, len(pairs)*2+1) - for _, p := range pairs </span><span class="cov4" title="2">{ - if strings.TrimSpace(p.q) != "" </span><span class="cov4" title="2">{ + <span class="cov3" title="2">msgs := make([]llm.Message, 0, len(pairs)*2+1) + for _, p := range pairs </span><span class="cov3" title="2">{ + if strings.TrimSpace(p.q) != "" </span><span class="cov3" title="2">{ msgs = append(msgs, llm.Message{Role: "user", Content: p.q}) }</span> - <span class="cov4" title="2">if strings.TrimSpace(p.a) != "" </span><span class="cov4" title="2">{ + <span class="cov3" title="2">if strings.TrimSpace(p.a) != "" </span><span class="cov3" title="2">{ msgs = append(msgs, llm.Message{Role: "assistant", Content: p.a}) }</span> } - <span class="cov4" title="2">msgs = append(msgs, llm.Message{Role: "user", Content: currentPrompt}) + <span class="cov3" title="2">msgs = append(msgs, llm.Message{Role: "user", Content: currentPrompt}) return msgs</span> } // stripTrailingTrigger removes the trailing chat trigger punctuation from a line if present. -func stripTrailingTrigger(sx string) string <span class="cov10" title="7">{ - s := strings.TrimRight(sx, " \t") - if len(s) >= 2 && s[len(s)-1] == '>' </span><span class="cov7" title="4">{ // new triggers - prev := s[len(s)-2] - if prev == '?' || prev == '!' || prev == ':' || prev == ';' </span><span class="cov7" title="4">{ - return strings.TrimRight(s[:len(s)-1], " \t") - }</span> - } - <span class="cov6" title="3">if strings.HasSuffix(s, ";;") </span><span class="cov0" title="0">{ // legacy inline cleanup used in history building - return strings.TrimRight(strings.TrimSuffix(s, ";;"), " \t") - }</span> - <span class="cov6" title="3">if len(s) == 0 </span><span class="cov0" title="0">{ - return sx - }</span> - <span class="cov6" title="3">last := s[len(s)-1] - switch last </span>{ // legacy: remove one trailing punctuation - case '?', '!', ':':<span class="cov1" title="1"> - return strings.TrimRight(s[:len(s)-1], " \t")</span> - default:<span class="cov4" title="2"> - return sx</span> +func stripTrailingTrigger(sx string) string <span class="cov8" title="7">{ + s := strings.TrimRight(sx, " \t") + if len(s) == 0 </span><span class="cov0" title="0">{ + return sx + }</span> + // Configurable suffix removal when preceded by configured prefixes + <span class="cov8" title="7">if len(s) >= 2 && s[len(s)-1] == chatSuffixChar </span><span class="cov6" title="4">{ + prev := string(s[len(s)-2]) + for _, pf := range chatPrefixSingles </span><span class="cov10" title="10">{ + if prev == pf </span><span class="cov6" title="4">{ + return strings.TrimRight(s[:len(s)-1], " \t") + }</span> } + } + // Legacy: inline cleanup for old semicolon form ";;" + <span class="cov5" title="3">if strings.HasSuffix(s, ";;") </span><span class="cov0" title="0">{ + return strings.TrimRight(strings.TrimSuffix(s, ";;"), " \t") + }</span> + // Legacy: remove one trailing punctuation (?, !, :) to build history nicely + <span class="cov5" title="3">last := s[len(s)-1] + switch last </span>{ + case '?', '!', ':':<span class="cov1" title="1"> + return strings.TrimRight(s[:len(s)-1], " \t")</span> + default:<span class="cov3" title="2"> + return sx</span> + } } // clientApplyEdit sends a workspace/applyEdit request to the client. @@ -3636,7 +3704,7 @@ func (s *Server) clientApplyEdit(label string, edit WorkspaceEdit) <span class=" }</span> // nextReqID returns a unique json.RawMessage id for server-initiated requests. -func (s *Server) nextReqID() json.RawMessage <span class="cov7" title="4">{ +func (s *Server) nextReqID() json.RawMessage <span class="cov6" title="4">{ s.mu.Lock() s.nextID++ idNum := s.nextID @@ -3646,7 +3714,7 @@ func (s *Server) nextReqID() json.RawMessage <span class="cov7" title="4">{ }</span> // clientShowDocument asks the client to open/focus a document and select a range. -func (s *Server) clientShowDocument(uri string, sel *Range) <span class="cov6" title="3">{ +func (s *Server) clientShowDocument(uri string, sel *Range) <span class="cov5" title="3">{ var params struct { URI string `json:"uri"` External bool `json:"external,omitempty"` @@ -3763,6 +3831,11 @@ import ( "time" ) +// Configurable inline trigger characters (default to '>') used by free helpers below. +// NewServer assigns these based on ServerOptions. +var inlineOpenChar byte = '>' +var inlineCloseChar byte = '>' + // llmRequestOpts builds request options from server settings. func (s *Server) llmRequestOpts() []llm.RequestOption <span class="cov5" title="11">{ opts := []llm.RequestOption{llm.WithMaxTokens(s.maxTokens)} @@ -3810,8 +3883,8 @@ func (s *Server) logLLMStats() <span class="cov4" title="7">{ } // Completion prompt builders and filters -func inParamList(current string, cursor int) bool <span class="cov5" title="10">{ - if !strings.Contains(current, "func ") </span><span class="cov4" title="5">{ +func inParamList(current string, cursor int) bool <span class="cov5" title="11">{ + if !strings.Contains(current, "func ") </span><span class="cov4" title="6">{ return false }</span> <span class="cov4" title="5">open := strings.Index(current, "(") @@ -3878,10 +3951,10 @@ func isIdentChar(ch byte) bool <span class="cov7" title="24">{ // Inline prompt utilities func lineHasInlinePrompt(line string) bool <span class="cov6" title="18">{ - if _, _, _, ok := findStrictSemicolonTag(line); ok </span><span class="cov1" title="1">{ - return true - }</span> - <span class="cov6" title="17">return hasDoubleSemicolonTrigger(line)</span> + if _, _, _, ok := findStrictSemicolonTag(line); ok </span><span class="cov1" title="1">{ + return true + }</span> + <span class="cov6" title="17">return hasDoubleSemicolonTrigger(line)</span> } func leadingIndent(line string) string <span class="cov2" title="2">{ @@ -3918,61 +3991,64 @@ func applyIndent(indent, suggestion string) string <span class="cov2" title="2"> // --- Inline marker parsing and general string utilities --- -// findStrictSemicolonTag finds ;text; with no space after first ';' and no space -// before the last ';' on the given line. Returns the text between semicolons, -// the start index of the opening ';', the end index just after the closing ';', -// and whether it was found. +// findStrictSemicolonTag now finds >text> (configurable), with no space after the first +// opening marker and no space immediately before the closing marker. Returns the +// text between markers, the start index, the end index just after closing, and ok. func findStrictSemicolonTag(line string) (string, int, int, bool) <span class="cov8" title="46">{ - pos := 0 - for pos < len(line) </span><span class="cov9" title="58">{ - j := strings.Index(line[pos:], ";") - if j < 0 </span><span class="cov7" title="30">{ - return "", 0, 0, false - }</span> - <span class="cov7" title="28">j += pos - // ensure single ';' (not ';;') and non-space after - if j+1 >= len(line) || line[j+1] == ';' || line[j+1] == ' ' </span><span class="cov6" title="14">{ - pos = j + 1 - continue</span> - } - <span class="cov6" title="14">k := strings.Index(line[j+1:], ";") - if k < 0 </span><span class="cov2" title="2">{ - return "", 0, 0, false - }</span> - <span class="cov5" title="12">closeIdx := j + 1 + k - if closeIdx-1 < 0 || line[closeIdx-1] == ' ' </span><span class="cov1" title="1">{ - pos = closeIdx + 1 - continue</span> - } - <span class="cov5" title="11">inner := strings.TrimSpace(line[j+1 : closeIdx]) - if inner == "" </span><span class="cov0" title="0">{ - pos = closeIdx + 1 - continue</span> - } - <span class="cov5" title="11">end := closeIdx + 1 - return inner, j, end, true</span> + pos := 0 + for pos < len(line) </span><span class="cov9" title="58">{ + // find opening marker + j := strings.IndexByte(line[pos:], inlineOpenChar) + if j < 0 </span><span class="cov7" title="27">{ + return "", 0, 0, false + }</span> + <span class="cov7" title="31">j += pos + // ensure single open (not double) and non-space after + if j+1 >= len(line) || line[j+1] == inlineOpenChar || line[j+1] == ' ' </span><span class="cov6" title="18">{ + pos = j + 1 + continue</span> + } + // find closing marker + <span class="cov6" title="13">k := strings.IndexByte(line[j+1:], inlineCloseChar) + if k < 0 </span><span class="cov1" title="1">{ + return "", 0, 0, false + }</span> + <span class="cov5" title="12">closeIdx := j + 1 + k + if closeIdx-1 < 0 || line[closeIdx-1] == ' ' </span><span class="cov1" title="1">{ + pos = closeIdx + 1 + continue</span> + } + <span class="cov5" title="11">inner := strings.TrimSpace(line[j+1 : closeIdx]) + if inner == "" </span><span class="cov0" title="0">{ + pos = closeIdx + 1 + continue</span> } - <span class="cov3" title="3">return "", 0, 0, false</span> + <span class="cov5" title="11">end := closeIdx + 1 + return inner, j, end, true</span> + } + <span class="cov4" title="7">return "", 0, 0, false</span> } // isBareDoubleSemicolon reports whether the line contains a standalone // double-semicolon marker with no inline content (";;" possibly with only // whitespace after it). It explicitly excludes the valid form ";;text;". -func isBareDoubleSemicolon(line string) bool <span class="cov6" title="18">{ - t := strings.TrimSpace(line) - if !strings.Contains(t, ";;") </span><span class="cov6" title="16">{ - return false - }</span> - <span class="cov2" title="2">if hasDoubleSemicolonTrigger(t) </span><span class="cov1" title="1">{ - return false +func isBareDoubleSemicolon(line string) bool <span class="cov6" title="19">{ + t := strings.TrimSpace(line) + // check for double-open pattern + dbl := string([]byte{inlineOpenChar, inlineOpenChar}) + if !strings.Contains(t, dbl) </span><span class="cov6" title="16">{ + return false + }</span> + <span class="cov3" title="3">if hasDoubleSemicolonTrigger(t) </span><span class="cov1" title="1">{ + return false + }</span> + <span class="cov2" title="2">if strings.HasPrefix(t, dbl) </span><span class="cov2" title="2">{ + rest := strings.TrimSpace(t[len(dbl):]) + if rest == "" || rest == ";" </span><span class="cov2" title="2">{ + return true }</span> - <span class="cov1" title="1">if strings.HasPrefix(t, ";;") </span><span class="cov1" title="1">{ - rest := strings.TrimSpace(t[2:]) - if rest == "" || rest == ";" </span><span class="cov1" title="1">{ - return true - }</span> - } - <span class="cov0" title="0">return false</span> + } + <span class="cov0" title="0">return false</span> } // stripDuplicateAssignmentPrefix removes a duplicated assignment prefix from the suggestion. @@ -4155,81 +4231,84 @@ func (s *Server) collectPromptRemovalEdits(uri string) []TextEdit <span class="c } func promptRemovalEditsForLine(line string, lineNum int) []TextEdit <span class="cov4" title="7">{ - if hasDoubleSemicolonTrigger(line) </span><span class="cov3" title="3">{ - return []TextEdit{{Range: Range{Start: Position{Line: lineNum, Character: 0}, End: Position{Line: lineNum, Character: len(line)}}, NewText: ""}} - }</span> - <span class="cov3" title="4">return collectSemicolonMarkers(line, lineNum)</span> + if hasDoubleSemicolonTrigger(line) </span><span class="cov3" title="3">{ + return []TextEdit{{Range: Range{Start: Position{Line: lineNum, Character: 0}, End: Position{Line: lineNum, Character: len(line)}}, NewText: ""}} + }</span> + <span class="cov3" title="4">return collectSemicolonMarkers(line, lineNum)</span> } -func hasDoubleSemicolonTrigger(line string) bool <span class="cov8" title="51">{ - pos := 0 - for pos < len(line) </span><span class="cov8" title="55">{ - j := strings.Index(line[pos:], ";;") - if j < 0 </span><span class="cov7" title="34">{ - return false - }</span> - <span class="cov7" title="21">j += pos - contentStart := j + 2 - if contentStart >= len(line) </span><span class="cov4" title="7">{ - return false - }</span> - <span class="cov6" title="14">first := line[contentStart] - if first == ' ' || first == ';' </span><span class="cov4" title="5">{ - pos = contentStart + 1 - continue</span> - } - <span class="cov5" title="9">k := strings.Index(line[contentStart+1:], ";") - if k < 0 </span><span class="cov0" title="0">{ - return false - }</span> - <span class="cov5" title="9">closeIdx := contentStart + 1 + k - if closeIdx-1 >= 0 && line[closeIdx-1] == ' ' </span><span class="cov1" title="1">{ - pos = closeIdx + 1 - continue</span> - } - <span class="cov5" title="8">return true</span> +func hasDoubleSemicolonTrigger(line string) bool <span class="cov8" title="49">{ + pos := 0 + for pos < len(line) </span><span class="cov8" title="50">{ + // look for double-open sequence + dbl := string([]byte{inlineOpenChar, inlineOpenChar}) + j := strings.Index(line[pos:], dbl) + if j < 0 </span><span class="cov7" title="32">{ + return false + }</span> + <span class="cov6" title="18">j += pos + contentStart := j + len(dbl) + if contentStart >= len(line) </span><span class="cov4" title="6">{ + return false + }</span> + <span class="cov5" title="12">first := line[contentStart] + if first == ' ' || first == inlineOpenChar </span><span class="cov3" title="3">{ + pos = contentStart + 1 + continue</span> } - <span class="cov2" title="2">return false</span> + // find closing + <span class="cov5" title="9">k := strings.IndexByte(line[contentStart+1:], inlineCloseChar) + if k < 0 </span><span class="cov0" title="0">{ + return false + }</span> + <span class="cov5" title="9">closeIdx := contentStart + 1 + k + if closeIdx-1 >= 0 && line[closeIdx-1] == ' ' </span><span class="cov1" title="1">{ + pos = closeIdx + 1 + continue</span> + } + <span class="cov5" title="8">return true</span> + } + <span class="cov3" title="3">return false</span> } func collectSemicolonMarkers(line string, lineNum int) []TextEdit <span class="cov4" title="5">{ - var edits []TextEdit - startSemi := 0 - for startSemi < len(line) </span><span class="cov5" title="9">{ - j := strings.Index(line[startSemi:], ";") - if j < 0 </span><span class="cov3" title="4">{ - break</span> - } - <span class="cov4" title="5">j += startSemi - k := strings.Index(line[j+1:], ";") - if k < 0 </span><span class="cov0" title="0">{ - break</span> - } - <span class="cov4" title="5">if j+1 >= len(line) || line[j+1] == ' ' </span><span class="cov0" title="0">{ - startSemi = j + 1 - continue</span> - } - <span class="cov4" title="5">if line[j+1] == ';' </span><span class="cov0" title="0">{ - startSemi = j + 2 - continue</span> - } - <span class="cov4" title="5">closeIdx := j + 1 + k - if closeIdx-1 < 0 || line[closeIdx-1] == ' ' </span><span class="cov0" title="0">{ - startSemi = closeIdx + 1 - continue</span> - } - <span class="cov4" title="5">if closeIdx-(j+1) < 1 </span><span class="cov0" title="0">{ - startSemi = closeIdx + 1 - continue</span> - } - <span class="cov4" title="5">endChar := closeIdx + 1 - if endChar < len(line) && line[endChar] == ' ' </span><span class="cov3" title="4">{ - endChar++ - }</span> - <span class="cov4" title="5">edits = append(edits, TextEdit{Range: Range{Start: Position{Line: lineNum, Character: j}, End: Position{Line: lineNum, Character: endChar}}, NewText: ""}) - startSemi = endChar</span> + var edits []TextEdit + startSemi := 0 + for startSemi < len(line) </span><span class="cov5" title="9">{ + j := strings.IndexByte(line[startSemi:], inlineOpenChar) + if j < 0 </span><span class="cov3" title="4">{ + break</span> + } + <span class="cov4" title="5">j += startSemi + k := strings.IndexByte(line[j+1:], inlineCloseChar) + if k < 0 </span><span class="cov0" title="0">{ + break</span> + } + <span class="cov4" title="5">if j+1 >= len(line) || line[j+1] == ' ' </span><span class="cov0" title="0">{ + startSemi = j + 1 + continue</span> } - <span class="cov4" title="5">return edits</span> + <span class="cov4" title="5">if line[j+1] == inlineOpenChar </span><span class="cov0" title="0">{ // skip double-open start + startSemi = j + 2 + continue</span> + } + <span class="cov4" title="5">closeIdx := j + 1 + k + if closeIdx-1 < 0 || line[closeIdx-1] == ' ' </span><span class="cov0" title="0">{ + startSemi = closeIdx + 1 + continue</span> + } + <span class="cov4" title="5">if closeIdx-(j+1) < 1 </span><span class="cov0" title="0">{ + startSemi = closeIdx + 1 + continue</span> + } + <span class="cov4" title="5">endChar := closeIdx + 1 + if endChar < len(line) && line[endChar] == ' ' </span><span class="cov3" title="4">{ + endChar++ + }</span> + <span class="cov4" title="5">edits = append(edits, TextEdit{Range: Range{Start: Position{Line: lineNum, Character: j}, End: Position{Line: lineNum, Character: endChar}}, NewText: ""}) + startSemi = endChar</span> + } + <span class="cov4" title="5">return edits</span> } </pre> @@ -4237,14 +4316,15 @@ func collectSemicolonMarkers(line string, lineNum int) []TextEdit <span class="c package lsp import ( - "bufio" - "encoding/json" - "codeberg.org/snonux/hexai/internal/llm" - "codeberg.org/snonux/hexai/internal/logging" - "io" - "log" - "sync" - "time" + "bufio" + "encoding/json" + "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/logging" + "io" + "log" + "strings" + "sync" + "time" ) // Server implements a minimal LSP over stdio. @@ -4286,6 +4366,12 @@ type Server struct { // Dispatch table for JSON-RPC methods → handler functions handlers map[string]func(Request) + + // Configurable trigger characters + inlineOpen string + inlineClose string + chatSuffix string + chatPrefixes []string } // ServerOptions collects configuration for NewServer to avoid long parameter lists. @@ -4302,50 +4388,67 @@ type ServerOptions struct { ManualInvokeMinPrefix int CompletionDebounceMs int CompletionThrottleMs int + + // Inline/chat triggers + InlineOpen string + InlineClose string + ChatSuffix string + ChatPrefixes []string } -func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) *Server <span class="cov10" title="3">{ +func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) *Server <span class="cov10" title="4">{ s := &Server{in: bufio.NewReader(r), out: w, logger: logger, docs: make(map[string]*document), logContext: opts.LogContext} maxTokens := opts.MaxTokens - if maxTokens <= 0 </span><span class="cov6" title="2">{ + if maxTokens <= 0 </span><span class="cov8" title="3">{ maxTokens = 500 }</span> - <span class="cov10" title="3">s.maxTokens = maxTokens + <span class="cov10" title="4">s.maxTokens = maxTokens contextMode := opts.ContextMode - if contextMode == "" </span><span class="cov6" title="2">{ + if contextMode == "" </span><span class="cov8" title="3">{ contextMode = "file-on-new-func" }</span> - <span class="cov10" title="3">windowLines := opts.WindowLines - if windowLines <= 0 </span><span class="cov6" title="2">{ + <span class="cov10" title="4">windowLines := opts.WindowLines + if windowLines <= 0 </span><span class="cov8" title="3">{ windowLines = 120 }</span> - <span class="cov10" title="3">maxContextTokens := opts.MaxContextTokens - if maxContextTokens <= 0 </span><span class="cov6" title="2">{ + <span class="cov10" title="4">maxContextTokens := opts.MaxContextTokens + if maxContextTokens <= 0 </span><span class="cov8" title="3">{ maxContextTokens = 2000 }</span> - <span class="cov10" title="3">s.contextMode = contextMode + <span class="cov10" title="4">s.contextMode = contextMode s.windowLines = windowLines s.maxContextTokens = maxContextTokens s.startTime = time.Now() s.llmClient = opts.Client - if len(opts.TriggerCharacters) == 0 </span><span class="cov10" title="3">{ + if len(opts.TriggerCharacters) == 0 </span><span class="cov10" title="4">{ // Defaults (no space to avoid auto-trigger after whitespace) s.triggerChars = []string{".", ":", "/", "_", ")", "{"} }</span> else<span class="cov0" title="0"> { s.triggerChars = append([]string{}, opts.TriggerCharacters...) }</span> - <span class="cov10" title="3">s.codingTemperature = opts.CodingTemperature + <span class="cov10" title="4">s.codingTemperature = opts.CodingTemperature s.compCache = make(map[string]string) s.manualInvokeMinPrefix = opts.ManualInvokeMinPrefix if opts.CompletionDebounceMs > 0 </span><span class="cov1" title="1">{ s.completionDebounce = time.Duration(opts.CompletionDebounceMs) * time.Millisecond }</span> - <span class="cov10" title="3">if opts.CompletionThrottleMs > 0 </span><span class="cov0" title="0">{ + <span class="cov10" title="4">if opts.CompletionThrottleMs > 0 </span><span class="cov0" title="0">{ s.throttleInterval = time.Duration(opts.CompletionThrottleMs) * time.Millisecond }</span> + // Trigger character config (with sane defaults if missing) + <span class="cov10" title="4">if strings.TrimSpace(opts.InlineOpen) == "" </span><span class="cov8" title="3">{ s.inlineOpen = ">" }</span> else<span class="cov1" title="1"> { s.inlineOpen = opts.InlineOpen }</span> + <span class="cov10" title="4">if strings.TrimSpace(opts.InlineClose) == "" </span><span class="cov8" title="3">{ s.inlineClose = ">" }</span> else<span class="cov1" title="1"> { s.inlineClose = opts.InlineClose }</span> + <span class="cov10" title="4">if strings.TrimSpace(opts.ChatSuffix) == "" </span><span class="cov8" title="3">{ s.chatSuffix = ">" }</span> else<span class="cov1" title="1"> { s.chatSuffix = opts.ChatSuffix }</span> + <span class="cov10" title="4">if len(opts.ChatPrefixes) == 0 </span><span class="cov8" title="3">{ s.chatPrefixes = []string{"?","!",":",";"} }</span> else<span class="cov1" title="1"> { s.chatPrefixes = append([]string{}, opts.ChatPrefixes...) }</span> + + // Assign package-level inline trigger chars for free helper functions + <span class="cov10" title="4">if s.inlineOpen != "" </span><span class="cov10" title="4">{ inlineOpenChar = s.inlineOpen[0] }</span> + <span class="cov10" title="4">if s.inlineClose != "" </span><span class="cov10" title="4">{ inlineCloseChar = s.inlineClose[0] }</span> + <span class="cov10" title="4">if s.chatSuffix != "" </span><span class="cov10" title="4">{ chatSuffixChar = s.chatSuffix[0] }</span> + <span class="cov10" title="4">if len(s.chatPrefixes) > 0 </span><span class="cov10" title="4">{ chatPrefixSingles = append([]string{}, s.chatPrefixes...) }</span> // Initialize dispatch table - <span class="cov10" title="3">s.handlers = map[string]func(Request){ + <span class="cov10" title="4">s.handlers = map[string]func(Request){ "initialize": s.handleInitialize, "initialized": func(_ Request) </span><span class="cov0" title="0">{ s.handleInitialized() }</span>, "shutdown": s.handleShutdown, @@ -4358,7 +4461,7 @@ func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) "codeAction/resolve": s.handleCodeActionResolve, "workspace/executeCommand": s.handleExecuteCommand, } - <span class="cov10" title="3">return s</span> + <span class="cov10" title="4">return s</span> } func (s *Server) Run() error <span class="cov1" title="1">{ |
