From f14eb9199f4e1aee49594e590c08996244bb77b3 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 28 Sep 2025 21:56:32 +0300 Subject: Add slash toggle for completions --- internal/lsp/chat_commands.go | 22 ++++++++++++++++ internal/lsp/chat_commands_test.go | 41 ++++++++++++++++++++++++++++++ internal/lsp/completion_toggle_test.go | 46 ++++++++++++++++++++++++++++++++++ internal/lsp/handlers_completion.go | 4 +++ internal/lsp/server.go | 16 ++++++++++++ internal/version.go | 2 +- 6 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 internal/lsp/completion_toggle_test.go (limited to 'internal') diff --git a/internal/lsp/chat_commands.go b/internal/lsp/chat_commands.go index b2da7d4..11e5a28 100644 --- a/internal/lsp/chat_commands.go +++ b/internal/lsp/chat_commands.go @@ -22,6 +22,10 @@ func (s *Server) chatCommandResponse(uri string, lineIdx int, prompt string) (ch return s.handleReloadCommand(), true case strings.HasPrefix(trimmed, "/help"): return s.handleHelpCommand(), true + case strings.HasPrefix(trimmed, "/disable"): + return s.handleDisableCompletionCommand(), true + case strings.HasPrefix(trimmed, "/enable"): + return s.handleEnableCompletionCommand(), true default: return chatCommandResult{message: fmt.Sprintf("Unknown command %q. Try /help?>", trimmed)}, true } @@ -31,6 +35,8 @@ func (s *Server) handleHelpCommand() chatCommandResult { lines := []string{ "Available slash commands:", "- /reload?> reload configuration from file (ignores env overrides)", + "- /disable?> disable auto-completions for this session", + "- /enable?> re-enable auto-completions", } return chatCommandResult{message: strings.Join(lines, "\n")} } @@ -50,3 +56,19 @@ func (s *Server) handleReloadCommand() chatCommandResult { s.logger.Print(summary) return chatCommandResult{message: summary} } + +func (s *Server) handleDisableCompletionCommand() chatCommandResult { + prev := s.setCompletionsDisabled(true) + if prev { + return chatCommandResult{message: "Auto-completions were already disabled."} + } + return chatCommandResult{message: "Auto-completions disabled. Use /enable?> to restore."} +} + +func (s *Server) handleEnableCompletionCommand() chatCommandResult { + prev := s.setCompletionsDisabled(false) + if !prev { + return chatCommandResult{message: "Auto-completions are already enabled."} + } + return chatCommandResult{message: "Auto-completions enabled."} +} diff --git a/internal/lsp/chat_commands_test.go b/internal/lsp/chat_commands_test.go index 87cc1b4..0e31c7b 100644 --- a/internal/lsp/chat_commands_test.go +++ b/internal/lsp/chat_commands_test.go @@ -32,6 +32,9 @@ func TestHandleHelpCommandListsReload(t *testing.T) { if !strings.Contains(res.message, "/reload?>") { t.Fatalf("expected reload command in help output: %q", res.message) } + if !strings.Contains(res.message, "/disable?>") || !strings.Contains(res.message, "/enable?>") { + t.Fatalf("expected completion toggle commands in help output: %q", res.message) + } } func TestHandleReloadCommandReloadsStore(t *testing.T) { @@ -105,6 +108,7 @@ func TestDetectAndHandleChatExecutesSlashCommand(t *testing.T) { s.configStore = store var out bytes.Buffer s.out = &out + s.setCompletionsDisabled(true) // chat commands should remain available when completions are disabled uri := "file:///cmd.go" s.setDocument(uri, "/reload>\n") @@ -119,3 +123,40 @@ func TestDetectAndHandleChatExecutesSlashCommand(t *testing.T) { t.Fatalf("expected reload summary logged, got %q", logBuf.String()) } } + +func TestDisableEnableCommandsToggleCompletions(t *testing.T) { + s := newTestServer() + if s.completionDisabled() { + t.Fatalf("expected completions enabled initially") + } + + if res, ok := s.chatCommandResponse("file:///x", 0, "/disable>"); !ok { + t.Fatalf("expected disable command to be handled") + } else if !strings.Contains(res.message, "disabled") { + t.Fatalf("unexpected disable message: %q", res.message) + } + if !s.completionDisabled() { + t.Fatalf("expected completions disabled after command") + } + + if res, ok := s.chatCommandResponse("file:///x", 0, "/disable>"); !ok { + t.Fatalf("expected repeated disable command to be handled") + } else if !strings.Contains(res.message, "already disabled") { + t.Fatalf("expected already-disabled message, got %q", res.message) + } + + if res, ok := s.chatCommandResponse("file:///x", 0, "/enable>"); !ok { + t.Fatalf("expected enable command to be handled") + } else if !strings.Contains(res.message, "enabled") { + t.Fatalf("unexpected enable message: %q", res.message) + } + if s.completionDisabled() { + t.Fatalf("expected completions enabled after command") + } + + if res, ok := s.chatCommandResponse("file:///x", 0, "/enable>"); !ok { + t.Fatalf("expected repeated enable command to be handled") + } else if !strings.Contains(res.message, "already enabled") { + t.Fatalf("expected already-enabled message, got %q", res.message) + } +} diff --git a/internal/lsp/completion_toggle_test.go b/internal/lsp/completion_toggle_test.go new file mode 100644 index 0000000..57ee1fd --- /dev/null +++ b/internal/lsp/completion_toggle_test.go @@ -0,0 +1,46 @@ +package lsp + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +func TestHandleCompletionRespectsDisableCommand(t *testing.T) { + s := newTestServer() + var buf bytes.Buffer + s.out = &buf + + // Disable completions and trigger handler + s.setCompletionsDisabled(true) + + params := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///test.go"}, Position: Position{Line: 0, Character: 0}} + req := Request{JSONRPC: "2.0", ID: json.RawMessage("1"), Method: "textDocument/completion", Params: mustJSON(params)} + + s.handleCompletion(req) + + payload := buf.String() + parts := strings.SplitN(payload, "\r\n\r\n", 2) + if len(parts) != 2 { + t.Fatalf("unexpected response framing: %q", payload) + } + var resp Response + if err := json.Unmarshal([]byte(parts[1]), &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + resultMap, ok := resp.Result.(map[string]any) + if !ok { + t.Fatalf("expected map result, got %T", resp.Result) + } + switch v := resultMap["items"].(type) { + case []any: + if len(v) != 0 { + t.Fatalf("expected no completion items when disabled, got %d", len(v)) + } + case nil: + // ok: encoder emitted null for slice + default: + t.Fatalf("expected items slice or null, got %T", v) + } +} diff --git a/internal/lsp/handlers_completion.go b/internal/lsp/handlers_completion.go index 78e685a..db6866b 100644 --- a/internal/lsp/handlers_completion.go +++ b/internal/lsp/handlers_completion.go @@ -30,6 +30,10 @@ type completionPlan struct { } func (s *Server) handleCompletion(req Request) { + if s.completionDisabled() { + s.reply(req.ID, CompletionList{IsIncomplete: false, Items: nil}, nil) + return + } var p CompletionParams var docStr string if err := json.Unmarshal(req.Params, &p); err == nil { diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 974b926..8e210b4 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -48,6 +48,8 @@ type Server struct { nextID int64 lastLLMCall time.Time + completionsDisabled bool + // Dispatch table for JSON-RPC methods → handler functions handlers map[string]func(Request) } @@ -334,6 +336,20 @@ func (s *Server) storePendingCompletion(key string, items []CompletionItem) { s.mu.Unlock() } +func (s *Server) setCompletionsDisabled(disabled bool) bool { + s.mu.Lock() + prev := s.completionsDisabled + s.completionsDisabled = disabled + s.mu.Unlock() + return prev +} + +func (s *Server) completionDisabled() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.completionsDisabled +} + func (s *Server) takePendingCompletion(key string) []CompletionItem { s.mu.Lock() defer s.mu.Unlock() diff --git a/internal/version.go b/internal/version.go index cda626b..f781c7a 100644 --- a/internal/version.go +++ b/internal/version.go @@ -1,4 +1,4 @@ // Summary: Hexai semantic version identifier used by CLI and LSP binaries. package internal -const Version = "0.13.0" +const Version = "0.14.0" -- cgit v1.2.3