summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-06-11 00:16:27 +0300
committerPaul Buetow <paul@buetow.org>2026-06-11 00:16:27 +0300
commit236f6543b093e54157f40ec0c021b35177e1713e (patch)
treec6214c549739c997c8b572bb8904159c85fd2140 /internal
parent247b79114d83e13cdfb2136333a949ff4dbe385b (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')
-rw-r--r--internal/lsp/build_prompts_table_test.go2
-rw-r--r--internal/lsp/chat_commands.go25
-rw-r--r--internal/lsp/chat_commands_test.go14
-rw-r--r--internal/lsp/chat_context_mode_test.go10
-rw-r--r--internal/lsp/chat_handlers.go337
-rw-r--r--internal/lsp/chat_history_test.go12
-rw-r--r--internal/lsp/chat_no_double_answer_test.go2
-rw-r--r--internal/lsp/chat_prompt_test.go2
-rw-r--r--internal/lsp/chat_service.go52
-rw-r--r--internal/lsp/chat_trigger_suppression_test.go4
-rw-r--r--internal/lsp/completion_cache_test.go4
-rw-r--r--internal/lsp/completion_codex_path_test.go8
-rw-r--r--internal/lsp/completion_helpers_more_test.go10
-rw-r--r--internal/lsp/completion_messages_test.go16
-rw-r--r--internal/lsp/completion_prefix_strip_test.go24
-rw-r--r--internal/lsp/completion_provider_fallback_test.go2
-rw-r--r--internal/lsp/completion_service.go76
-rw-r--r--internal/lsp/completion_state.go28
-rw-r--r--internal/lsp/completion_toggle_test.go2
-rw-r--r--internal/lsp/debounce_throttle_more_test.go13
-rw-r--r--internal/lsp/debounce_throttle_test.go12
-rw-r--r--internal/lsp/document.go5
-rw-r--r--internal/lsp/document_test.go6
-rw-r--r--internal/lsp/handlers_completion.go90
-rw-r--r--internal/lsp/handlers_document.go322
-rw-r--r--internal/lsp/handlers_end_to_end_test.go2
-rw-r--r--internal/lsp/handlers_utils.go2
-rw-r--r--internal/lsp/helpers_inline_prompt_test.go2
-rw-r--r--internal/lsp/helpers_more_test.go2
-rw-r--r--internal/lsp/ignore_test.go3
-rw-r--r--internal/lsp/inline_prompt_completion_test.go2
-rw-r--r--internal/lsp/log_context_test.go2
-rw-r--r--internal/lsp/postprocess_indent_test.go2
-rw-r--r--internal/lsp/provider_native_success_test.go6
-rw-r--r--internal/lsp/server.go34
-rw-r--r--internal/lsp/server_test.go4
-rw-r--r--internal/lsp/triggers_config_test.go8
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 {