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</