From 9bcccbd80d36ae678d58cd8f83c4d0c790c16b48 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 26 Sep 2025 08:19:26 +0300 Subject: Auto apply inline prompt completions --- docs/coverage.html | 715 ++--- docs/coverage.out | 3874 +++++++++++++------------ internal/lsp/handlers_document.go | 45 + internal/lsp/inline_prompt_completion_test.go | 67 + 4 files changed, 2573 insertions(+), 2128 deletions(-) create mode 100644 internal/lsp/inline_prompt_completion_test.go diff --git a/docs/coverage.html b/docs/coverage.html index a1db0c8..7cda0d1 100644 --- a/docs/coverage.html +++ b/docs/coverage.html @@ -111,13 +111,13 @@ - + - + @@ -3593,7 +3593,7 @@ type RequestOption func(*Options) func WithModel(model string) RequestOption { return func(o *Options) { o.Model = model } } func WithTemperature(t float64) RequestOption { return func(o *Options) { o.Temperature = t } } -func WithMaxTokens(n int) RequestOption { return func(o *Options) { o.MaxTokens = n } } +func WithMaxTokens(n int) RequestOption { return func(o *Options) { o.MaxTokens = n } } func WithStop(stop ...string) RequestOption { return func(o *Options) { o.Stop = append([]string{}, stop...) } } @@ -3773,11 +3773,11 @@ var std *log.Logger func Bind(l *log.Logger) { std = l } // Logf prints a formatted message with a module prefix and base ANSI style. -func Logf(prefix, format string, args ...any) { +func Logf(prefix, format string, args ...any) { if std == nil { return } - msg := fmt.Sprintf(format, args...) + msg := fmt.Sprintf(format, args...) std.Print(AnsiBase + prefix + msg + AnsiReset) } @@ -3868,18 +3868,18 @@ import ( // - window: include a window of lines around the cursor // - file-on-new-func: include full file only when defining a new function // - always-full: always include the full file -func (s *Server) buildAdditionalContext(newFunc bool, uri string, pos Position) (string, bool) { +func (s *Server) buildAdditionalContext(newFunc bool, uri string, pos Position) (string, bool) { mode := s.contextMode() switch mode { case "minimal": return "", false case "window": return s.windowContext(uri, pos), true - case "file-on-new-func": + case "file-on-new-func": if newFunc { return s.fullFileContext(uri), true } - return "", false + return "", false case "always-full": return s.fullFileContext(uri), true default: @@ -3953,7 +3953,7 @@ type document struct { lines []string } -func (s *Server) setDocument(uri, text string) { +func (s *Server) setDocument(uri, text string) { s.mu.Lock() defer s.mu.Unlock() s.docs[uri] = &document{uri: uri, text: text, lines: splitLines(text)} @@ -3971,76 +3971,76 @@ func (s *Server) markActivity() { s.mu.Unlock() } -func (s *Server) getDocument(uri string) *document { +func (s *Server) getDocument(uri string) *document { s.mu.RLock() defer s.mu.RUnlock() return s.docs[uri] } // splitLines splits the input string into lines, normalizing line endings to '\n'. -func splitLines(sx string) []string { +func splitLines(sx string) []string { sx = strings.ReplaceAll(sx, "\r\n", "\n") return strings.Split(sx, "\n") } -func (s *Server) lineContext(uri string, pos Position) (above, current, below, funcCtx string) { +func (s *Server) lineContext(uri string, pos Position) (above, current, below, funcCtx string) { d := s.getDocument(uri) if d == nil || len(d.lines) == 0 { return "", "", "", "" } - idx := pos.Line + idx := pos.Line if idx < 0 { idx = 0 } - if idx >= len(d.lines) { + if idx >= len(d.lines) { idx = len(d.lines) - 1 } - current = d.lines[idx] + current = d.lines[idx] if idx-1 >= 0 { above = d.lines[idx-1] } - if idx+1 < len(d.lines) { + if idx+1 < len(d.lines) { below = d.lines[idx+1] } - for i := idx; i >= 0; i-- { + for i := idx; i >= 0; i-- { line := strings.TrimSpace(d.lines[i]) if hasAny(line, []string{"func ", "def ", "class ", "fn ", "procedure ", "sub "}) { funcCtx = line break } } - return above, current, below, funcCtx + return above, current, below, funcCtx } // isDefiningNewFunction returns true when the cursor appears to be within // a function declaration/signature and before the opening '{' of the body. // Heuristic: find nearest preceding line containing "func "; ensure no '{' // appears before the cursor across those lines. -func (s *Server) isDefiningNewFunction(uri string, pos Position) bool { +func (s *Server) isDefiningNewFunction(uri string, pos Position) bool { d := s.getDocument(uri) if d == nil || len(d.lines) == 0 { return false } - idx := pos.Line + idx := pos.Line if idx < 0 { idx = 0 } - if idx >= len(d.lines) { + if idx >= len(d.lines) { idx = len(d.lines) - 1 } // Find signature start - sigStart := -1 - for i := idx; i >= 0; i-- { + sigStart := -1 + for i := idx; i >= 0; i-- { if strings.Contains(d.lines[i], "func ") { sigStart = i break } // stop if we hit a closing brace which likely ends a previous block - if strings.Contains(d.lines[i], "}") { + if strings.Contains(d.lines[i], "}") { break } } - if sigStart == -1 { + if sigStart == -1 { return false } // Scan for '{' from sigStart up to cursor position; if found before or at cursor, we're in body @@ -4060,29 +4060,29 @@ func (s *Server) isDefiningNewFunction(uri string, pos Position) bool return true } -func hasAny(s string, needles []string) bool { - for _, n := range needles { +func hasAny(s string, needles []string) bool { + for _, n := range needles { if strings.Contains(s, n) { return true } } - return false + return false } -func trimLen(s string) string { +func trimLen(s string) string { s = strings.TrimSpace(s) if len(s) > 200 { return s[:200] + "…" } - return s + return s } -func firstLine(s string) string { +func firstLine(s string) string { s = strings.ReplaceAll(s, "\r\n", "\n") if idx := strings.IndexByte(s, '\n'); idx >= 0 { return s[:idx] } - return s + return s } @@ -4203,7 +4203,7 @@ func (s *Server) findFirstInstructionInLine(line string) (instr string, cleaned // handleCompletion moved to handlers_completion.go -func (s *Server) reply(id json.RawMessage, result any, err *RespError) { +func (s *Server) reply(id json.RawMessage, result any, err *RespError) { resp := Response{JSONRPC: "2.0", ID: id, Result: result, Error: err} s.writeMessage(resp) } @@ -4277,33 +4277,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 client := s.currentLLMClient(); client != nil { + if client := s.currentLLMClient(); client != nil { prov = client.Name() model = client.DefaultModel() } - temp := "" + temp := "" if tempPtr := s.codingTemperature(); tempPtr != nil { temp = fmt.Sprintf("%.3f", *tempPtr) } - 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, @@ -4320,11 +4320,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 @@ -4332,13 +4332,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 { @@ -4347,7 +4347,7 @@ func (s *Server) completionCachePut(key, value string) - return + return } // update existing and mark most-recent s.compCache[key] = value @@ -4431,15 +4431,15 @@ func (s *Server) isTriggerEvent(p CompletionParams, current string) bool 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 client := s.currentLLMClient(); client != nil { + if client := s.currentLLMClient(); client != nil { detail = "Hexai " + client.Name() + ":" + client.DefaultModel() } - return []CompletionItem{{ + return []CompletionItem{{ Label: label, Kind: 1, Detail: detail, @@ -5183,10 +5183,10 @@ type completionPlan struct { cacheKey string } -func (s *Server) handleCompletion(req Request) { +func (s *Server) handleCompletion(req Request) { var p CompletionParams var docStr string - if err := json.Unmarshal(req.Params, &p); err == nil { + if err := json.Unmarshal(req.Params, &p); err == nil { // Log trigger information for every completion request from client tk, tch := extractTriggerInfo(p) logging.Logf("lsp ", "completion trigger kind=%d char=%q uri=%s line=%d char=%d", @@ -5196,11 +5196,11 @@ func (s *Server) handleCompletion(req Request) { if s.logContext { s.logCompletionContext(p, above, current, below, funcCtx) } - if s.llmClient != nil { + if s.llmClient != nil { newFunc := s.isDefiningNewFunction(p.TextDocument.URI, p.Position) extra, has := s.buildAdditionalContext(newFunc, p.TextDocument.URI, p.Position) items, ok := s.tryLLMCompletion(p, above, current, below, funcCtx, docStr, has, extra) - if ok { + if ok { s.reply(req.ID, CompletionList{IsIncomplete: false, Items: items}, nil) return } @@ -5212,26 +5212,26 @@ func (s *Server) handleCompletion(req Request) { // extractTriggerInfo returns the LSP completion TriggerKind and TriggerCharacter // if provided by the client; when absent it returns zeros. -func extractTriggerInfo(p CompletionParams) (kind int, ch string) { +func extractTriggerInfo(p CompletionParams) (kind int, ch string) { if p.Context == nil { return 0, "" } - var ctx struct { + var ctx struct { TriggerKind int `json:"triggerKind"` TriggerCharacter string `json:"triggerCharacter,omitempty"` } if raw, ok := p.Context.(json.RawMessage); ok { _ = json.Unmarshal(raw, &ctx) - } else { + } else { b, _ := json.Marshal(p.Context) _ = json.Unmarshal(b, &ctx) } - return ctx.TriggerKind, ctx.TriggerCharacter + return ctx.TriggerKind, ctx.TriggerCharacter } // --- completion helpers --- -func (s *Server) buildDocString(p CompletionParams, above, current, below, funcCtx string) string { +func (s *Server) buildDocString(p CompletionParams, above, current, below, funcCtx string) string { return fmt.Sprintf("file: %s\nline: %d\nabove: %s\ncurrent: %s\nbelow: %s\nfunction: %s", p.TextDocument.URI, p.Position.Line, trimLen(above), trimLen(current), trimLen(below), trimLen(funcCtx)) } @@ -5241,7 +5241,7 @@ func (s *Server) logCompletionContext(p CompletionParams, above, current, below, p.TextDocument.URI, p.Position.Line, p.Position.Character, trimLen(above), trimLen(current), trimLen(below), trimLen(funcCtx)) } -func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, funcCtx, docStr string, hasExtra bool, extraText string) ([]CompletionItem, bool) { +func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, funcCtx, docStr string, hasExtra bool, extraText string) ([]CompletionItem, bool) { ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) defer cancel() @@ -5250,14 +5250,14 @@ func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, fun return items, true } - if items, ok := s.tryProviderNativeCompletion(current, p, above, below, funcCtx, docStr, hasExtra, extraText, plan.inParams); ok { + if items, ok := s.tryProviderNativeCompletion(current, p, above, below, funcCtx, docStr, hasExtra, extraText, plan.inParams); ok { return items, true } - return s.executeChatCompletion(ctx, plan) + return s.executeChatCompletion(ctx, plan) } -func (s *Server) prepareCompletionPlan(p CompletionParams, above, current, below, funcCtx, docStr string, hasExtra bool, extraText string) (completionPlan, []CompletionItem, bool) { +func (s *Server) prepareCompletionPlan(p CompletionParams, above, current, below, funcCtx, docStr string, hasExtra bool, extraText string) (completionPlan, []CompletionItem, bool) { plan := completionPlan{ params: p, above: above, @@ -5274,10 +5274,10 @@ func (s *Server) prepareCompletionPlan(p CompletionParams, above, current, below 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 plan, []CompletionItem{}, true } - if s.shouldSuppressForChatTriggerEOL(current, p) { + if s.shouldSuppressForChatTriggerEOL(current, p) { return plan, []CompletionItem{}, true } - plan.inParams = inParamList(current, p.Position.Character) + plan.inParams = inParamList(current, p.Position.Character) plan.manualInvoke = parseManualInvoke(p.Context) plan.cacheKey = s.completionCacheKey(p, above, current, below, funcCtx, plan.inParams, hasExtra, extraText) if cleaned, ok := s.completionCacheGet(plan.cacheKey); ok && strings.TrimSpace(cleaned) != "" { @@ -5286,107 +5286,107 @@ func (s *Server) prepareCompletionPlan(p CompletionParams, above, current, below logging.AnsiGreen, logging.PreviewForLog(cleaned), logging.AnsiBase) return plan, s.makeCompletionItems(cleaned, plan.inParams, current, p, docStr), true } - if isBareDoubleOpen(current, openChar, closeChar) || isBareDoubleOpen(below, openChar, closeChar) { + if isBareDoubleOpen(current, openChar, closeChar) || isBareDoubleOpen(below, openChar, closeChar) { 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 plan, []CompletionItem{}, true } - if !plan.inParams && !s.prefixHeuristicAllows(plan.inlinePrompt, current, p, plan.manualInvoke) { + if !plan.inParams && !s.prefixHeuristicAllows(plan.inlinePrompt, current, p, plan.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) return plan, []CompletionItem{}, true } - return plan, nil, false + return plan, nil, false } -func (s *Server) executeChatCompletion(ctx context.Context, plan completionPlan) ([]CompletionItem, bool) { +func (s *Server) executeChatCompletion(ctx context.Context, plan completionPlan) ([]CompletionItem, bool) { messages := s.buildCompletionMessages(plan.inlinePrompt, plan.hasExtra, plan.extraText, plan.inParams, plan.params, plan.above, plan.current, plan.below, plan.funcCtx) sentSize := 0 - for _, m := range messages { + for _, m := range messages { sentSize += len(m.Content) } - s.incSentCounters(sentSize) + s.incSentCounters(sentSize) opts := s.llmRequestOpts() s.waitForDebounce(ctx) if !s.waitForThrottle(ctx) { return nil, false } - client := s.currentLLMClient() + client := s.currentLLMClient() if client == nil { return nil, false } - logging.Logf("lsp ", "completion llm=requesting model=%s", client.DefaultModel()) + logging.Logf("lsp ", "completion llm=requesting model=%s", client.DefaultModel()) text, err := client.Chat(ctx, messages, opts...) if err != nil { logging.Logf("lsp ", "llm completion error: %v", err) s.logLLMStats() return nil, false } - s.incRecvCounters(len(text)) + s.incRecvCounters(len(text)) s.logLLMStats() trimmed := strings.TrimSpace(text) cleaned := s.postProcessCompletion(trimmed, plan.current[:plan.params.Position.Character], plan.current) if cleaned == "" { return nil, false } - s.completionCachePut(plan.cacheKey, cleaned) + s.completionCachePut(plan.cacheKey, cleaned) items := s.makeCompletionItems(cleaned, plan.inParams, plan.current, plan.params, plan.docStr) return items, true } // 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 { _ = json.Unmarshal(raw, &c) - } else { + } 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 { +func (s *Server) shouldSuppressForChatTriggerEOL(current string, p CompletionParams) bool { t := strings.TrimRight(current, " \t") suffix, prefixes, _ := s.chatConfig() if suffix == "" { return false } - if strings.HasSuffix(t, suffix) { + if strings.HasSuffix(t, suffix) { if len(t) < len(suffix)+1 { return false } - prev := string(t[len(t)-len(suffix)-1]) - for _, pf := range prefixes { + prev := string(t[len(t)-len(suffix)-1]) + for _, pf := range prefixes { 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. -func (s *Server) prefixHeuristicAllows(inlinePrompt bool, current string, p CompletionParams, manualInvoke bool) bool { +func (s *Server) prefixHeuristicAllows(inlinePrompt bool, current string, p CompletionParams, manualInvoke bool) bool { // Determine the effective cursor index within current line, clamped, and // skip over trailing spaces/tabs to support cases like "type Matrix| ". idx := p.Position.Character if idx > len(current) { idx = len(current) } - allowNoPrefix := inlinePrompt - if idx > 0 { + allowNoPrefix := inlinePrompt + if idx > 0 { ch := current[idx-1] if ch == '.' || ch == ':' || ch == '/' || ch == '_' || ch == ')' { allowNoPrefix = true } } - if allowNoPrefix { + if allowNoPrefix { return true } // Walk left over whitespace @@ -5410,10 +5410,10 @@ func (s *Server) prefixHeuristicAllows(inlinePrompt bool, current string, p Comp } // tryProviderNativeCompletion attempts provider-native completion and returns items when successful. -func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, above, below, funcCtx, docStr string, hasExtra bool, extraText string, inParams bool) ([]CompletionItem, bool) { +func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, above, below, funcCtx, docStr string, hasExtra bool, extraText string, inParams bool) ([]CompletionItem, bool) { client := s.currentLLMClient() cc, ok := client.(llm.CodeCompleter) - if !ok { + if !ok { return nil, false } before, after := s.docBeforeAfter(p.TextDocument.URI, p.Position) @@ -5484,9 +5484,9 @@ func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, // waitForDebounce sleeps until there has been no input activity for at least // completionDebounce. If debounce is zero or ctx is done, it returns promptly. -func (s *Server) waitForDebounce(ctx context.Context) { +func (s *Server) waitForDebounce(ctx context.Context) { d := s.completionDebounce() - if d <= 0 { + if d <= 0 { return } for { @@ -5514,9 +5514,9 @@ func (s *Server) waitForDebounce(ctx context.Context) { +func (s *Server) waitForThrottle(ctx context.Context) bool { interval := s.completionThrottle() - if interval <= 0 { + if interval <= 0 { return true } var wait time.Duration @@ -5545,7 +5545,7 @@ func (s *Server) waitForThrottle(ctx context.Context) bool { +func (s *Server) buildCompletionMessages(inlinePrompt, hasExtra bool, extraText string, inParams bool, p CompletionParams, above, current, below, funcCtx string) []llm.Message { vars := map[string]string{ "file": p.TextDocument.URI, "function": funcCtx, @@ -5561,10 +5561,10 @@ func (s *Server) buildCompletionMessages(inlinePrompt, hasExtra bool, extraText sys = cfg.PromptCompletionSystemParams userTpl = cfg.PromptCompletionUserParams } - if inlinePrompt && strings.TrimSpace(cfg.PromptCompletionSystemInline) != "" { + if inlinePrompt && strings.TrimSpace(cfg.PromptCompletionSystemInline) != "" { sys = cfg.PromptCompletionSystemInline } - user := renderTemplate(userTpl, vars) + user := renderTemplate(userTpl, vars) messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} if hasExtra && strings.TrimSpace(extraText) != "" { extra := renderTemplate(cfg.PromptCompletionExtraHeader, map[string]string{"context": extraText}) @@ -5573,30 +5573,30 @@ func (s *Server) buildCompletionMessages(inlinePrompt, hasExtra bool, extraText } messages = append(messages, llm.Message{Role: "user", Content: extra}) } - return messages + return messages } // postProcessCompletion normalizes and deduplicates completion text and applies indentation rules. -func (s *Server) postProcessCompletion(text string, leftOfCursor string, currentLine string) string { +func (s *Server) postProcessCompletion(text string, leftOfCursor string, currentLine string) string { cleaned := stripCodeFences(text) if cleaned != "" && strings.ContainsRune(cleaned, '`') { if inline := stripInlineCodeSpan(cleaned); strings.TrimSpace(inline) != "" { cleaned = inline } } - if cleaned != "" { + if cleaned != "" { cleaned = stripDuplicateAssignmentPrefix(leftOfCursor, cleaned) } - if cleaned != "" { + if cleaned != "" { cleaned = stripDuplicateGeneralPrefix(leftOfCursor, cleaned) } - _, _, openChar, closeChar := s.inlineMarkers() - if cleaned != "" && hasDoubleOpenTrigger(currentLine, openChar, closeChar) { + _, _, openChar, closeChar := s.inlineMarkers() + if cleaned != "" && hasDoubleOpenTrigger(currentLine, openChar, closeChar) { if indent := leadingIndent(currentLine); indent != "" { cleaned = applyIndent(indent, cleaned) } } - return cleaned + return cleaned } @@ -5696,7 +5696,11 @@ func (s *Server) detectAndHandleChat(uri string) { _, _, openChar, closeChar := s.inlineMarkers() for i, raw := range d.lines { if lineHasInlinePrompt(raw, openChar, closeChar) { - continue + if s.currentLLMClient() != nil { + pos := Position{Line: i, Character: len(raw)} + go s.runInlinePrompt(uri, pos) + } + continue } // Find last non-space character index j := len(raw) - 1 @@ -5812,6 +5816,47 @@ func (s *Server) applyChatEdits(uri string, lineIdx int, lastNonSpace int, remov s.clientApplyEdit("Hexai: insert chat response", we) } +func (s *Server) runInlinePrompt(uri string, pos Position) { + if s.currentLLMClient() == nil { + return + } + d := s.getDocument(uri) + if d == nil || pos.Line < 0 || pos.Line >= len(d.lines) { + return + } + line := d.lines[pos.Line] + _, _, openChar, closeChar := s.inlineMarkers() + if !lineHasInlinePrompt(line, openChar, closeChar) { + return + } + p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: uri}, Position: Position{Line: pos.Line, Character: len(line)}} + p.Context = map[string]int{"triggerKind": 1} + above, current, below, funcCtx := s.lineContext(uri, p.Position) + docStr := s.buildDocString(p, above, current, below, funcCtx) + newFunc := s.isDefiningNewFunction(uri, p.Position) + extra, hasExtra := s.buildAdditionalContext(newFunc, uri, p.Position) + items, ok := s.tryLLMCompletion(p, above, current, below, funcCtx, docStr, hasExtra, extra) + if !ok || len(items) == 0 { + return + } + s.applyInlineCompletion(uri, items[0]) +} + +func (s *Server) applyInlineCompletion(uri string, item CompletionItem) { + var edits []TextEdit + if len(item.AdditionalTextEdits) > 0 { + edits = append(edits, item.AdditionalTextEdits...) + } + if item.TextEdit != nil { + edits = append(edits, *item.TextEdit) + } + if len(edits) == 0 { + return + } + we := WorkspaceEdit{Changes: map[string][]TextEdit{uri: edits}} + s.clientApplyEdit("Hexai: inline prompt", we) +} + // 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 { @@ -6068,7 +6113,7 @@ import ( ) // llmRequestOpts builds request options from server settings. -func (s *Server) llmRequestOpts() []llm.RequestOption { +func (s *Server) llmRequestOpts() []llm.RequestOption { maxTokens := s.maxTokens() client := s.currentLLMClient() tempPtr := s.codingTemperature() @@ -6084,63 +6129,63 @@ func (s *Server) llmRequestOpts() []llm.RequestOption opts = append(opts, llm.WithTemperature(temp)) } - return opts + return opts } // small helpers for LLM traffic stats -func (s *Server) incSentCounters(n int) { +func (s *Server) incSentCounters(n int) { s.mu.Lock() s.llmReqTotal++ s.llmSentBytesTotal += int64(n) s.mu.Unlock() } -func (s *Server) incRecvCounters(n int) { +func (s *Server) incRecvCounters(n int) { s.mu.Lock() s.llmRespTotal++ s.llmRespBytesTotal += int64(n) s.mu.Unlock() } -func (s *Server) logLLMStats() { +func (s *Server) logLLMStats() { s.mu.RLock() avgSent := int64(0) - if s.llmReqTotal > 0 { + if s.llmReqTotal > 0 { avgSent = s.llmSentBytesTotal / s.llmReqTotal } - avgRecv := int64(0) - if s.llmRespTotal > 0 { + avgRecv := int64(0) + if s.llmRespTotal > 0 { avgRecv = s.llmRespBytesTotal / s.llmRespTotal } - reqs, sentTot, recvTot := s.llmReqTotal, s.llmSentBytesTotal, s.llmRespBytesTotal + reqs, sentTot, recvTot := s.llmReqTotal, s.llmSentBytesTotal, s.llmRespBytesTotal s.mu.RUnlock() mins := time.Since(s.startTime).Minutes() if mins <= 0 { mins = 0.001 } - rpmLocal := float64(reqs) / mins + rpmLocal := float64(reqs) / mins sentPerMin := float64(sentTot) / mins recvPerMin := float64(recvTot) / mins // Log local process counters logging.Logf("lsp ", "llm stats (local) reqs=%d avg_sent=%d avg_recv=%d sent_total=%d recv_total=%d rpm=%.2f sent_per_min=%.0f recv_per_min=%.0f", reqs, avgSent, avgRecv, sentTot, recvTot, rpmLocal, sentPerMin, recvPerMin) // Global snapshot for tmux status snap, err := stats.TakeSnapshot() - if err == nil { - if client := s.currentLLMClient(); client != nil { + if err == nil { + if client := s.currentLLMClient(); client != nil { provider := client.Name() model := client.DefaultModel() // Per-scope rpm estimated from window scopeReqs := int64(0) - if pe, ok := snap.Providers[provider]; ok { - if mc, ok2 := pe.Models[model]; ok2 { + if pe, ok := snap.Providers[provider]; ok { + if mc, ok2 := pe.Models[model]; ok2 { scopeReqs = mc.Reqs } } - minsWin := snap.Window.Minutes() + minsWin := snap.Window.Minutes() if minsWin <= 0 { minsWin = 0.001 } - scopeRPM := float64(scopeReqs) / minsWin + scopeRPM := float64(scopeReqs) / minsWin status := tmx.FormatGlobalStatusColored(snap.Global.Reqs, snap.RPM, snap.Global.Sent, snap.Global.Recv, provider, model, scopeRPM, scopeReqs, snap.Window) _ = tmx.SetStatus(status) } @@ -6148,8 +6193,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, "(") @@ -6158,78 +6203,78 @@ func inParamList(current string, cursor int) bool } // renderTemplate performs simple {{var}} replacement in a template string. -func renderTemplate(t string, vars map[string]string) string { return textutil.RenderTemplate(t, vars) } +func renderTemplate(t string, vars map[string]string) string { return textutil.RenderTemplate(t, vars) } -func computeTextEditAndFilter(cleaned string, inParams bool, current string, p CompletionParams) (*TextEdit, string) { - if inParams { +func computeTextEditAndFilter(cleaned string, inParams bool, current string, p CompletionParams) (*TextEdit, string) { + if inParams { open := strings.Index(current, "(") close := strings.Index(current, ")") - if open >= 0 { + if open >= 0 { left := open + 1 right := len(current) - if close >= 0 && close >= left { + if close >= 0 && close >= left { right = close } - if p.Position.Character < right { + if p.Position.Character < right { right = p.Position.Character } - te := &TextEdit{Range: Range{Start: Position{Line: p.Position.Line, Character: left}, End: Position{Line: p.Position.Line, Character: right}}, NewText: cleaned} + te := &TextEdit{Range: Range{Start: Position{Line: p.Position.Line, Character: left}, End: Position{Line: p.Position.Line, Character: right}}, NewText: cleaned} var filter string - if left >= 0 && right >= left && right <= len(current) { + if left >= 0 && right >= left && right <= len(current) { filter = strings.TrimLeft(current[left:right], " \t") } - return te, filter + return te, filter } } - startChar := computeWordStart(current, p.Position.Character) + startChar := computeWordStart(current, p.Position.Character) te := &TextEdit{Range: Range{Start: Position{Line: p.Position.Line, Character: startChar}, End: Position{Line: p.Position.Line, Character: p.Position.Character}}, NewText: cleaned} filter := strings.TrimLeft(current[startChar:p.Position.Character], " \t") return te, filter } -func computeWordStart(current string, at int) int { +func computeWordStart(current string, at int) int { if at > len(current) { at = len(current) } - for at > 0 { + for at > 0 { ch := current[at-1] if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' { at-- continue } - break + break } - return at + return at } -func isIdentChar(ch byte) bool { +func isIdentChar(ch byte) bool { return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' } // chatWithStats wraps llmClient.Chat to increment counters and emit a tmux heartbeat. -func (s *Server) chatWithStats(ctx context.Context, msgs []llm.Message, opts ...llm.RequestOption) (string, error) { +func (s *Server) chatWithStats(ctx context.Context, msgs []llm.Message, opts ...llm.RequestOption) (string, error) { // Count bytes sent sent := 0 for _, m := range msgs { sent += len(m.Content) } - s.incSentCounters(sent) + s.incSentCounters(sent) // Debounce/throttle if configured (reuse completion gates) s.waitForDebounce(ctx) if !s.waitForThrottle(ctx) { return "", context.Canceled } // Perform request - client := s.currentLLMClient() + client := s.currentLLMClient() if client == nil { return "", fmt.Errorf("llm client unavailable") } - txt, err := client.Chat(ctx, msgs, opts...) + txt, err := client.Chat(ctx, msgs, opts...) if err != nil { s.logLLMStats() return "", err } - s.incRecvCounters(len(txt)) + s.incRecvCounters(len(txt)) // Update global stats cache _ = stats.Update(ctx, client.Name(), client.DefaultModel(), sent, len(txt)) s.logLLMStats() @@ -6238,23 +6283,23 @@ func (s *Server) chatWithStats(ctx context.Context, msgs []llm.Message, opts ... // Inline prompt utilities -func lineHasInlinePrompt(line string, open, close byte) bool { - if _, _, _, ok := findStrictInlineTag(line, open, close); ok { +func lineHasInlinePrompt(line string, open, close byte) bool { + if _, _, _, ok := findStrictInlineTag(line, open, close); ok { return true } - return hasDoubleOpenTrigger(line, open, close) + return hasDoubleOpenTrigger(line, open, close) } -func leadingIndent(line string) string { +func leadingIndent(line string) string { i := 0 - for i < len(line) { + for i < len(line) { if line[i] == ' ' || line[i] == '\t' { i++ continue } - break + break } - if i == 0 { + if i == 0 { return "" } return line[:i] @@ -6269,10 +6314,10 @@ func applyIndent(indent, suggestion string) string if strings.TrimSpace(ln) == "" { continue } - if strings.HasPrefix(ln, indent) { + if strings.HasPrefix(ln, indent) { continue } - lines[i] = indent + ln + lines[i] = indent + ln } return strings.Join(lines, "\n") } @@ -6282,36 +6327,36 @@ func applyIndent(indent, suggestion string) string // findStrictInlineTag 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 findStrictInlineTag(line string, open, close byte) (string, int, int, bool) { +func findStrictInlineTag(line string, open, close byte) (string, int, int, bool) { pos := 0 - for pos < len(line) { + for pos < len(line) { // find opening marker j := strings.IndexByte(line[pos:], open) - if j < 0 { + if j < 0 { return "", 0, 0, false } - j += pos + j += pos // ensure single open (not double) and non-space after - if j+1 >= len(line) || line[j+1] == open || line[j+1] == ' ' { + if j+1 >= len(line) || line[j+1] == open || line[j+1] == ' ' { pos = j + 1 continue } // find closing marker - k := strings.IndexByte(line[j+1:], close) + k := strings.IndexByte(line[j+1:], close) if k < 0 { return "", 0, 0, false } - closeIdx := j + 1 + k + closeIdx := j + 1 + k if closeIdx-1 < 0 || line[closeIdx-1] == ' ' { pos = closeIdx + 1 continue } - inner := strings.TrimSpace(line[j+1 : closeIdx]) + inner := strings.TrimSpace(line[j+1 : closeIdx]) if inner == "" { pos = closeIdx + 1 continue } - end := closeIdx + 1 + end := closeIdx + 1 return inner, j, end, true } return "", 0, 0, false @@ -6320,14 +6365,14 @@ func findStrictInlineTag(line string, open, close byte) (string, int, int, bool) // 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 isBareDoubleOpen(line string, open, close byte) bool { +func isBareDoubleOpen(line string, open, close byte) bool { t := strings.TrimSpace(line) // check for double-open pattern dbl := string([]byte{open, open}) - if !strings.Contains(t, dbl) { + if !strings.Contains(t, dbl) { return false } - if hasDoubleOpenTrigger(t, open, close) { + if hasDoubleOpenTrigger(t, open, close) { return false } if strings.HasPrefix(t, dbl) { @@ -6340,7 +6385,7 @@ func isBareDoubleOpen(line string, open, close byte) bool { +func stripDuplicateAssignmentPrefix(prefixBeforeCursor, suggestion string) string { s2 := strings.TrimLeft(suggestion, " \t") // Prefer := if present at end of prefix if idx := strings.LastIndex(prefixBeforeCursor, ":="); idx >= 0 && idx+2 <= len(prefixBeforeCursor) { @@ -6358,7 +6403,7 @@ func stripDuplicateAssignmentPrefix(prefixBeforeCursor, suggestion string) strin } } // Fallback to plain '=' if present - if idx := strings.LastIndex(prefixBeforeCursor, "="); idx >= 0 { + if idx := strings.LastIndex(prefixBeforeCursor, "="); idx >= 0 { if !(idx > 0 && prefixBeforeCursor[idx-1] == ':') { // not := tail := prefixBeforeCursor[idx+1:] if strings.TrimSpace(tail) == "" { @@ -6374,40 +6419,40 @@ func stripDuplicateAssignmentPrefix(prefixBeforeCursor, suggestion string) strin } } } - return suggestion + return suggestion } // stripDuplicateGeneralPrefix removes any already-typed prefix that the model repeated. -func stripDuplicateGeneralPrefix(prefixBeforeCursor, suggestion string) string { +func stripDuplicateGeneralPrefix(prefixBeforeCursor, suggestion string) string { if suggestion == "" { return suggestion } - s := strings.TrimLeft(suggestion, " \t") + s := strings.TrimLeft(suggestion, " \t") p := strings.TrimRight(prefixBeforeCursor, " \t") - if p != "" && strings.HasPrefix(s, p) { + if p != "" && strings.HasPrefix(s, p) { return strings.TrimLeft(s[len(p):], " \t") }