diff options
Diffstat (limited to 'internal/lsp/handlers_document.go')
| -rw-r--r-- | internal/lsp/handlers_document.go | 322 |
1 files changed, 5 insertions, 317 deletions
diff --git a/internal/lsp/handlers_document.go b/internal/lsp/handlers_document.go index 25f4223..5b285ab 100644 --- a/internal/lsp/handlers_document.go +++ b/internal/lsp/handlers_document.go @@ -1,4 +1,7 @@ -// Document open/change/close and in-editor chat handlers split out of handlers.go. +// Document open/change/close handlers plus the shared client-edit transport +// helpers (workspace/applyEdit, window/showDocument). The in-editor chat logic +// that used to live here was extracted into chatService (chat_handlers.go); +// these handlers now delegate chat detection to s.chatSvc(). package lsp import ( @@ -7,7 +10,6 @@ import ( "strings" "time" - "codeberg.org/snonux/hexai/internal/llm" "codeberg.org/snonux/hexai/internal/logging" ) @@ -31,7 +33,7 @@ func (s *Server) handleDidChange(req Request) { } s.markActivity() // Detect in-editor chat trigger lines and respond inline. - s.detectAndHandleChat(p.TextDocument.URI) + s.chatSvc().detectAndHandleChat(p.TextDocument.URI) } } @@ -84,320 +86,6 @@ func (s *Server) docBeforeAfter(uri string, pos Position) (string, string) { return before, a.String() } -// --- 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 (s *Server) detectAndHandleChat(uri string) { - 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 s.maybeRunInlinePrompt(uri, i, raw, openStr, openChar, closeChar) { - continue - } - match, ok := parseChatPromptLine(raw, suffix, prefixes) - if !ok { - continue - } - if hasChatResponseBelow(d, i) { - continue - } - s.handleChatPrompt(uri, i, match) - // Only handle one per change tick to avoid flooding - break - } -} - -type chatPromptLine struct { - lastNonSpace int - removeCount int - prompt string -} - -func (s *Server) maybeRunInlinePrompt(uri string, lineIdx int, raw string, openStr string, openChar byte, closeChar byte) bool { - 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() - s.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 (s *Server) handleChatPrompt(uri string, lineIdx int, match chatPromptLine) { - if resp, ok := s.chatCommandResponse(uri, lineIdx, match.prompt); ok { - msg := strings.TrimSpace(resp.message) - if msg != "" { - s.applyChatEdits(uri, lineIdx, match.lastNonSpace, match.removeCount, "> "+msg) - } - return - } - s.inflight.Add(1) - go func() { - defer s.inflight.Done() - s.requestChatResponse(uri, lineIdx, match) - }() -} - -func (s *Server) requestChatResponse(uri string, lineIdx int, match chatPromptLine) { - ctx, cancel := s.requestTimeoutContext(25 * time.Second) - defer cancel() - pos := Position{Line: lineIdx, Character: match.lastNonSpace + 1} - msgs := s.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 - } - s.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 (s *Server) applyChatEdits(uri string, lineIdx int, lastNonSpace int, removeCount int, response string) { - 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 (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] - 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.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 { - 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 = s.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 (s *Server) stripTrailingTrigger(sx string) string { - trim := strings.TrimRight(sx, " \t") - if len(trim) == 0 { - return sx - } - _, prefixes, suffixChar := s.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 (s *Server) buildChatMessages(uri string, pos Position, prompt string) []llm.Message { - // Base system and history - cfg := s.currentConfig() - sys := cfg.PromptChatSystem - // Determine line index for history from position - lineIdx := pos.Line - history := s.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 -} - // clientApplyEdit sends a workspace/applyEdit request to the client. func (s *Server) clientApplyEdit(label string, edit WorkspaceEdit) { params := ApplyWorkspaceEditParams{Label: label, Edit: edit} |
