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 | |
| 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')
37 files changed, 644 insertions, 503 deletions
diff --git a/internal/lsp/build_prompts_table_test.go b/internal/lsp/build_prompts_table_test.go index bc4f031..3c40f26 100644 --- a/internal/lsp/build_prompts_table_test.go +++ b/internal/lsp/build_prompts_table_test.go @@ -13,7 +13,7 @@ func TestBuildPrompts_Table(t *testing.T) { } for _, c := range cases { s := newTestServer() - msgs := s.buildCompletionMessages(false, false, "", c.inParams, p, "above", "current", "below", "func ctx") + msgs := s.completion.buildCompletionMessages(false, false, "", c.inParams, p, "above", "current", "below", "func ctx") if len(msgs) < 2 || msgs[0].Role != "system" || msgs[1].Role != "user" { t.Fatalf("%s: unexpected messages", c.name) } diff --git a/internal/lsp/chat_commands.go b/internal/lsp/chat_commands.go index 11e5a28..480643a 100644 --- a/internal/lsp/chat_commands.go +++ b/internal/lsp/chat_commands.go @@ -11,27 +11,27 @@ type chatCommandResult struct { message string } -func (s *Server) chatCommandResponse(uri string, lineIdx int, prompt string) (chatCommandResult, bool) { - trimmed := strings.TrimSpace(s.stripTrailingTrigger(prompt)) +func (c *chatService) chatCommandResponse(uri string, lineIdx int, prompt string) (chatCommandResult, bool) { + trimmed := strings.TrimSpace(c.stripTrailingTrigger(prompt)) if trimmed == "" || !strings.HasPrefix(trimmed, "/") { return chatCommandResult{}, false } switch { case strings.HasPrefix(trimmed, "/reload"): - return s.handleReloadCommand(), true + return c.handleReloadCommand(), true case strings.HasPrefix(trimmed, "/help"): - return s.handleHelpCommand(), true + return c.handleHelpCommand(), true case strings.HasPrefix(trimmed, "/disable"): - return s.handleDisableCompletionCommand(), true + return c.handleDisableCompletionCommand(), true case strings.HasPrefix(trimmed, "/enable"): - return s.handleEnableCompletionCommand(), true + return c.handleEnableCompletionCommand(), true default: return chatCommandResult{message: fmt.Sprintf("Unknown command %q. Try /help?>", trimmed)}, true } } -func (s *Server) handleHelpCommand() chatCommandResult { +func (c *chatService) handleHelpCommand() chatCommandResult { lines := []string{ "Available slash commands:", "- /reload?> reload configuration from file (ignores env overrides)", @@ -41,7 +41,8 @@ func (s *Server) handleHelpCommand() chatCommandResult { return chatCommandResult{message: strings.Join(lines, "\n")} } -func (s *Server) handleReloadCommand() chatCommandResult { +func (c *chatService) handleReloadCommand() chatCommandResult { + s := c.srv if s.configStore == nil { return chatCommandResult{message: "Reload unavailable: no config store"} } @@ -57,16 +58,16 @@ func (s *Server) handleReloadCommand() chatCommandResult { return chatCommandResult{message: summary} } -func (s *Server) handleDisableCompletionCommand() chatCommandResult { - prev := s.setCompletionsDisabled(true) +func (c *chatService) handleDisableCompletionCommand() chatCommandResult { + prev := c.srv.setCompletionsDisabled(true) if prev { return chatCommandResult{message: "Auto-completions were already disabled."} } return chatCommandResult{message: "Auto-completions disabled. Use /enable?> to restore."} } -func (s *Server) handleEnableCompletionCommand() chatCommandResult { - prev := s.setCompletionsDisabled(false) +func (c *chatService) handleEnableCompletionCommand() chatCommandResult { + prev := c.srv.setCompletionsDisabled(false) if !prev { return chatCommandResult{message: "Auto-completions are already enabled."} } diff --git a/internal/lsp/chat_commands_test.go b/internal/lsp/chat_commands_test.go index ffe31dd..15a3acf 100644 --- a/internal/lsp/chat_commands_test.go +++ b/internal/lsp/chat_commands_test.go @@ -28,7 +28,7 @@ func TestFormatReloadSummary(t *testing.T) { func TestHandleHelpCommandListsReload(t *testing.T) { s := newTestServer() - res := s.handleHelpCommand() + res := s.chatSvc().handleHelpCommand() if !strings.Contains(res.message, "/reload?>") { t.Fatalf("expected reload command in help output: %q", res.message) } @@ -70,7 +70,7 @@ func TestHandleReloadCommandReloadsStore(t *testing.T) { t.Fatalf("update config: %v", err) } - res := s.handleReloadCommand() + res := s.chatSvc().handleReloadCommand() if !strings.Contains(res.message, "Reloaded config (1 changes):") { t.Fatalf("unexpected reload summary: %q", res.message) } @@ -115,7 +115,7 @@ func TestDetectAndHandleChatExecutesSlashCommand(t *testing.T) { uri := "file:///cmd.go" s.setDocument(uri, "/reload>\n") - s.detectAndHandleChat(uri) + s.chatSvc().detectAndHandleChat(uri) outStr := out.String() if !strings.Contains(outStr, "Reloaded config") { @@ -132,7 +132,7 @@ func TestDisableEnableCommandsToggleCompletions(t *testing.T) { t.Fatalf("expected completions enabled initially") } - if res, ok := s.chatCommandResponse("file:///x", 0, "/disable>"); !ok { + if res, ok := s.chatSvc().chatCommandResponse("file:///x", 0, "/disable>"); !ok { t.Fatalf("expected disable command to be handled") } else if !strings.Contains(res.message, "disabled") { t.Fatalf("unexpected disable message: %q", res.message) @@ -141,13 +141,13 @@ func TestDisableEnableCommandsToggleCompletions(t *testing.T) { t.Fatalf("expected completions disabled after command") } - if res, ok := s.chatCommandResponse("file:///x", 0, "/disable>"); !ok { + if res, ok := s.chatSvc().chatCommandResponse("file:///x", 0, "/disable>"); !ok { t.Fatalf("expected repeated disable command to be handled") } else if !strings.Contains(res.message, "already disabled") { t.Fatalf("expected already-disabled message, got %q", res.message) } - if res, ok := s.chatCommandResponse("file:///x", 0, "/enable>"); !ok { + if res, ok := s.chatSvc().chatCommandResponse("file:///x", 0, "/enable>"); !ok { t.Fatalf("expected enable command to be handled") } else if !strings.Contains(res.message, "enabled") { t.Fatalf("unexpected enable message: %q", res.message) @@ -156,7 +156,7 @@ func TestDisableEnableCommandsToggleCompletions(t *testing.T) { t.Fatalf("expected completions enabled after command") } - if res, ok := s.chatCommandResponse("file:///x", 0, "/enable>"); !ok { + if res, ok := s.chatSvc().chatCommandResponse("file:///x", 0, "/enable>"); !ok { t.Fatalf("expected repeated enable command to be handled") } else if !strings.Contains(res.message, "already enabled") { t.Fatalf("expected already-enabled message, got %q", res.message) diff --git a/internal/lsp/chat_context_mode_test.go b/internal/lsp/chat_context_mode_test.go index 876f092..e6696f4 100644 --- a/internal/lsp/chat_context_mode_test.go +++ b/internal/lsp/chat_context_mode_test.go @@ -23,7 +23,7 @@ func TestChat_RespectsContextModeWindow(t *testing.T) { src := "package main\nline2 context\nwhat?>\n" s.setDocument(uri, src) - s.detectAndHandleChat(uri) + s.chatSvc().detectAndHandleChat(uri) s.inflight.Wait() if len(cap.msgs) == 0 { t.Fatalf("expected Chat to be called") @@ -59,7 +59,7 @@ func TestChat_ContextModeMinimal_NoExtra(t *testing.T) { uri := "file:///ctx2.go" s.setDocument(uri, "package main\nhelp?>\n") - s.detectAndHandleChat(uri) + s.chatSvc().detectAndHandleChat(uri) s.inflight.Wait() if len(cap.msgs) != 2 { @@ -81,7 +81,7 @@ func TestChat_ContextModeAlwaysFull_AddsExtra(t *testing.T) { uri := "file:///ctx3.go" s.setDocument(uri, "package main\nline2\nhelp?>\n") - s.detectAndHandleChat(uri) + s.chatSvc().detectAndHandleChat(uri) s.inflight.Wait() if len(cap.msgs) < 3 { @@ -109,7 +109,7 @@ func TestChat_ContextModeFileOnNewFunc_NoExtraWithoutSignature(t *testing.T) { uri := "file:///ctx4.go" s.setDocument(uri, "package main\nhelp?>\n") - s.detectAndHandleChat(uri) + s.chatSvc().detectAndHandleChat(uri) s.inflight.Wait() if len(cap.msgs) != 2 { @@ -130,7 +130,7 @@ func TestChat_ContextModeFileOnNewFunc_WithSignature_AddsExtra(t *testing.T) { // Signature without '{' yet; chat prompt appears before the body, so newFunc=true src := "package main\n\nfunc add(x int) int\nhelp?>\n" s.setDocument(uri, src) - s.detectAndHandleChat(uri) + s.chatSvc().detectAndHandleChat(uri) s.inflight.Wait() if len(cap.msgs) < 3 { 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 +} diff --git a/internal/lsp/chat_history_test.go b/internal/lsp/chat_history_test.go index 70080f3..a6d6266 100644 --- a/internal/lsp/chat_history_test.go +++ b/internal/lsp/chat_history_test.go @@ -4,19 +4,19 @@ import "testing" func TestStripTrailingTrigger(t *testing.T) { s := newTestServer() - if got := s.stripTrailingTrigger("what?"); got != "what" { + if got := s.chatSvc().stripTrailingTrigger("what?"); got != "what" { t.Fatalf("should remove trailing ?") } - if got := s.stripTrailingTrigger("what?>"); got != "what?" { + if got := s.chatSvc().stripTrailingTrigger("what?>"); got != "what?" { t.Fatalf("should drop trailing > when preceded by ?") } - if got := s.stripTrailingTrigger("ok!>"); got != "ok!" { + if got := s.chatSvc().stripTrailingTrigger("ok!>"); got != "ok!" { t.Fatalf("should drop > after !") } - if got := s.stripTrailingTrigger("note:>"); got != "note:" { + if got := s.chatSvc().stripTrailingTrigger("note:>"); got != "note:" { t.Fatalf("should drop > after :") } - if got := s.stripTrailingTrigger("go;>"); got != "go;" { + if got := s.chatSvc().stripTrailingTrigger("go;>"); got != "go;" { t.Fatalf("should drop > after ;") } } @@ -27,7 +27,7 @@ func TestBuildChatHistory_OrderAndLimit(t *testing.T) { // Conversation: q1, > a1, blank, q2, > a2 lines, then current prompt doc := "q1\n> a1\n\nq2\n> a2\n\n" s.setDocument(uri, doc) - msgs := s.buildChatHistory(uri, 5, "q3") + msgs := s.chatSvc().buildChatHistory(uri, 5, "q3") // Expect: user q1, assistant a1, user q2, assistant a2, user q3 if len(msgs) != 5 || msgs[0].Role != "user" || msgs[1].Role != "assistant" || msgs[2].Role != "user" || msgs[3].Role != "assistant" || msgs[4].Role != "user" { t.Fatalf("unexpected roles: %+v", msgs) diff --git a/internal/lsp/chat_no_double_answer_test.go b/internal/lsp/chat_no_double_answer_test.go index 04196f8..68ad16f 100644 --- a/internal/lsp/chat_no_double_answer_test.go +++ b/internal/lsp/chat_no_double_answer_test.go @@ -15,7 +15,7 @@ func TestDetectAndHandleChat_NoDoubleAnswer(t *testing.T) { uri := "file:///x.go" // Question line with trigger, followed by an existing answer line starting with '>' s.setDocument(uri, "What?>\n> already answered\n") - s.detectAndHandleChat(uri) + s.chatSvc().detectAndHandleChat(uri) if out.Len() != 0 { t.Fatalf("expected no applyEdit request when answer exists; got %d bytes", out.Len()) } diff --git a/internal/lsp/chat_prompt_test.go b/internal/lsp/chat_prompt_test.go index 3a1146f..dbc6158 100644 --- a/internal/lsp/chat_prompt_test.go +++ b/internal/lsp/chat_prompt_test.go @@ -18,7 +18,7 @@ func TestDetectAndHandleChat_UsesConfiguredSystemPrompt(t *testing.T) { s.out = &out // Line that should trigger chat: ends with '>' and previous char in prefixes s.setDocument(uri, "help?>\n") - s.detectAndHandleChat(uri) + s.chatSvc().detectAndHandleChat(uri) // Wait for the background chat goroutine to finish. s.inflight.Wait() if len(cap.msgs) == 0 { diff --git a/internal/lsp/chat_service.go b/internal/lsp/chat_service.go new file mode 100644 index 0000000..fd39e14 --- /dev/null +++ b/internal/lsp/chat_service.go @@ -0,0 +1,52 @@ +package lsp + +import ( + "sync" + "time" +) + +// chatService owns the in-editor chat subsystem that used to be inlined on +// Server. It tracks editor input activity (used by the completion debounce +// gate) and reaches back into the Server (via srv) for shared infrastructure +// such as configuration, LLM clients, document access and edit dispatch. +// +// Pulling this out of Server keeps the chat detection/history/command logic +// cohesive and gives the input-activity clock its own small mutex instead of +// piggy-backing on Server.mu. +type chatService struct { + srv *Server + + activityMu sync.RWMutex + lastInput time.Time +} + +// newChatService constructs the chat subsystem bound to srv. +func newChatService(srv *Server) *chatService { + return &chatService{srv: srv} +} + +// markActivity records that the editor just sent input. The completion +// debounce gate uses this timestamp to decide how long to wait before issuing +// an LLM request. +func (c *chatService) markActivity() { + c.activityMu.Lock() + c.lastInput = time.Now() + c.activityMu.Unlock() +} + +// lastActivity returns the most recent input timestamp (zero if none yet). +func (c *chatService) lastActivity() time.Time { + c.activityMu.RLock() + defer c.activityMu.RUnlock() + return c.lastInput +} + +// chatSvc returns the chat subsystem, lazily constructing it for the bare +// Server literals used in some tests. Production code always has it wired up +// by NewServer. +func (s *Server) chatSvc() *chatService { + if s.chat == nil { + s.chat = newChatService(s) + } + return s.chat +} diff --git a/internal/lsp/chat_trigger_suppression_test.go b/internal/lsp/chat_trigger_suppression_test.go index 852f955..8cfd178 100644 --- a/internal/lsp/chat_trigger_suppression_test.go +++ b/internal/lsp/chat_trigger_suppression_test.go @@ -7,13 +7,13 @@ func TestCompletionSuppressedOnChatTriggerEOL(t *testing.T) { s := newTestServer() s.cfg.MaxTokens = 32 s.cfg.TriggerCharacters = []string{".", ":", "/", "_"} - s.compCache = make(map[string]string) + s.completion.compCache = make(map[string]string) initServerDefaults(s) s.llmClient = &countingLLM{} tests := []string{"What now?>", "Explain!>", "Refactor:>", "note ;>"} for i, line := range tests { p := CompletionParams{Position: Position{Line: 0, Character: len(line)}, TextDocument: TextDocumentIdentifier{URI: "file://chat-suppr.go"}} - items, ok, _ := s.tryLLMCompletion(p, "", line, "", "", "", false, "") + items, ok, _ := s.completion.tryLLMCompletion(p, "", line, "", "", "", false, "") if !ok { t.Fatalf("case %d: expected ok=true", i) } diff --git a/internal/lsp/completion_cache_test.go b/internal/lsp/completion_cache_test.go |
