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 --- docs/coverage.html | 647 +++++++++++++++++++++++++++++++---------------------- 1 file changed, 375 insertions(+), 272 deletions(-) (limited to 'docs/coverage.html') 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+1:], ";") - if k < 0 { - break - } - if j+1 >= len(line) || line[j+1] == ' ' { - startSemi = j + 1 - continue - } - if line[j+1] == ';' { - startSemi = j + 2 - continue - } - closeIdx := j + 1 + k - if closeIdx-1 < 0 || line[closeIdx-1] == ' ' { - startSemi = closeIdx + 1 - continue - } - if closeIdx-(j+1) < 1 { - startSemi = closeIdx + 1 - continue - } - endChar := closeIdx + 1 - if endChar < len(line) && line[endChar] == ' ' { - endChar++ - } - edits = append(edits, TextEdit{Range: Range{Start: Position{Line: lineNum, Character: j}, End: Position{Line: lineNum, Character: endChar}}, NewText: ""}) - startSemi = endChar + var edits []TextEdit + startSemi := 0 + for startSemi < len(line) { + j := strings.IndexByte(line[startSemi:], inlineOpenChar) + if j < 0 { + break + } + j += startSemi + k := strings.IndexByte(line[j+1:], inlineCloseChar) + if k < 0 { + break + } + if j+1 >= len(line) || line[j+1] == ' ' { + startSemi = j + 1 + continue } - return edits + if line[j+1] == inlineOpenChar { // skip double-open start + startSemi = j + 2 + continue + } + closeIdx := j + 1 + k + if closeIdx-1 < 0 || line[closeIdx-1] == ' ' { + startSemi = closeIdx + 1 + continue + } + if closeIdx-(j+1) < 1 { + startSemi = closeIdx + 1 + continue + } + endChar := closeIdx + 1 + if endChar < len(line) && line[endChar] == ' ' { + endChar++ + } + edits = append(edits, TextEdit{Range: Range{Start: Position{Line: lineNum, Character: j}, End: Position{Line: lineNum, Character: endChar}}, NewText: ""}) + startSemi = endChar + } + return edits } @@ -4237,14 +4316,15 @@ func collectSemicolonMarkers(line string, lineNum int) []TextEdit { +func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) *Server { s := &Server{in: bufio.NewReader(r), out: w, logger: logger, docs: make(map[string]*document), logContext: opts.LogContext} maxTokens := opts.MaxTokens - if maxTokens <= 0 { + if maxTokens <= 0 { maxTokens = 500 } - s.maxTokens = maxTokens + s.maxTokens = maxTokens contextMode := opts.ContextMode - if contextMode == "" { + if contextMode == "" { contextMode = "file-on-new-func" } - windowLines := opts.WindowLines - if windowLines <= 0 { + windowLines := opts.WindowLines + if windowLines <= 0 { windowLines = 120 } - maxContextTokens := opts.MaxContextTokens - if maxContextTokens <= 0 { + maxContextTokens := opts.MaxContextTokens + if maxContextTokens <= 0 { maxContextTokens = 2000 } - s.contextMode = contextMode + s.contextMode = contextMode s.windowLines = windowLines s.maxContextTokens = maxContextTokens s.startTime = time.Now() s.llmClient = opts.Client - if len(opts.TriggerCharacters) == 0 { + if len(opts.TriggerCharacters) == 0 { // Defaults (no space to avoid auto-trigger after whitespace) s.triggerChars = []string{".", ":", "/", "_", ")", "{"} } else { s.triggerChars = append([]string{}, opts.TriggerCharacters...) } - s.codingTemperature = opts.CodingTemperature + s.codingTemperature = opts.CodingTemperature s.compCache = make(map[string]string) s.manualInvokeMinPrefix = opts.ManualInvokeMinPrefix if opts.CompletionDebounceMs > 0 { s.completionDebounce = time.Duration(opts.CompletionDebounceMs) * time.Millisecond } - if opts.CompletionThrottleMs > 0 { + if opts.CompletionThrottleMs > 0 { s.throttleInterval = time.Duration(opts.CompletionThrottleMs) * time.Millisecond } + // Trigger character config (with sane defaults if missing) + if strings.TrimSpace(opts.InlineOpen) == "" { s.inlineOpen = ">" } else { s.inlineOpen = opts.InlineOpen } + if strings.TrimSpace(opts.InlineClose) == "" { s.inlineClose = ">" } else { s.inlineClose = opts.InlineClose } + if strings.TrimSpace(opts.ChatSuffix) == "" { s.chatSuffix = ">" } else { s.chatSuffix = opts.ChatSuffix } + if len(opts.ChatPrefixes) == 0 { s.chatPrefixes = []string{"?","!",":",";"} } else { s.chatPrefixes = append([]string{}, opts.ChatPrefixes...) } + + // Assign package-level inline trigger chars for free helper functions + if s.inlineOpen != "" { inlineOpenChar = s.inlineOpen[0] } + if s.inlineClose != "" { inlineCloseChar = s.inlineClose[0] } + if s.chatSuffix != "" { chatSuffixChar = s.chatSuffix[0] } + if len(s.chatPrefixes) > 0 { chatPrefixSingles = append([]string{}, s.chatPrefixes...) } // Initialize dispatch table - s.handlers = map[string]func(Request){ + s.handlers = map[string]func(Request){ "initialize": s.handleInitialize, "initialized": func(_ Request) { s.handleInitialized() }, "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, } - return s + return s } func (s *Server) Run() error { -- cgit v1.2.3