diff options
| author | Paul Buetow <paul@buetow.org> | 2026-06-11 00:16:27 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-06-11 00:16:27 +0300 |
| commit | 236f6543b093e54157f40ec0c021b35177e1713e (patch) | |
| tree | c6214c549739c997c8b572bb8904159c85fd2140 /internal/lsp/chat_handlers.go | |
| parent | 247b79114d83e13cdfb2136333a949ff4dbe385b (diff) | |
Refactor lsp.Server God Object: extract chat and completion subsystems
The Server type accumulated two large, tangled feature subsystems (in-editor
chat and code completion) alongside its core LSP dispatch role. Pull them into
cohesive types that own their state and logic while delegating shared
infrastructure back to Server via a back-reference.
- Add completionService (completion_service.go): owns the completion
cache/throttle state (completionState) and all completion request-handling
logic (handleCompletion, plan/jobs/execute, provider-native path,
post-processing, message building, prefix heuristics). Methods moved off
Server in handlers_completion.go to completionService.
- Add chatService (chat_service.go + chat_handlers.go): owns the input-activity
clock (lastInput, with its own mutex instead of Server.mu) and all in-editor
chat logic (detection, transcript history, message building, edit
application, inline prompts) plus the slash-command handlers
(chat_commands.go).
- Server now holds chat/completion fields, wires them in NewServer, and
delegates (dispatch table, didChange, debounce gate) to them. Thin Server
shims preserve the existing completion-state API for callers/tests.
Pure refactor: no behavior or LSP protocol changes. Tests adjusted only to
reach the relocated methods/state via the services. All tests pass with -race;
coverage 86.1%.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat (limited to 'internal/lsp/chat_handlers.go')
| -rw-r--r-- | internal/lsp/chat_handlers.go | 337 |
1 files changed, 337 insertions, 0 deletions
diff --git a/internal/lsp/chat_handlers.go b/internal/lsp/chat_handlers.go new file mode 100644 index 0000000..6fffaca --- /dev/null +++ b/internal/lsp/chat_handlers.go @@ -0,0 +1,337 @@ +// In-editor chat handling for the LSP server. These are methods on chatService +// (the extracted in-editor chat subsystem), which detects chat/inline-prompt +// trigger lines, builds the rolling transcript history and request messages, +// and applies the LLM reply back into the document. It reaches into Server (via +// c.srv, aliased to s) for shared infrastructure such as LLM clients, document +// access, config and edit dispatch. +package lsp + +import ( + "strings" + "time" + + "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/logging" +) + +// --- in-editor chat (";C ...") --- + +// detectAndHandleChat scans the current document for any line that starts with +// a new trigger pair (e.g., "?>" ",>" ":>" ";>") at EOL and inserts the LLM +// reply below. +func (c *chatService) detectAndHandleChat(uri string) { + s := c.srv + d := s.getDocument(uri) + if d == nil || len(d.lines) == 0 { + return + } + suffix, prefixes, _ := s.chatConfig() + openStr, _, openChar, closeChar := s.inlineMarkers() + for i, raw := range d.lines { + if c.maybeRunInlinePrompt(uri, i, raw, openStr, openChar, closeChar) { + continue + } + match, ok := parseChatPromptLine(raw, suffix, prefixes) + if !ok { + continue + } + if hasChatResponseBelow(d, i) { + continue + } + c.handleChatPrompt(uri, i, match) + // Only handle one per change tick to avoid flooding + break + } +} + +type chatPromptLine struct { + lastNonSpace int + removeCount int + prompt string +} + +func (c *chatService) maybeRunInlinePrompt(uri string, lineIdx int, raw string, openStr string, openChar byte, closeChar byte) bool { + s := c.srv + if !lineHasInlinePrompt(raw, openStr, openChar, closeChar) { + return false + } + if s.currentLLMClient() != nil { + pos := Position{Line: lineIdx, Character: len(raw)} + s.inflight.Add(1) + go func() { + defer s.inflight.Done() + c.runInlinePrompt(uri, pos) + }() + } + return true +} + +func parseChatPromptLine(raw string, suffix string, prefixes []string) (chatPromptLine, bool) { + if suffix == "" { + return chatPromptLine{}, false + } + last := findLastNonSpaceIndex(raw) + if last < 0 || string(raw[last]) != suffix { + return chatPromptLine{}, false + } + removeCount := len(suffix) + baseEnd := last + 1 - removeCount + if baseEnd < 0 { + return chatPromptLine{}, false + } + prompt := strings.TrimSpace(raw[:baseEnd]) + if prompt == "" { + return chatPromptLine{}, false + } + if !strings.HasPrefix(prompt, "/") && !hasTriggerPrefix(raw, last, prefixes) { + return chatPromptLine{}, false + } + return chatPromptLine{lastNonSpace: last, removeCount: removeCount, prompt: prompt}, true +} + +func findLastNonSpaceIndex(raw string) int { + for i := len(raw) - 1; i >= 0; i-- { + if raw[i] != ' ' && raw[i] != '\t' { + return i + } + } + return -1 +} + +func hasTriggerPrefix(raw string, suffixIdx int, prefixes []string) bool { + if suffixIdx < 1 { + return false + } + prev := string(raw[suffixIdx-1]) + for _, pfx := range prefixes { + if prev == pfx { + return true + } + } + return false +} + +func hasChatResponseBelow(d *document, lineIdx int) bool { + for i := lineIdx + 1; i < len(d.lines); i++ { + trimmed := strings.TrimSpace(d.lines[i]) + if trimmed == "" { + continue + } + return strings.HasPrefix(trimmed, ">") + } + return false +} + +func (c *chatService) handleChatPrompt(uri string, lineIdx int, match chatPromptLine) { + s := c.srv + if resp, ok := c.chatCommandResponse(uri, lineIdx, match.prompt); ok { + msg := strings.TrimSpace(resp.message) + if msg != "" { + c.applyChatEdits(uri, lineIdx, match.lastNonSpace, match.removeCount, "> "+msg) + } + return + } + s.inflight.Add(1) + go func() { + defer s.inflight.Done() + c.requestChatResponse(uri, lineIdx, match) + }() +} + +func (c *chatService) requestChatResponse(uri string, lineIdx int, match chatPromptLine) { + s := c.srv + ctx, cancel := s.requestTimeoutContext(25 * time.Second) + defer cancel() + pos := Position{Line: lineIdx, Character: match.lastNonSpace + 1} + msgs := c.buildChatMessages(uri, pos, match.prompt) + spec := s.buildRequestSpec(surfaceChat) + client := s.clientFor(spec) + if client == nil { + return + } + modelUsed := spec.effectiveModel(client.DefaultModel()) + logging.Logf("lsp ", "chat llm=requesting model=%s", modelUsed) + text, err := s.chatWithStats(ctx, surfaceChat, spec, msgs) + if err != nil { + logging.Logf("lsp ", "chat llm error: %v", err) + return + } + out := strings.TrimSpace(stripCodeFences(text)) + if out == "" { + return + } + c.applyChatEdits(uri, lineIdx, match.lastNonSpace, match.removeCount, "> "+out) +} + +// applyChatEdits removes the triggering punctuation at end of the line and +// inserts two newlines followed by a new line with the response prefixed. +func (c *chatService) applyChatEdits(uri string, lineIdx int, lastNonSpace int, removeCount int, response string) { + s := c.srv + d := s.getDocument(uri) + if d == nil { + return + } + // 1) Delete the trailing punctuation (1 or 2 chars) + delStart := Position{Line: lineIdx, Character: lastNonSpace + 1 - removeCount} + delEnd := Position{Line: lineIdx, Character: lastNonSpace + 1} + // 2) Insert two newlines and the response at end-of-line, then one extra blank line + insPos := Position{Line: lineIdx, Character: len(d.lines[lineIdx])} + resp := strings.TrimRight(response, "\n") + "\n" + insert := "\n\n" + resp + "\n" + edits := []TextEdit{ + {Range: Range{Start: delStart, End: delEnd}, NewText: ""}, + {Range: Range{Start: insPos, End: insPos}, NewText: insert}, + } + we := WorkspaceEdit{Changes: map[string][]TextEdit{uri: edits}} + s.clientApplyEdit("Hexai: insert chat response", we) +} + +func (c *chatService) runInlinePrompt(uri string, pos Position) { + s := c.srv + 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] + openStr, _, openChar, closeChar := s.inlineMarkers() + if !lineHasInlinePrompt(line, openStr, 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.completion.buildDocString(p, above, current, below, funcCtx) + newFunc := s.isDefiningNewFunction(uri, p.Position) + extra, hasExtra := s.buildAdditionalContext(newFunc, uri, p.Position) + items, ok, _ := s.completion.tryLLMCompletion(p, above, current, below, funcCtx, docStr, hasExtra, extra) + if !ok || len(items) == 0 { + return + } + c.applyInlineCompletion(uri, items[0]) +} + +func (c *chatService) 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}} + c.srv.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 (c *chatService) buildChatHistory(uri string, lineIdx int, currentPrompt string) []llm.Message { + s := c.srv + d := s.getDocument(uri) + if d == nil { + return []llm.Message{{Role: "user", Content: currentPrompt}} + } + type pair struct{ q, a string } + pairs := []pair{} + i := lineIdx - 1 + for i >= 0 && len(pairs) < 3 { + for i >= 0 && strings.TrimSpace(d.lines[i]) == "" { + i-- + } + if i < 0 { + break + } + if !strings.HasPrefix(strings.TrimSpace(d.lines[i]), ">") { + break + } + var replyLines []string + for i >= 0 { + line := strings.TrimSpace(d.lines[i]) + if strings.HasPrefix(line, ">") { + replyLines = append([]string{strings.TrimSpace(strings.TrimPrefix(line, ">"))}, replyLines...) + i-- + continue + } + break + } + for i >= 0 && strings.TrimSpace(d.lines[i]) == "" { + i-- + } + if i < 0 { + break + } + q := strings.TrimSpace(d.lines[i]) + q = c.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 = append(msgs, llm.Message{Role: "user", Content: p.q}) + } + if strings.TrimSpace(p.a) != "" { + msgs = append(msgs, llm.Message{Role: "assistant", Content: p.a}) + } + } + msgs = append(msgs, llm.Message{Role: "user", Content: currentPrompt}) + return msgs +} + +// stripTrailingTrigger removes the trailing chat trigger punctuation from a line if present. +func (c *chatService) stripTrailingTrigger(sx string) string { + trim := strings.TrimRight(sx, " \t") + if len(trim) == 0 { + return sx + } + _, prefixes, suffixChar := c.srv.chatConfig() + if len(trim) >= 2 && suffixChar != 0 && trim[len(trim)-1] == suffixChar { + prev := string(trim[len(trim)-2]) + for _, pf := range prefixes { + if prev == pf { + return strings.TrimRight(trim[:len(trim)-1], " \t") + } + } + } + last := trim[len(trim)-1] + switch last { + case '?', '!', ':': + return strings.TrimRight(trim[:len(trim)-1], " \t") + default: + return sx + } +} + +// buildChatMessages assembles the chat request messages using: +// - system from prompts.chat.system +// - rolling in-editor history up to current prompt +// - optional extra context per general.context_mode (window/full-file/new-func) +func (c *chatService) buildChatMessages(uri string, pos Position, prompt string) []llm.Message { + s := c.srv + // Base system and history + cfg := s.currentConfig() + sys := cfg.PromptChatSystem + // Determine line index for history from position + lineIdx := pos.Line + history := c.buildChatHistory(uri, lineIdx, prompt) + // Start with system + msgs := []llm.Message{{Role: "system", Content: sys}} + // Optional additional context like completion path (insert before history so last remains the prompt) + newFunc := s.isDefiningNewFunction(uri, pos) + if extra, has := s.buildAdditionalContext(newFunc, uri, pos); has && strings.TrimSpace(extra) != "" { + // Reuse completion's extra header template to avoid duplication + header := renderTemplate(cfg.PromptCompletionExtraHeader, map[string]string{"context": extra}) + if strings.TrimSpace(header) == "" { + header = extra + } + msgs = append(msgs, llm.Message{Role: "user", Content: header}) + } + // Then add history (which ends with the current prompt) + msgs = append(msgs, history...) + return msgs +} |
