diff options
| author | Paul Buetow <paul@buetow.org> | 2025-08-16 23:16:54 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-08-16 23:16:54 +0300 |
| commit | 765eda955eb811d08d867ff4d3914fc6d60c22dd (patch) | |
| tree | fdc87da6af9d86dbda2ea9ab08244e93fd167188 | |
| parent | 1b01e35c34b953cbf51298f4650dc3215c382a4f (diff) | |
refactor(config): drop env-based config (except OPENAI_API_KEY)
- Switch to config-file-only; only OPENAI_API_KEY read from env.\n- llm: replace env autodetect with Config + NewFromConfig; add newOpenAI/newOllama.\n- lsp: NewServer now accepts injected llm.Client.\n- cli: remove env overrides; extend appConfig with provider-specific fields; build client from config + OPENAI_API_KEY.\n- docs: update README (config-only, defaults to OpenAI, minimal example); simplify flags table.\n- add config.json.example.\n- prompts: enforce ;text; (no spaces) and add ;;text; to remove entire line; tests added.
| -rw-r--r-- | .gitignore | 18 | ||||
| -rw-r--r-- | IDEAS.md | 15 | ||||
| -rw-r--r-- | README.md | 63 | ||||
| -rw-r--r-- | cmd/hexai/main.go | 131 | ||||
| -rw-r--r-- | config.json.example | 17 | ||||
| -rwxr-xr-x | hexai | bin | 0 -> 8914340 bytes | |||
| -rw-r--r-- | internal/llm/ollama.go | 17 | ||||
| -rw-r--r-- | internal/llm/openai.go | 84 | ||||
| -rw-r--r-- | internal/llm/provider.go | 55 | ||||
| -rw-r--r-- | internal/lsp/handlers.go | 106 | ||||
| -rw-r--r-- | internal/lsp/handlers_test.go | 57 | ||||
| -rw-r--r-- | internal/lsp/server.go | 9 |
12 files changed, 397 insertions, 175 deletions
@@ -1,17 +1 @@ -# Build artifacts and caches -bin/ -.gocache/ -.gomodcache/ - -# Local binaries and logs -hexai -*.log - -# OS/editor files -.DS_Store -Thumbs.db -*.swp -*.swo -.idea/ -.vscode/ - +./hexai @@ -15,6 +15,7 @@ ### New features +* [ ] Use hexai as a gh copilot... CLI replacemant for command line questions * [ ] Resolve diagnostics code action feature * [X] LSP server to be used with the Helix text editor * [X] Code completion using LLMs @@ -64,15 +65,5 @@ command = "hexai" ## Prompting -* Write a new function: `` -* In-place code add: `` - -# Summary: -# - Refactor and modularize code, add unit tests -# - Address TODOs and improve diagnostics/code actions -# - Integrate LLM-based code/text completion, code generation, and inline chat -# - Support multiple LLMs (Ollama, OpenAI, Claude, Gemini) -# - Enable code block actions (commenting, refactoring) -# - Provide Helix LSP server integration and usage notes -# - Facilitate prompting for code generation and actions:w - +* Write a new function: `;Implement a function that adds two numbers;` +* Replace a whole line: `some other text here ;Implement a function that adds two numbers;` @@ -4,38 +4,36 @@ Hexai, the AI LSP for the Helix editor. -At the moment this project is only in the proof of concept phase. +At the moment this project is only in the proof of PoC phase. ## LLM provider Hexai exposes a simple LLM provider interface. It supports OpenAI and a local -Ollama server. Provider selection and models are configured via environment -variables. +Ollama server. Provider selection and models are configured via a JSON +configuration file. ### Selecting a provider -- Set `HEXAI_LLM_PROVIDER` to `openai` or `ollama` to force a provider. -- If not set, Hexai auto‑detects: - - Uses OpenAI when `OPENAI_API_KEY` is present. - - Uses Ollama when any `OLLAMA_*` variables are present. - - Otherwise, Hexai falls back to a basic, local completion. +- Set `provider` in the config file to `openai` or `ollama`. +- If omitted, Hexai defaults to `openai`. ### OpenAI configuration -- Required: `OPENAI_API_KEY` — your OpenAI API key. -- Optional: `OPENAI_MODEL` — model name (default: `gpt-4o-mini`). -- Optional: `OPENAI_BASE_URL` — override the API base (e.g., a compatible endpoint). +- Required: `OPENAI_API_KEY` — provided via environment variable only. +- In config file: + - `openai_model` — model name (default: `gpt-4o-mini`). + - `openai_base_url` — API base (default: `https://api.openai.com/v1`). ### Ollama configuration (local) -- Optional: `OLLAMA_MODEL` — model name/tag (default: `qwen2.5-coder:latest`). -- Optional: `OLLAMA_BASE_URL` or `OLLAMA_HOST` — base URL to Ollama - (default: `http://localhost:11434`). +- In config file: + - `ollama_model` — model name/tag (default: `qwen2.5-coder:latest`). + - `ollama_base_url` — base URL to Ollama (default: `http://localhost:11434`). Notes: - For Ollama, ensure the model is available locally (e.g., `ollama pull qwen2.5-coder:latest`). - If you run Ollama in OpenAI‑compatible mode, you may alternatively use the - OpenAI provider with `OPENAI_BASE_URL` pointing to your local endpoint. + OpenAI provider with `openai_base_url` in the config pointing to your local endpoint. ## CLI usage and configuration @@ -52,12 +50,13 @@ Notes: ### Flags quick reference -| Flag | Env override | Description | -|-------------------------|----------------------------|----------------------------------------------------| -| `-log` | — | Path to log file (optional). | -| `-version` | — | Print version and exit. | +| Flag | Description | +|------------|--------------------------------------| +| `-log` | Path to log file (optional). | +| `-version` | Print version and exit. | -Configuration is via JSON file and environment variables (env has precedence). +Configuration is via a JSON file only. Environment variables are not used +except for `OPENAI_API_KEY`. ### JSON config file @@ -72,18 +71,24 @@ Configuration is via JSON file and environment variables (env has precedence). "max_context_tokens": 4000, "log_preview_limit": 100, "no_disk_io": true, - "provider": "ollama" // or "openai" + "provider": "ollama", // or "openai" + // OpenAI-only options + "openai_model": "gpt-4.1", + "openai_base_url": "https://api.openai.com/v1", + // Ollama-only options + "ollama_model": "qwen2.5-coder:latest", + "ollama_base_url": "http://localhost:11434" } ``` -### Environment overrides (take precedence) +Minimal config (defaults to OpenAI): -- `HEXAI_MAX_TOKENS`, `HEXAI_CONTEXT_MODE`, `HEXAI_CONTEXT_WINDOW_LINES`, `HEXAI_MAX_CONTEXT_TOKENS` -- `HEXAI_LOG_PREVIEW_LIMIT`, `HEXAI_NO_DISK_IO` -- `HEXAI_LLM_PROVIDER` (forces provider) +``` +{} +``` + +Ensure `OPENAI_API_KEY` is set in your environment. -### Environment quick reference (providers) +### Environment -- `HEXAI_LLM_PROVIDER`: `openai` | `ollama` (optional; otherwise auto‑detect). -- OpenAI: `OPENAI_API_KEY` (required), `OPENAI_MODEL`, `OPENAI_BASE_URL`. -- Ollama: `OLLAMA_MODEL`, `OLLAMA_BASE_URL` or `OLLAMA_HOST`. +- Only `OPENAI_API_KEY` is read from the environment when `provider` is `openai`. diff --git a/cmd/hexai/main.go b/cmd/hexai/main.go new file mode 100644 index 0000000..7cd5296 --- /dev/null +++ b/cmd/hexai/main.go @@ -0,0 +1,131 @@ +package main + +import ( + "encoding/json" + "flag" + "log" + "os" + "path/filepath" + "strings" + + "hexai/internal" + "hexai/internal/logging" + "hexai/internal/lsp" + "hexai/internal/llm" +) + +func main() { + var logPath string + var showVersion bool + flag.StringVar(&logPath, "log", "/tmp/hexai.log", "path to log file (optional)") + flag.BoolVar(&showVersion, "version", false, "print version and exit") + flag.Parse() + + if showVersion { + log.Println(internal.Version) + return + } + + // Configure logging (path flag only) + logger := log.New(os.Stderr, "hexai-lsp ", log.LstdFlags|log.Lmsgprefix) + if logPath != "" { + f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + logger.Fatalf("failed to open log file: %v", err) + } + defer f.Close() + logger.SetOutput(f) + } + logging.Bind(logger) + + // Load config file + cfg := loadConfig(logger) + + // Normalize and apply logging config + cfg.ContextMode = strings.ToLower(strings.TrimSpace(cfg.ContextMode)) + if cfg.LogPreviewLimit >= 0 { + logging.SetLogPreviewLimit(cfg.LogPreviewLimit) + } + + // Build LLM client from config (only OPENAI_API_KEY may come from env) + var client llm.Client + { + llmCfg := llm.Config{ + Provider: cfg.Provider, + OpenAIBaseURL: cfg.OpenAIBaseURL, + OpenAIModel: cfg.OpenAIModel, + OllamaBaseURL: cfg.OllamaBaseURL, + OllamaModel: cfg.OllamaModel, + } + oaKey := os.Getenv("OPENAI_API_KEY") + if c, err := llm.NewFromConfig(llmCfg, oaKey); err != nil { + logging.Logf("lsp ", "llm disabled: %v", err) + } else { + client = c + logging.Logf("lsp ", "llm enabled provider=%s model=%s", c.Name(), c.DefaultModel()) + } + } + + server := lsp.NewServer(os.Stdin, os.Stdout, logger, logPath != "", cfg.MaxTokens, cfg.ContextMode, cfg.ContextWindowLines, cfg.MaxContextTokens, cfg.NoDiskIO, client) + if err := server.Run(); err != nil { + logger.Fatalf("server error: %v", err) + } +} + +// appConfig holds user-configurable settings. +type appConfig struct { + MaxTokens int `json:"max_tokens"` + ContextMode string `json:"context_mode"` + ContextWindowLines int `json:"context_window_lines"` + MaxContextTokens int `json:"max_context_tokens"` + LogPreviewLimit int `json:"log_preview_limit"` + NoDiskIO bool `json:"no_disk_io"` + Provider string `json:"provider"` + // Provider-specific options + OpenAIBaseURL string `json:"openai_base_url"` + OpenAIModel string `json:"openai_model"` + OllamaBaseURL string `json:"ollama_base_url"` + OllamaModel string `json:"ollama_model"` +} + +func loadConfig(logger *log.Logger) appConfig { + // Defaults (mirror prior sensible values) + cfg := appConfig{ + MaxTokens: 4000, + ContextMode: "always-full", + ContextWindowLines: 120, + MaxContextTokens: 4000, + LogPreviewLimit: 100, + NoDiskIO: true, + } + home, err := os.UserHomeDir() + if err != nil { + return cfg + } + path := filepath.Join(home, ".config", "hexai", "config.json") + f, err := os.Open(path) + if err != nil { + return cfg + } + defer f.Close() + dec := json.NewDecoder(f) + var fileCfg appConfig + if err := dec.Decode(&fileCfg); err != nil { + logger.Printf("invalid config file %s: %v", path, err) + return cfg + } + // Merge: file overrides defaults when provided + if fileCfg.MaxTokens > 0 { cfg.MaxTokens = fileCfg.MaxTokens } + if strings.TrimSpace(fileCfg.ContextMode) != "" { cfg.ContextMode = fileCfg.ContextMode } + if fileCfg.ContextWindowLines > 0 { cfg.ContextWindowLines = fileCfg.ContextWindowLines } + if fileCfg.MaxContextTokens > 0 { cfg.MaxContextTokens = fileCfg.MaxContextTokens } + if fileCfg.LogPreviewLimit >= 0 { cfg.LogPreviewLimit = fileCfg.LogPreviewLimit } + cfg.NoDiskIO = fileCfg.NoDiskIO + if strings.TrimSpace(fileCfg.Provider) != "" { cfg.Provider = fileCfg.Provider } + // Provider-specific options + if strings.TrimSpace(fileCfg.OpenAIBaseURL) != "" { cfg.OpenAIBaseURL = fileCfg.OpenAIBaseURL } + if strings.TrimSpace(fileCfg.OpenAIModel) != "" { cfg.OpenAIModel = fileCfg.OpenAIModel } + if strings.TrimSpace(fileCfg.OllamaBaseURL) != "" { cfg.OllamaBaseURL = fileCfg.OllamaBaseURL } + if strings.TrimSpace(fileCfg.OllamaModel) != "" { cfg.OllamaModel = fileCfg.OllamaModel } + return cfg +} diff --git a/config.json.example b/config.json.example new file mode 100644 index 0000000..4dda9d0 --- /dev/null +++ b/config.json.example @@ -0,0 +1,17 @@ +{ + "max_tokens": 4000, + "context_mode": "file-on-new-func", + "context_window_lines": 120, + "max_context_tokens": 4000, + "log_preview_limit": 100, + "no_disk_io": true, + + "provider": "openai", + + "openai_model": "gpt-4.1", + "openai_base_url": "https://api.openai.com/v1", + + "ollama_model": "qwen2.5-coder:latest", + "ollama_base_url": "http://localhost:11434" +} + Binary files differdiff --git a/internal/llm/ollama.go b/internal/llm/ollama.go index 495b5c2..db3e06b 100644 --- a/internal/llm/ollama.go +++ b/internal/llm/ollama.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "net/http" - "os" "strings" "time" @@ -21,22 +20,16 @@ type ollamaClient struct { defaultModel string } -func newOllamaFromEnv() Client { - // Prefer OLLAMA_BASE_URL, fall back to OLLAMA_HOST, then default. - base := strings.TrimSpace(os.Getenv("OLLAMA_BASE_URL")) - if base == "" { - base = strings.TrimSpace(os.Getenv("OLLAMA_HOST")) +func newOllama(baseURL, model string) Client { + if strings.TrimSpace(baseURL) == "" { + baseURL = "http://localhost:11434" } - if base == "" { - base = "http://localhost:11434" - } - model := strings.TrimSpace(os.Getenv("OLLAMA_MODEL")) - if model == "" { + if strings.TrimSpace(model) == "" { model = "qwen2.5-coder:latest" } return &ollamaClient{ httpClient: &http.Client{Timeout: 30 * time.Second}, - baseURL: strings.TrimRight(base, "/"), + baseURL: strings.TrimRight(baseURL, "/"), defaultModel: model, } } diff --git a/internal/llm/openai.go b/internal/llm/openai.go index dbcee4d..03e894a 100644 --- a/internal/llm/openai.go +++ b/internal/llm/openai.go @@ -7,7 +7,7 @@ import ( "errors" "fmt" "net/http" - "os" + "strings" "time" "hexai/internal/logging" @@ -15,27 +15,27 @@ import ( // openAIClient implements Client against OpenAI's Chat Completions API. type openAIClient struct { - httpClient *http.Client - apiKey string - baseURL string - defaultModel string + httpClient *http.Client + apiKey string + baseURL string + defaultModel string } // Colors and base styling are provided by logging.go -func newOpenAIFromEnv(apiKey string) Client { - base := os.Getenv("OPENAI_BASE_URL") - if base == "" { - base = "https://api.openai.com/v1" +// newOpenAI constructs an OpenAI client using explicit configuration values. +// The apiKey may be empty; calls will fail until a valid key is supplied. +func newOpenAI(baseURL, model, apiKey string) Client { + if strings.TrimSpace(baseURL) == "" { + baseURL = "https://api.openai.com/v1" } - model := os.Getenv("OPENAI_MODEL") - if model == "" { - model = "gpt-4o-mini" + if strings.TrimSpace(model) == "" { + model = "gpt-4.1" } return &openAIClient{ httpClient: &http.Client{Timeout: 30 * time.Second}, apiKey: apiKey, - baseURL: base, + baseURL: baseURL, defaultModel: model, } } @@ -82,10 +82,10 @@ func (c *openAIClient) Chat(ctx context.Context, messages []Message, opts ...Req o.Model = c.defaultModel } start := time.Now() - logging.Logf("llm/openai ", "chat start model=%s temp=%.2f max_tokens=%d stop=%d messages=%d", o.Model, o.Temperature, o.MaxTokens, len(o.Stop), len(messages)) + logging.Logf("llm/openai ", "chat start model=%s temp=%.2f max_tokens=%d stop=%d messages=%d", o.Model, o.Temperature, o.MaxTokens, len(o.Stop), len(messages)) for i, m := range messages { - // Sending context (cyan) - logging.Logf("llm/openai ", "msg[%d] role=%s size=%d preview=%s%s%s", i, m.Role, len(m.Content), logging.AnsiCyan, logging.PreviewForLog(m.Content), logging.AnsiBase) + // Sending context (cyan) + logging.Logf("llm/openai ", "msg[%d] role=%s size=%d preview=%s%s%s", i, m.Role, len(m.Content), logging.AnsiCyan, logging.PreviewForLog(m.Content), logging.AnsiBase) } req := oaChatRequest{Model: o.Model} req.Messages = make([]oaMessage, len(messages)) @@ -108,7 +108,7 @@ func (c *openAIClient) Chat(ctx context.Context, messages []Message, opts ...Req return "", err } endpoint := c.baseURL + "/chat/completions" - logging.Logf("llm/openai ", "POST %s", endpoint) + logging.Logf("llm/openai ", "POST %s", endpoint) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { c.logf("new request error: %v", err) @@ -118,34 +118,34 @@ func (c *openAIClient) Chat(ctx context.Context, messages []Message, opts ...Req httpReq.Header.Set("Authorization", "Bearer "+c.apiKey) resp, err := c.httpClient.Do(httpReq) - if err != nil { - logging.Logf("llm/openai ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) - return "", err - } + if err != nil { + logging.Logf("llm/openai ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + return "", err + } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { var apiErr oaChatResponse _ = json.NewDecoder(resp.Body).Decode(&apiErr) - if apiErr.Error != nil && apiErr.Error.Message != "" { - logging.Logf("llm/openai ", "%sapi error status=%d type=%s msg=%s duration=%s%s", logging.AnsiRed, resp.StatusCode, apiErr.Error.Type, apiErr.Error.Message, time.Since(start), logging.AnsiBase) - return "", fmt.Errorf("openai error: %s (status %d)", apiErr.Error.Message, resp.StatusCode) - } - logging.Logf("llm/openai ", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase) - return "", fmt.Errorf("openai http error: status %d", resp.StatusCode) - } + if apiErr.Error != nil && apiErr.Error.Message != "" { + logging.Logf("llm/openai ", "%sapi error status=%d type=%s msg=%s duration=%s%s", logging.AnsiRed, resp.StatusCode, apiErr.Error.Type, apiErr.Error.Message, time.Since(start), logging.AnsiBase) + return "", fmt.Errorf("openai error: %s (status %d)", apiErr.Error.Message, resp.StatusCode) + } + logging.Logf("llm/openai ", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase) + return "", fmt.Errorf("openai http error: status %d", resp.StatusCode) + } var out oaChatResponse - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - logging.Logf("llm/openai ", "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) - return "", err - } - if len(out.Choices) == 0 { - logging.Logf("llm/openai ", "%sno choices returned duration=%s%s", logging.AnsiRed, time.Since(start), logging.AnsiBase) - return "", errors.New("openai: no choices returned") - } - content := out.Choices[0].Message.Content - // Received context (green) - logging.Logf("llm/openai ", "success choice=0 finish=%s size=%d preview=%s%s%s duration=%s", out.Choices[0].FinishReason, len(content), logging.AnsiGreen, logging.PreviewForLog(content), logging.AnsiBase, time.Since(start)) - return content, nil + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + logging.Logf("llm/openai ", "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + return "", err + } + if len(out.Choices) == 0 { + logging.Logf("llm/openai ", "%sno choices returned duration=%s%s", logging.AnsiRed, time.Since(start), logging.AnsiBase) + return "", errors.New("openai: no choices returned") + } + content := out.Choices[0].Message.Content + // Received context (green) + logging.Logf("llm/openai ", "success choice=0 finish=%s size=%d preview=%s%s%s duration=%s", out.Choices[0].FinishReason, len(content), logging.AnsiGreen, logging.PreviewForLog(content), logging.AnsiBase, time.Since(start)) + return content, nil } // small helper to keep return type consistent @@ -161,5 +161,5 @@ func trimPreview(s string, n int) string { } // Provider metadata -func (c *openAIClient) Name() string { return "openai" } -func (c *openAIClient) DefaultModel() string { return c.defaultModel } +func (c *openAIClient) Name() string { return "openai" } +func (c *openAIClient) DefaultModel() string { return c.defaultModel } diff --git a/internal/llm/provider.go b/internal/llm/provider.go index f7dad31..c7367ed 100644 --- a/internal/llm/provider.go +++ b/internal/llm/provider.go @@ -3,7 +3,6 @@ package llm import ( "context" "errors" - "os" "strings" ) @@ -42,34 +41,34 @@ func WithStop(stop ...string) RequestOption { return func(o *Options) { o.Stop = append([]string{}, stop...) } } -// NewDefault returns the default provider using environment configuration. -// Selection order: -// 1) HEXAI_LLM_PROVIDER=openai|ollama -// 2) If OPENAI_API_KEY is set -> OpenAI -// 3) If any OLLAMA_* vars are set -> Ollama -func NewDefault() (Client, error) { - // Explicit provider selection - if p := strings.ToLower(strings.TrimSpace(os.Getenv("HEXAI_LLM_PROVIDER"))); p != "" { - switch p { - case "openai": - apiKey := os.Getenv("OPENAI_API_KEY") - if apiKey == "" { - return nil, errors.New("OPENAI_API_KEY is not set") - } - return newOpenAIFromEnv(apiKey), nil - case "ollama": - return newOllamaFromEnv(), nil - default: - return nil, errors.New("unknown HEXAI_LLM_PROVIDER: " + p) - } - } +// Config defines provider configuration read from the Hexai config file. +type Config struct { + Provider string + // OpenAI options + OpenAIBaseURL string + OpenAIModel string + // Ollama options + OllamaBaseURL string + OllamaModel string +} - // Auto-detect - if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" { - return newOpenAIFromEnv(apiKey), nil +// NewFromConfig creates an LLM client using only the supplied configuration. +// The OpenAI API key is supplied separately and may be read from the environment +// by the caller; other environment-based configuration is not used. +func NewFromConfig(cfg Config, openAIAPIKey string) (Client, error) { + p := strings.ToLower(strings.TrimSpace(cfg.Provider)) + if p == "" { + p = "openai" } - if os.Getenv("OLLAMA_BASE_URL") != "" || os.Getenv("OLLAMA_HOST") != "" || os.Getenv("OLLAMA_MODEL") != "" { - return newOllamaFromEnv(), nil + switch p { + case "openai": + if strings.TrimSpace(openAIAPIKey) == "" { + return nil, errors.New("missing OPENAI_API_KEY for provider openai") + } + return newOpenAI(cfg.OpenAIBaseURL, cfg.OpenAIModel, openAIAPIKey), nil + case "ollama": + return newOllama(cfg.OllamaBaseURL, cfg.OllamaModel), nil + default: + return nil, errors.New("unknown LLM provider: " + p) } - return nil, errors.New("no LLM provider configured (set OPENAI_API_KEY or HEXAI_LLM_PROVIDER/OLLAMA_*)") } diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go index d8c13a1..8edfbb6 100644 --- a/internal/lsp/handlers.go +++ b/internal/lsp/handlers.go @@ -237,36 +237,86 @@ func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, fun // collectPromptRemovalEdits returns edits to remove all inline prompt markers. // Supported form (inclusive): -// - ";...;" (optional single space after trailing ';') +// - ";...;" where there is no space immediately after the first ';' +// and no space immediately before the last ';'. An optional single space +// after the trailing ';' is also removed for cleanliness. // Multiple markers per line are supported. func (s *Server) collectPromptRemovalEdits(uri string) []TextEdit { - d := s.getDocument(uri) - if d == nil || len(d.lines) == 0 { - return nil - } - var edits []TextEdit - for i, line := range d.lines { - // Scan for ;...; markers - startSemi := 0 - for startSemi < len(line) { - j := strings.Index(line[startSemi:], ";") - if j < 0 { - break - } - j += startSemi - k := strings.Index(line[j+1:], ";") - if k < 0 { - break - } - endChar := j + 1 + k + 1 // include trailing ';' - if endChar < len(line) && line[endChar] == ' ' { - endChar++ - } - edits = append(edits, TextEdit{Range: Range{Start: Position{Line: i, Character: j}, End: Position{Line: i, Character: endChar}}, NewText: ""}) - startSemi = endChar - } - } - return edits + d := s.getDocument(uri) + if d == nil || len(d.lines) == 0 { + return nil + } + var edits []TextEdit + for i, line := range d.lines { + // If the line contains a double-semicolon trigger of the form + // ";;text;" (no space after the ";;" and no space before the closing ';'), + // remove the entire line. + removeWholeLine := false + { + pos := 0 + for pos < len(line) { + j := strings.Index(line[pos:], ";;") + if j < 0 { break } + j += pos + // ensure there's a non-space after the two semicolons + if j+2 >= len(line) || line[j+2] == ' ' { pos = j + 2; continue } + // find closing ';' after the content + k := strings.Index(line[j+2:], ";") + if k < 0 { break } + closeIdx := j + 2 + k + // ensure char before closing ';' is not a space + if closeIdx-1 < 0 || line[closeIdx-1] == ' ' { pos = closeIdx + 1; continue } + removeWholeLine = true + break + } + } + if removeWholeLine { + edits = append(edits, TextEdit{Range: Range{Start: Position{Line: i, Character: 0}, End: Position{Line: i, Character: len(line)}}, NewText: ""}) + continue + } + // Scan for ;...; markers that have no spaces directly inside the semicolons + startSemi := 0 + for startSemi < len(line) { + j := strings.Index(line[startSemi:], ";") + if j < 0 { + break + } + j += startSemi + k := strings.Index(line[j+1:], ";") + if k < 0 { + break + } + // Require no space immediately after the first ';' + if j+1 >= len(line) || line[j+1] == ' ' { + startSemi = j + 1 + continue + } + // Ignore patterns that start with double semicolon here; handled above + if line[j+1] == ';' { + startSemi = j + 2 + continue + } + // Index of the closing ';' + closeIdx := j + 1 + k + // Require no space immediately before the closing ';' + if closeIdx-1 < 0 || line[closeIdx-1] == ' ' { + startSemi = closeIdx + 1 + continue + } + // Require at least one character between the semicolons + if closeIdx-(j+1) < 1 { + startSemi = closeIdx + 1 + continue + } + endChar := closeIdx + 1 // include trailing ';' + if endChar < len(line) && line[endChar] == ' ' { + endChar++ + } + edits = append(edits, TextEdit{Range: Range{Start: Position{Line: i, Character: j}, End: Position{Line: i, Character: endChar}}, NewText: ""}) + startSemi = endChar + } + } + return edits } func inParamList(current string, cursor int) bool { diff --git a/internal/lsp/handlers_test.go b/internal/lsp/handlers_test.go index 6ce1e5d..3ebddfb 100644 --- a/internal/lsp/handlers_test.go +++ b/internal/lsp/handlers_test.go @@ -147,3 +147,60 @@ no markers here` t.Fatalf("e0 start not at ;") } } + +func TestCollectPromptRemovalEdits_SkipSpacedMarkers(t *testing.T) { + s := newTestServer() + uri := "file:///y.go" + // Only ;ok; should be removed; "; spaced ;" must be ignored + src := `prefix ;ok; middle ; spaced ; suffix` + s.setDocument(uri, src) + edits := s.collectPromptRemovalEdits(uri) + if len(edits) != 1 { + t.Fatalf("expected 1 edit (only ;ok;), got %d", len(edits)) + } + // Ensure the removed region starts at the first ';' of ;ok; + line := s.getDocument(uri).lines[0] + wantStart := strings.Index(line, ";ok;") + if wantStart < 0 { + t.Fatalf("test setup: could not find ;ok; in %q", line) + } + if edits[0].Range.Start.Line != 0 || edits[0].Range.Start.Character != wantStart { + t.Fatalf("unexpected first edit start: got line=%d char=%d want line=0 char=%d", edits[0].Range.Start.Line, edits[0].Range.Start.Character, wantStart) + } +} + +func TestCollectPromptRemovalEdits_DoubleSemicolonRemovesWholeLine(t *testing.T) { + s := newTestServer() + uri := "file:///z.go" + line0 := "keep" + line1 := ";;todo; remove this whole line" + line2 := "keep ;ok; end" + src := strings.Join([]string{line0, line1, line2}, "\n") + s.setDocument(uri, src) + edits := s.collectPromptRemovalEdits(uri) + if len(edits) != 2 { + t.Fatalf("expected 2 edits (whole line + ;ok;), got %d", len(edits)) + } + // Find the whole-line removal for line1 + found := false + for _, e := range edits { + if e.Range.Start.Line == 1 && e.Range.Start.Character == 0 && e.Range.End.Line == 1 && e.Range.End.Character == len(line1) { + found = true + break + } + } + if !found { + t.Fatalf("did not find whole-line removal edit for line 1") + } +} + +func TestCollectPromptRemovalEdits_SkipSpacedDouble(t *testing.T) { + s := newTestServer() + uri := "file:///w.go" + src := "prefix ;; spaced ; suffix" + s.setDocument(uri, src) + edits := s.collectPromptRemovalEdits(uri) + if len(edits) != 0 { + t.Fatalf("expected 0 edits for spaced double-semicolon trigger, got %d", len(edits)) + } +} diff --git a/internal/lsp/server.go b/internal/lsp/server.go index ef51636..bfdbca2 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -35,7 +35,7 @@ type Server struct { startTime time.Time } -func NewServer(r io.Reader, w io.Writer, logger *log.Logger, logContext bool, maxTokens int, contextMode string, windowLines int, maxContextTokens int, noDiskIO bool) *Server { +func NewServer(r io.Reader, w io.Writer, logger *log.Logger, logContext bool, maxTokens int, contextMode string, windowLines int, maxContextTokens int, noDiskIO bool, client llm.Client) *Server { s := &Server{in: bufio.NewReader(r), out: w, logger: logger, docs: make(map[string]*document), logContext: logContext} if maxTokens <= 0 { maxTokens = 500 @@ -55,12 +55,7 @@ func NewServer(r io.Reader, w io.Writer, logger *log.Logger, logContext bool, ma s.maxContextTokens = maxContextTokens s.noDiskIO = noDiskIO s.startTime = time.Now() - if c, err := llm.NewDefault(); err != nil { - logging.Logf("lsp ", "llm disabled: %v", err) - } else { - s.llmClient = c - logging.Logf("lsp ", "llm enabled provider=%s model=%s", c.Name(), c.DefaultModel()) - } + s.llmClient = client return s } |
