summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-09-24 23:21:43 +0300
committerPaul Buetow <paul@buetow.org>2025-09-24 23:21:43 +0300
commitc3c71345db9086392cd9b7529c7f5287009c226e (patch)
treed227894ab900d6050cbe1418984526088a692db5 /internal
parent127844a4ee481590ef53b6777d34bf2114cb3ab1 (diff)
Add runtime config store and reload command
Diffstat (limited to 'internal')
-rw-r--r--internal/appconfig/config.go23
-rw-r--r--internal/hexaiaction/run_more_test.go79
-rw-r--r--internal/hexailsp/run.go23
-rw-r--r--internal/hexailsp/run_more_test.go54
-rw-r--r--internal/llm/copilot_http_test.go25
-rw-r--r--internal/llm/openai_test.go112
-rw-r--r--internal/llm/test_helpers_test.go3
-rw-r--r--internal/lsp/chat_commands.go63
-rw-r--r--internal/lsp/chat_commands_test.go82
-rw-r--r--internal/lsp/chat_context_mode_test.go22
-rw-r--r--internal/lsp/chat_prompt_test.go4
-rw-r--r--internal/lsp/chat_trigger_suppression_test.go5
-rw-r--r--internal/lsp/codeaction_custom_errors_test.go17
-rw-r--r--internal/lsp/codeaction_custom_test.go38
-rw-r--r--internal/lsp/codeaction_prompts_test.go24
-rw-r--r--internal/lsp/completion_cache_test.go8
-rw-r--r--internal/lsp/completion_codex_path_test.go10
-rw-r--r--internal/lsp/completion_messages_test.go2
-rw-r--r--internal/lsp/completion_prefix_strip_test.go64
-rw-r--r--internal/lsp/context.go8
-rw-r--r--internal/lsp/context_test.go14
-rw-r--r--internal/lsp/debounce_throttle_more_test.go8
-rw-r--r--internal/lsp/debounce_throttle_test.go21
-rw-r--r--internal/lsp/document_test.go86
-rw-r--r--internal/lsp/handlers.go27
-rw-r--r--internal/lsp/handlers_codeaction.go149
-rw-r--r--internal/lsp/handlers_completion.go78
-rw-r--r--internal/lsp/handlers_document.go38
-rw-r--r--internal/lsp/handlers_end_to_end_test.go14
-rw-r--r--internal/lsp/handlers_init.go11
-rw-r--r--internal/lsp/handlers_utils.go61
-rw-r--r--internal/lsp/helpers_inline_prompt_test.go12
-rw-r--r--internal/lsp/init_and_trigger_test.go13
-rw-r--r--internal/lsp/llm_request_opts_test.go2
-rw-r--r--internal/lsp/provider_native_success_test.go4
-rw-r--r--internal/lsp/server.go385
-rw-r--r--internal/lsp/server_test.go87
-rw-r--r--internal/lsp/triggers_config_test.go25
-rw-r--r--internal/runtimeconfig/store.go178
-rw-r--r--internal/runtimeconfig/store_test.go59
40 files changed, 1368 insertions, 570 deletions
diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go
index 9119688..adf9b75 100644
--- a/internal/appconfig/config.go
+++ b/internal/appconfig/config.go
@@ -162,7 +162,16 @@ func newDefaultConfig() App {
// Load reads configuration from a file and merges with defaults.
// It respects the XDG Base Directory Specification.
-func Load(logger *log.Logger) App {
+func Load(logger *log.Logger) App { return LoadWithOptions(logger, LoadOptions{}) }
+
+// LoadOptions tune how configuration is loaded at runtime.
+type LoadOptions struct {
+ // IgnoreEnv skips applying environment overrides when true.
+ IgnoreEnv bool
+}
+
+// LoadWithOptions reads configuration and applies the requested loading options.
+func LoadWithOptions(logger *log.Logger, opts LoadOptions) App {
cfg := newDefaultConfig()
if logger == nil {
return cfg // Return defaults if no logger is provided (e.g. in tests)
@@ -171,18 +180,20 @@ func Load(logger *log.Logger) App {
configPath, err := getConfigPath()
if err != nil {
logger.Printf("%v", err)
- // Even if config path cannot be resolved, still allow env overrides below.
+ // Even if config path cannot be resolved, keep defaults and optionally apply env overrides below.
} else {
if fileCfg, err := loadFromFile(configPath, logger); err == nil && fileCfg != nil {
cfg.mergeWith(fileCfg)
}
// When the config file is missing or invalid, we keep defaults and still
- // apply any environment overrides below.
+ // apply any environment overrides below (unless disabled).
}
- // Environment overrides (take precedence over file)
- if envCfg := loadFromEnv(logger); envCfg != nil {
- cfg.mergeWith(envCfg)
+ if !opts.IgnoreEnv {
+ // Environment overrides (take precedence over file)
+ if envCfg := loadFromEnv(logger); envCfg != nil {
+ cfg.mergeWith(envCfg)
+ }
}
return cfg
}
diff --git a/internal/hexaiaction/run_more_test.go b/internal/hexaiaction/run_more_test.go
index 1c0eb51..a3e7f25 100644
--- a/internal/hexaiaction/run_more_test.go
+++ b/internal/hexaiaction/run_more_test.go
@@ -4,7 +4,11 @@ import (
"bytes"
"context"
"os"
+ "strings"
"testing"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/llm"
)
// Covers the early error path in Run when no API key is available for the default provider.
@@ -23,3 +27,78 @@ func TestRun_MissingAPIKey(t *testing.T) {
}
_ = os.Stderr
}
+
+type stubChatDoer struct {
+ calls int
+ msgs [][]llm.Message
+}
+
+func (s *stubChatDoer) Chat(ctx context.Context, msgs []llm.Message, opts ...llm.RequestOption) (string, error) {
+ s.calls++
+ s.msgs = append(s.msgs, msgs)
+ return "ok", nil
+}
+
+func (s *stubChatDoer) DefaultModel() string { return "stub" }
+
+func TestHandleDiagnosticsActionInvokesLLM(t *testing.T) {
+ t.Setenv("HEXAI_TMUX_STATUS", "0")
+ parts := InputParts{Diagnostics: []string{"warn1"}, Selection: "code"}
+ client := &stubChatDoer{}
+ cfg := appconfig.Load(nil)
+ if _, err := handleDiagnosticsAction(context.Background(), parts, cfg, client); err != nil {
+ t.Fatalf("handleDiagnosticsAction: %v", err)
+ }
+ if client.calls != 1 {
+ t.Fatalf("expected 1 chat call, got %d", client.calls)
+ }
+ found := false
+ for _, msg := range client.msgs[0] {
+ if msg.Role == "user" && strings.Contains(msg.Content, "warn1") {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("expected diagnostics content in message: %#v", client.msgs[0])
+ }
+}
+
+func TestHandleSimplifyActionPassesSelection(t *testing.T) {
+ t.Setenv("HEXAI_TMUX_STATUS", "0")
+ parts := InputParts{Selection: "value := 1"}
+ client := &stubChatDoer{}
+ cfg := appconfig.Load(nil)
+ if _, err := handleSimplifyAction(context.Background(), parts, cfg, client); err != nil {
+ t.Fatalf("handleSimplifyAction: %v", err)
+ }
+ if client.calls != 1 {
+ t.Fatalf("expected single chat invocation, got %d", client.calls)
+ }
+ seen := false
+ for _, msg := range client.msgs[0] {
+ if msg.Role == "user" && strings.Contains(msg.Content, "value := 1") {
+ seen = true
+ }
+ }
+ if !seen {
+ t.Fatalf("expected selection echoed in prompt: %#v", client.msgs[0])
+ }
+}
+
+func TestHandleCustomActionUsesSelectedCustom(t *testing.T) {
+ t.Setenv("HEXAI_TMUX_STATUS", "0")
+ sel := appconfig.CustomAction{ID: "custom", Title: "Do", Instruction: "do it"}
+ selectedCustom = &sel
+ parts := InputParts{Selection: "text"}
+ client := &stubChatDoer{}
+ cfg := appconfig.Load(nil)
+ if _, err := handleCustomAction(context.Background(), parts, cfg, client); err != nil {
+ t.Fatalf("handleCustomAction: %v", err)
+ }
+ if client.calls != 1 {
+ t.Fatalf("expected custom action to invoke chat, got %d calls", client.calls)
+ }
+ if selectedCustom != nil {
+ t.Fatal("expected selectedCustom to be cleared")
+ }
+}
diff --git a/internal/hexailsp/run.go b/internal/hexailsp/run.go
index 554e604..ffb9f86 100644
--- a/internal/hexailsp/run.go
+++ b/internal/hexailsp/run.go
@@ -13,6 +13,7 @@ import (
"codeberg.org/snonux/hexai/internal/llm"
"codeberg.org/snonux/hexai/internal/logging"
"codeberg.org/snonux/hexai/internal/lsp"
+ "codeberg.org/snonux/hexai/internal/runtimeconfig"
"codeberg.org/snonux/hexai/internal/stats"
)
@@ -55,8 +56,26 @@ func RunWithFactory(logPath string, stdin io.Reader, stdout io.Writer, logger *l
client = buildClientIfNil(cfg, client)
factory = ensureFactory(factory)
- opts := makeServerOptions(cfg, strings.TrimSpace(logPath) != "", client)
+ store := runtimeconfig.New(cfg)
+ logContext := strings.TrimSpace(logPath) != ""
+ opts := makeServerOptions(cfg, logContext, client)
+ opts.ConfigStore = store
server := factory(stdin, stdout, logger, opts)
+ if configurable, ok := server.(interface{ ApplyOptions(lsp.ServerOptions) }); ok {
+ store.Subscribe(func(oldCfg, newCfg appconfig.App) {
+ updated := newCfg
+ normalizeLoggingConfig(&updated)
+ if updated.StatsWindowMinutes > 0 {
+ stats.SetWindow(time.Duration(updated.StatsWindowMinutes) * time.Minute)
+ }
+ if newClient := buildClientIfNil(updated, nil); newClient != nil {
+ client = newClient
+ }
+ opts := makeServerOptions(updated, logContext, client)
+ opts.ConfigStore = store
+ configurable.ApplyOptions(opts)
+ })
+ }
if err := server.Run(); err != nil {
logger.Fatalf("server error: %v", err)
}
@@ -135,6 +154,8 @@ func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client) ls
}
return lsp.ServerOptions{
LogContext: logContext,
+ ConfigStore: nil,
+ Config: &cfg,
MaxTokens: cfg.MaxTokens,
ContextMode: cfg.ContextMode,
WindowLines: cfg.ContextWindowLines,
diff --git a/internal/hexailsp/run_more_test.go b/internal/hexailsp/run_more_test.go
index 00b79c1..faaae41 100644
--- a/internal/hexailsp/run_more_test.go
+++ b/internal/hexailsp/run_more_test.go
@@ -2,18 +2,34 @@ package hexailsp
import (
"bytes"
+ "context"
"io"
"log"
"testing"
"codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/llm"
"codeberg.org/snonux/hexai/internal/lsp"
+ "codeberg.org/snonux/hexai/internal/runtimeconfig"
)
type recRunner struct{ ran bool }
func (r *recRunner) Run() error { r.ran = true; return nil }
+type applyRunner struct{ opts []lsp.ServerOptions }
+
+func (r *applyRunner) Run() error { return nil }
+func (r *applyRunner) ApplyOptions(opts lsp.ServerOptions) { r.opts = append(r.opts, opts) }
+
+type stubClient struct{}
+
+func (stubClient) Chat(context.Context, []llm.Message, ...llm.RequestOption) (string, error) {
+ return "", nil
+}
+func (stubClient) Name() string { return "stub" }
+func (stubClient) DefaultModel() string { return "stub-model" }
+
func TestRunWithFactory_BuildsOptionsAndClient(t *testing.T) {
var captured lsp.ServerOptions
factory := func(r io.Reader, w io.Writer, logger *log.Logger, opts lsp.ServerOptions) ServerRunner {
@@ -41,3 +57,41 @@ func TestRunWithFactory_BuildsOptionsAndClient(t *testing.T) {
t.Fatalf("expected client to be constructed")
}
}
+
+func TestRunWithFactory_SubscriptionAppliesUpdates(t *testing.T) {
+ var in, out bytes.Buffer
+ logger := log.New(io.Discard, "", 0)
+ runner := &applyRunner{}
+ var capturedStore *runtimeconfig.Store
+ factory := func(r io.Reader, w io.Writer, logger *log.Logger, opts lsp.ServerOptions) ServerRunner {
+ capturedStore = opts.ConfigStore
+ runner.opts = append(runner.opts, opts)
+ return runner
+ }
+ cfg := appconfig.Load(nil)
+ cfg.StatsWindowMinutes = 0
+ cfg.ContextMode = " WINDOW "
+ if err := RunWithFactory("", &in, &out, logger, cfg, stubClient{}, factory); err != nil {
+ t.Fatalf("RunWithFactory error: %v", err)
+ }
+ if capturedStore == nil {
+ t.Fatal("expected config store to be passed to factory")
+ }
+ if len(runner.opts) == 0 {
+ t.Fatal("expected initial options to be recorded")
+ }
+ updated := cfg
+ updated.MaxTokens = cfg.MaxTokens + 10
+ updated.ContextMode = "always-full"
+ capturedStore.Set(updated)
+ if len(runner.opts) < 2 {
+ t.Fatalf("expected ApplyOptions to be invoked on config update, got %d calls", len(runner.opts))
+ }
+ latest := runner.opts[len(runner.opts)-1]
+ if latest.MaxTokens != updated.MaxTokens {
+ t.Fatalf("expected updated max tokens, got %+v", latest)
+ }
+ if latest.ContextMode != "always-full" {
+ t.Fatalf("expected normalized context mode, got %+v", latest)
+ }
+}
diff --git a/internal/llm/copilot_http_test.go b/internal/llm/copilot_http_test.go
index d66311c..9dd4aee 100644
--- a/internal/llm/copilot_http_test.go
+++ b/internal/llm/copilot_http_test.go
@@ -5,6 +5,7 @@ import (
"encoding/base64"
"encoding/json"
"io"
+ "net"
"net/http"
"net/http/httptest"
"os"
@@ -22,7 +23,7 @@ func TestCopilot_EnsureSession_AndChat_Success(t *testing.T) {
t.Skip("skip network-bound tests in restricted environments")
}
// Mock chat endpoint
- chatSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ chatSrv := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/chat/completions" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
@@ -92,7 +93,7 @@ func TestCopilot_Chat_MultiChoice_And_ErrorBody(t *testing.T) {
t.Skip("skip network-bound tests in restricted environments")
}
// Chat multi-choice: return two choices; client returns first content
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ srv := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"choices": []map[string]any{
{"index": 0, "finish_reason": "stop", "message": map[string]string{"role": "assistant", "content": "FIRST"}},
@@ -120,7 +121,7 @@ func TestCopilot_Chat_MultiChoice_And_ErrorBody(t *testing.T) {
}
// Non-2xx with error body
- srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ srv2 := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(403)
_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"message": "denied", "type": "forbidden"}})
}))
@@ -136,7 +137,7 @@ func TestCopilot_Chat_NoChoices_Error(t *testing.T) {
if os.Getenv("HEXAI_TEST_SKIP_NET") == "1" {
t.Skip("skip network-bound tests in restricted environments")
}
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ srv := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{}})
}))
defer srv.Close()
@@ -162,7 +163,7 @@ func TestCopilot_Chat_DecodeError_StatusOK(t *testing.T) {
t.Skip("skip network-bound tests in restricted environments")
}
// Chat returns 200 but invalid JSON; expect decode error
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ srv := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "{invalid")
}))
defer srv.Close()
@@ -254,6 +255,20 @@ func TestParseJWTExp_AndParseInt64(t *testing.T) {
}
}
+func newIPv4Server(t *testing.T, handler http.Handler) *httptest.Server {
+ t.Helper()
+ l, err := net.Listen("tcp4", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("failed to listen on tcp4: %v", err)
+ }
+ srv := &httptest.Server{
+ Listener: l,
+ Config: &http.Server{Handler: handler},
+ }
+ srv.Start()
+ return srv
+}
+
// bytesReader wraps a byte slice with an io.ReadCloser without importing extra.
type bytesReader []byte
diff --git a/internal/llm/openai_test.go b/internal/llm/openai_test.go
index f7ce080..686d535 100644
--- a/internal/llm/openai_test.go
+++ b/internal/llm/openai_test.go
@@ -1,67 +1,89 @@
package llm
import (
- "bytes"
- "encoding/json"
+ "context"
"io"
"net/http"
"strings"
"testing"
- "time"
+
+ "codeberg.org/snonux/hexai/internal/logging"
)
-func f64p(v float64) *float64 { return &v }
+func TestOpenAIChatSuccess(t *testing.T) {
+ transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
+ if r.URL.Path != "/chat/completions" {
+ t.Fatalf("unexpected path: %s", r.URL.Path)
+ }
+ if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
+ t.Fatalf("expected auth header, got %q", got)
+ }
+ return &http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(strings.NewReader(`{"choices":[{"index":0,"message":{"role":"assistant","content":"hi there"},"finish_reason":"stop"}]}`)),
+ Header: make(http.Header),
+ }, nil
+ })
-func TestBuildOAChatRequest_TempFallbackAndFields(t *testing.T) {
- o := Options{Model: "m1", Temperature: 0, MaxTokens: 42, Stop: []string{"END"}}
- msgs := []Message{{Role: "user", Content: "hi"}}
- req := buildOAChatRequest(o, msgs, f64p(0.3), false)
- if req.Model != "m1" || req.Stream {
- t.Fatalf("model/stream mismatch: %+v", req)
- }
- if req.Temperature == nil || *req.Temperature != 0.3 {
- t.Fatalf("expected default temp 0.3, got %#v", req.Temperature)
- }
- if req.MaxTokens == nil || *req.MaxTokens != 42 {
- t.Fatalf("expected max tokens 42")
+ client := openAIClient{
+ httpClient: &http.Client{Transport: transport},
+ apiKey: "test-key",
+ baseURL: "https://example.com",
+ defaultModel: "gpt-test",
+ chatLogger: logging.NewChatLogger("openai"),
}
- if len(req.Stop) != 1 || req.Stop[0] != "END" {
- t.Fatalf("stop not propagated: %#v", req.Stop)
+
+ out, err := client.Chat(context.Background(), []Message{{Role: "user", Content: "hello"}})
+ if err != nil {
+ t.Fatalf("Chat returned error: %v", err)
}
- if len(req.Messages) != 1 || req.Messages[0].Content != "hi" {
- t.Fatalf("messages not copied")
+ if out != "hi there" {
+ t.Fatalf("unexpected chat output: %q", out)
}
+}
- // stream on
- req2 := buildOAChatRequest(o, msgs, f64p(0.3), true)
- if !req2.Stream {
- t.Fatalf("expected stream=true")
+func TestOpenAIChatStreamDeliversChunks(t *testing.T) {
+ client := openAIClient{
+ httpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
+ body := "data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n" +
+ "data: {\"choices\":[{\"finish_reason\":\"stop\"}]}\n" +
+ "data: [DONE]\n"
+ return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
+ })},
+ apiKey: "test-key",
+ baseURL: "https://example.com",
+ defaultModel: "gpt-test",
+ chatLogger: logging.NewChatLogger("openai"),
}
-}
-func TestHandleOpenAINon2xx_WithAPIError(t *testing.T) {
- api := oaChatResponse{Error: &struct {
- Message string `json:"message"`
- Type string `json:"type"`
- Param any `json:"param"`
- Code any `json:"code"`
- }{Message: "bad", Type: "invalid"}}
- b, _ := json.Marshal(api)
- resp := &http.Response{StatusCode: 400, Body: io.NopCloser(bytes.NewReader(b))}
- if err := handleOpenAINon2xx(resp, time.Now()); err == nil {
- t.Fatalf("expected error for non-2xx with body")
+ var received string
+ err := client.ChatStream(context.Background(), []Message{{Role: "user", Content: "hello"}}, func(chunk string) {
+ received += chunk
+ })
+ if err != nil {
+ t.Fatalf("ChatStream returned error: %v", err)
+ }
+ if received != "Hello" {
+ t.Fatalf("expected streamed content, got %q", received)
}
}
-func TestParseOpenAIStream_DeliversChunks(t *testing.T) {
- stream := "data: {\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n\n" +
- "data: [DONE]\n"
- resp := &http.Response{Body: io.NopCloser(strings.NewReader(stream))}
- var got strings.Builder
- if err := parseOpenAIStream(resp, time.Now(), func(s string) { got.WriteString(s) }); err != nil {
- t.Fatalf("unexpected error: %v", err)
+func TestOpenAIChatHandlesNon2xx(t *testing.T) {
+ client := openAIClient{
+ httpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
+ return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader("denied")), Header: make(http.Header)}, nil
+ })},
+ apiKey: "test-key",
+ baseURL: "https://example.com",
+ defaultModel: "gpt-test",
+ chatLogger: logging.NewChatLogger("openai"),
}
- if got.String() != "Hi" {
- t.Fatalf("got %q want %q", got.String(), "Hi")
+
+ if _, err := client.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}); err == nil {
+ t.Fatal("expected error for non-2xx response")
}
}
+
+type roundTripFunc func(*http.Request) (*http.Response, error)
+
+func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
diff --git a/internal/llm/test_helpers_test.go b/internal/llm/test_helpers_test.go
new file mode 100644
index 0000000..051747a
--- /dev/null
+++ b/internal/llm/test_helpers_test.go
@@ -0,0 +1,3 @@
+package llm
+
+func f64p(v float64) *float64 { return &v }
diff --git a/internal/lsp/chat_commands.go b/internal/lsp/chat_commands.go
new file mode 100644
index 0000000..31347e9
--- /dev/null
+++ b/internal/lsp/chat_commands.go
@@ -0,0 +1,63 @@
+package lsp
+
+import (
+ "fmt"
+ "strings"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/runtimeconfig"
+)
+
+type chatCommandResult struct {
+ message string
+}
+
+func (s *Server) chatCommandResponse(uri string, lineIdx int, prompt string) (chatCommandResult, bool) {
+ trimmed := strings.TrimSpace(s.stripTrailingTrigger(prompt))
+ if trimmed == "" || !strings.HasPrefix(trimmed, "/") {
+ return chatCommandResult{}, false
+ }
+
+ switch {
+ case strings.HasPrefix(trimmed, "/reload"):
+ return s.handleReloadCommand(), true
+ case strings.HasPrefix(trimmed, "/help"):
+ return s.handleHelpCommand(), true
+ default:
+ return chatCommandResult{message: fmt.Sprintf("Unknown command %q. Try /help?>", trimmed)}, true
+ }
+}
+
+func (s *Server) handleHelpCommand() chatCommandResult {
+ lines := []string{
+ "Available slash commands:",
+ "- /reload?> reload configuration from file (ignores env overrides)",
+ }
+ return chatCommandResult{message: strings.Join(lines, "\n")}
+}
+
+func (s *Server) handleReloadCommand() chatCommandResult {
+ if s.configStore == nil {
+ return chatCommandResult{message: "Reload unavailable: no config store"}
+ }
+ changes, err := s.configStore.Reload(s.logger, appconfig.LoadOptions{IgnoreEnv: true})
+ if err != nil {
+ s.logger.Printf("config reload failed: %v", err)
+ return chatCommandResult{message: fmt.Sprintf("Reload failed: %v", err)}
+ }
+ summary := formatReloadSummary(changes)
+ s.logger.Print(summary)
+ return chatCommandResult{message: summary}
+}
+
+func formatReloadSummary(changes []runtimeconfig.Change) string {
+ if len(changes) == 0 {
+ return "Reloaded config (no changes detected)."
+ }
+ lines := make([]string, 0, len(changes)+1)
+ lines = append(lines, fmt.Sprintf("Reloaded config (%d changes):", len(changes)))
+ for _, ch := range changes {
+ lines = append(lines, fmt.Sprintf("- %s: %s → %s", ch.Key, ch.Old, ch.New))
+ }
+ return strings.Join(lines, "\n")
+}
diff --git a/internal/lsp/chat_commands_test.go b/internal/lsp/chat_commands_test.go
new file mode 100644
index 0000000..bedfaed
--- /dev/null
+++ b/internal/lsp/chat_commands_test.go
@@ -0,0 +1,82 @@
+package lsp
+
+import (
+ "bytes"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/runtimeconfig"
+)
+
+func TestFormatReloadSummary(t *testing.T) {
+ changes := []runtimeconfig.Change{
+ {Key: "max_tokens", Old: "200", New: "128"},
+ {Key: "provider", Old: "openai", New: "ollama"},
+ }
+ got := formatReloadSummary(changes)
+ if !strings.Contains(got, "Reloaded config (2 changes):") {
+ t.Fatalf("expected change count line, got %q", got)
+ }
+ if !strings.Contains(got, "max_tokens: 200") || !strings.Contains(got, "provider: openai") {
+ t.Fatalf("expected formatted entries, got %q", got)
+ }
+}
+
+func TestHandleHelpCommandListsReload(t *testing.T) {
+ s := newTestServer()
+ res := s.handleHelpCommand()
+ if !strings.Contains(res.message, "/reload?>") {
+ t.Fatalf("expected reload command in help output: %q", res.message)
+ }
+}
+
+func TestHandleReloadCommandReloadsStore(t *testing.T) {
+ tmp := t.TempDir()
+ configDir := filepath.Join(tmp, "hexai")
+ if err := os.MkdirAll(configDir, 0o755); err != nil {
+ t.Fatalf("mkdir: %v", err)
+ }
+ configPath := filepath.Join(configDir, "config.toml")
+ if err := os.WriteFile(configPath, []byte("[general]\nmax_tokens = 64\n"), 0o644); err != nil {
+ t.Fatalf("write config: %v", err)
+ }
+
+ t.Setenv("XDG_CONFIG_HOME", tmp)
+ t.Setenv("HEXAI_MAX_TOKENS", "321")
+
+ var logBuf bytes.Buffer
+ logger := log.New(&logBuf, "", 0)
+
+ initial := appconfig.Load(logger)
+ if initial.MaxTokens != 321 {
+ t.Fatalf("expected env override to win initial load, got %d", initial.MaxTokens)
+ }
+
+ store := runtimeconfig.New(initial)
+
+ s := newTestServer()
+ s.logger = logger
+ s.configStore = store
+
+ if err := os.WriteFile(configPath, []byte("[general]\nmax_tokens = 128\n"), 0o644); err != nil {
+ t.Fatalf("update config: %v", err)
+ }
+
+ res := s.handleReloadCommand()
+ if !strings.Contains(res.message, "Reloaded config (1 changes):") {
+ t.Fatalf("unexpected reload summary: %q", res.message)
+ }
+ if !strings.Contains(res.message, "max_tokens: 321") || !strings.Contains(res.message, "128") {
+ t.Fatalf("expected diff for max_tokens: %q", res.message)
+ }
+ if store.Snapshot().MaxTokens != 128 {
+ t.Fatalf("expected snapshot to reflect new value, got %d", store.Snapshot().MaxTokens)
+ }
+ if !strings.Contains(logBuf.String(), "Reloaded config") {
+ t.Fatalf("expected summary logged, got %q", logBuf.String())
+ }
+}
diff --git a/internal/lsp/chat_context_mode_test.go b/internal/lsp/chat_context_mode_test.go
index 85fa4a9..895c2f3 100644
--- a/internal/lsp/chat_context_mode_test.go
+++ b/internal/lsp/chat_context_mode_test.go
@@ -11,9 +11,9 @@ import (
func TestChat_RespectsContextModeWindow(t *testing.T) {
s := newTestServer()
// Configure window mode with small window
- s.contextMode = "window"
- s.windowLines = 2
- s.maxContextTokens = 2000
+ s.cfg.ContextMode = "window"
+ s.cfg.ContextWindowLines = 2
+ s.cfg.MaxContextTokens = 2000
cap := &captureLLM{}
s.llmClient = cap
var out bytes.Buffer
@@ -54,8 +54,8 @@ func TestChat_RespectsContextModeWindow(t *testing.T) {
func TestChat_ContextModeMinimal_NoExtra(t *testing.T) {
s := newTestServer()
- s.contextMode = "minimal"
- s.maxContextTokens = 2000
+ s.cfg.ContextMode = "minimal"
+ s.cfg.MaxContextTokens = 2000
cap := &captureLLM{}
s.llmClient = cap
var out bytes.Buffer
@@ -78,8 +78,8 @@ func TestChat_ContextModeMinimal_NoExtra(t *testing.T) {
func TestChat_ContextModeAlwaysFull_AddsExtra(t *testing.T) {
s := newTestServer()
- s.contextMode = "always-full"
- s.maxContextTokens = 2000
+ s.cfg.ContextMode = "always-full"
+ s.cfg.MaxContextTokens = 2000
cap := &captureLLM{}
s.llmClient = cap
var out bytes.Buffer
@@ -108,8 +108,8 @@ func TestChat_ContextModeAlwaysFull_AddsExtra(t *testing.T) {
func TestChat_ContextModeFileOnNewFunc_NoExtraWithoutSignature(t *testing.T) {
s := newTestServer()
- s.contextMode = "file-on-new-func"
- s.maxContextTokens = 2000
+ s.cfg.ContextMode = "file-on-new-func"
+ s.cfg.MaxContextTokens = 2000
cap := &captureLLM{}
s.llmClient = cap
var out bytes.Buffer
@@ -129,8 +129,8 @@ func TestChat_ContextModeFileOnNewFunc_NoExtraWithoutSignature(t *testing.T) {
func TestChat_ContextModeFileOnNewFunc_WithSignature_AddsExtra(t *testing.T) {
s := newTestServer()
- s.contextMode = "file-on-new-func"
- s.maxContextTokens = 2000
+ s.cfg.ContextMode = "file-on-new-func"
+ s.cfg.MaxContextTokens = 2000
cap := &captureLLM{}
s.llmClient = cap
var out bytes.Buffer
diff --git a/internal/lsp/chat_prompt_test.go b/internal/lsp/chat_prompt_test.go
index 25767ab..1f7b266 100644
--- a/internal/lsp/chat_prompt_test.go
+++ b/internal/lsp/chat_prompt_test.go
@@ -10,7 +10,9 @@ func TestDetectAndHandleChat_UsesConfiguredSystemPrompt(t *testing.T) {
s := newTestServer()
cap := &captureLLM{}
s.llmClient = cap
- s.promptChatSystem = "CHAT-SYS"
+ cfg := s.cfg
+ cfg.PromptChatSystem = "CHAT-SYS"
+ s.cfg = cfg
uri := "file:///chat.txt"
// Avoid nil writer in applyChatEdits
var out bytes.Buffer
diff --git a/internal/lsp/chat_trigger_suppression_test.go b/internal/lsp/chat_trigger_suppression_test.go
index 8d016d1..9f9f5bc 100644
--- a/internal/lsp/chat_trigger_suppression_test.go
+++ b/internal/lsp/chat_trigger_suppression_test.go
@@ -4,7 +4,10 @@ import "testing"
// Ensure completion is suppressed when a chat trigger is at EOL (?>,!>,:>,;>)
func TestCompletionSuppressedOnChatTriggerEOL(t *testing.T) {
- s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)}
+ s := newTestServer()
+ s.cfg.MaxTokens = 32
+ s.cfg.TriggerCharacters = []string{".", ":", "/", "_"}
+ s.compCache = make(map[string]string)
initServerDefaults(s)
s.llmClient = &countingLLM{}
tests := []string{"What now?>", "Explain!>", "Refactor:>", "note ;>"}
diff --git a/internal/lsp/codeaction_custom_errors_test.go b/internal/lsp/codeaction_custom_errors_test.go
index ca6111f..c572542 100644
--- a/internal/lsp/codeaction_custom_errors_test.go
+++ b/internal/lsp/codeaction_custom_errors_test.go
@@ -7,13 +7,16 @@ import (
"errors"
"testing"
+ "codeberg.org/snonux/hexai/internal/appconfig"
"codeberg.org/snonux/hexai/internal/llm"
)
func TestResolveCodeAction_Custom_UnknownID(t *testing.T) {
s := newTestServer()
// No matching custom action configured
- s.customActions = []CustomAction{{ID: "known", Title: "Known", Instruction: "x"}}
+ cfg := s.cfg
+ cfg.CustomActions = []appconfig.CustomAction{{ID: "known", Title: "Known", Instruction: "x"}}
+ s.cfg = cfg
uri := "file:///t.go"
payload := struct {
Type string `json:"type"`
@@ -41,7 +44,9 @@ func TestResolveCodeAction_Custom_EmptyAndError(t *testing.T) {
// empty output case
s1 := newTestServer()
s1.llmClient = fakeLLM{resp: " \n\n"}
- s1.customActions = []CustomAction{{ID: "empty", Title: "Empty", Instruction: "x"}}
+ cfg1 := s1.cfg
+ cfg1.CustomActions = []appconfig.CustomAction{{ID: "empty", Title: "Empty", Instruction: "x"}}
+ s1.cfg = cfg1
raw1, _ := json.Marshal(struct {
Type, ID, URI, Selection string
Range Range
@@ -53,7 +58,9 @@ func TestResolveCodeAction_Custom_EmptyAndError(t *testing.T) {
// error case
s2 := newTestServer()
s2.llmClient = errLLM{}
- s2.customActions = []CustomAction{{ID: "err", Title: "Err", Instruction: "x"}}
+ cfg2 := s2.cfg
+ cfg2.CustomActions = []appconfig.CustomAction{{ID: "err", Title: "Err", Instruction: "x"}}
+ s2.cfg = cfg2
raw2, _ := json.Marshal(struct {
Type, ID, URI, Selection string
Range Range
@@ -67,10 +74,12 @@ func TestHandleCodeAction_Custom_SelectionSuppressedWhenEmpty(t *testing.T) {
s := newTestServer()
s.llmClient = fakeLLM{resp: "IGN"}
// One selection-scoped and one diagnostics-scoped custom
- s.customActions = []CustomAction{
+ cfg := s.cfg
+ cfg.CustomActions = []appconfig.CustomAction{
{ID: "sel", Title: "Sel", Scope: "selection", Instruction: "x"},
{ID: "diag", Title: "Diag", Scope: "diagnostics", User: "{{diagnostics}}"},
}
+ s.cfg = cfg
uri := "file:///t.go"
s.setDocument(uri, "package p\nfunc f(){}\n")
// Empty selection range (start==end)
diff --git a/internal/lsp/codeaction_custom_test.go b/internal/lsp/codeaction_custom_test.go
index 1ea4c3c..ea8ae82 100644
--- a/internal/lsp/codeaction_custom_test.go
+++ b/internal/lsp/codeaction_custom_test.go
@@ -7,6 +7,8 @@ import (
"log"
"strings"
"testing"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
)
// local copy of captureResponse for this test file
@@ -27,24 +29,23 @@ func capResp(t *testing.T, buf *bytes.Buffer) Response {
func TestHandleCodeAction_ListsCustomActions(t *testing.T) {
var out bytes.Buffer
- s := &Server{
- logger: log.New(io.Discard, "", 0),
- docs: make(map[string]*document),
- out: &out,
- inlineOpen: ">",
- inlineClose: ">",
- inlineOpenChar: '>',
- inlineCloseChar: '>',
- chatSuffix: ">",
- chatSuffixChar: '>',
- chatPrefixes: []string{"?", "!", ":", ";"},
+ cfg := appconfig.App{
+ InlineOpen: ">",
+ InlineClose: ">",
+ ChatSuffix: ">",
+ ChatPrefixes: []string{"?", "!", ":", ";"},
+ CustomActions: []appconfig.CustomAction{
+ {ID: "extract", Title: "Extract function", Scope: "selection", Kind: "refactor.extract", Instruction: "Extract into function"},
+ {ID: "fix", Title: "Fix diagnostics", Scope: "diagnostics", Kind: "quickfix", User: "Fix:\n{{diagnostics}}\n\n{{selection}}"},
+ },
}
- s.llmClient = fakeLLM{resp: "IGN"}
- // Inject two custom actions
- s.customActions = []CustomAction{
- {ID: "extract", Title: "Extract function", Scope: "selection", Kind: "refactor.extract", Instruction: "Extract into function"},
- {ID: "fix", Title: "Fix diagnostics", Scope: "diagnostics", Kind: "quickfix", User: "Fix:\n{{diagnostics}}\n\n{{selection}}"},
+ s := &Server{
+ logger: log.New(io.Discard, "", 0),
+ docs: make(map[string]*document),
+ out: &out,
+ cfg: cfg,
}
+ s.llmClient = fakeLLM{resp: "ok"}
// Prepare document and params
uri := "file:///t.go"
s.setDocument(uri, "package x\n\nfunc f(){}\n")
@@ -82,11 +83,12 @@ func TestHandleCodeAction_ListsCustomActions(t *testing.T) {
func TestResolveCodeAction_CustomInstructionAndUser(t *testing.T) {
s := newTestServer()
s.llmClient = fakeLLM{resp: "REPLACED"}
- // one instruction-based and one user-based
- s.customActions = []CustomAction{
+ cfg := s.cfg
+ cfg.CustomActions = []appconfig.CustomAction{
{ID: "extract", Title: "Extract function", Scope: "selection", Kind: "refactor.extract", Instruction: "Extract into function"},
{ID: "fix", Title: "Fix diagnostics", Scope: "diagnostics", Kind: "quickfix", User: "Fix: {{diagnostics}}\n{{selection}}"},
}
+ s.cfg = cfg
uri := "file:///t.go"
p := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: uri}, Range: Range{Start: Position{Line: 1}, End: Position{Line: 1, Character: 3}}}
diff --git a/internal/lsp/codeaction_prompts_test.go b/internal/lsp/codeaction_prompts_test.go
index bbfad10..c5fd5e2 100644
--- a/internal/lsp/codeaction_prompts_test.go
+++ b/internal/lsp/codeaction_prompts_test.go
@@ -9,8 +9,10 @@ func TestResolveCodeAction_UsesRewritePrompts(t *testing.T) {
s := newTestServer()
cap := &captureLLM{}
s.llmClient = cap
- s.promptRewriteSystem = "RSYS"
- s.promptRewriteUser = "RUSER {{instruction}} {{selection}}"
+ cfg := s.cfg
+ cfg.PromptCodeActionRewriteSystem = "RSYS"
+ cfg.PromptCodeActionRewriteUser = "RUSER {{instruction}} {{selection}}"
+ s.cfg = cfg
uri := "file:///x.go"
s.setDocument(uri, "package p\nvar a=1\n")
payload := struct {
@@ -35,8 +37,10 @@ func TestResolveCodeAction_UsesDiagnosticsPrompts(t *testing.T) {
s := newTestServer()
cap := &captureLLM{}
s.llmClient = cap
- s.promptDiagnosticsSystem = "DSYS"
- s.promptDiagnosticsUser = "DUSER {{diagnostics}} {{selection}}"
+ cfg := s.cfg
+ cfg.PromptCodeActionDiagnosticsSystem = "DSYS"
+ cfg.PromptCodeActionDiagnosticsUser = "DUSER {{diagnostics}} {{selection}}"
+ s.cfg = cfg
uri := "file:///x.go"
s.setDocument(uri, "package p\nvar a=1\n")
payload := struct {
@@ -64,8 +68,10 @@ func TestResolveCodeAction_UsesDocumentPrompts(t *testing.T) {
s := newTestServer()
cap := &captureLLM{}
s.llmClient = cap
- s.promptDocumentSystem = "DOCSYS"
- s.promptDocumentUser = "DOCUSER {{selection}}"
+ cfg := s.cfg
+ cfg.PromptCodeActionDocumentSystem = "DOCSYS"
+ cfg.PromptCodeActionDocumentUser = "DOCUSER {{selection}}"
+ s.cfg = cfg
uri := "file:///x.go"
s.setDocument(uri, "package p\nvar a=1\n")
payload := struct {
@@ -89,8 +95,10 @@ func TestGenerateGoTest_UsesPrompts(t *testing.T) {
s := newTestServer()
cap := &captureLLM{}
s.llmClient = cap
- s.promptGoTestSystem = "GTSYS"
- s.promptGoTestUser = "GTUSER {{function}}"
+ cfg := s.cfg
+ cfg.PromptCodeActionGoTestSystem = "GTSYS"
+ cfg.PromptCodeActionGoTestUser = "GTUSER {{function}}"
+ s.cfg = cfg
_ = s.generateGoTestFunction("func Add(a,b int) int {return a+b}")
if len(cap.msgs) < 2 {
t.Fatalf("expected chat messages")
diff --git a/internal/lsp/completion_cache_test.go b/internal/lsp/completion_cache_test.go
index 65631f9..057b5c5 100644
--- a/internal/lsp/completion_cache_test.go
+++ b/internal/lsp/completion_cache_test.go
@@ -12,9 +12,13 @@ import (
func TestCompletionCache_IgnoresWhitespaceBeforeCursor(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
- s := NewServer(bytes.NewBuffer(nil), &buf, logger, ServerOptions{})
+ s := newTestServer()
+ s.logger = logger
+ s.out = &buf
logging.Bind(logger)
- s.triggerChars = []string{" ", "."}
+ cfg := s.cfg
+ cfg.TriggerCharacters = []string{" ", "."}
+ s.cfg = cfg
fake := &countingLLM{}
s.llmClient = fake
diff --git a/internal/lsp/completion_codex_path_test.go b/internal/lsp/completion_codex_path_test.go
index 6c0a60f..ea27c6e 100644
--- a/internal/lsp/completion_codex_path_test.go
+++ b/internal/lsp/completion_codex_path_test.go
@@ -39,7 +39,10 @@ func (f *fakeCodeLLM) Name() string { return "fake" }
func (f *fakeCodeLLM) DefaultModel() string { return "m" }
func TestTryLLMCompletion_PrefersCodeCompleterOverChat(t *testing.T) {
- s := &Server{maxTokens: 32, triggerChars: []string{"."}, compCache: make(map[string]string)}
+ s := newTestServer()
+ s.cfg.MaxTokens = 32
+ s.cfg.TriggerCharacters = []string{"."}
+ s.compCache = make(map[string]string)
initServerDefaults(s)
fake := &fakeCodeLLM{result: "DoThing()"}
s.llmClient = fake
@@ -58,7 +61,10 @@ func TestTryLLMCompletion_PrefersCodeCompleterOverChat(t *testing.T) {
}
func TestTryLLMCompletion_FallsBackToChatOnCodeCompleterError(t *testing.T) {
- s := &Server{maxTokens: 32, triggerChars: []string{"."}, compCache: make(map[string]string)}
+ s := newTestServer()
+ s.cfg.MaxTokens = 32
+ s.cfg.TriggerCharacters = []string{"."}
+ s.compCache = make(map[string]string)
initServerDefaults(s)
fake := &fakeCodeLLM{result: "DoThing()", codeErr: errors.New("boom")}
s.llmClient = fake
diff --git a/internal/lsp/completion_messages_test.go b/internal/lsp/completion_messages_test.go
index 20aac69..f0c693c 100644
--- a/internal/lsp/completion_messages_test.go
+++ b/internal/lsp/completion_messages_test.go
@@ -37,7 +37,7 @@ func TestBuildCompletionMessages_ExtraContextIncluded(t *testing.T) {
func TestPrefixHeuristic_AllVariants(t *testing.T) {
s := newTestServer()
// manual invoke requires at least min prefix; set to 2
- s.manualInvokeMinPrefix = 2
+ s.cfg.ManualInvokeMinPrefix = 2
cur := "a"
p := CompletionParams{Position: Position{Line: 0, Character: 1}}
if s.prefixHeuristicAllows(false, cur, p, true) {
diff --git a/internal/lsp/completion_prefix_strip_test.go b/internal/lsp/completion_prefix_strip_test.go
index acc7921..6173d6f 100644
--- a/internal/lsp/completion_prefix_strip_test.go
+++ b/internal/lsp/completion_prefix_strip_test.go
@@ -41,8 +41,12 @@ func TestStripDuplicateAssignmentPrefix_AssignAndWalrus(t *testing.T) {
}
func TestTryLLMCompletion_ManualInvokeAfterWhitespace_Allows(t *testing.T) {
- s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)}
- initServerDefaults(s)
+ s := newTestServer()
+ s.compCache = make(map[string]string)
+ cfg := s.cfg
+ cfg.MaxTokens = 32
+ cfg.TriggerCharacters = []string{".", ":", "/", "_"}
+ s.cfg = cfg
s.llmClient = fakeLLM{resp: tut.MultilineFunctionSuggestion()}
line := "func fib(i int) " // cursor after space
p := CompletionParams{Position: Position{Line: 0, Character: len(line)}, TextDocument: TextDocumentIdentifier{URI: "file://x.go"}}
@@ -58,8 +62,12 @@ func TestTryLLMCompletion_ManualInvokeAfterWhitespace_Allows(t *testing.T) {
}
func TestTryLLMCompletion_InlinePromptAlwaysTriggers(t *testing.T) {
- s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)}
- initServerDefaults(s)
+ s := newTestServer()
+ s.compCache = make(map[string]string)
+ cfg := s.cfg
+ cfg.MaxTokens = 32
+ cfg.TriggerCharacters = []string{".", ":", "/", "_"}
+ s.cfg = cfg
s.llmClient = fakeLLM{resp: "replacement"}
line := "prefix >do something> suffix"
// No trigger char immediately before cursor; place cursor at end
@@ -71,17 +79,12 @@ func TestTryLLMCompletion_InlinePromptAlwaysTriggers(t *testing.T) {
}
func TestTryLLMCompletion_DoubleOpenEmpty_DoesNotAutoTrigger(t *testing.T) {
- s := &Server{
- maxTokens: 32,
- triggerChars: []string{".", ":", "/", "_"},
- compCache: make(map[string]string),
- inlineOpen: ">",
- inlineClose: ">",
- inlineOpenChar: '>',
- inlineCloseChar: '>',
- }
- initServerDefaults(s)
- initServerDefaults(s)
+ s := newTestServer()
+ s.compCache = make(map[string]string)
+ cfg := s.cfg
+ cfg.MaxTokens = 32
+ cfg.TriggerCharacters = []string{".", ":", "/", "_"}
+ s.cfg = cfg
fake := &countingLLM{}
s.llmClient = fake
line := ">> " // empty content after double-open should not force-trigger
@@ -114,15 +117,12 @@ func TestHasDoubleSemicolonTrigger_Variants(t *testing.T) {
}
func TestBareDoubleOpenPreventsAutoTriggerEvenWithOtherTriggers(t *testing.T) {
- s := &Server{
- maxTokens: 32,
- triggerChars: []string{".", ":", "/", "_"},
- compCache: make(map[string]string),
- inlineOpen: ">",
- inlineClose: ">",
- inlineOpenChar: '>',
- inlineCloseChar: '>',
- }
+ s := newTestServer()
+ s.compCache = make(map[string]string)
+ cfg := s.cfg
+ cfg.MaxTokens = 32
+ cfg.TriggerCharacters = []string{".", ":", "/", "_"}
+ s.cfg = cfg
fake := &countingLLM{}
s.llmClient = fake
// Place a '.' earlier but also include bare double-open at end; should not auto-trigger
@@ -141,8 +141,12 @@ func TestBareDoubleOpenPreventsAutoTriggerEvenWithOtherTriggers(t *testing.T) {
}
func TestBareDoubleOpenOnNextLine_PreventsAutoTrigger(t *testing.T) {
- s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)}
- initServerDefaults(s)
+ s := newTestServer()
+ s.compCache = make(map[string]string)
+ cfg := s.cfg
+ cfg.MaxTokens = 32
+ cfg.TriggerCharacters = []string{".", ":", "/", "_"}
+ s.cfg = cfg
fake := &countingLLM{}
s.llmClient = fake
current := "expression := flag.String(\"expression\", \"\", \"Expression to evaluate\")"
@@ -161,8 +165,12 @@ func TestBareDoubleOpenOnNextLine_PreventsAutoTrigger(t *testing.T) {
}
func TestBareDoubleOpenPreventsManualInvoke(t *testing.T) {
- s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)}
- initServerDefaults(s)
+ s := newTestServer()
+ s.compCache = make(map[string]string)
+ cfg := s.cfg
+ cfg.MaxTokens = 32
+ cfg.TriggerCharacters = []string{".", ":", "/", "_"}
+ s.cfg = cfg
fake := &countingLLM{}
s.llmClient = fake
line := ">>"
diff --git a/internal/lsp/context.go b/internal/lsp/context.go
index 5a4983c..8b584fb 100644
--- a/internal/lsp/context.go
+++ b/internal/lsp/context.go
@@ -14,7 +14,7 @@ import (
// - file-on-new-func: include full file only when defining a new function
// - always-full: always include the full file
func (s *Server) buildAdditionalContext(newFunc bool, uri string, pos Position) (string, bool) {
- mode := s.contextMode
+ mode := s.contextMode()
switch mode {
case "minimal":
return "", false
@@ -40,7 +40,7 @@ func (s *Server) windowContext(uri string, pos Position) string {
return ""
}
n := len(d.lines)
- half := s.windowLines / 2
+ half := s.windowLines() / 2
start := pos.Line - half
if start < 0 {
start = 0
@@ -50,7 +50,7 @@ func (s *Server) windowContext(uri string, pos Position) string {
end = n
}
text := strings.Join(d.lines[start:end], "\n")
- return truncateToApproxTokens(text, s.maxContextTokens)
+ return truncateToApproxTokens(text, s.maxContextTokens())
}
func (s *Server) fullFileContext(uri string) string {
@@ -59,7 +59,7 @@ func (s *Server) fullFileContext(uri string) string {
logging.Logf("lsp ", "context: full-file requested but document not open; skipping uri=%s", uri)
return ""
}
- return truncateToApproxTokens(d.text, s.maxContextTokens)
+ return truncateToApproxTokens(d.text, s.maxContextTokens())
}
// truncateToApproxTokens naively truncates the input to fit approx N tokens.
diff --git a/internal/lsp/context_test.go b/internal/lsp/context_test.go
index dcda042..875eec9 100644
--- a/internal/lsp/context_test.go
+++ b/internal/lsp/context_test.go
@@ -9,8 +9,8 @@ import (
func TestWindowContext_Bounds(t *testing.T) {
s := newTestServer()
- s.windowLines = 4 // half=2
- s.maxContextTokens = 9999
+ s.cfg.ContextWindowLines = 4 // half=2
+ s.cfg.MaxContextTokens = 9999
lines := make([]string, 10)
for i := 0; i < 10; i++ {
lines[i] = "L" + strconv.Itoa(i)
@@ -28,7 +28,7 @@ func TestWindowContext_Bounds(t *testing.T) {
func TestBuildAdditionalContext_Minimal(t *testing.T) {
s := newTestServer()
- s.contextMode = "minimal"
+ s.cfg.ContextMode = "minimal"
if ctx, ok := s.buildAdditionalContext(false, "file:///x.go", Position{}); ok || ctx != "" {
t.Fatalf("expected no context in minimal mode; got ok=%v ctx=%q", ok, ctx)
}
@@ -36,8 +36,8 @@ func TestBuildAdditionalContext_Minimal(t *testing.T) {
func TestBuildAdditionalContext_FileOnNewFunc(t *testing.T) {
s := newTestServer()
- s.contextMode = "file-on-new-func"
- s.maxContextTokens = 9999
+ s.cfg.ContextMode = "file-on-new-func"
+ s.cfg.MaxContextTokens = 9999
uri := "file:///x.go"
body := "package x\n\nfunc a(){}\n"
s.setDocument(uri, body)
@@ -51,8 +51,8 @@ func TestBuildAdditionalContext_FileOnNewFunc(t *testing.T) {
func TestBuildAdditionalContext_AlwaysFull(t *testing.T) {
s := newTestServer()
- s.contextMode = "always-full"
- s.maxContextTokens = 9999
+ s.cfg.ContextMode = "always-full"
+ s.cfg.MaxContextTokens = 9999
uri := "file:///x.go"
body := "line1\nline2\n"
s.setDocument(uri, body)
diff --git a/internal/lsp/debounce_throttle_more_test.go b/internal/lsp/debounce_throttle_more_test.go
index ed61336..7657cab 100644
--- a/internal/lsp/debounce_throttle_more_test.go
+++ b/internal/lsp/debounce_throttle_more_test.go
@@ -8,7 +8,9 @@ import (
func TestWaitForDebounce_WaitsRoughlyDebounce(t *testing.T) {
s := newTestServer()
- s.completionDebounce = 20 * time.Millisecond
+ cfg := s.cfg
+ cfg.CompletionDebounceMs = 20
+ s.cfg = cfg
s.mu.Lock()
s.lastInput = time.Now()
s.mu.Unlock()
@@ -21,7 +23,9 @@ func TestWaitForDebounce_WaitsRoughlyDebounce(t *testing.T) {
func TestWaitForThrottle_WaitsRoughlyInterval(t *testing.T) {
s := newTestServer()
- s.throttleInterval = 20 * time.Millisecond
+ cfg := s.cfg
+ cfg.CompletionThrottleMs = 20
+ s.cfg = cfg
s.mu.Lock()
s.lastLLMCall = time.Now()
s.mu.Unlock()
diff --git a/internal/lsp/debounce_throttle_test.go b/internal/lsp/debounce_throttle_test.go
index 0b49b1b..81a2c1a 100644
--- a/internal/lsp/debounce_throttle_test.go
+++ b/internal/lsp/debounce_throttle_test.go
@@ -22,9 +22,11 @@ func (t *timeLLM) DefaultModel() string { return "m" }
func TestCompletionDebounce_WaitsUntilQuiet(t *testing.T) {
s := newTestServer()
s.compCache = make(map[string]string)
- s.triggerChars = []string{".", ":", "/", "_"}
- s.maxTokens = 32
- s.completionDebounce = 30 * time.Millisecond
+ cfg := s.cfg
+ cfg.TriggerCharacters = []string{".", ":", "/", "_"}
+ cfg.MaxTokens = 32
+ cfg.CompletionDebounceMs = 30
+ s.cfg = cfg
s.markActivity() // simulate recent input
f := &timeLLM{}
@@ -50,9 +52,11 @@ func TestCompletionDebounce_WaitsUntilQuiet(t *testing.T) {
func TestCompletionThrottle_SerializesCalls(t *testing.T) {
s := newTestServer()
s.compCache = make(map[string]string)
- s.triggerChars = []string{".", ":", "/", "_"}
- s.maxTokens = 32
- s.throttleInterval = 25 * time.Millisecond
+ cfg := s.cfg
+ cfg.TriggerCharacters = []string{".", ":", "/", "_"}
+ cfg.MaxTokens = 32
+ cfg.CompletionThrottleMs = 25
+ s.cfg = cfg
// first call uses timeLLM to record time
f1 := &timeLLM{}
@@ -79,7 +83,8 @@ func TestCompletionThrottle_SerializesCalls(t *testing.T) {
if f2.t.IsZero() {
t.Fatalf("expected second call time recorded")
}
- if f2.t.Sub(start) < s.throttleInterval {
- t.Fatalf("expected throttle spacing >= %s, got %s", s.throttleInterval, f2.t.Sub(start))
+ interval := time.Duration(cfg.CompletionThrottleMs) * time.Millisecond
+ if f2.t.Sub(start) < interval {
+ t.Fatalf("expected throttle spacing >= %s, got %s", interval, f2.t.Sub(start))
}
}
diff --git a/internal/lsp/document_test.go b/internal/lsp/document_test.go
index cbea62a..ed2ccea 100644
--- a/internal/lsp/document_test.go
+++ b/internal/lsp/document_test.go
@@ -6,62 +6,56 @@ import (
"log"
"strings"
"testing"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
)
func newTestServer() *Server {
- s := &Server{
- logger: log.New(io.Discard, "", 0),
- docs: make(map[string]*document),
- inlineOpen: ">",
- inlineClose: ">",
- chatSuffix: ">",
- chatPrefixes: []string{"?", "!", ":", ";"},
- inlineOpenChar: '>',
- inlineCloseChar: '>',
- chatSuffixChar: '>',
- }
- // Default prompt templates (mirror app defaults)
- s.promptCompSysParams = "You are a code completion engine for function signatures. Return only the parameter list contents (without parentheses), no braces, no prose. Prefer idiomatic names and types."
- s.promptCompUserParams = "Cursor is inside the function parameter list. Suggest only the parameter list (no parentheses).\nFunction line: {{function}}\nCurrent line (cursor at {{char}}): {{current}}"
- s.promptCompSysGeneral = "You are a terse code completion engine. Return only the code to insert, no surrounding prose or backticks. Only continue from the cursor; never repeat characters already present to the left of the cursor on the current line (e.g., if 'name :=' is already typed, only return the right-hand side expression)."
- s.promptCompUserGeneral = "Provide the next likely code to insert at the cursor.\nFile: {{file}}\nFunction/context: {{function}}\nAbove line: {{above}}\nCurrent line (cursor at character {{char}}): {{current}}\nBelow line: {{below}}\nOnly return the completion snippet."
- s.promptCompSysInline = "You are a precise code completion/refactoring engine. Output only the code to insert with no prose, no comments, and no backticks. Return raw code only."
- s.promptCompExtraHeader = "Additional context:\n{{context}}"
- s.promptNativeCompletion = "// Path: {{path}}\n{{before}}"
- s.promptChatSystem = "You are a helpful coding assistant. Answer concisely and clearly."
- s.promptRewriteSystem = "You are a precise code refactoring engine. Rewrite the given code strictly according to the instruction. Return only the updated code with no prose or backticks. Preserve formatting where reasonable."
- s.promptDiagnosticsSystem = "You are a precise code fixer. Resolve the given diagnostics by editing only the selected code. Return only the corrected code with no prose or backticks. Keep behavior and style, and avoid unrelated changes."
- s.promptDocumentSystem = "You are a precise code documentation engine. Add idiomatic documentation comments to the given code. Preserve exact behavior and formatting as much as possible. Return only the updated code with comments, no prose or backticks."
- s.promptRewriteUser = "Instruction: {{instruction}}\n\nSelected code to transform:\n{{selection}}"
- s.promptDiagnosticsUser = "Diagnostics to resolve (selection only):\n{{diagnostics}}\n\nSelected code:\n{{selection}}"
- s.promptDocumentUser = "Add documentation comments to this code:\n{{selection}}"
- s.promptGoTestSystem = "You are a precise Go unit test generator. Given a Go function, write one or more Test* functions using the testing package. Do NOT include package or imports, only the test function(s). Prefer table-driven tests. Keep it minimal and idiomatic."
- s.promptGoTestUser = "Function under test:\n{{function}}"
- return s
+ cfg := appconfig.App{
+ InlineOpen: ">",
+ InlineClose: ">",
+ ChatSuffix: ">",
+ ChatPrefixes: []string{"?", "!", ":", ";"},
+
+ PromptCompletionSystemParams: "You are a code completion engine for function signatures. Return only the parameter list contents (without parentheses), no braces, no prose. Prefer idiomatic names and types.",
+ PromptCompletionUserParams: "Cursor is inside the function parameter list. Suggest only the parameter list (no parentheses).\nFunction line: {{function}}\nCurrent line (cursor at {{char}}): {{current}}",
+ PromptCompletionSystemGeneral: "You are a terse code completion engine. Return only the code to insert, no surrounding prose or backticks. Only continue from the cursor; never repeat characters already present to the left of the cursor on the current line (e.g., if 'name :=' is already typed, only return the right-hand side expression).",
+ PromptCompletionUserGeneral: "Provide the next likely code to insert at the cursor.\nFile: {{file}}\nFunction/context: {{function}}\nAbove line: {{above}}\nCurrent line (cursor at character {{char}}): {{current}}\nBelow line: {{below}}\nOnly return the completion snippet.",
+ PromptCompletionSystemInline: "You are a precise code completion/refactoring engine. Output only the code to insert with no prose, no comments, and no backticks. Return raw code only.",
+ PromptCompletionExtraHeader: "Additional context:\n{{context}}",
+ PromptNativeCompletion: "// Path: {{path}}\n{{before}}",
+ PromptChatSystem: "You are a helpful coding assistant. Answer concisely and clearly.",
+ PromptCodeActionRewriteSystem: "You are a precise code refactoring engine. Rewrite the given code strictly according to the instruction. Return only the updated code with no prose or backticks. Preserve formatting where reasonable.",
+ PromptCodeActionDiagnosticsSystem: "You are a precise code fixer. Resolve the given diagnostics by editing only the selected code. Return only the corrected code with no prose or backticks. Keep behavior and style, and avoid unrelated changes.",
+ PromptCodeActionDocumentSystem: "You are a precise code documentation engine. Add idiomatic documentation comments to the given code. Preserve exact behavior and formatting as much as possible. Return only the updated code with comments, no prose or backticks.",
+ PromptCodeActionRewriteUser: "Instruction: {{instruction}}\n\nSelected code to transform:\n{{selection}}",
+ PromptCodeActionDiagnosticsUser: "Diagnostics to resolve (selection only):\n{{diagnostics}}\n\nSelected code:\n{{selection}}",
+ PromptCodeActionDocumentUser: "Add documentation comments to this code:\n{{selection}}",
+ PromptCodeActionGoTestSystem: "You are a precise Go unit test generator. Given a Go function, write one or more Test* functions using the testing package. Do NOT include package or imports, only the test function(s). Prefer table-driven tests. Keep it minimal and idiomatic.",
+ PromptCodeActionGoTestUser: "Function under test:\n{{function}}",
+ }
+ return &Server{
+ logger: log.New(io.Discard, "", 0),
+ docs: make(map[string]*document),
+ cfg: cfg,
+ }
}
func initServerDefaults(s *Server) {
- if s.inlineOpen == "" {
- s.inlineOpen = ">"
- }
- if s.inlineClose == "" {
- s.inlineClose = ">"
- }
- if s.inlineOpenChar == 0 && s.inlineOpen != "" {
- s.inlineOpenChar = s.inlineOpen[0]
- }
- if s.inlineCloseChar == 0 && s.inlineClose != "" {
- s.inlineCloseChar = s.inlineClose[0]
+ cfg := s.cfg
+ if strings.TrimSpace(cfg.InlineOpen) == "" {
+ cfg.InlineOpen = ">"
}
- if s.chatSuffix == "" {
- s.chatSuffix = ">"
+ if strings.TrimSpace(cfg.InlineClose) == "" {
+ cfg.InlineClose = ">"
}
- if s.chatSuffixChar == 0 && s.chatSuffix != "" {
- s.chatSuffixChar = s.chatSuffix[0]
+ if strings.TrimSpace(cfg.ChatSuffix) == "" {
+ cfg.ChatSuffix = ">"
}
- if len(s.chatPrefixes) == 0 {
- s.chatPrefixes = []string{"?", "!", ":", ";"}
+ if len(cfg.ChatPrefixes) == 0 {
+ cfg.ChatPrefixes = []string{"?", "!", ":", ";"}
}
+ s.cfg = cfg
}
func TestSplitLines(t *testing.T) {
diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go
index 9452551..c1a637f 100644
--- a/internal/lsp/handlers.go
+++ b/internal/lsp/handlers.go
@@ -51,7 +51,8 @@ func (s *Server) findFirstInstructionInLine(line string) (instr string, cleaned
text string
}
cands := []cand{}
- if t, l, r, ok := findStrictInlineTag(line, s.inlineOpenChar, s.inlineCloseChar); ok {
+ _, _, openChar, closeChar := s.inlineMarkers()
+ if t, l, r, ok := findStrictInlineTag(line, openChar, closeChar); ok {
cands = append(cands, cand{start: l, end: r, text: t})
}
if i := strings.Index(line, "/*"); i >= 0 {
@@ -201,13 +202,13 @@ func (s *Server) completionCacheKey(p CompletionParams, above, current, below, f
}
prov := ""
model := ""
- if s.llmClient != nil {
- prov = s.llmClient.Name()
- model = s.llmClient.DefaultModel()
+ if client := s.currentLLMClient(); client != nil {
+ prov = client.Name()
+ model = client.DefaultModel()
}
temp := ""
- if s.codingTemperature != nil {
- temp = fmt.Sprintf("%.3f", *s.codingTemperature)
+ if tempPtr := s.codingTemperature(); tempPtr != nil {
+ temp = fmt.Sprintf("%.3f", *tempPtr)
}
extra := ""
if hasExtra {
@@ -286,6 +287,8 @@ func (s *Server) compCacheTouchLocked(key string) {
// CompletionContext if provided and also falls back to inspecting the character
// immediately to the left of the cursor.
func (s *Server) isTriggerEvent(p CompletionParams, current string) bool {
+ open, _, openChar, closeChar := s.inlineMarkers()
+ triggerChars := s.triggerCharacters()
// 1) Inspect LSP completion context if present
if p.Context != nil {
var ctx struct {
@@ -300,7 +303,7 @@ func (s *Server) isTriggerEvent(p CompletionParams, current string) bool {
}
// If configured and the line contains a bare double-open marker (e.g., '>>' with no '>>text>'),
// do not treat as a trigger source.
- if s.inlineOpen != "" && strings.Contains(current, s.inlineOpen+s.inlineOpen) && !hasDoubleOpenTrigger(current, s.inlineOpenChar, s.inlineCloseChar) {
+ if open != "" && strings.Contains(current, open+open) && !hasDoubleOpenTrigger(current, openChar, closeChar) {
return false
}
// TriggerKind 1 = Invoked (manual). Always allow manual invoke.
@@ -310,7 +313,7 @@ func (s *Server) isTriggerEvent(p CompletionParams, current string) bool {
// TriggerKind 2 is TriggerCharacter per LSP spec
if ctx.TriggerKind == 2 {
if ctx.TriggerCharacter != "" {
- for _, c := range s.triggerChars {
+ for _, c := range triggerChars {
if c == ctx.TriggerCharacter {
return true
}
@@ -328,11 +331,11 @@ func (s *Server) isTriggerEvent(p CompletionParams, current string) bool {
return false
}
// Bare double-open should not trigger via fallback char either (only when configured)
- if s.inlineOpen != "" && strings.Contains(current, s.inlineOpen+s.inlineOpen) && !hasDoubleOpenTrigger(current, s.inlineOpenChar, s.inlineCloseChar) {
+ if open != "" && strings.Contains(current, open+open) && !hasDoubleOpenTrigger(current, openChar, closeChar) {
return false
}
ch := string(current[idx-1])
- for _, c := range s.triggerChars {
+ for _, c := range triggerChars {
if c == ch {
return true
}
@@ -345,8 +348,8 @@ func (s *Server) makeCompletionItems(cleaned string, inParams bool, current stri
rm := s.collectPromptRemovalEdits(p.TextDocument.URI)
label := labelForCompletion(cleaned, filter)
detail := "Hexai LLM completion"
- if s.llmClient != nil {
- detail = "Hexai " + s.llmClient.Name() + ":" + s.llmClient.DefaultModel()
+ if client := s.currentLLMClient(); client != nil {
+ detail = "Hexai " + client.Name() + ":" + client.DefaultModel()
}
return []CompletionItem{{
Label: label,
diff --git a/internal/lsp/handlers_codeaction.go b/internal/lsp/handlers_codeaction.go
index 8764525..7631935 100644
--- a/internal/lsp/handlers_codeaction.go
+++ b/internal/lsp/handlers_codeaction.go
@@ -23,7 +23,7 @@ func (s *Server) handleCodeAction(req Request) {
return
}
d := s.getDocument(p.TextDocument.URI)
- if d == nil || len(d.lines) == 0 || s.llmClient == nil {
+ if d == nil || len(d.lines) == 0 || s.currentLLMClient() == nil {
if len(req.ID) != 0 {
s.reply(req.ID, []CodeAction{}, nil)
}
@@ -56,11 +56,12 @@ func (s *Server) handleCodeAction(req Request) {
// appendCustomActions adds user-defined actions depending on scope and availability.
func (s *Server) appendCustomActions(actions *[]CodeAction, p CodeActionParams, sel string) {
- if len(s.customActions) == 0 {
+ customs := s.customActions()
+ if len(customs) == 0 {
return
}
diags := s.diagnosticsInRange(p.Context, p.Range)
- for _, ca := range s.customActions {
+ for _, ca := range customs {
title := strings.TrimSpace(ca.Title)
if title == "" {
continue
@@ -155,7 +156,7 @@ func (s *Server) buildDiagnosticsCodeAction(p CodeActionParams, sel string) *Cod
}
func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) {
- if s.llmClient == nil || len(ca.Data) == 0 {
+ if s.currentLLMClient() == nil || len(ca.Data) == 0 {
return ca, false
}
var payload struct {
@@ -170,25 +171,14 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) {
if err := json.Unmarshal(ca.Data, &payload); err != nil {
return ca, false
}
+ cfg := s.currentConfig()
switch payload.Type {
case "rewrite":
- sys := s.promptRewriteSystem
- user := renderTemplate(s.promptRewriteUser, map[string]string{"instruction": payload.Instruction, "selection": payload.Selection})
- ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
- defer cancel()
- messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
- opts := s.llmRequestOpts()
- if text, err := s.chatWithStats(ctx, messages, opts...); err == nil {
- if out := stripCodeFences(strings.TrimSpace(text)); out != "" {
- edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}}
- ca.Edit = &edit
- return ca, true
- }
- } else {
- logging.Logf("lsp ", "codeAction rewrite llm error: %v", err)
- }
+ sys := cfg.PromptCodeActionRewriteSystem
+ user := renderTemplate(cfg.PromptCodeActionRewriteUser, map[string]string{"instruction": payload.Instruction, "selection": payload.Selection})
+ return s.completeCodeAction(ca, payload.URI, payload.Range, sys, user, 20*time.Second)
case "diagnostics":
- sys := s.promptDiagnosticsSystem
+ sys := cfg.PromptCodeActionDiagnosticsSystem
var b strings.Builder
for i, dgn := range payload.Diagnostics {
if dgn.Source != "" {
@@ -198,114 +188,72 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) {
}
}
diagList := b.String()
- user := renderTemplate(s.promptDiagnosticsUser, map[string]string{"diagnostics": diagList, "selection": payload.Selection})
- ctx, cancel := context.WithTimeout(context.Background(), 22*time.Second)
- defer cancel()
- messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
- opts := s.llmRequestOpts()
- if text, err := s.chatWithStats(ctx, messages, opts...); err == nil {
- if out := stripCodeFences(strings.TrimSpace(text)); out != "" {
- edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}}
- ca.Edit = &edit
- return ca, true
- }
- } else {
- logging.Logf("lsp ", "codeAction diagnostics llm error: %v", err)
- }
+ user := renderTemplate(cfg.PromptCodeActionDiagnosticsUser, map[string]string{"diagnostics": diagList, "selection": payload.Selection})
+ return s.completeCodeAction(ca, payload.URI, payload.Range, sys, user, 22*time.Second)
case "document":
- sys := s.promptDocumentSystem
- user := renderTemplate(s.promptDocumentUser, map[string]string{"selection": payload.Selection})
- ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
- defer cancel()
- messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
- opts := s.llmRequestOpts()
- if text, err := s.chatWithStats(ctx, messages, opts...); err == nil {
- if out := stripCodeFences(strings.TrimSpace(text)); out != "" {
- edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}}
- ca.Edit = &edit
- return ca, true
- }
- } else {
- logging.Logf("lsp ", "codeAction document llm error: %v", err)
- }
+ sys := cfg.PromptCodeActionDocumentSystem
+ user := renderTemplate(cfg.PromptCodeActionDocumentUser, map[string]string{"selection": payload.Selection})
+ return s.completeCodeAction(ca, payload.URI, payload.Range, sys, user, 20*time.Second)
case "go_test":
if edit, jumpURI, jumpRange, ok := s.resolveGoTest(payload.URI, payload.Range.Start); ok {
ca.Edit = &edit
- // After edit is applied, ask client to jump to new test function
ca.Command = &Command{Title: "Jump to generated test", Command: "hexai.showDocument", Arguments: []any{jumpURI, jumpRange}}
- // Also send a server-initiated showDocument shortly after resolve to cover
- // clients that do not execute commands from code actions.
s.deferShowDocument(jumpURI, jumpRange)
return ca, true
}
case "simplify":
- sys := s.promptRewriteSystem
- // Reuse rewrite user template with a fixed instruction
- user := renderTemplate(s.promptRewriteUser, map[string]string{"instruction": "Simplify and improve the code while preserving behavior. Return only the improved code.", "selection": payload.Selection})
- ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
- defer cancel()
- messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
- opts := s.llmRequestOpts()
- if text, err := s.chatWithStats(ctx, messages, opts...); err == nil {
- if out := stripCodeFences(strings.TrimSpace(text)); out != "" {
- edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}}
- ca.Edit = &edit
- return ca, true
- }
- } else {
- logging.Logf("lsp ", "codeAction simplify llm error: %v", err)
- }
+ sys := cfg.PromptCodeActionRewriteSystem
+ user := renderTemplate(cfg.PromptCodeActionRewriteUser, map[string]string{"instruction": "Simplify and improve the code while preserving behavior. Return only the improved code.", "selection": payload.Selection})
+ return s.completeCodeAction(ca, payload.URI, payload.Range, sys, user, 20*time.Second)
case "custom":
- // Lookup action by ID
var action *CustomAction
- for i := range s.customActions {
- if s.customActions[i].ID == payload.ID {
- action = &s.customActions[i]
+ for _, caDef := range s.customActions() {
+ if caDef.ID == payload.ID {
+ action = &caDef
break
}
}
if action == nil {
return ca, false
}
- // Build messages
var sys, user string
if strings.TrimSpace(action.User) != "" {
if strings.TrimSpace(action.System) != "" {
sys = action.System
} else {
- sys = s.promptRewriteSystem
+ sys = cfg.PromptCodeActionRewriteSystem
}
var diagList string
if len(payload.Diagnostics) > 0 {
var b strings.Builder
- for i, dgn := range payload.Diagnostics {
- if dgn.Source != "" {
- fmt.Fprintf(&b, "%d. [%s] %s\n", i+1, dgn.Source, dgn.Message)
- } else {
- fmt.Fprintf(&b, "%d. %s\n", i+1, dgn.Message)
- }
+ for _, d := range payload.Diagnostics {
+ fmt.Fprintf(&b, "%s\n", d.Message)
}
diagList = b.String()
}
- user = renderTemplate(action.User, map[string]string{"selection": payload.Selection, "diagnostics": diagList})
+ user = renderTemplate(action.User, map[string]string{"selection": payload.Selection, "diagnostics": strings.TrimSpace(diagList)})
} else {
- // Use rewrite templates with fixed instruction
- sys = s.promptRewriteSystem
- user = renderTemplate(s.promptRewriteUser, map[string]string{"instruction": action.Instruction, "selection": payload.Selection})
+ sys = cfg.PromptCodeActionRewriteSystem
+ user = renderTemplate(cfg.PromptCodeActionRewriteUser, map[string]string{"instruction": payload.Instruction, "selection": payload.Selection})
}
- ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
- defer cancel()
- messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
- opts := s.llmRequestOpts()
- if text, err := s.chatWithStats(ctx, messages, opts...); err == nil {
- if out := stripCodeFences(strings.TrimSpace(text)); out != "" {
- edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}}
- ca.Edit = &edit
- return ca, true
- }
- } else {
- logging.Logf("lsp ", "codeAction custom id=%s llm error: %v", action.ID, err)
+ return s.completeCodeAction(ca, payload.URI, payload.Range, sys, user, 20*time.Second)
+ }
+ return ca, false
+}
+
+func (s *Server) completeCodeAction(ca CodeAction, uri string, rng Range, sys, user string, timeout time.Duration) (CodeAction, bool) {
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+ messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
+ opts := s.llmRequestOpts()
+ if text, err := s.chatWithStats(ctx, messages, opts...); err == nil {
+ if out := stripCodeFences(strings.TrimSpace(text)); out != "" {
+ edit := WorkspaceEdit{Changes: map[string][]TextEdit{uri: {{Range: rng, NewText: out}}}}
+ ca.Edit = &edit
+ return ca, true
}
+ } else {
+ logging.Logf("lsp ", "codeAction llm error: %v", err)
}
return ca, false
}
@@ -410,7 +358,7 @@ func (s *Server) buildGoUnitTestCodeAction(p CodeActionParams) *CodeAction {
// buildDocumentCodeAction offers to document the selected code by injecting comments.
func (s *Server) buildDocumentCodeAction(p CodeActionParams, sel string) *CodeAction {
- if s.llmClient == nil {
+ if s.currentLLMClient() == nil {
return nil
}
if strings.TrimSpace(sel) == "" {
@@ -607,9 +555,10 @@ func findGoFunctionAtLine(lines []string, idx int) (int, int) {
// generateGoTestFunction uses LLM to produce a test function; falls back to a stub when unavailable.
func (s *Server) generateGoTestFunction(funcCode string) string {
- if s.llmClient != nil {
- sys := s.promptGoTestSystem
- user := renderTemplate(s.promptGoTestUser, map[string]string{"function": funcCode})
+ if client := s.currentLLMClient(); client != nil {
+ cfg := s.currentConfig()
+ sys := cfg.PromptCodeActionGoTestSystem
+ user := renderTemplate(cfg.PromptCodeActionGoTestUser, map[string]string{"function": funcCode})
ctx, cancel := context.WithTimeout(context.Background(), 18*time.Second)
defer cancel()
messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
diff --git a/internal/lsp/handlers_completion.go b/internal/lsp/handlers_completion.go
index df541cc..f7f41ef 100644
--- a/internal/lsp/handlers_completion.go
+++ b/internal/lsp/handlers_completion.go
@@ -113,7 +113,8 @@ func (s *Server) prepareCompletionPlan(p CompletionParams, above, current, below
hasExtra: hasExtra,
extraText: extraText,
}
- plan.inlinePrompt = lineHasInlinePrompt(current, s.inlineOpenChar, s.inlineCloseChar)
+ _, _, openChar, closeChar := s.inlineMarkers()
+ plan.inlinePrompt = lineHasInlinePrompt(current, openChar, closeChar)
if !plan.inlinePrompt && !s.isTriggerEvent(p, current) {
logging.Logf("lsp ", "%scompletion skip=no-trigger line=%d char=%d current=%q%s", logging.AnsiYellow, p.Position.Line, p.Position.Character, trimLen(current), logging.AnsiBase)
return plan, []CompletionItem{}, true
@@ -130,7 +131,7 @@ func (s *Server) prepareCompletionPlan(p CompletionParams, above, current, below
logging.AnsiGreen, logging.PreviewForLog(cleaned), logging.AnsiBase)
return plan, s.makeCompletionItems(cleaned, plan.inParams, current, p, docStr), true
}
- if isBareDoubleOpen(current, s.inlineOpenChar, s.inlineCloseChar) || isBareDoubleOpen(below, s.inlineOpenChar, s.inlineCloseChar) {
+ if isBareDoubleOpen(current, openChar, closeChar) || isBareDoubleOpen(below, openChar, closeChar) {
logging.Logf("lsp ", "%scompletion skip=empty-double-semicolon line=%d char=%d current=%q%s", logging.AnsiYellow, p.Position.Line, p.Position.Character, trimLen(current), logging.AnsiBase)
return plan, []CompletionItem{}, true
}
@@ -148,19 +149,17 @@ func (s *Server) executeChatCompletion(ctx context.Context, plan completionPlan)
sentSize += len(m.Content)
}
s.incSentCounters(sentSize)
- opts := []llm.RequestOption{llm.WithMaxTokens(s.maxTokens)}
- if s.codingTemperature != nil {
- opts = append(opts, llm.WithTemperature(*s.codingTemperature))
- }
+ opts := s.llmRequestOpts()
s.waitForDebounce(ctx)
if !s.waitForThrottle(ctx) {
return nil, false
}
- if s.llmClient == nil {
+ client := s.currentLLMClient()
+ if client == nil {
return nil, false
}
- logging.Logf("lsp ", "completion llm=requesting model=%s", s.llmClient.DefaultModel())
- text, err := s.llmClient.Chat(ctx, messages, opts...)
+ logging.Logf("lsp ", "completion llm=requesting model=%s", client.DefaultModel())
+ text, err := client.Chat(ctx, messages, opts...)
if err != nil {
logging.Logf("lsp ", "llm completion error: %v", err)
s.logLLMStats()
@@ -198,15 +197,16 @@ func parseManualInvoke(ctx any) bool {
// shouldSuppressForChatTriggerEOL returns true when a chat trigger like ">" follows ?, !, :, or ; at EOL.
func (s *Server) shouldSuppressForChatTriggerEOL(current string, p CompletionParams) bool {
t := strings.TrimRight(current, " \t")
- if s.chatSuffix == "" {
+ suffix, prefixes, _ := s.chatConfig()
+ if suffix == "" {
return false
}
- if strings.HasSuffix(t, s.chatSuffix) {
- if len(t) < len(s.chatSuffix)+1 {
+ if strings.HasSuffix(t, suffix) {
+ if len(t) < len(suffix)+1 {
return false
}
- prev := string(t[len(t)-len(s.chatSuffix)-1])
- for _, pf := range s.chatPrefixes {
+ prev := string(t[len(t)-len(suffix)-1])
+ for _, pf := range prefixes {
if prev == pf {
logging.Logf("lsp ", "completion skip=chat-trigger-eol uri=%s line=%d", p.TextDocument.URI, p.Position.Line)
return true
@@ -246,33 +246,38 @@ func (s *Server) prefixHeuristicAllows(inlinePrompt bool, current string, p Comp
}
start := computeWordStart(current, j)
min := 1
- if manualInvoke && s.manualInvokeMinPrefix >= 0 {
- min = s.manualInvokeMinPrefix
+ if manualInvoke {
+ if v := s.manualInvokeMinPrefix(); v >= 0 {
+ min = v
+ }
}
return j-start >= min
}
// tryProviderNativeCompletion attempts provider-native completion and returns items when successful.
func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, above, below, funcCtx, docStr string, hasExtra bool, extraText string, inParams bool) ([]CompletionItem, bool) {
- cc, ok := s.llmClient.(llm.CodeCompleter)
+ client := s.currentLLMClient()
+ cc, ok := client.(llm.CodeCompleter)
if !ok {
return nil, false
}
before, after := s.docBeforeAfter(p.TextDocument.URI, p.Position)
path := strings.TrimPrefix(p.TextDocument.URI, "file://")
// Build provider-native prompt from template
- prompt := renderTemplate(s.promptNativeCompletion, map[string]string{
+ cfg := s.currentConfig()
+ _, _, openChar, closeChar := s.inlineMarkers()
+ prompt := renderTemplate(cfg.PromptNativeCompletion, map[string]string{
"path": path,
"before": before,
})
lang := ""
temp := 0.0
- if s.codingTemperature != nil {
- temp = *s.codingTemperature
+ if cfg.CodingTemperature != nil {
+ temp = *cfg.CodingTemperature
}
prov := ""
- if s.llmClient != nil {
- prov = s.llmClient.Name()
+ if client != nil {
+ prov = client.Name()
}
logging.Logf("lsp ", "completion path=codex provider=%s uri=%s", prov, path)
ctx2, cancel2 := context.WithTimeout(context.Background(), 15*time.Second)
@@ -291,8 +296,8 @@ func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams,
s.incSentCounters(sentBytes)
s.incRecvCounters(len(suggestions[0]))
// Contribute to global stats (provider-native path)
- if s.llmClient != nil {
- _ = stats.Update(ctx2, s.llmClient.Name(), s.llmClient.DefaultModel(), sentBytes, len(suggestions[0]))
+ if client != nil {
+ _ = stats.Update(ctx2, client.Name(), client.DefaultModel(), sentBytes, len(suggestions[0]))
}
s.logLLMStats()
cleaned := strings.TrimSpace(suggestions[0])
@@ -301,7 +306,7 @@ func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams,
if cleaned != "" {
cleaned = stripDuplicateGeneralPrefix(current[:p.Position.Character], cleaned)
}
- if cleaned != "" && hasDoubleOpenTrigger(current, s.inlineOpenChar, s.inlineCloseChar) {
+ if cleaned != "" && hasDoubleOpenTrigger(current, openChar, closeChar) {
indent := leadingIndent(current)
if indent != "" {
cleaned = applyIndent(indent, cleaned)
@@ -325,7 +330,7 @@ func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams,
// waitForDebounce sleeps until there has been no input activity for at least
// completionDebounce. If debounce is zero or ctx is done, it returns promptly.
func (s *Server) waitForDebounce(ctx context.Context) {
- d := s.completionDebounce
+ d := s.completionDebounce()
if d <= 0 {
return
}
@@ -355,7 +360,7 @@ func (s *Server) waitForDebounce(ctx context.Context) {
// waitForThrottle enforces a minimum spacing between LLM calls. Returns false
// if the context is canceled while waiting.
func (s *Server) waitForThrottle(ctx context.Context) bool {
- interval := s.throttleInterval
+ interval := s.completionThrottle()
if interval <= 0 {
return true
}
@@ -386,7 +391,6 @@ func (s *Server) waitForThrottle(ctx context.Context) bool {
// buildCompletionMessages constructs the LLM messages for completion.
func (s *Server) buildCompletionMessages(inlinePrompt, hasExtra bool, extraText string, inParams bool, p CompletionParams, above, current, below, funcCtx string) []llm.Message {
- // Vars for templates
vars := map[string]string{
"file": p.TextDocument.URI,
"function": funcCtx,
@@ -395,19 +399,20 @@ func (s *Server) buildCompletionMessages(inlinePrompt, hasExtra bool, extraText
"below": below,
"char": fmt.Sprintf("%d", p.Position.Character),
}
- sys := s.promptCompSysGeneral
- userTpl := s.promptCompUserGeneral
+ cfg := s.currentConfig()
+ sys := cfg.PromptCompletionSystemGeneral
+ userTpl := cfg.PromptCompletionUserGeneral
if inParams {
- sys = s.promptCompSysParams
- userTpl = s.promptCompUserParams
+ sys = cfg.PromptCompletionSystemParams
+ userTpl = cfg.PromptCompletionUserParams
}
- if inlinePrompt && strings.TrimSpace(s.promptCompSysInline) != "" {
- sys = s.promptCompSysInline
+ if inlinePrompt && strings.TrimSpace(cfg.PromptCompletionSystemInline) != "" {
+ sys = cfg.PromptCompletionSystemInline
}
user := renderTemplate(userTpl, vars)
messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
if hasExtra && strings.TrimSpace(extraText) != "" {
- extra := renderTemplate(s.promptCompExtraHeader, map[string]string{"context": extraText})
+ extra := renderTemplate(cfg.PromptCompletionExtraHeader, map[string]string{"context": extraText})
if strings.TrimSpace(extra) == "" {
extra = extraText
}
@@ -430,7 +435,8 @@ func (s *Server) postProcessCompletion(text string, leftOfCursor string, current
if cleaned != "" {
cleaned = stripDuplicateGeneralPrefix(leftOfCursor, cleaned)
}
- if cleaned != "" && hasDoubleOpenTrigger(currentLine, s.inlineOpenChar, s.inlineCloseChar) {
+ _, _, openChar, closeChar := s.inlineMarkers()
+ if cleaned != "" && hasDoubleOpenTrigger(currentLine, openChar, closeChar) {
if indent := leadingIndent(currentLine); indent != "" {
cleaned = applyIndent(indent, cleaned)
}
diff --git a/internal/lsp/handlers_document.go b/internal/lsp/handlers_document.go
index ca0cb8d..e82e683 100644
--- a/internal/lsp/handlers_document.go
+++ b/internal/lsp/handlers_document.go
@@ -86,13 +86,11 @@ func (s *Server) docBeforeAfter(uri string, pos Position) (string, string) {
// a new trigger pair (e.g., "?>" ",>" ":>" ";>") at EOL and inserts the LLM
// reply below.
func (s *Server) detectAndHandleChat(uri string) {
- if s.llmClient == nil {
- return
- }
d := s.getDocument(uri)
if d == nil || len(d.lines) == 0 {
return
}
+ suffix, prefixes, _ := s.chatConfig()
for i, raw := range d.lines {
// Find last non-space character index
j := len(raw) - 1
@@ -107,11 +105,11 @@ func (s *Server) detectAndHandleChat(uri string) {
continue
}
// Check suffix/prefix according to configuration
- if s.chatSuffix == "" {
+ if suffix == "" {
continue
}
// Last non-space must equal suffix
- if string(raw[j]) != s.chatSuffix {
+ if string(raw[j]) != suffix {
continue
}
// Require at least one char before suffix and that char must be in chatPrefixes
@@ -120,7 +118,7 @@ func (s *Server) detectAndHandleChat(uri string) {
}
prev := string(raw[j-1])
isTrigger := false
- for _, pfx := range s.chatPrefixes {
+ for _, pfx := range prefixes {
if prev == pfx {
isTrigger = true
break
@@ -138,7 +136,7 @@ func (s *Server) detectAndHandleChat(uri string) {
continue
}
// Derive prompt by removing only the trailing '>'
- removeCount := len(s.chatSuffix)
+ removeCount := len(suffix)
base := raw[:j+1-removeCount]
prompt := strings.TrimSpace(base)
if prompt == "" {
@@ -146,6 +144,16 @@ func (s *Server) detectAndHandleChat(uri string) {
}
lineIdx := i
lastIdx := j
+ if resp, ok := s.chatCommandResponse(uri, lineIdx, prompt); ok {
+ msg := strings.TrimSpace(resp.message)
+ if msg != "" {
+ s.applyChatEdits(uri, lineIdx, lastIdx, removeCount, "> "+msg)
+ }
+ return
+ }
+ if s.currentLLMClient() == nil {
+ continue
+ }
go func(prompt string, remove int) {
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
@@ -153,7 +161,11 @@ func (s *Server) detectAndHandleChat(uri string) {
pos := Position{Line: lineIdx, Character: lastIdx + 1}
msgs := s.buildChatMessages(uri, pos, prompt)
opts := s.llmRequestOpts()
- logging.Logf("lsp ", "chat llm=requesting model=%s", s.llmClient.DefaultModel())
+ client := s.currentLLMClient()
+ if client == nil {
+ return
+ }
+ logging.Logf("lsp ", "chat llm=requesting model=%s", client.DefaultModel())
text, err := s.chatWithStats(ctx, msgs, opts...)
if err != nil {
logging.Logf("lsp ", "chat llm error: %v", err)
@@ -252,9 +264,10 @@ func (s *Server) stripTrailingTrigger(sx string) string {
if len(trim) == 0 {
return sx
}
- if len(trim) >= 2 && s.chatSuffixChar != 0 && trim[len(trim)-1] == s.chatSuffixChar {
+ _, prefixes, suffixChar := s.chatConfig()
+ if len(trim) >= 2 && suffixChar != 0 && trim[len(trim)-1] == suffixChar {
prev := string(trim[len(trim)-2])
- for _, pf := range s.chatPrefixes {
+ for _, pf := range prefixes {
if prev == pf {
return strings.TrimRight(trim[:len(trim)-1], " \t")
}
@@ -275,7 +288,8 @@ func (s *Server) stripTrailingTrigger(sx string) string {
// - 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
- sys := s.promptChatSystem
+ cfg := s.currentConfig()
+ sys := cfg.PromptChatSystem
// Determine line index for history from position
lineIdx := pos.Line
history := s.buildChatHistory(uri, lineIdx, prompt)
@@ -285,7 +299,7 @@ func (s *Server) buildChatMessages(uri string, pos Position, prompt string) []ll
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(s.promptCompExtraHeader, map[string]string{"context": extra})
+ header := renderTemplate(cfg.PromptCompletionExtraHeader, map[string]string{"context": extra})
if strings.TrimSpace(header) == "" {
header = extra
}
diff --git a/internal/lsp/handlers_end_to_end_test.go b/internal/lsp/handlers_end_to_end_test.go
index 5489b97..4528c1d 100644
--- a/internal/lsp/handlers_end_to_end_test.go
+++ b/internal/lsp/handlers_end_to_end_test.go
@@ -72,10 +72,8 @@ func captureRequest(t *testing.T, buf *bytes.Buffer) Request {
func TestHandleCodeAction_ListsHexaiActions(t *testing.T) {
// Prepare server
var out bytes.Buffer
- s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document), out: &out}
- initServerDefaults(s)
- s.chatSuffix = ">"
- s.chatPrefixes = []string{"?", "!", ":", ";"}
+ s := newTestServer()
+ s.out = &out
s.llmClient = fakeLLM{resp: "// doc\nfunc add(a,b int) int { return a+b }"}
// Document with a function
@@ -219,6 +217,12 @@ func TestHandle_Dispatch_Initialize(t *testing.T) {
func TestDetectAndHandleChat_InsertsReply(t *testing.T) {
var out bytes.Buffer
s := NewServer(bytes.NewReader(nil), &out, log.New(io.Discard, "", 0), ServerOptions{})
+ cfg := s.cfg
+ if strings.TrimSpace(cfg.ChatSuffix) == "" {
+ cfg.ChatSuffix = ">"
+ cfg.ChatPrefixes = []string{"?", "!", ":", ";"}
+ s.cfg = cfg
+ }
s.llmClient = fakeLLM{resp: tut.MultilineChatReply()}
uri := "file:///chat.go"
// Place a prompt line with a supported trigger at EOL, then a blank line
@@ -226,7 +230,7 @@ func TestDetectAndHandleChat_InsertsReply(t *testing.T) {
out.Reset()
s.detectAndHandleChat(uri)
// Allow async goroutine to write the request
- for i := 0; i < 20 && out.Len() == 0; i++ {
+ for i := 0; i < 100 && out.Len() == 0; i++ {
time.Sleep(10 * time.Millisecond)
}
if out.Len() == 0 {
diff --git a/internal/lsp/handlers_init.go b/internal/lsp/handlers_init.go
index ba00333..d86d104 100644
--- a/internal/lsp/handlers_init.go
+++ b/internal/lsp/handlers_init.go
@@ -10,16 +10,17 @@ import (
)
func (s *Server) handleInitialize(req Request) {
+ client := s.currentLLMClient()
version := internal.Version
- if s.llmClient != nil {
- version = version + " [" + s.llmClient.Name() + ":" + s.llmClient.DefaultModel() + "]"
+ if client != nil {
+ version = version + " [" + client.Name() + ":" + client.DefaultModel() + "]"
}
res := InitializeResult{
Capabilities: ServerCapabilities{
TextDocumentSync: 1, // 1 = TextDocumentSyncKindFull
CompletionProvider: &CompletionOptions{
ResolveProvider: false,
- TriggerCharacters: s.triggerChars,
+ TriggerCharacters: s.triggerCharacters(),
},
CodeActionProvider: CodeActionOptions{ResolveProvider: true},
},
@@ -31,8 +32,8 @@ func (s *Server) handleInitialize(req Request) {
func (s *Server) handleInitialized() {
logging.Logf("lsp ", "client initialized")
// Emit an initial tmux heartbeat with provider/model
- if s.llmClient != nil {
- _ = tmx.SetStatus(tmx.FormatLLMStartStatus(s.llmClient.Name(), s.llmClient.DefaultModel()))
+ if client := s.currentLLMClient(); client != nil {
+ _ = tmx.SetStatus(tmx.FormatLLMStartStatus(client.Name(), client.DefaultModel()))
}
}
diff --git a/internal/lsp/handlers_utils.go b/internal/lsp/handlers_utils.go
index 56d752d..5d5ca27 100644
--- a/internal/lsp/handlers_utils.go
+++ b/internal/lsp/handlers_utils.go
@@ -3,6 +3,7 @@ package lsp
import (
"context"
+ "fmt"
"strings"
"time"
@@ -15,12 +16,15 @@ import (
// llmRequestOpts builds request options from server settings.
func (s *Server) llmRequestOpts() []llm.RequestOption {
- opts := []llm.RequestOption{llm.WithMaxTokens(s.maxTokens)}
- if s.codingTemperature != nil {
- temp := *s.codingTemperature
- if s.llmClient != nil {
- prov := strings.ToLower(strings.TrimSpace(s.llmClient.Name()))
- model := strings.ToLower(strings.TrimSpace(s.llmClient.DefaultModel()))
+ maxTokens := s.maxTokens()
+ client := s.currentLLMClient()
+ tempPtr := s.codingTemperature()
+ opts := []llm.RequestOption{llm.WithMaxTokens(maxTokens)}
+ if tempPtr != nil {
+ temp := *tempPtr
+ if client != nil {
+ prov := strings.ToLower(strings.TrimSpace(client.Name()))
+ model := strings.ToLower(strings.TrimSpace(client.DefaultModel()))
if prov == "openai" && strings.HasPrefix(model, "gpt-5") {
temp = 1.0
}
@@ -68,23 +72,25 @@ func (s *Server) logLLMStats() {
logging.Logf("lsp ", "llm stats (local) reqs=%d avg_sent=%d avg_recv=%d sent_total=%d recv_total=%d rpm=%.2f sent_per_min=%.0f recv_per_min=%.0f", reqs, avgSent, avgRecv, sentTot, recvTot, rpmLocal, sentPerMin, recvPerMin)
// Global snapshot for tmux status
snap, err := stats.TakeSnapshot()
- if err == nil && s.llmClient != nil {
- provider := s.llmClient.Name()
- model := s.llmClient.DefaultModel()
- // Per-scope rpm estimated from window
- scopeReqs := int64(0)
- if pe, ok := snap.Providers[provider]; ok {
- if mc, ok2 := pe.Models[model]; ok2 {
- scopeReqs = mc.Reqs
+ if err == nil {
+ if client := s.currentLLMClient(); client != nil {
+ provider := client.Name()
+ model := client.DefaultModel()
+ // Per-scope rpm estimated from window
+ scopeReqs := int64(0)
+ if pe, ok := snap.Providers[provider]; ok {
+ if mc, ok2 := pe.Models[model]; ok2 {
+ scopeReqs = mc.Reqs
+ }
}
+ minsWin := snap.Window.Minutes()
+ if minsWin <= 0 {
+ minsWin = 0.001
+ }
+ scopeRPM := float64(scopeReqs) / minsWin
+ status := tmx.FormatGlobalStatusColored(snap.Global.Reqs, snap.RPM, snap.Global.Sent, snap.Global.Recv, provider, model, scopeRPM, scopeReqs, snap.Window)
+ _ = tmx.SetStatus(status)
}
- minsWin := snap.Window.Minutes()
- if minsWin <= 0 {
- minsWin = 0.001
- }
- scopeRPM := float64(scopeReqs) / minsWin
- status := tmx.FormatGlobalStatusColored(snap.Global.Reqs, snap.RPM, snap.Global.Sent, snap.Global.Recv, provider, model, scopeRPM, scopeReqs, snap.Window)
- _ = tmx.SetStatus(status)
}
}
@@ -161,16 +167,18 @@ func (s *Server) chatWithStats(ctx context.Context, msgs []llm.Message, opts ...
return "", context.Canceled
}
// Perform request
- txt, err := s.llmClient.Chat(ctx, msgs, opts...)
+ client := s.currentLLMClient()
+ if client == nil {
+ return "", fmt.Errorf("llm client unavailable")
+ }
+ txt, err := client.Chat(ctx, msgs, opts...)
if err != nil {
s.logLLMStats()
return "", err
}
s.incRecvCounters(len(txt))
// Update global stats cache
- if s.llmClient != nil {
- _ = stats.Update(ctx, s.llmClient.Name(), s.llmClient.DefaultModel(), sent, len(txt))
- }
+ _ = stats.Update(ctx, client.Name(), client.DefaultModel(), sent, len(txt))
s.logLLMStats()
return txt, nil
}
@@ -427,8 +435,9 @@ func (s *Server) collectPromptRemovalEdits(uri string) []TextEdit {
return nil
}
var edits []TextEdit
+ _, _, openChar, closeChar := s.inlineMarkers()
for i, line := range d.lines {
- edits = append(edits, promptRemovalEditsForLine(line, i, s.inlineOpenChar, s.inlineCloseChar)...)
+ edits = append(edits, promptRemovalEditsForLine(line, i, openChar, closeChar)...)
}
return edits
}
diff --git a/internal/lsp/helpers_inline_prompt_test.go b/internal/lsp/helpers_inline_prompt_test.go
index e4a38f5..5554d89 100644
--- a/internal/lsp/helpers_inline_prompt_test.go
+++ b/internal/lsp/helpers_inline_prompt_test.go
@@ -18,7 +18,9 @@ func TestLineHasInlinePrompt_BasicAndDoubleOpen(t *testing.T) {
func TestIsTriggerEvent_TriggerCharNotAllowed(t *testing.T) {
s := newTestServer()
- s.triggerChars = []string{"."} // only dot allowed
+ cfg := s.cfg
+ cfg.TriggerCharacters = []string{"."}
+ s.cfg = cfg
p := CompletionParams{Position: Position{Line: 0, Character: 3}}
if s.isTriggerEvent(p, "ab:") { // ':' not in triggerChars
t.Fatalf("expected false when TriggerCharacter not configured")
@@ -27,7 +29,9 @@ func TestIsTriggerEvent_TriggerCharNotAllowed(t *testing.T) {
func TestShouldSuppressForChatTriggerEOL_EmptySuffix_NoSuppression(t *testing.T) {
s := newTestServer()
- s.chatSuffix = "" // disabled
+ cfg := s.cfg
+ cfg.ChatSuffix = ""
+ s.cfg = cfg
p := CompletionParams{Position: Position{Line: 0, Character: 5}}
if s.shouldSuppressForChatTriggerEOL("What?>", p) {
t.Fatalf("expected no suppression when chat suffix is empty")
@@ -49,7 +53,9 @@ func TestIsTriggerEvent_TriggerCharacterMissing_ReturnsFalse(t *testing.T) {
func TestIsTriggerEvent_TriggerForIncomplete_FallsBackToChar(t *testing.T) {
s := newTestServer()
- s.triggerChars = []string{"."}
+ cfg := s.cfg
+ cfg.TriggerCharacters = []string{"."}
+ s.cfg = cfg
// TriggerKind=3 should consult fallback char check
ctx := struct {
TriggerKind int `json:"triggerKind"`
diff --git a/internal/lsp/init_and_trigger_test.go b/internal/lsp/init_and_trigger_test.go
index 10d0968..2c5cd62 100644
--- a/internal/lsp/init_and_trigger_test.go
+++ b/internal/lsp/init_and_trigger_test.go
@@ -10,9 +10,12 @@ import (
func TestHandleInitialize_Capabilities(t *testing.T) {
var out bytes.Buffer
- s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document), out: &out}
- initServerDefaults(s)
- s.triggerChars = []string{".", ":"}
+ s := newTestServer()
+ s.logger = log.New(io.Discard, "", 0)
+ s.out = &out
+ cfg := s.cfg
+ cfg.TriggerCharacters = []string{".", ":"}
+ s.cfg = cfg
req := Request{JSONRPC: "2.0", ID: json.RawMessage("7"), Method: "initialize"}
out.Reset()
s.handleInitialize(req)
@@ -41,7 +44,9 @@ func TestHandleInitialize_Capabilities(t *testing.T) {
func TestIsTriggerEvent_Variants(t *testing.T) {
s := newTestServer()
- s.triggerChars = []string{".", ":"}
+ cfg := s.cfg
+ cfg.TriggerCharacters = []string{".", ":"}
+ s.cfg = cfg
// 1) Manual invoke via context
ctx := struct {
TriggerKind int `json:"triggerKind"`
diff --git a/internal/lsp/llm_request_opts_test.go b/internal/lsp/llm_request_opts_test.go
index f4d2ef3..c6699b0 100644
--- a/internal/lsp/llm_request_opts_test.go
+++ b/internal/lsp/llm_request_opts_test.go
@@ -18,7 +18,7 @@ func (f fakeClient) DefaultModel() string { return f.model }
func TestLlmRequestOpts_Gpt5_ForcesTemp1(t *testing.T) {
s := newTestServer()
one := 0.2
- s.codingTemperature = &one
+ s.cfg.CodingTemperature = &one
s.llmClient = fakeClient{name: "openai", model: "gpt-5.0"}
opts := s.llmRequestOpts()
var got llm.Options
diff --git a/internal/lsp/provider_native_success_test.go b/internal/lsp/provider_native_success_test.go
index ac227be..6df5698 100644
--- a/internal/lsp/provider_native_success_test.go
+++ b/internal/lsp/provider_native_success_test.go
@@ -77,7 +77,9 @@ func TestProviderNativeCompletion_UsesPromptTemplate(t *testing.T) {
s := newTestServer()
cap := &fakeCompleterCapture{}
s.llmClient = cap
- s.promptNativeCompletion = "NATIVE {{path}} {{before}}"
+ cfg := s.cfg
+ cfg.PromptNativeCompletion = "NATIVE {{path}} {{before}}"
+ s.cfg = cfg
uri := "file:///x.go"
s.setDocument(uri, "AAA\nBBB\nCCC")
current := "fmt."
diff --git a/internal/lsp/server.go b/internal/lsp/server.go
index 13066f7..7b8bc88 100644
--- a/internal/lsp/server.go
+++ b/internal/lsp/server.go
@@ -10,29 +10,26 @@ import (
"sync"
"time"
+ "codeberg.org/snonux/hexai/internal/appconfig"
"codeberg.org/snonux/hexai/internal/llm"
"codeberg.org/snonux/hexai/internal/logging"
+ "codeberg.org/snonux/hexai/internal/runtimeconfig"
)
// Server implements a minimal LSP over stdio.
type Server struct {
- in *bufio.Reader
- out io.Writer
- outMu sync.Mutex
- logger *log.Logger
- exited bool
- mu sync.RWMutex
- docs map[string]*document
- logContext bool
- llmClient llm.Client
- lastInput time.Time
- maxTokens int
- contextMode string
- windowLines int
- maxContextTokens int
- triggerChars []string
- // If set, used as the LSP coding temperature for all LLM calls
- codingTemperature *float64
+ in *bufio.Reader
+ out io.Writer
+ outMu sync.Mutex
+ logger *log.Logger
+ exited bool
+ mu sync.RWMutex
+ docs map[string]*document
+ logContext bool
+ configStore *runtimeconfig.Store
+ cfg appconfig.App
+ llmClient llm.Client
+ lastInput time.Time
// LLM request stats
llmReqTotal int64
llmSentBytesTotal int64
@@ -43,58 +40,18 @@ type Server struct {
compCache map[string]string
compCacheOrder []string // most-recent at end; cap ~10
// Outgoing JSON-RPC id counter for server-initiated requests
- nextID int64
- // Minimum identifier chars required for manual invoke to bypass prefix checks
- manualInvokeMinPrefix int
-
- // Debounce and throttle settings
- completionDebounce time.Duration
- throttleInterval time.Duration
- lastLLMCall time.Time
+ nextID int64
+ lastLLMCall time.Time
// Dispatch table for JSON-RPC methods → handler functions
handlers map[string]func(Request)
-
- // Configurable trigger characters
- inlineOpen string
- inlineClose string
- chatSuffix string
- chatPrefixes []string
- inlineOpenChar byte
- inlineCloseChar byte
- chatSuffixChar byte
-
- // Prompt templates
- // Completion
- promptCompSysGeneral string
- promptCompSysParams string
- promptCompSysInline string
- promptCompUserGeneral string
- promptCompUserParams string
- promptCompExtraHeader string
- // Provider-native code completion
- promptNativeCompletion string
- // In-editor chat
- promptChatSystem string
- // Code actions
- promptRewriteSystem string
- promptDiagnosticsSystem string
- promptDocumentSystem string
- promptRewriteUser string
- promptDiagnosticsUser string
- promptDocumentUser string
- promptGoTestSystem string
- promptGoTestUser string
- promptSimplifySystem string
- promptSimplifyUser string
-
- // Custom actions configured by user
- customActions []CustomAction
}
// ServerOptions collects configuration for NewServer to avoid long parameter lists.
type ServerOptions struct {
LogContext bool
+ ConfigStore *runtimeconfig.Store
+ Config *appconfig.App
MaxTokens int
ContextMode string
WindowLines int
@@ -149,121 +106,239 @@ type CustomAction struct {
}
func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) *Server {
- s := &Server{in: bufio.NewReader(r), out: w, logger: logger, docs: make(map[string]*document), logContext: opts.LogContext}
- maxTokens := opts.MaxTokens
- if maxTokens <= 0 {
- maxTokens = 500
- }
- s.maxTokens = maxTokens
- contextMode := opts.ContextMode
- if contextMode == "" {
- contextMode = "file-on-new-func"
+ s := &Server{in: bufio.NewReader(r), out: w, logger: logger, docs: make(map[string]*document), logContext: opts.LogContext, configStore: opts.ConfigStore}
+ s.startTime = time.Now()
+ s.compCache = make(map[string]string)
+ s.applyOptions(opts)
+ // Initialize dispatch table
+ s.handlers = map[string]func(Request){
+ "initialize": s.handleInitialize,
+ "initialized": func(_ Request) { s.handleInitialized() },
+ "shutdown": s.handleShutdown,
+ "exit": func(_ Request) { s.handleExit() },
+ "textDocument/didOpen": s.handleDidOpen,
+ "textDocument/didChange": s.handleDidChange,
+ "textDocument/didClose": s.handleDidClose,
+ "textDocument/completion": s.handleCompletion,
+ "textDocument/codeAction": s.handleCodeAction,
+ "codeAction/resolve": s.handleCodeActionResolve,
+ "workspace/executeCommand": s.handleExecuteCommand,
}
- windowLines := opts.WindowLines
- if windowLines <= 0 {
- windowLines = 120
+ return s
+}
+
+func (s *Server) applyOptions(opts ServerOptions) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.logContext = opts.LogContext
+ if opts.ConfigStore != nil {
+ s.configStore = opts.ConfigStore
}
- maxContextTokens := opts.MaxContextTokens
- if maxContextTokens <= 0 {
- maxContextTokens = 2000
+ if opts.Config != nil {
+ s.cfg = *opts.Config
+ } else if opts.ConfigStore != nil {
+ s.cfg = opts.ConfigStore.Snapshot()
+ } else {
+ s.cfg = appconfig.App{}
+ // populate from legacy ServerOptions fields
+ s.cfg.MaxTokens = opts.MaxTokens
+ s.cfg.ContextMode = opts.ContextMode
+ s.cfg.ContextWindowLines = opts.WindowLines
+ s.cfg.MaxContextTokens = opts.MaxContextTokens
+ s.cfg.TriggerCharacters = append([]string{}, opts.TriggerCharacters...)
+ s.cfg.CodingTemperature = opts.CodingTemperature
+ s.cfg.ManualInvokeMinPrefix = opts.ManualInvokeMinPrefix
+ s.cfg.CompletionDebounceMs = opts.CompletionDebounceMs
+ s.cfg.CompletionThrottleMs = opts.CompletionThrottleMs
+ s.cfg.InlineOpen = opts.InlineOpen
+ s.cfg.InlineClose = opts.InlineClose
+ s.cfg.ChatSuffix = opts.ChatSuffix
+ s.cfg.ChatPrefixes = append([]string{}, opts.ChatPrefixes...)
+ s.cfg.PromptCompletionSystemGeneral = opts.PromptCompSysGeneral
+ s.cfg.PromptCompletionSystemParams = opts.PromptCompSysParams
+ s.cfg.PromptCompletionSystemInline = opts.PromptCompSysInline
+ s.cfg.PromptCompletionUserGeneral = opts.PromptCompUserGeneral
+ s.cfg.PromptCompletionUserParams = opts.PromptCompUserParams
+ s.cfg.PromptCompletionExtraHeader = opts.PromptCompExtraHeader
+ s.cfg.PromptNativeCompletion = opts.PromptNativeCompletion
+ s.cfg.PromptChatSystem = opts.PromptChatSystem
+ s.cfg.PromptCodeActionRewriteSystem = opts.PromptRewriteSystem
+ s.cfg.PromptCodeActionDiagnosticsSystem = opts.PromptDiagnosticsSystem
+ s.cfg.PromptCodeActionDocumentSystem = opts.PromptDocumentSystem
+ s.cfg.PromptCodeActionRewriteUser = opts.PromptRewriteUser
+ s.cfg.PromptCodeActionDiagnosticsUser = opts.PromptDiagnosticsUser
+ s.cfg.PromptCodeActionDocumentUser = opts.PromptDocumentUser
+ s.cfg.PromptCodeActionGoTestSystem = opts.PromptGoTestSystem
+ s.cfg.PromptCodeActionGoTestUser = opts.PromptGoTestUser
+ s.cfg.PromptCodeActionSimplifySystem = opts.PromptSimplifySystem
+ s.cfg.PromptCodeActionSimplifyUser = opts.PromptSimplifyUser
+ s.cfg.CustomActions = make([]appconfig.CustomAction, len(opts.CustomActions))
+ for i, ca := range opts.CustomActions {
+ s.cfg.CustomActions[i] = appconfig.CustomAction{
+ ID: ca.ID,
+ Title: ca.Title,
+ Kind: ca.Kind,
+ Scope: ca.Scope,
+ Instruction: ca.Instruction,
+ System: ca.System,
+ User: ca.User,
+ }
+ }
}
- s.contextMode = contextMode
- s.windowLines = windowLines
- s.maxContextTokens = maxContextTokens
-
- s.startTime = time.Now()
s.llmClient = opts.Client
- if len(opts.TriggerCharacters) == 0 {
- // Defaults (no space to avoid auto-trigger after whitespace)
- s.triggerChars = []string{".", ":", "/", "_", ")", "{"}
- } else {
- s.triggerChars = append([]string{}, opts.TriggerCharacters...)
+}
+
+// ApplyOptions updates the server's configuration at runtime.
+func (s *Server) ApplyOptions(opts ServerOptions) {
+ s.applyOptions(opts)
+}
+
+func (s *Server) currentLLMClient() llm.Client {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.llmClient
+}
+
+func (s *Server) currentConfig() appconfig.App {
+ if s.configStore != nil {
+ return s.configStore.Snapshot()
}
- s.codingTemperature = opts.CodingTemperature
- s.compCache = make(map[string]string)
- s.manualInvokeMinPrefix = opts.ManualInvokeMinPrefix
- if opts.CompletionDebounceMs > 0 {
- s.completionDebounce = time.Duration(opts.CompletionDebounceMs) * time.Millisecond
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.cfg
+}
+
+func (s *Server) maxTokens() int {
+ cfg := s.currentConfig()
+ if cfg.MaxTokens <= 0 {
+ return 500
}
- if opts.CompletionThrottleMs > 0 {
- s.throttleInterval = time.Duration(opts.CompletionThrottleMs) * time.Millisecond
+ return cfg.MaxTokens
+}
+
+func (s *Server) contextMode() string {
+ mode := strings.TrimSpace(s.currentConfig().ContextMode)
+ if mode == "" {
+ return "file-on-new-func"
}
- // Trigger character config (with sane defaults if missing)
- if strings.TrimSpace(opts.InlineOpen) == "" {
- s.inlineOpen = ">"
- } else {
- s.inlineOpen = opts.InlineOpen
+ return mode
+}
+
+func (s *Server) windowLines() int {
+ cfg := s.currentConfig()
+ if cfg.ContextWindowLines <= 0 {
+ return 120
}
- if strings.TrimSpace(opts.InlineClose) == "" {
- s.inlineClose = ">"
- } else {
- s.inlineClose = opts.InlineClose
+ return cfg.ContextWindowLines
+}
+
+func (s *Server) maxContextTokens() int {
+ cfg := s.currentConfig()
+ if cfg.MaxContextTokens <= 0 {
+ return 2000
}
- if strings.TrimSpace(opts.ChatSuffix) == "" {
- s.chatSuffix = ">"
- } else {
- s.chatSuffix = opts.ChatSuffix
+ return cfg.MaxContextTokens
+}
+
+func (s *Server) triggerCharacters() []string {
+ cfg := s.currentConfig()
+ if len(cfg.TriggerCharacters) == 0 {
+ return []string{".", ":", "/", "_", ")", "{"}
}
- if len(opts.ChatPrefixes) == 0 {
- s.chatPrefixes = []string{"?", "!", ":", ";"}
- } else {
- s.chatPrefixes = append([]string{}, opts.ChatPrefixes...)
+ return append([]string{}, cfg.TriggerCharacters...)
+}
+
+func (s *Server) codingTemperature() *float64 {
+ cfg := s.currentConfig()
+ return cfg.CodingTemperature
+}
+
+func (s *Server) manualInvokeMinPrefix() int {
+ return s.currentConfig().ManualInvokeMinPrefix
+}
+
+func (s *Server) completionDebounce() time.Duration {
+ cfg := s.currentConfig()
+ if cfg.CompletionDebounceMs <= 0 {
+ return 0
}
+ return time.Duration(cfg.CompletionDebounceMs) * time.Millisecond
+}
- // Prompts
- s.promptCompSysGeneral = opts.PromptCompSysGeneral
- s.promptCompSysParams = opts.PromptCompSysParams
- s.promptCompSysInline = opts.PromptCompSysInline
- s.promptCompUserGeneral = opts.PromptCompUserGeneral
- s.promptCompUserParams = opts.PromptCompUserParams
- s.promptCompExtraHeader = opts.PromptCompExtraHeader
- s.promptNativeCompletion = opts.PromptNativeCompletion
- s.promptChatSystem = opts.PromptChatSystem
- s.promptRewriteSystem = opts.PromptRewriteSystem
- s.promptDiagnosticsSystem = opts.PromptDiagnosticsSystem
- s.promptDocumentSystem = opts.PromptDocumentSystem
- s.promptRewriteUser = opts.PromptRewriteUser
- s.promptDiagnosticsUser = opts.PromptDiagnosticsUser
- s.promptDocumentUser = opts.PromptDocumentUser
- s.promptGoTestSystem = opts.PromptGoTestSystem
- s.promptGoTestUser = opts.PromptGoTestUser
- s.promptSimplifySystem = opts.PromptSimplifySystem
- s.promptSimplifyUser = opts.PromptSimplifyUser
+func (s *Server) completionThrottle() time.Duration {
+ cfg := s.currentConfig()
+ if cfg.CompletionThrottleMs <= 0 {
+ return 0
+ }
+ return time.Duration(cfg.CompletionThrottleMs) * time.Millisecond
+}
- if len(opts.CustomActions) > 0 {
- s.customActions = append([]CustomAction{}, opts.CustomActions...)
+func (s *Server) inlineMarkers() (open string, close string, openChar byte, closeChar byte) {
+ cfg := s.currentConfig()
+ open = strings.TrimSpace(cfg.InlineOpen)
+ if open == "" {
+ open = ">"
+ }
+ close = strings.TrimSpace(cfg.InlineClose)
+ if close == "" {
+ close = ">"
+ }
+ openChar = '>'
+ if len(open) > 0 {
+ openChar = open[0]
}
+ closeChar = '>'
+ if len(close) > 0 {
+ closeChar = close[0]
+ }
+ return open, close, openChar, closeChar
+}
- if s.inlineOpen != "" {
- s.inlineOpenChar = s.inlineOpen[0]
+func (s *Server) chatConfig() (suffix string, prefixes []string, suffixChar byte) {
+ cfg := s.currentConfig()
+ suffix = cfg.ChatSuffix
+ if suffix != "" {
+ suffix = strings.TrimSpace(suffix)
+ if suffix == "" {
+ suffix = ">"
+ }
} else {
- s.inlineOpenChar = '>'
+ suffix = ""
}
- if s.inlineClose != "" {
- s.inlineCloseChar = s.inlineClose[0]
+ if len(cfg.ChatPrefixes) == 0 {
+ prefixes = []string{"?", "!", ":", ";"}
} else {
- s.inlineCloseChar = '>'
+ prefixes = append([]string{}, cfg.ChatPrefixes...)
}
- if s.chatSuffix != "" {
- s.chatSuffixChar = s.chatSuffix[0]
- } else {
- s.chatSuffixChar = '>'
+ suffixChar = '>'
+ if len(suffix) > 0 {
+ suffixChar = suffix[0]
}
- // Initialize dispatch table
- s.handlers = map[string]func(Request){
- "initialize": s.handleInitialize,
- "initialized": func(_ Request) { s.handleInitialized() },
- "shutdown": s.handleShutdown,
- "exit": func(_ Request) { s.handleExit() },
- "textDocument/didOpen": s.handleDidOpen,
- "textDocument/didChange": s.handleDidChange,
- "textDocument/didClose": s.handleDidClose,
- "textDocument/completion": s.handleCompletion,
- "textDocument/codeAction": s.handleCodeAction,
- "codeAction/resolve": s.handleCodeActionResolve,
- "workspace/executeCommand": s.handleExecuteCommand,
+ return suffix, prefixes, suffixChar
+}
+
+func (s *Server) promptSet() appconfig.App {
+ return s.currentConfig()
+}
+
+func (s *Server) customActions() []CustomAction {
+ cfg := s.currentConfig()
+ if len(cfg.CustomActions) == 0 {
+ return nil
}
- return s
+ customs := make([]CustomAction, 0, len(cfg.CustomActions))
+ for _, ca := range cfg.CustomActions {
+ customs = append(customs, CustomAction{
+ ID: ca.ID,
+ Title: ca.Title,
+ Kind: ca.Kind,
+ Scope: ca.Scope,
+ Instruction: ca.Instruction,
+ System: ca.System,
+ User: ca.User,
+ })
+ }
+ return customs
}
func (s *Server) Run() error {
diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go
new file mode 100644
index 0000000..4f24b57
--- /dev/null
+++ b/internal/lsp/server_test.go
@@ -0,0 +1,87 @@
+package lsp
+
+import (
+ "context"
+ "testing"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/llm"
+ "codeberg.org/snonux/hexai/internal/runtimeconfig"
+)
+
+func TestPromptSetUsesConfigStoreSnapshot(t *testing.T) {
+ s := newTestServer()
+ initial := appconfig.App{MaxTokens: 77}
+ store := runtimeconfig.New(initial)
+ s.configStore = store
+
+ got := s.promptSet()
+ if got.MaxTokens != 77 {
+ t.Fatalf("expected initial snapshot, got %+v", got)
+ }
+
+ updated := initial
+ updated.MaxTokens = 42
+ store.Set(updated)
+
+ got = s.promptSet()
+ if got.MaxTokens != 42 {
+ t.Fatalf("expected updated snapshot, got %+v", got)
+ }
+}
+
+func TestChatConfigRespectsExplicitEmptySuffix(t *testing.T) {
+ s := newTestServer()
+ cfg := s.cfg
+ cfg.ChatSuffix = ""
+ cfg.ChatPrefixes = []string{"#"}
+ s.cfg = cfg
+
+ suffix, prefixes, suffixChar := s.chatConfig()
+ if suffix != "" {
+ t.Fatalf("expected explicit empty suffix, got %q", suffix)
+ }
+ if len(prefixes) == 0 || prefixes[0] != "#" {
+ t.Fatalf("expected custom prefixes, got %v", prefixes)
+ }
+ if suffixChar != '>' {
+ t.Fatalf("expected default suffix char fallback, got %q", suffixChar)
+ }
+}
+
+func TestChatConfigTrimsWhitespaceSuffix(t *testing.T) {
+ s := newTestServer()
+ cfg := s.cfg
+ cfg.ChatSuffix = " >> "
+ s.cfg = cfg
+
+ suffix, _, suffixChar := s.chatConfig()
+ if suffix != ">>" {
+ t.Fatalf("expected trimmed suffix '>>', got %q", suffix)
+ }
+ if suffixChar != '>' {
+ t.Fatalf("expected suffixChar to use trimmed value, got %q", suffixChar)
+ }
+}
+
+type stubLLMClient struct{}
+
+func (stubLLMClient) Chat(context.Context, []llm.Message, ...llm.RequestOption) (string, error) {
+ return "", nil
+}
+func (stubLLMClient) Name() string { return "stub" }
+func (stubLLMClient) DefaultModel() string { return "stub-model" }
+
+func TestServerApplyOptions(t *testing.T) {
+ s := newTestServer()
+ client := stubLLMClient{}
+ cfg := appconfig.App{MaxTokens: 88}
+ opts := ServerOptions{Config: &cfg, Client: client}
+ s.ApplyOptions(opts)
+ if s.currentLLMClient() != client {
+ t.Fatalf("expected client to be replaced")
+ }
+ if got := s.currentConfig().MaxTokens; got != 88 {
+ t.Fatalf("expected config to update, got %d", got)
+ }
+}
diff --git a/internal/lsp/triggers_config_test.go b/internal/lsp/triggers_config_test.go
index 0fcbd15..96ac4ba 100644
--- a/internal/lsp/triggers_config_test.go
+++ b/internal/lsp/triggers_config_test.go
@@ -12,8 +12,10 @@ import (
func TestShouldSuppressForChatTriggerEOL_CustomConfig(t *testing.T) {
s := newTestServer()
// Customize: only ")#" at EOL suppresses
- s.chatSuffix = "#"
- s.chatPrefixes = []string{")"}
+ cfg := s.cfg
+ cfg.ChatSuffix = "#"
+ cfg.ChatPrefixes = []string{")"}
+ s.cfg = cfg
p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///x"}, Position: Position{Line: 0, Character: 6}}
if !s.shouldSuppressForChatTriggerEOL("ok)#", p) {
@@ -29,14 +31,15 @@ func TestNewServer_AssignsTriggerGlobals_AndParsingUsesThem(t *testing.T) {
s := NewServer(bytes.NewReader(nil), &out, log.New(io.Discard, "", 0), ServerOptions{
InlineOpen: "<", InlineClose: ">", ChatSuffix: ")", ChatPrefixes: []string{":"},
})
- _ = s // ensure server constructed applies globals
- if s.inlineOpenChar != '<' || s.inlineCloseChar != '>' {
- t.Fatalf("inline markers not applied: %q %q", string(s.inlineOpenChar), string(s.inlineCloseChar))
+ _, _, openChar, closeChar := s.inlineMarkers()
+ if openChar != '<' || closeChar != '>' {
+ t.Fatalf("inline markers not applied: %q %q", string(openChar), string(closeChar))
}
- if s.chatSuffixChar != ')' || len(s.chatPrefixes) == 0 || s.chatPrefixes[0] != ":" {
- t.Fatalf("chat markers not applied: suffix=%q prefixes=%v", string(s.chatSuffixChar), s.chatPrefixes)
+ _, prefixes, suffixChar := s.chatConfig()
+ if suffixChar != ')' || len(prefixes) == 0 || prefixes[0] != ":" {
+ t.Fatalf("chat markers not applied: suffix=%q prefixes=%v", string(suffixChar), prefixes)
}
- if txt, l, r, ok := findStrictInlineTag("x<do>y", s.inlineOpenChar, s.inlineCloseChar); !ok || txt != "do" || l != 1 || r != 5 {
+ if txt, l, r, ok := findStrictInlineTag("x<do>y", openChar, closeChar); !ok || txt != "do" || l != 1 || r != 5 {
t.Fatalf("findStrictInlineTag failed: ok=%v txt=%q l=%d r=%d", ok, txt, l, r)
}
if got := s.stripTrailingTrigger("note:)"); got != "note:" {
@@ -46,8 +49,10 @@ func TestNewServer_AssignsTriggerGlobals_AndParsingUsesThem(t *testing.T) {
func TestIsTriggerEvent_BareDoubleOpenBlocksEvenWithContextTriggerChar(t *testing.T) {
s := newTestServer()
- s.inlineOpen = ">" // ensure bare ">>" check is active
- s.triggerChars = []string{"."}
+ cfg := s.cfg
+ cfg.InlineOpen = ">"
+ cfg.TriggerCharacters = []string{"."}
+ s.cfg = cfg
// LSP context indicates TriggerCharacter '.' but current line is bare ">>"
ctx := struct {
TriggerKind int `json:"triggerKind"`
diff --git a/internal/runtimeconfig/store.go b/internal/runtimeconfig/store.go
new file mode 100644
index 0000000..e0a594c
--- /dev/null
+++ b/internal/runtimeconfig/store.go
@@ -0,0 +1,178 @@
+package runtimeconfig
+
+import (
+ "fmt"
+ "log"
+ "reflect"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+)
+
+// Change captures a single configuration delta.
+type Change struct {
+ Key string
+ Old string
+ New string
+}
+
+// Listener receives the previous and new application configuration when updates occur.
+type Listener func(old appconfig.App, new appconfig.App)
+
+// Store holds the active runtime configuration and notifies listeners on updates.
+type Store struct {
+ mu sync.RWMutex
+ cfg appconfig.App
+ listeners map[int]Listener
+ nextID int
+}
+
+// New creates a Store seeded with the provided configuration snapshot.
+func New(cfg appconfig.App) *Store {
+ return &Store{cfg: cfg, listeners: make(map[int]Listener)}
+}
+
+// Snapshot returns the current configuration snapshot. Callers must treat it as read-only.
+func (s *Store) Snapshot() appconfig.App {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.cfg
+}
+
+// Subscribe registers a listener that will be invoked on configuration changes.
+// The returned function removes the listener.
+func (s *Store) Subscribe(listener Listener) func() {
+ if listener == nil {
+ return func() {}
+ }
+ s.mu.Lock()
+ id := s.nextID
+ s.nextID++
+ s.listeners[id] = listener
+ s.mu.Unlock()
+ return func() {
+ s.mu.Lock()
+ delete(s.listeners, id)
+ s.mu.Unlock()
+ }
+}
+
+// Set replaces the current configuration with the provided snapshot and notifies listeners.
+// It returns the list of detected changes between the previous and new configuration.
+func (s *Store) Set(cfg appconfig.App) []Change {
+ s.mu.Lock()
+ old := s.cfg
+ s.cfg = cfg
+ listeners := make([]Listener, 0, len(s.listeners))
+ for _, l := range s.listeners {
+ listeners = append(listeners, l)
+ }
+ s.mu.Unlock()
+
+ changes := Diff(old, cfg)
+ for _, l := range listeners {
+ l(old, cfg)
+ }
+ return changes
+}
+
+// Reload re-reads configuration using the supplied options and applies it when valid.
+func (s *Store) Reload(logger *log.Logger, opts appconfig.LoadOptions) ([]Change, error) {
+ cfg := appconfig.LoadWithOptions(logger, opts)
+ if err := cfg.Validate(); err != nil {
+ return nil, err
+ }
+ return s.Set(cfg), nil
+}
+
+// Diff computes a stable, sorted list of key/value changes between two configuration snapshots.
+func Diff(oldCfg, newCfg appconfig.App) []Change {
+ before := flattenAppConfig(oldCfg)
+ after := flattenAppConfig(newCfg)
+ keys := make(map[string]struct{}, len(before)+len(after))
+ for k := range before {
+ keys[k] = struct{}{}
+ }
+ for k := range after {
+ keys[k] = struct{}{}
+ }
+ ordered := make([]string, 0, len(keys))
+ for k := range keys {
+ ordered = append(ordered, k)
+ }
+ sort.Strings(ordered)
+ changes := make([]Change, 0, len(ordered))
+ for _, k := range ordered {
+ if before[k] == after[k] {
+ continue
+ }
+ changes = append(changes, Change{Key: k, Old: before[k], New: after[k]})
+ }
+ return changes
+}
+
+func flattenAppConfig(cfg appconfig.App) map[string]string {
+ result := make(map[string]string)
+ val := reflect.ValueOf(cfg)
+ typ := val.Type()
+ for i := 0; i < typ.NumField(); i++ {
+ field := typ.Field(i)
+ key := strings.TrimSpace(field.Tag.Get("toml"))
+ if key == "" || key == "-" {
+ switch field.Name {
+ case "StatsWindowMinutes":
+ key = "stats_window_minutes"
+ default:
+ continue
+ }
+ }
+ if idx := strings.Index(key, ","); idx >= 0 {
+ key = key[:idx]
+ }
+ if key == "" || key == "-" {
+ continue
+ }
+ result[key] = stringifyValue(val.Field(i))
+ }
+ return result
+}
+
+func stringifyValue(v reflect.Value) string {
+ if !v.IsValid() {
+ return ""
+ }
+ switch v.Kind() {
+ case reflect.String:
+ return v.String()
+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+ return strconv.FormatInt(v.Int(), 10)
+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
+ return strconv.FormatUint(v.Uint(), 10)
+ case reflect.Float32, reflect.Float64:
+ return strconv.FormatFloat(v.Float(), 'f', -1, 64)
+ case reflect.Bool:
+ return strconv.FormatBool(v.Bool())
+ case reflect.Slice:
+ if v.IsNil() {
+ return ""
+ }
+ if v.Type().Elem().Kind() == reflect.String {
+ parts := make([]string, v.Len())
+ for i := range parts {
+ parts[i] = v.Index(i).String()
+ }
+ return strings.Join(parts, ",")
+ }
+ return fmt.Sprint(v.Interface())
+ case reflect.Ptr:
+ if v.IsNil() {
+ return "(unset)"
+ }
+ return stringifyValue(v.Elem())
+ default:
+ return fmt.Sprint(v.Interface())
+ }
+}
diff --git a/internal/runtimeconfig/store_test.go b/internal/runtimeconfig/store_test.go
new file mode 100644
index 0000000..9973a1a
--- /dev/null
+++ b/internal/runtimeconfig/store_test.go
@@ -0,0 +1,59 @@
+package runtimeconfig
+
+import (
+ "io"
+ "log"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+)
+
+func TestStoreReloadSkipsEnvOverrides(t *testing.T) {
+ logger := log.New(io.Discard, "", 0)
+ tmp := t.TempDir()
+ configDir := filepath.Join(tmp, "hexai")
+ if err := os.MkdirAll(configDir, 0o755); err != nil {
+ t.Fatalf("failed to create config dir: %v", err)
+ }
+ configPath := filepath.Join(configDir, "config.toml")
+ if err := os.WriteFile(configPath, []byte("[general]\nmax_tokens = 64\n"), 0o644); err != nil {
+ t.Fatalf("failed to write config file: %v", err)
+ }
+
+ t.Setenv("XDG_CONFIG_HOME", tmp)
+ t.Setenv("HEXAI_MAX_TOKENS", "321")
+
+ initial := appconfig.Load(logger)
+ if initial.MaxTokens != 321 {
+ t.Fatalf("expected env override to win initial load, got %d", initial.MaxTokens)
+ }
+
+ store := New(initial)
+ if err := os.WriteFile(configPath, []byte("[general]\nmax_tokens = 128\n"), 0o644); err != nil {
+ t.Fatalf("failed to update config file: %v", err)
+ }
+
+ changes, err := store.Reload(logger, appconfig.LoadOptions{IgnoreEnv: true})
+ if err != nil {
+ t.Fatalf("reload failed: %v", err)
+ }
+
+ if snap := store.Snapshot(); snap.MaxTokens != 128 {
+ t.Fatalf("expected reload to apply file value, got %d", snap.MaxTokens)
+ }
+
+ found := false
+ for _, change := range changes {
+ if change.Key == "max_tokens" {
+ found = true
+ if change.Old != "321" || change.New != "128" {
+ t.Fatalf("unexpected change diff: %+v", change)
+ }
+ }
+ }
+ if !found {
+ t.Fatalf("expected max_tokens change in diff, got %#v", changes)
+ }
+}