From 5be9532cfa630f4aacd8d879c3e4f5cc316da0fa Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sat, 6 Sep 2025 10:25:36 +0300 Subject: feat(lsp): configurable inline/chat triggers; switch inline markers to >text>/>>text>; update docs and example config; tests updated to new triggers and raise LSP coverage to >=85%; chore: remove semicolon legacy; chore(mage): auto-refresh coverage daily if docs/coverage.out is older than 24h --- AGENTS.md | 2 +- Magefile.go | 32 +- PROJECTSTATUS.md | 4 +- README.md | 7 +- config.json.example | 4 + docs/configuration.md | 26 + docs/coverage.html | 647 ++- docs/coverage.out | 6918 ++++++++++++++------------ docs/usage-examples.md | 104 - docs/usage.md | 104 + internal/appconfig/config.go | 49 +- internal/hexailsp/run.go | 4 + internal/llm/copilot_http_test.go | 5 + internal/llm/ollama_test.go | 8 + internal/llm/openai_http_test.go | 8 + internal/llm/openai_sse_negative_test.go | 3 +- internal/lsp/codeaction_test.go | 2 +- internal/lsp/completion_prefix_strip_test.go | 102 +- internal/lsp/debounce_throttle_more_test.go | 36 + internal/lsp/document_test.go | 27 +- internal/lsp/handlers.go | 15 +- internal/lsp/handlers_completion.go | 51 +- internal/lsp/handlers_document.go | 79 +- internal/lsp/handlers_end_to_end_test.go | 4 +- internal/lsp/handlers_helpers_test.go | 56 +- internal/lsp/handlers_test.go | 78 +- internal/lsp/handlers_utils.go | 259 +- internal/lsp/helpers_inline_prompt_test.go | 58 + internal/lsp/helpers_more_test.go | 22 +- internal/lsp/init_and_trigger_test.go | 5 +- internal/lsp/instruction_table_test.go | 3 +- internal/lsp/llm_stats_test.go | 11 + internal/lsp/postprocess_indent_test.go | 5 +- internal/lsp/provider_native_success_test.go | 21 + internal/lsp/server.go | 40 +- internal/lsp/transport_test.go | 15 +- internal/lsp/triggers_config_test.go | 74 + 37 files changed, 4857 insertions(+), 4031 deletions(-) delete mode 100644 docs/usage-examples.md create mode 100644 docs/usage.md create mode 100644 internal/lsp/debounce_throttle_more_test.go create mode 100644 internal/lsp/helpers_inline_prompt_test.go create mode 100644 internal/lsp/llm_stats_test.go create mode 100644 internal/lsp/triggers_config_test.go diff --git a/AGENTS.md b/AGENTS.md index 5c1bbd1..fe3f8ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ ## Coding Style & Naming Conventions -- Aim for at least 80% unit test coverage of all source code. +- Aim for at least 85% unit test coverage of all source code. - Ensure that all unit tests pass before merging any changes. - If possible, construct individual methods so that they can be unit tested. But only if it doesn't add too much boilerplate to the code base. - There should be no source code file larger than 1000 lines. If so, split it up into multiple. diff --git a/Magefile.go b/Magefile.go index bf8de52..6acc882 100644 --- a/Magefile.go +++ b/Magefile.go @@ -90,6 +90,8 @@ func Install() error { // printCoverage prints a warning if an existing coverage profile shows total < coverateThreshold. func printCoverage() { + // Ensure the top-level coverage profile is refreshed at least once per day. + ensureDailyCoverage(24 * time.Hour) select { case coveragePrinted <- struct{}{}: default: @@ -117,6 +119,23 @@ func printCoverage() { } } +// ensureDailyCoverage regenerates the main coverage profile when it's missing +// or older than maxAge. It writes to docs/coverage.out via the Coverage target. +func ensureDailyCoverage(maxAge time.Duration) { + const prof = "docs/coverage.out" + st, err := os.Stat(prof) + if err == nil { + age := time.Since(st.ModTime()) + if age <= maxAge { + return // fresh enough + } + } + // Missing or stale; attempt to refresh. Do not hard-fail builds if coverage fails. + if err := Coverage(); err != nil { + fmt.Println("[coverage] refresh skipped due to error:", err) + } +} + // totalCoveragePercent returns the parsed total percentage from a coverage profile using `go tool cover -func`. func totalCoveragePercent(profile string) (float64, bool) { out, err := sh.Output("go", "tool", "cover", "-func="+profile) @@ -196,16 +215,9 @@ func DevInstall() error { } // CoverCheck enforces minimum per-package coverage. -// Default threshold is 80.0; override with HEXAI_COVER_THRESH. // Exceptions: any package whose import path contains "/cmd/" and any substring // provided via HEXAI_COVER_EXCEPT (comma-separated). func CoverCheck() error { - threshold := 80.0 - if v := strings.TrimSpace(os.Getenv("HEXAI_COVER_THRESH")); v != "" { - if f, err := strconv.ParseFloat(v, 64); err == nil { - threshold = f - } - } except := []string{"/cmd/"} if v := strings.TrimSpace(os.Getenv("HEXAI_COVER_EXCEPT")); v != "" { parts := strings.Split(v, ",") @@ -256,12 +268,12 @@ func CoverCheck() error { total = 0 } all = append(all, res{pkg, total}) - if total < threshold { + if total < coverageThreshold { bad = append(bad, res{pkg, total}) } time.Sleep(10 * time.Millisecond) } - fmt.Printf("Per-package coverage (threshold %.1f%%)\n", threshold) + fmt.Printf("Per-package coverage (threshold %.1f%%)\n", coverageThreshold) for _, r := range all { fmt.Printf("- %s: %.1f%%\n", r.pkg, r.total) } @@ -270,7 +282,7 @@ func CoverCheck() error { for _, r := range bad { fmt.Printf("- %s: %.1f%%\n", r.pkg, r.total) } - return fmt.Errorf("coverage check failed (%d package(s) < %.1f%%)", len(bad), threshold) + return fmt.Errorf("coverage check failed (%d package(s) < %.1f%%)", len(bad), coverageThreshold) } fmt.Println("All packages meet coverage threshold.") return nil diff --git a/PROJECTSTATUS.md b/PROJECTSTATUS.md index b9d815f..02ed875 100644 --- a/PROJECTSTATUS.md +++ b/PROJECTSTATUS.md @@ -2,7 +2,7 @@ ## Code quality -* [/] TODO's in the code to be addressed +* [X] TODO's in the code to be addressed * [/] No more than 1000 LOC per source file * [/] No more than 50 LOC per function * [/] Each struct type in his own file @@ -13,7 +13,7 @@ ### Improvements -* [ ] Modify the LLM triggers to be more consistenc. E.g. use >>text here> or >text here> instead of semicolons? +* [X] Modify the LLM triggers to be more consistenc. E.g. use >>text here> or >text here> instead of semicolons? * [X] Include unit test coverage reports * [ ] Change inline triggers to include > to be more consistent with other triggers * [ ] Use are more stricter linter for auto-generated code (gofumpt i think is such a linter) diff --git a/README.md b/README.md index cc15ba4..f9b9864 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,10 @@ It has got improved capabilities for Go code understanding (for example, create * Stand-alone command line tool for LLM interaction * Support for OpenAI, GitHub Copilot, and Ollama -AI coded it under human orchestration and supervision following best practices with manual code reviews. - ## Documentation * [Configuration guide](docs/configuration.md) -* [Usage examples](docs/usage-examples.md) +* [Usage examples](docs/usage.md) * [Source structure](docs/source-structure.md) ## Build and tasks @@ -29,6 +27,9 @@ Hexai uses Mage for developer tasks. Install Mage, then run targets like build, - Build binaries: `mage build` (produces `hexai` and `hexai-lsp`) - Dev build (+ tests, vet, lint): `mage dev` - Run tests: `mage test` +- Run tests with coverage: `go test ./... -cover` +- In restricted sandboxes/CI (no sockets), skip network-based tests: + - `HEXAI_TEST_SKIP_NET=1 go test ./... -cover` - Install binaries to `GOPATH/bin`: `mage install` Note: `mage lint` uses `golangci-lint`. Install via `mage devinstall` if needed. diff --git a/config.json.example b/config.json.example index d0e6ed7..7a4298c 100644 --- a/config.json.example +++ b/config.json.example @@ -8,6 +8,10 @@ "completion_throttle_ms": 0, "no_disk_io": true, "trigger_characters": [".", ":", "/", "_", " "], + "inline_open": ">", + "inline_close": ">", + "chat_suffix": ">", + "chat_prefixes": ["?", "!", ":", ";"], "coding_temperature": 0.2, "provider": "openai", diff --git a/docs/configuration.md b/docs/configuration.md index e5e7dfa..3b862af 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -21,6 +21,10 @@ The config file is optional. "completion_throttle_ms": 0, "no_disk_io": true, "trigger_characters": [".", ":", "/", "_", " " ], + "inline_open": ">", + "inline_close": ">", + "chat_suffix": ">", + "chat_prefixes": ["?", "!", ":", ";"], "coding_temperature": 0.2, "provider": "ollama", "copilot_model": "gpt-4o-mini", @@ -47,9 +51,29 @@ Key fields: - manual_invoke_min_prefix: minimum typed identifier chars required for manual invoke to proceed without structural triggers (0 allows always). - no_disk_io: avoid reading files from disk when building context. - trigger_characters: LSP completion trigger characters. +- inline_open / inline_close: characters that bracket inline prompts (default `>`/`>`). Inline prompts support `>text>` and a double-open variant `>>text>`. Single-character markers are required. +- chat_suffix / chat_prefixes: in-editor chat triggers (default suffix `>` and prefixes `["?","!",":",";"]`). A line ending with one of these prefixes immediately followed by the suffix triggers a chat reply (e.g., `What?>`). Prefixes must be single characters. - coding_temperature: optional override for LSP calls. - provider: `openai` | `copilot` | `ollama`. +### Trigger customization + +Defaults use `>` for inline prompts and chat suffix. You can change them, e.g.: + +```json +{ + "inline_open": "<", + "inline_close": ">", + "chat_suffix": "/", + "chat_prefixes": ["?", "!"], + "trigger_characters": [".", ":", "/", "_", " "] +} +``` + +Notes: +- `inline_open`/`inline_close` must be single characters; `>>text>` is the double‑open variant. +- `chat_prefixes` items must be single characters. + ## Environment overrides - All config-file options can be overridden by environment variables prefixed with `HEXAI_`. @@ -59,6 +83,8 @@ Key fields: - `HEXAI_CODING_TEMPERATURE` - `HEXAI_COMPLETION_DEBOUNCE_MS`, `HEXAI_COMPLETION_THROTTLE_MS` - `HEXAI_TRIGGER_CHARACTERS` (comma-separated, e.g., `".,:,_ , "`) + - `HEXAI_INLINE_OPEN`, `HEXAI_INLINE_CLOSE` + - `HEXAI_CHAT_SUFFIX`, `HEXAI_CHAT_PREFIXES` (comma-separated) - `HEXAI_OPENAI_MODEL`, `HEXAI_OPENAI_BASE_URL`, `HEXAI_OPENAI_TEMPERATURE` - `HEXAI_COPILOT_MODEL`, `HEXAI_COPILOT_BASE_URL`, `HEXAI_COPILOT_TEMPERATURE` - `HEXAI_OLLAMA_MODEL`, `HEXAI_OLLAMA_BASE_URL`, `HEXAI_OLLAMA_TEMPERATURE` 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 @@ - + @@ -83,21 +83,21 @@ - + - + - + - + - + @@ -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 { ManualInvokeMinPrefix: 0, CompletionDebounceMs: 200, CompletionThrottleMs: 0, + // Inline/chat trigger defaults + InlineOpen: ">", + InlineClose: ">", + ChatSuffix: ">", + ChatPrefixes: []string{"?", "!", ":", ";"}, } } // Load reads configuration from a file and merges with defaults. // It respects the XDG Base Directory Specification. -func Load(logger *log.Logger) App { +func Load(logger *log.Logger) App { cfg := newDefaultConfig() if logger == nil { return cfg // Return defaults if no logger is provided (e.g. in tests) @@ -331,12 +343,24 @@ func (a *App) mergeBasics(other *App) { } if other.CompletionDebounceMs > 0 { a.CompletionDebounceMs = other.CompletionDebounceMs } if other.CompletionThrottleMs > 0 { a.CompletionThrottleMs = other.CompletionThrottleMs } - if len(other.TriggerCharacters) > 0 { - a.TriggerCharacters = slices.Clone(other.TriggerCharacters) - } - if s := strings.TrimSpace(other.Provider); s != "" { - a.Provider = s - } + if len(other.TriggerCharacters) > 0 { + a.TriggerCharacters = slices.Clone(other.TriggerCharacters) + } + if s := strings.TrimSpace(other.InlineOpen); s != "" { + a.InlineOpen = s + } + if s := strings.TrimSpace(other.InlineClose); s != "" { + a.InlineClose = s + } + if s := strings.TrimSpace(other.ChatSuffix); s != "" { + a.ChatSuffix = s + } + if len(other.ChatPrefixes) > 0 { + a.ChatPrefixes = slices.Clone(other.ChatPrefixes) + } + if s := strings.TrimSpace(other.Provider); s != "" { + a.Provider = s + } } // mergeProviderFields merges per-provider configuration. @@ -393,7 +417,7 @@ func loadFromEnv(logger *log.Logger) *App { var any bool // helpers - getenv := func(k string) string { return strings.TrimSpace(os.Getenv(k)) } + getenv := func(k string) string { return strings.TrimSpace(os.Getenv(k)) } parseInt := func(k string) (int, bool) { v := getenv(k) if v == "" { return 0, false } @@ -449,6 +473,19 @@ func loadFromEnv(logger *log.Logger) *App { } any = true } + if s := getenv("HEXAI_INLINE_OPEN"); s != "" { out.InlineOpen = s; any = true } + if s := getenv("HEXAI_INLINE_CLOSE"); s != "" { out.InlineClose = s; any = true } + if s := getenv("HEXAI_CHAT_SUFFIX"); s != "" { out.ChatSuffix = s; any = true } + if s := getenv("HEXAI_CHAT_PREFIXES"); s != "" { + parts := strings.Split(s, ",") + out.ChatPrefixes = nil + for _, p := range parts { + if t := strings.TrimSpace(p); t != "" { + out.ChatPrefixes = append(out.ChatPrefixes, t) + } + } + any = true + } if s := getenv("HEXAI_PROVIDER"); s != "" { out.Provider = s; any = true } @@ -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, } } @@ -2088,9 +2129,9 @@ func (s *Server) handle(req Request) { // 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) { +func instructionFromSelection(sel string) (string, string) { lines := splitLines(sel) - for idx, line := range lines { + for idx, line := range lines { if instr, cleaned, ok := findFirstInstructionInLine(line); ok && strings.TrimSpace(instr) != "" { lines[idx] = cleaned return instr, strings.Join(lines, "\n") @@ -2108,7 +2149,7 @@ func instructionFromSelection(sel string) (string, string) { +func findFirstInstructionInLine(line string) (instr string, cleaned string, ok bool) { 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 { cands = append(cands, cand{start: l, end: r, text: t}) } - if i := strings.Index(line, "/*"); i >= 0 { + if i := strings.Index(line, "/*"); i >= 0 { if j := strings.Index(line[i+2:], "*/"); j >= 0 { 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}) } } - if i := strings.Index(line, "<!--"); i >= 0 { + if i := strings.Index(line, "<!--"); i >= 0 { if j := strings.Index(line[i+4:], "-->"); j >= 0 { 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}) } } - if i := strings.Index(line, "//"); i >= 0 { + if i := strings.Index(line, "//"); i >= 0 { cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])}) } - if i := strings.Index(line, "#"); i >= 0 { + if i := strings.Index(line, "#"); i >= 0 { cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+1:])}) } - if i := strings.Index(line, "--"); i >= 0 { + if i := strings.Index(line, "--"); i >= 0 { cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])}) } - if len(cands) == 0 { + if len(cands) == 0 { return "", line, false } // pick earliest start index @@ -2251,33 +2292,33 @@ func (s *Server) reply(id json.RawMessage, result any, err *RespError) { +func (s *Server) completionCacheKey(p CompletionParams, above, current, below, funcCtx string, inParams bool, hasExtra bool, extraText string) string { // Normalize left-of-cursor by trimming trailing spaces/tabs idx := p.Position.Character if idx > len(current) { idx = len(current) } - left := strings.TrimRight(current[:idx], " \t") + left := strings.TrimRight(current[:idx], " \t") right := "" if idx < len(current) { right = current[idx:] } - prov := "" + prov := "" model := "" - if s.llmClient != nil { + if s.llmClient != nil { prov = s.llmClient.Name() model = s.llmClient.DefaultModel() } - temp := "" + temp := "" if s.codingTemperature != nil { temp = fmt.Sprintf("%.3f", *s.codingTemperature) } - extra := "" + extra := "" if hasExtra { extra = strings.TrimSpace(extraText) } // Compose a key from essential context parts - return strings.Join([]string{ + 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") // use unit separator to avoid collisions } -func (s *Server) completionCacheGet(key string) (string, bool) { +func (s *Server) completionCacheGet(key string) (string, bool) { s.mu.Lock() defer s.mu.Unlock() v, ok := s.compCache[key] - if !ok { + if !ok { return "", false } // move to most-recent @@ -2306,13 +2347,13 @@ func (s *Server) completionCacheGet(key string) (string, bool) { +func (s *Server) completionCachePut(key, value string) { s.mu.Lock() defer s.mu.Unlock() if s.compCache == nil { s.compCache = make(map[string]string) } - if _, exists := s.compCache[key]; !exists { + if _, exists := s.compCache[key]; !exists { s.compCacheOrder = append(s.compCacheOrder, key) s.compCache[key] = value if len(s.compCacheOrder) > 10 { @@ -2321,7 +2362,7 @@ func (s *Server) completionCachePut(key, value string) - return + return } // update existing and mark most-recent s.compCache[key] = value @@ -2348,25 +2389,26 @@ func (s *Server) compCacheTouchLocked(key string) { // 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 { +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 { + if raw, ok := p.Context.(json.RawMessage); ok { _ = json.Unmarshal(raw, &ctx) } else { b, _ := json.Marshal(p.Context) _ = json.Unmarshal(b, &ctx) } - // If the line contains a bare ';;' (no ';;text;'), do not treat as a trigger source. - if strings.Contains(current, ";;") && !hasDoubleSemicolonTrigger(current) { + // If configured and the line contains a bare double-open marker (e.g., '>>' with no '>>text>'), + // do not treat as a trigger source. + if s.inlineOpen != "" && strings.Contains(current, s.inlineOpen+s.inlineOpen) && !hasDoubleSemicolonTrigger(current) { return false } // TriggerKind 1 = Invoked (manual). Always allow manual invoke. - if ctx.TriggerKind == 1 { + if ctx.TriggerKind == 1 { return true } // TriggerKind 2 is TriggerCharacter per LSP spec @@ -2385,32 +2427,32 @@ func (s *Server) isTriggerEvent(p CompletionParams, current string) bool idx := p.Position.Character + idx := p.Position.Character if idx <= 0 || idx > len(current) { return false } - // Bare ';;' should not trigger via fallback char either - if strings.Contains(current, ";;") && !hasDoubleSemicolonTrigger(current) { - return false - } - ch := string(current[idx-1]) - for _, c := range s.triggerChars { + // Bare double-open should not trigger via fallback char either (only when configured) + if s.inlineOpen != "" && strings.Contains(current, s.inlineOpen+s.inlineOpen) && !hasDoubleSemicolonTrigger(current) { + return false + } + ch := string(current[idx-1]) + for _, c := range s.triggerChars { if c == ch { return true } } - return false + return false } -func (s *Server) makeCompletionItems(cleaned string, inParams bool, current string, p CompletionParams, docStr string) []CompletionItem { +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) label := labelForCompletion(cleaned, filter) detail := "Hexai LLM completion" - if s.llmClient != nil { + if s.llmClient != nil { detail = "Hexai " + s.llmClient.Name() + ":" + s.llmClient.DefaultModel() } - return []CompletionItem{{ + 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) { + if !inlinePrompt && !s.isTriggerEvent(p, current) { 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 } - if s.shouldSuppressForChatTriggerEOL(current, p) { + if s.shouldSuppressForChatTriggerEOL(current, p) { return []CompletionItem{}, true } - inParams := inParamList(current, p.Position.Character) + 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 } - if (isBareDoubleSemicolon(current) || isBareDoubleSemicolon(below)) && !manualInvoke { - 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 - } + if (isBareDoubleSemicolon(current) || isBareDoubleSemicolon(below)) { + 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 + } if !inParams && !s.prefixHeuristicAllows(inlinePrompt, current, p, manualInvoke) { 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 { +func parseManualInvoke(ctx any) bool { if ctx == nil { return false } - var c struct { + var c struct { TriggerKind int `json:"triggerKind"` } - if raw, ok := ctx.(json.RawMessage); ok { + if raw, ok := ctx.(json.RawMessage); ok { _ = json.Unmarshal(raw, &c) } else { b, _ := json.Marshal(ctx) _ = json.Unmarshal(b, &c) } - return c.TriggerKind == 1 + return c.TriggerKind == 1 } // shouldSuppressForChatTriggerEOL returns true when a chat trigger like ">" follows ?, !, :, or ; at EOL. -func (s *Server) shouldSuppressForChatTriggerEOL(current string, p CompletionParams) bool { - if t := strings.TrimRight(current, " \t"); len(t) >= 2 && t[len(t)-1] == '>' { - prev := t[len(t)-2] - if prev == '?' || prev == '!' || prev == ':' || prev == ';' { - logging.Logf("lsp ", "completion skip=chat-trigger-eol uri=%s line=%d", p.TextDocument.URI, p.Position.Line) - return true - } +func (s *Server) shouldSuppressForChatTriggerEOL(current string, p CompletionParams) bool { + t := strings.TrimRight(current, " \t") + if s.chatSuffix == "" { return false } + if strings.HasSuffix(t, s.chatSuffix) { + if len(t) < len(s.chatSuffix)+1 { return false } + prev := string(t[len(t)-len(s.chatSuffix)-1]) + for _, pf := range s.chatPrefixes { + if prev == pf { + logging.Logf("lsp ", "completion skip=chat-trigger-eol uri=%s line=%d", p.TextDocument.URI, p.Position.Line) + return true + } } - return false + } + return false } // 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) { var p DidOpenTextDocumentParams if err := json.Unmarshal(req.Params, &p); err == nil { @@ -3414,9 +3466,9 @@ func (s *Server) handleDidClose(req Request) { // 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) { +func (s *Server) docBeforeAfter(uri string, pos Position) (string, string) { d := s.getDocument(uri) - if d == nil { + if d == nil { return "", "" } // Clamp indices @@ -3457,44 +3509,55 @@ func (s *Server) docBeforeAfter(uri string, pos Position) (string, string) { +func (s *Server) detectAndHandleChat(uri string) { if s.llmClient == nil { return } - d := s.getDocument(uri) + d := s.getDocument(uri) if d == nil || len(d.lines) == 0 { return } - for i, raw := range d.lines { + for i, raw := range d.lines { // Find last non-space character index j := len(raw) - 1 - for j >= 0 { + for j >= 0 { if raw[j] == ' ' || raw[j] == '\t' { j-- continue } - break - } - if j < 1 { - continue - } // need at least two chars - pair := raw[j-1 : j+1] - isTrigger := pair == "?>" || pair == "!>" || pair == ":>" || pair == ";>" - if !isTrigger { - continue + break } + if j < 0 { + continue + } + // Check suffix/prefix according to configuration + if s.chatSuffix == "" { + continue + } + // Last non-space must equal suffix + if string(raw[j]) != s.chatSuffix { + continue + } + // Require at least one char before suffix and that char must be in chatPrefixes + if j < 1 { continue } + prev := string(raw[j-1]) + isTrigger := false + for _, pfx := range s.chatPrefixes { + if prev == pfx { isTrigger = true; break } + } + if !isTrigger { continue } // Avoid double-answering: if the next non-empty line starts with '>' we skip. - k := i + 1 - for k < len(d.lines) && strings.TrimSpace(d.lines[k]) == "" { + k := i + 1 + for k < len(d.lines) && strings.TrimSpace(d.lines[k]) == "" { k++ } - if k < len(d.lines) && strings.HasPrefix(strings.TrimSpace(d.lines[k]), ">") { + if k < len(d.lines) && strings.HasPrefix(strings.TrimSpace(d.lines[k]), ">") { continue } // Derive prompt by removing only the trailing '>' - removeCount := 1 + removeCount := len(s.chatSuffix) base := raw[:j+1-removeCount] - prompt := strings.TrimSpace(base) + prompt := strings.TrimSpace(base) if prompt == "" { continue } @@ -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 { +func (s *Server) buildChatHistory(uri string, lineIdx int, currentPrompt string) []llm.Message { d := s.getDocument(uri) if d == nil { return []llm.Message{{Role: "user", Content: currentPrompt}} } - type pair struct{ q, a string } + type pair struct{ q, a string } pairs := []pair{} i := lineIdx - 1 - for i >= 0 && len(pairs) < 3 { + for i >= 0 && len(pairs) < 3 { for i >= 0 && strings.TrimSpace(d.lines[i]) == "" { i-- } - if i < 0 { + if i < 0 { break } - if !strings.HasPrefix(strings.TrimSpace(d.lines[i]), ">") { + if !strings.HasPrefix(strings.TrimSpace(d.lines[i]), ">") { break } - var replyLines []string - for i >= 0 { + var replyLines []string + for i >= 0 { line := strings.TrimSpace(d.lines[i]) - if strings.HasPrefix(line, ">") { + if strings.HasPrefix(line, ">") { replyLines = append([]string{strings.TrimSpace(strings.TrimPrefix(line, ">"))}, replyLines...) i-- continue } - break + break } - for i >= 0 && strings.TrimSpace(d.lines[i]) == "" { + for i >= 0 && strings.TrimSpace(d.lines[i]) == "" { i-- } - if i < 0 { + if i < 0 { break } - q := strings.TrimSpace(d.lines[i]) + q := strings.TrimSpace(d.lines[i]) q = stripTrailingTrigger(q) pairs = append([]pair{{q: q, a: strings.Join(replyLines, "\n")}}, pairs...) i-- } - msgs := make([]llm.Message, 0, len(pairs)*2+1) - for _, p := range pairs { - if strings.TrimSpace(p.q) != "" { + msgs := make([]llm.Message, 0, len(pairs)*2+1) + for _, p := range pairs { + if strings.TrimSpace(p.q) != "" { msgs = append(msgs, llm.Message{Role: "user", Content: p.q}) } - if strings.TrimSpace(p.a) != "" { + if strings.TrimSpace(p.a) != "" { msgs = append(msgs, llm.Message{Role: "assistant", Content: p.a}) } } - msgs = append(msgs, llm.Message{Role: "user", Content: currentPrompt}) + msgs = append(msgs, llm.Message{Role: "user", Content: currentPrompt}) return msgs } // stripTrailingTrigger removes the trailing chat trigger punctuation from a line if present. -func stripTrailingTrigger(sx string) string { - s := strings.TrimRight(sx, " \t") - if len(s) >= 2 && s[len(s)-1] == '>' { // new triggers - prev := s[len(s)-2] - if prev == '?' || prev == '!' || prev == ':' || prev == ';' { - return strings.TrimRight(s[:len(s)-1], " \t") - } - } - if strings.HasSuffix(s, ";;") { // legacy inline cleanup used in history building - return strings.TrimRight(strings.TrimSuffix(s, ";;"), " \t") - } - if len(s) == 0 { - return sx - } - last := s[len(s)-1] - switch last { // legacy: remove one trailing punctuation - case '?', '!', ':': - return strings.TrimRight(s[:len(s)-1], " \t") - default: - return sx +func stripTrailingTrigger(sx string) string { + s := strings.TrimRight(sx, " \t") + if len(s) == 0 { + return sx + } + // Configurable suffix removal when preceded by configured prefixes + if len(s) >= 2 && s[len(s)-1] == chatSuffixChar { + prev := string(s[len(s)-2]) + for _, pf := range chatPrefixSingles { + if prev == pf { + return strings.TrimRight(s[:len(s)-1], " \t") + } } + } + // Legacy: inline cleanup for old semicolon form ";;" + if strings.HasSuffix(s, ";;") { + return strings.TrimRight(strings.TrimSuffix(s, ";;"), " \t") + } + // Legacy: remove one trailing punctuation (?, !, :) to build history nicely + last := s[len(s)-1] + switch last { + case '?', '!', ':': + return strings.TrimRight(s[:len(s)-1], " \t") + default: + return sx + } } // clientApplyEdit sends a workspace/applyEdit request to the client. @@ -3636,7 +3704,7 @@ func (s *Server) clientApplyEdit(label string, edit WorkspaceEdit) { +func (s *Server) nextReqID() json.RawMessage { s.mu.Lock() s.nextID++ idNum := s.nextID @@ -3646,7 +3714,7 @@ func (s *Server) nextReqID() json.RawMessage { } // clientShowDocument asks the client to open/focus a document and select a range. -func (s *Server) clientShowDocument(uri string, sel *Range) { +func (s *Server) clientShowDocument(uri string, sel *Range) { 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 { opts := []llm.RequestOption{llm.WithMaxTokens(s.maxTokens)} @@ -3810,8 +3883,8 @@ func (s *Server) logLLMStats() { } // Completion prompt builders and filters -func inParamList(current string, cursor int) bool { - if !strings.Contains(current, "func ") { +func inParamList(current string, cursor int) bool { + if !strings.Contains(current, "func ") { return false } open := strings.Index(current, "(") @@ -3878,10 +3951,10 @@ func isIdentChar(ch byte) bool { // Inline prompt utilities func lineHasInlinePrompt(line string) bool { - if _, _, _, ok := findStrictSemicolonTag(line); ok { - return true - } - return hasDoubleSemicolonTrigger(line) + if _, _, _, ok := findStrictSemicolonTag(line); ok { + return true + } + return hasDoubleSemicolonTrigger(line) } func leadingIndent(line string) string { @@ -3918,61 +3991,64 @@ func applyIndent(indent, suggestion string) string // --- 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) { - pos := 0 - for pos < len(line) { - j := strings.Index(line[pos:], ";") - if j < 0 { - return "", 0, 0, false - } - j += pos - // ensure single ';' (not ';;') and non-space after - if j+1 >= len(line) || line[j+1] == ';' || line[j+1] == ' ' { - pos = j + 1 - continue - } - k := strings.Index(line[j+1:], ";") - if k < 0 { - return "", 0, 0, false - } - closeIdx := j + 1 + k - if closeIdx-1 < 0 || line[closeIdx-1] == ' ' { - pos = closeIdx + 1 - continue - } - inner := strings.TrimSpace(line[j+1 : closeIdx]) - if inner == "" { - pos = closeIdx + 1 - continue - } - end := closeIdx + 1 - return inner, j, end, true + pos := 0 + for pos < len(line) { + // find opening marker + j := strings.IndexByte(line[pos:], inlineOpenChar) + if j < 0 { + return "", 0, 0, false + } + j += pos + // ensure single open (not double) and non-space after + if j+1 >= len(line) || line[j+1] == inlineOpenChar || line[j+1] == ' ' { + pos = j + 1 + continue + } + // find closing marker + k := strings.IndexByte(line[j+1:], inlineCloseChar) + if k < 0 { + return "", 0, 0, false + } + closeIdx := j + 1 + k + if closeIdx-1 < 0 || line[closeIdx-1] == ' ' { + pos = closeIdx + 1 + continue + } + inner := strings.TrimSpace(line[j+1 : closeIdx]) + if inner == "" { + pos = closeIdx + 1 + continue } - return "", 0, 0, false + end := closeIdx + 1 + return inner, j, end, true + } + return "", 0, 0, false } // 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 { - t := strings.TrimSpace(line) - if !strings.Contains(t, ";;") { - return false - } - if hasDoubleSemicolonTrigger(t) { - return false +func isBareDoubleSemicolon(line string) bool { + t := strings.TrimSpace(line) + // check for double-open pattern + dbl := string([]byte{inlineOpenChar, inlineOpenChar}) + if !strings.Contains(t, dbl) { + return false + } + if hasDoubleSemicolonTrigger(t) { + return false + } + if strings.HasPrefix(t, dbl) { + rest := strings.TrimSpace(t[len(dbl):]) + if rest == "" || rest == ";" { + return true } - if strings.HasPrefix(t, ";;") { - rest := strings.TrimSpace(t[2:]) - if rest == "" || rest == ";" { - return true - } - } - return false + } + return false } // stripDuplicateAssignmentPrefix removes a duplicated assignment prefix from the suggestion. @@ -4155,81 +4231,84 @@ func (s *Server) collectPromptRemovalEdits(uri string) []TextEdit { - if hasDoubleSemicolonTrigger(line) { - return []TextEdit{{Range: Range{Start: Position{Line: lineNum, Character: 0}, End: Position{Line: lineNum, Character: len(line)}}, NewText: ""}} - } - return collectSemicolonMarkers(line, lineNum) + if hasDoubleSemicolonTrigger(line) { + return []TextEdit{{Range: Range{Start: Position{Line: lineNum, Character: 0}, End: Position{Line: lineNum, Character: len(line)}}, NewText: ""}} + } + return collectSemicolonMarkers(line, lineNum) } -func hasDoubleSemicolonTrigger(line string) bool { - pos := 0 - for pos < len(line) { - j := strings.Index(line[pos:], ";;") - if j < 0 { - return false - } - j += pos - contentStart := j + 2 - if contentStart >= len(line) { - return false - } - first := line[contentStart] - if first == ' ' || first == ';' { - pos = contentStart + 1 - continue - } - k := strings.Index(line[contentStart+1:], ";") - if k < 0 { - return false - } - closeIdx := contentStart + 1 + k - if closeIdx-1 >= 0 && line[closeIdx-1] == ' ' { - pos = closeIdx + 1 - continue - } - return true +func hasDoubleSemicolonTrigger(line string) bool { + pos := 0 + for pos < len(line) { + // look for double-open sequence + dbl := string([]byte{inlineOpenChar, inlineOpenChar}) + j := strings.Index(line[pos:], dbl) + if j < 0 { + return false + } + j += pos + contentStart := j + len(dbl) + if contentStart >= len(line) { + return false + } + first := line[contentStart] + if first == ' ' || first == inlineOpenChar { + pos = contentStart + 1 + continue } - return false + // find closing + k := strings.IndexByte(line[contentStart+1:], inlineCloseChar) + if k < 0 { + return false + } + closeIdx := contentStart + 1 + k + if closeIdx-1 >= 0 && line[closeIdx-1] == ' ' { + pos = closeIdx + 1 + continue + } + return true + } + return false } func collectSemicolonMarkers(line string, lineNum int) []TextEdit { - var edits []TextEdit - startSemi := 0 - for startSemi < len(line) { - j := strings.Index(line[startSemi:], ";") - if j < 0 { - break - } - j += startSemi - k := strings.Index(line[j