Repository: charmbracelet/crush

Issue:
Termux package fails to install
### Describe the bug

Trying to install the termux.deb package with dpkg returns this error:
```
❯ dpkg -i crush_0.6.2_aarch64.deb.termux.deb
(Reading database ... 30057 files and directories currently installed.)
Preparing to unpack crush_0.6.2_aarch64.deb.termux.deb ...
Unpacking crush (0.6.2) over (0.6.2) ...
dpkg: error processing archive crush_0.6.2_aarch64.deb.termux.deb (--install):
 error creating directory './etc/bash_completion.d': Read-only file system
dpkg: error while cleaning up:
 unable to remove newly-extracted version of '/etc/bash_completion.d': Read-only file system
Errors were encountered while processing:
 crush_0.6.2_aarch64.deb.termux.deb
```

### Setup

Please complete the following information along with version numbers, if applicable.
 - OS: Android 15
 - Shell: Zsh
 - Terminal Emulator: Termux
 - Terminal Multiplexer: tmux

### To Reproduce

Steps to reproduce the behavior:
1. Open termux
2. Try to install package with dpkg
3. dpkg -i crush.deb
4. See error

### Expected behavior

For it to install properly instead of trying to install into root.
(Proper path \/)
```
$PREFIX ==> /data/data/com.termux/files/usr

$PREFIX/bin
$PREFIX/lib
$PREFIX/etc
$PREFIX/tmp
```

Discussion:
@caarlos0: is the prefix with or without the `/usr`? 

E.g., is it `/data/data/com.termux/files/usr/lib` or `/data/data/com.termux/files/lib`?

See: #822@IamN4m3l3ss: > is the prefix with or without the `/usr`?
> 
> E.g., is it `/data/data/com.termux/files/usr/lib` or `/data/data/com.termux/files/lib`?
> 
> See: [#822](https://github.com/charmbracelet/crush/pull/822)

$PREFIX/lib points towards /data/data/com.termux/files/usr/lib
It's just like how you normally would navigate directories. 
https://wiki.termux.com/wiki/Getting_started@andreynering: Hi @IamN4m3l3ss,

This should be fixed in [v0.7.1](https://github.com/charmbracelet/crush/releases/tag/v0.7.1). Let me know if that works for you.@IamN4m3l3ss: @andreynering 
![Image](https://github.com/user-attachments/assets/1e0f6933-c031-4333-b5ab-e2032fc97a7a)

It installed properly(?) However I'm getting this error now. I've looked around already (#453), however connectivity to catwalk shouldn't be blocked. I tried on both Wi-Fi and data; maybe it's something with Termux? I have no clue.@andreynering: Maybe it's some kind of security restriction from Android, but I'm just guessing...@IamN4m3l3ss: Ping works on github and catwalk, so that couldn't be it right?
I suppose I'll wait for 453,  can't really do anything else to debug this.@IamN4m3l3ss: @andreynering

I think I figured it out: the `provider.go` file doesn't exist because it's still not completely installing properly and/or setting things up at first launch.

I found the log file when I was messing with Termux.
```
{"time":"2025-08-21T19:31:35.151267844Z","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/config.loadProviders","file":"/home/runner/work/crush/crush/internal/config/provider.go","line":113},"msg":"Getting live provider data"}
``` 
I don't know exactly where it's checking (Termux or root), but there is no runner folder in Termux's home directory, so I assume the latter, which is $HOME or /data/data/com.termux/files/home.

I also noticed it made a new etc folder with the bash_completion.d file in termux/files instead of termux/files/usr/etc.

However, there's always the possibility to install a Linux distro on Termux with proot-distro and use crush that way (if anyone using Termux is reading this and encountering the same problem).@caarlos0: > I think I figured it out: the provider.go file doesn't exist because it's still not completely installing properly and/or setting things up at first launch.

that file shouldn't exist if you installed a pre-compiled package, as it only contains the pre-compiled binaries.

> I don't know exactly where it's checking (Termux or root), but there is no runner folder in Termux's home directory, so I assume the latter, which is $HOME or /data/data/com.termux/files/home.

`/home/runner` not a path in your installation, its the path when it was built (GitHub actions).

I think what might be happening is:

1. either termux doesn't have perms to network calls for some reason
2. or, it's trying to cache things to a folder it doesn't have permissions to write/doesn't exist

Could you try starting crush with `--debug` and then check the logs?


> I also noticed it made a new etc folder with the bash_completion.d file in termux/files instead of termux/files/usr/etc.

fixed in 9d9372038e6b14907cdd71710e993bf9c71d3646@IamN4m3l3ss: I tried with `--debug` but there's nothing else other than what i send earlier.
I can ping both domains just fine and crush works fine when running arch with proot-distro in termux.
So definitely not a network issue.

[crush.log](https://github.com/user-attachments/files/21968905/crush.log)

Edit:
I tried running `crush logs` and that returned this error: 
`Failed to load configuration: failed to load providers: failed to load providers.`
Still nothing else in the log file tho.@caarlos0: made some changes towards helping debugging this: #884 & #885

Solution patch:
diff --git a/internal/app/app.go b/internal/app/app.go
index c2bfb0eb..961ce596 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -235,9 +235,7 @@ func setupSubscriber[T any](
 	subscriber func(context.Context) <-chan pubsub.Event[T],
 	outputCh chan<- tea.Msg,
 ) {
-	wg.Add(1)
-	go func() {
-		defer wg.Done()
+	wg.Go(func() {
 		subCh := subscriber(ctx)
 		for {
 			select {
@@ -260,7 +258,7 @@ func setupSubscriber[T any](
 				return
 			}
 		}
-	}()
+	})
 }
 
 func (app *App) InitCoderAgent() error {
diff --git a/internal/app/lsp_events.go b/internal/app/lsp_events.go
index 5961ec5c..08e54582 100644
--- a/internal/app/lsp_events.go
+++ b/internal/app/lsp_events.go
@@ -2,6 +2,7 @@ package app
 
 import (
 	"context"
+	"maps"
 	"time"
 
 	"github.com/charmbracelet/crush/internal/csync"
@@ -48,11 +49,7 @@ func SubscribeLSPEvents(ctx context.Context) <-chan pubsub.Event[LSPEvent] {
 
 // GetLSPStates returns the current state of all LSP clients
 func GetLSPStates() map[string]LSPClientInfo {
-	states := make(map[string]LSPClientInfo)
-	for name, info := range lspStates.Seq2() {
-		states[name] = info
-	}
-	return states
+	return maps.Collect(lspStates.Seq2())
 }
 
 // GetLSPState returns the state of a specific LSP client
diff --git a/internal/config/load.go b/internal/config/load.go
index b644eb3f..a703a049 100644
--- a/internal/config/load.go
+++ b/internal/config/load.go
@@ -16,6 +16,7 @@ import (
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/env"
 	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/home"
 	"github.com/charmbracelet/crush/internal/log"
 )
 
@@ -584,7 +585,7 @@ func globalConfig() string {
 		return filepath.Join(localAppData, appName, fmt.Sprintf("%s.json", appName))
 	}
 
-	return filepath.Join(os.Getenv("HOME"), ".config", appName, fmt.Sprintf("%s.json", appName))
+	return filepath.Join(home.Dir(), ".config", appName, fmt.Sprintf("%s.json", appName))
 }
 
 // GlobalConfigData returns the path to the main data directory for the application.
@@ -606,5 +607,5 @@ func GlobalConfigData() string {
 		return filepath.Join(localAppData, appName, fmt.Sprintf("%s.json", appName))
 	}
 
-	return filepath.Join(os.Getenv("HOME"), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
+	return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
 }
diff --git a/internal/config/provider.go b/internal/config/provider.go
index 56125d48..68ede509 100644
--- a/internal/config/provider.go
+++ b/internal/config/provider.go
@@ -12,6 +12,7 @@ import (
 	"time"
 
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/home"
 )
 
 type ProviderClient interface {
@@ -41,7 +42,7 @@ func providerCacheFileData() string {
 		return filepath.Join(localAppData, appName, "providers.json")
 	}
 
-	return filepath.Join(os.Getenv("HOME"), ".local", "share", appName, "providers.json")
+	return filepath.Join(home.Dir(), ".local", "share", appName, "providers.json")
 }
 
 func saveProvidersInCache(path string, providers []catwalk.Provider) error {
diff --git a/internal/csync/slices.go b/internal/csync/slices.go
index b7717f0c..c5c63568 100644
--- a/internal/csync/slices.go
+++ b/internal/csync/slices.go
@@ -16,11 +16,9 @@ type LazySlice[K any] struct {
 // to populate it.
 func NewLazySlice[K any](load func() []K) *LazySlice[K] {
 	s := &LazySlice[K]{}
-	s.wg.Add(1)
-	go func() {
+	s.wg.Go(func() {
 		s.inner = load()
-		s.wg.Done()
-	}()
+	})
 	return s
 }
 
diff --git a/internal/fsext/fileutil.go b/internal/fsext/fileutil.go
index e6888845..ee5fff66 100644
--- a/internal/fsext/fileutil.go
+++ b/internal/fsext/fileutil.go
@@ -10,6 +10,7 @@ import (
 
 	"github.com/bmatcuk/doublestar/v4"
 	"github.com/charlievieth/fastwalk"
+	"github.com/charmbracelet/crush/internal/home"
 
 	ignore "github.com/sabhiram/go-gitignore"
 )
@@ -182,12 +183,7 @@ func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool,
 }
 
 func PrettyPath(path string) string {
-	// replace home directory with ~
-	homeDir, err := os.UserHomeDir()
-	if err == nil {
-		path = strings.ReplaceAll(path, homeDir, "~")
-	}
-	return path
+	return home.Short(path)
 }
 
 func DirTrim(pwd string, lim int) string {
diff --git a/internal/fsext/home.go b/internal/fsext/home.go
deleted file mode 100644
index d81a4bc2..00000000
--- a/internal/fsext/home.go
+++ /dev/null
@@ -1,20 +0,0 @@
-package fsext
-
-import (
-	"cmp"
-	"os"
-	"os/user"
-	"sync"
-)
-
-var HomeDir = sync.OnceValue(func() string {
-	u, err := user.Current()
-	if err == nil {
-		return u.HomeDir
-	}
-	return cmp.Or(
-		os.Getenv("HOME"),
-		os.Getenv("USERPROFILE"),
-		os.Getenv("HOMEPATH"),
-	)
-})
diff --git a/internal/fsext/ls.go b/internal/fsext/ls.go
index e4b98bb2..884c5b15 100644
--- a/internal/fsext/ls.go
+++ b/internal/fsext/ls.go
@@ -9,6 +9,7 @@ import (
 
 	"github.com/charlievieth/fastwalk"
 	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/home"
 	ignore "github.com/sabhiram/go-gitignore"
 )
 
@@ -73,7 +74,7 @@ var commonIgnorePatterns = sync.OnceValue(func() ignore.IgnoreParser {
 })
 
 var homeIgnore = sync.OnceValue(func() ignore.IgnoreParser {
-	home := HomeDir()
+	home := home.Dir()
 	var lines []string
 	for _, name := range []string{
 		filepath.Join(home, ".gitignore"),
diff --git a/internal/fsext/parent.go b/internal/fsext/parent.go
index 1b041436..bd319361 100644
--- a/internal/fsext/parent.go
+++ b/internal/fsext/parent.go
@@ -4,6 +4,8 @@ import (
 	"errors"
 	"os"
 	"path/filepath"
+
+	"github.com/charmbracelet/crush/internal/home"
 )
 
 // SearchParent searches for a target file or directory starting from dir
@@ -33,7 +35,7 @@ func SearchParent(dir, target string) (string, bool) {
 
 	for {
 		parent := filepath.Dir(previousParent)
-		if parent == previousParent || parent == HomeDir() {
+		if parent == previousParent || parent == home.Dir() {
 			return "", false
 		}
 
diff --git a/internal/home/home.go b/internal/home/home.go
new file mode 100644
index 00000000..f2a9b73b
--- /dev/null
+++ b/internal/home/home.go
@@ -0,0 +1,42 @@
+package home
+
+import (
+	"log/slog"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+)
+
+// Dir returns the users home directory, or if it fails, tries to create a new
+// temporary directory and use that instead.
+var Dir = sync.OnceValue(func() string {
+	home, err := os.UserHomeDir()
+	if err == nil {
+		slog.Debug("user home directory", "home", home)
+		return home
+	}
+	tmp, err := os.MkdirTemp("crush", "")
+	if err != nil {
+		slog.Error("could not find the user home directory")
+		return ""
+	}
+	slog.Warn("could not find the user home directory, using a temporary one", "home", tmp)
+	return tmp
+})
+
+// Short replaces the actual home path from [Dir] with `~`.
+func Short(p string) string {
+	if !strings.HasPrefix(p, Dir()) || Dir() == "" {
+		return p
+	}
+	return filepath.Join("~", strings.TrimPrefix(p, Dir()))
+}
+
+// Long replaces the `~` with actual home path from [Dir].
+func Long(p string) string {
+	if !strings.HasPrefix(p, "~") || Dir() == "" {
+		return p
+	}
+	return strings.Replace(p, "~", Dir(), 1)
+}
diff --git a/internal/llm/prompt/prompt.go b/internal/llm/prompt/prompt.go
index 8c87482a..919686a7 100644
--- a/internal/llm/prompt/prompt.go
+++ b/internal/llm/prompt/prompt.go
@@ -9,6 +9,7 @@ import (
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/env"
+	"github.com/charmbracelet/crush/internal/home"
 )
 
 type PromptID string
@@ -44,18 +45,7 @@ func getContextFromPaths(workingDir string, contextPaths []string) string {
 
 // expandPath expands ~ and environment variables in file paths
 func expandPath(path string) string {
-	// Handle tilde expansion
-	if strings.HasPrefix(path, "~/") {
-		homeDir, err := os.UserHomeDir()
-		if err == nil {
-			path = filepath.Join(homeDir, path[2:])
-		}
-	} else if path == "~" {
-		homeDir, err := os.UserHomeDir()
-		if err == nil {
-			path = homeDir
-		}
-	}
+	path = home.Long(path)
 
 	// Handle environment variable expansion using the same pattern as config
 	if strings.HasPrefix(path, "$") {
diff --git a/internal/lsp/caps.go b/internal/lsp/caps.go
new file mode 100644
index 00000000..7edc0886
--- /dev/null
+++ b/internal/lsp/caps.go
@@ -0,0 +1,112 @@
+package lsp
+
+import "github.com/charmbracelet/crush/internal/lsp/protocol"
+
+func (c *Client) setCapabilities(caps protocol.ServerCapabilities) {
+	c.capsMu.Lock()
+	defer c.capsMu.Unlock()
+	c.caps = caps
+	c.capsSet.Store(true)
+}
+
+func (c *Client) getCapabilities() (protocol.ServerCapabilities, bool) {
+	c.capsMu.RLock()
+	defer c.capsMu.RUnlock()
+	return c.caps, c.capsSet.Load()
+}
+
+func (c *Client) IsMethodSupported(method string) bool {
+	// Always allow core lifecycle and generic methods
+	switch method {
+	case "initialize", "shutdown", "exit", "$/cancelRequest":
+		return true
+	}
+
+	caps, ok := c.getCapabilities()
+	if !ok {
+		// caps not set yet, be permissive
+		return true
+	}
+
+	switch method {
+	case "textDocument/hover":
+		return caps.HoverProvider != nil
+	case "textDocument/definition":
+		return caps.DefinitionProvider != nil
+	case "textDocument/references":
+		return caps.ReferencesProvider != nil
+	case "textDocument/implementation":
+		return caps.ImplementationProvider != nil
+	case "textDocument/typeDefinition":
+		return caps.TypeDefinitionProvider != nil
+	case "textDocument/documentColor", "textDocument/colorPresentation":
+		return caps.ColorProvider != nil
+	case "textDocument/foldingRange":
+		return caps.FoldingRangeProvider != nil
+	case "textDocument/declaration":
+		return caps.DeclarationProvider != nil
+	case "textDocument/selectionRange":
+		return caps.SelectionRangeProvider != nil
+	case "textDocument/prepareCallHierarchy", "callHierarchy/incomingCalls", "callHierarchy/outgoingCalls":
+		return caps.CallHierarchyProvider != nil
+	case "textDocument/semanticTokens/full", "textDocument/semanticTokens/full/delta", "textDocument/semanticTokens/range":
+		return caps.SemanticTokensProvider != nil
+	case "textDocument/linkedEditingRange":
+		return caps.LinkedEditingRangeProvider != nil
+	case "workspace/willCreateFiles":
+		return caps.Workspace != nil && caps.Workspace.FileOperations != nil && caps.Workspace.FileOperations.WillCreate != nil
+	case "workspace/willRenameFiles":
+		return caps.Workspace != nil && caps.Workspace.FileOperations != nil && caps.Workspace.FileOperations.WillRename != nil
+	case "workspace/willDeleteFiles":
+		return caps.Workspace != nil && caps.Workspace.FileOperations != nil && caps.Workspace.FileOperations.WillDelete != nil
+	case "textDocument/moniker":
+		return caps.MonikerProvider != nil
+	case "textDocument/prepareTypeHierarchy", "typeHierarchy/supertypes", "typeHierarchy/subtypes":
+		return caps.TypeHierarchyProvider != nil
+	case "textDocument/inlineValue":
+		return caps.InlineValueProvider != nil
+	case "textDocument/inlayHint", "inlayHint/resolve":
+		return caps.InlayHintProvider != nil
+	case "textDocument/diagnostic", "workspace/diagnostic":
+		return caps.DiagnosticProvider != nil
+	case "textDocument/inlineCompletion":
+		return caps.InlineCompletionProvider != nil
+	case "workspace/textDocumentContent":
+		return caps.Workspace != nil && caps.Workspace.TextDocumentContent != nil
+	case "textDocument/willSaveWaitUntil":
+		if caps.TextDocumentSync == nil {
+			return false
+		}
+		return true
+	case "textDocument/completion", "completionItem/resolve":
+		return caps.CompletionProvider != nil
+	case "textDocument/signatureHelp":
+		return caps.SignatureHelpProvider != nil
+	case "textDocument/documentHighlight":
+		return caps.DocumentHighlightProvider != nil
+	case "textDocument/documentSymbol":
+		return caps.DocumentSymbolProvider != nil
+	case "textDocument/codeAction", "codeAction/resolve":
+		return caps.CodeActionProvider != nil
+	case "workspace/symbol", "workspaceSymbol/resolve":
+		return caps.WorkspaceSymbolProvider != nil
+	case "textDocument/codeLens", "codeLens/resolve":
+		return caps.CodeLensProvider != nil
+	case "textDocument/documentLink", "documentLink/resolve":
+		return caps.DocumentLinkProvider != nil
+	case "textDocument/formatting":
+		return caps.DocumentFormattingProvider != nil
+	case "textDocument/rangeFormatting":
+		return caps.DocumentRangeFormattingProvider != nil
+	case "textDocument/rangesFormatting":
+		return caps.DocumentRangeFormattingProvider != nil
+	case "textDocument/onTypeFormatting":
+		return caps.DocumentOnTypeFormattingProvider != nil
+	case "textDocument/rename", "textDocument/prepareRename":
+		return caps.RenameProvider != nil
+	case "workspace/executeCommand":
+		return caps.ExecuteCommandProvider != nil
+	default:
+		return true
+	}
+}
diff --git a/internal/lsp/client.go b/internal/lsp/client.go
index a6b9fcbb..e09a6a44 100644
--- a/internal/lsp/client.go
+++ b/internal/lsp/client.go
@@ -62,6 +62,11 @@ type Client struct {
 
 	// Server state
 	serverState atomic.Value
+
+	// Server capabilities as returned by initialize
+	caps    protocol.ServerCapabilities
+	capsMu  sync.RWMutex
+	capsSet atomic.Bool
 }
 
 // NewClient creates a new LSP client.
@@ -143,7 +148,7 @@ func (c *Client) RegisterServerRequestHandler(method string, handler ServerReque
 }
 
 func (c *Client) InitializeLSPClient(ctx context.Context, workspaceDir string) (*protocol.InitializeResult, error) {
-	initParams := &protocol.InitializeParams{
+	initParams := protocol.ParamInitialize{
 		WorkspaceFoldersInitializeParams: protocol.WorkspaceFoldersInitializeParams{
 			WorkspaceFolders: []protocol.WorkspaceFolder{
 				{
@@ -220,12 +225,14 @@ func (c *Client) InitializeLSPClient(ctx context.Context, workspaceDir string) (
 		},
 	}
 
-	var result protocol.InitializeResult
-	if err := c.Call(ctx, "initialize", initParams, &result); err != nil {
+	result, err := c.Initialize(ctx, initParams)
+	if err != nil {
 		return nil, fmt.Errorf("initialize failed: %w", err)
 	}
 
-	if err := c.Notify(ctx, "initialized", struct{}{}); err != nil {
+	c.setCapabilities(result.Capabilities)
+
+	if err := c.Initialized(ctx, protocol.InitializedParams{}); err != nil {
 		return nil, fmt.Errorf("initialized notification failed: %w", err)
 	}
 
@@ -234,14 +241,9 @@ func (c *Client) InitializeLSPClient(ctx context.Context, workspaceDir string) (
 	c.RegisterServerRequestHandler("workspace/configuration", HandleWorkspaceConfiguration)
 	c.RegisterServerRequestHandler("client/registerCapability", HandleRegisterCapability)
 	c.RegisterNotificationHandler("window/showMessage", HandleServerMessage)
-	c.RegisterNotificationHandler("textDocument/publishDiagnostics",
-		func(params json.RawMessage) { HandleDiagnostics(c, params) })
-
-	// Notify the LSP server
-	err := c.Initialized(ctx, protocol.InitializedParams{})
-	if err != nil {
-		return nil, fmt.Errorf("initialization failed: %w", err)
-	}
+	c.RegisterNotificationHandler("textDocument/publishDiagnostics", func(params json.RawMessage) {
+		HandleDiagnostics(c, params)
+	})
 
 	return &result, nil
 }
@@ -329,16 +331,7 @@ func (c *Client) WaitForServerReady(ctx context.Context) error {
 		slog.Debug("Waiting for LSP server to be ready...")
 	}
 
-	// Determine server type for specialized initialization
-	serverType := c.detectServerType()
-
-	// For TypeScript-like servers, we need to open some key files first
-	if serverType == ServerTypeTypeScript {
-		if cfg.Options.DebugLSP {
-			slog.Debug("TypeScript-like server detected, opening key configuration files")
-		}
-		c.openKeyConfigFiles(ctx)
-	}
+	c.openKeyConfigFiles(ctx)
 
 	for {
 		select {
@@ -347,21 +340,19 @@ func (c *Client) WaitForServerReady(ctx context.Context) error {
 			return fmt.Errorf("timeout waiting for LSP server to be ready")
 		case <-ticker.C:
 			// Try a ping method appropriate for this server type
-			err := c.pingServerByType(ctx, serverType)
-			if err == nil {
-				// Server responded successfully
-				c.SetServerState(StateReady)
+			if err := c.ping(ctx); err != nil {
 				if cfg.Options.DebugLSP {
-					slog.Debug("LSP server is ready")
+					slog.Debug("LSP server not ready yet", "error", err, "server", c.name)
 				}
-				return nil
-			} else {
-				slog.Debug("LSP server not ready yet", "error", err, "serverType", serverType)
+				continue
 			}
 
+			// Server responded successfully
+			c.SetServerState(StateReady)
 			if cfg.Options.DebugLSP {
-				slog.Debug("LSP server not ready yet", "error", err, "serverType", serverType)
+				slog.Debug("LSP server is ready")
 			}
+			return nil
 		}
 	}
 }
@@ -443,86 +434,13 @@ func (c *Client) openKeyConfigFiles(ctx context.Context) {
 	}
 }
 
-// pingServerByType sends a ping request appropriate for the server type
-func (c *Client) pingServerByType(ctx context.Context, serverType ServerType) error {
-	switch serverType {
-	case ServerTypeTypeScript:
-		// For TypeScript, try a document symbol request on an open file
-		return c.pingTypeScriptServer(ctx)
-	case ServerTypeGo:
-		// For Go, workspace/symbol works well
-		return c.pingWithWorkspaceSymbol(ctx)
-	case ServerTypeRust:
-		// For Rust, workspace/symbol works well
-		return c.pingWithWorkspaceSymbol(ctx)
-	default:
-		// Default ping method
-		return c.pingWithWorkspaceSymbol(ctx)
-	}
-}
-
-// pingTypeScriptServer tries to ping a TypeScript server with appropriate methods
-func (c *Client) pingTypeScriptServer(ctx context.Context) error {
-	// First try workspace/symbol which works for many servers
-	if err := c.pingWithWorkspaceSymbol(ctx); err == nil {
-		return nil
-	}
-
-	// If that fails, try to find an open file and request document symbols
-	c.openFilesMu.RLock()
-	defer c.openFilesMu.RUnlock()
-
-	// If we have any open files, try to get document symbols for one
-	for uri := range c.openFiles {
-		filePath, err := protocol.DocumentURI(uri).Path()
-		if err != nil {
-			slog.Error("Failed to convert URI to path for TypeScript symbol collection", "uri", uri, "error", err)
-			continue
-		}
-
-		if strings.HasSuffix(filePath, ".ts") || strings.HasSuffix(filePath, ".js") ||
-			strings.HasSuffix(filePath, ".tsx") || strings.HasSuffix(filePath, ".jsx") {
-			var symbols []protocol.DocumentSymbol
-			err := c.Call(ctx, "textDocument/documentSymbol", protocol.DocumentSymbolParams{
-				TextDocument: protocol.TextDocumentIdentifier{
-					URI: protocol.DocumentURI(uri),
-				},
-			}, &symbols)
-			if err == nil {
-				return nil
-			}
-		}
-	}
-
-	// If we have no open TypeScript files, try to find and open one
-	workDir := config.Get().WorkingDir()
-	err := filepath.WalkDir(workDir, func(path string, d os.DirEntry, err error) error {
-		if err != nil {
-			return err
-		}
-
-		// Skip directories and non-TypeScript files
-		if d.IsDir() {
-			return nil
-		}
-
-		ext := filepath.Ext(path)
-		if ext == ".ts" || ext == ".js" || ext == ".tsx" || ext == ".jsx" {
-			// Found a TypeScript file, try to open it
-			if err := c.OpenFile(ctx, path); err == nil {
-				// Successfully opened, stop walking
-				return filepath.SkipAll
-			}
-		}
-
+// ping sends a ping request...
+func (c *Client) ping(ctx context.Context) error {
+	if _, err := c.Symbol(ctx, protocol.WorkspaceSymbolParams{}); err == nil {
 		return nil
-	})
-	if err != nil {
-		slog.Debug("Error walking directory for TypeScript files", "error", err)
 	}
-
-	// Final fallback - just try a generic capability
-	return c.pingWithServerCapabilities(ctx)
+	// This is a very lightweight request that should work for most servers
+	return c.Notify(ctx, "$/cancelRequest", protocol.CancelParams{ID: "1"})
 }
 
 // openTypeScriptFiles finds and opens TypeScript files to help initialize the server
@@ -597,20 +515,6 @@ func shouldSkipDir(path string) bool {
 	return skipDirs[dirName]
 }
 
-// pingWithWorkspaceSymbol tries a workspace/symbol request
-func (c *Client) pingWithWorkspaceSymbol(ctx context.Context) error {
-	var result []protocol.SymbolInformation
-	return c.Call(ctx, "workspace/symbol", protocol.WorkspaceSymbolParams{
-		Query: "",
-	}, &result)
-}
-
-// pingWithServerCapabilities tries to get server capabilities
-func (c *Client) pingWithServerCapabilities(ctx context.Context) error {
-	// This is a very lightweight request that should work for most servers
-	return c.Notify(ctx, "$/cancelRequest", struct{ ID int }{ID: -1})
-}
-
 type OpenFileInfo struct {
 	Version int32
 	URI     protocol.DocumentURI
@@ -668,7 +572,7 @@ func (c *Client) OpenFile(ctx context.Context, filepath string) error {
 		},
 	}
 
-	if err := c.Notify(ctx, "textDocument/didOpen", params); err != nil {
+	if err := c.DidOpen(ctx, params); err != nil {
 		return err
 	}
 
@@ -718,7 +622,7 @@ func (c *Client) NotifyChange(ctx context.Context, filepath string) error {
 		},
 	}
 
-	return c.Notify(ctx, "textDocument/didChange", params)
+	return c.DidChange(ctx, params)
 }
 
 func (c *Client) CloseFile(ctx context.Context, filepath string) error {
@@ -741,7 +645,7 @@ func (c *Client) CloseFile(ctx context.Context, filepath string) error {
 	if cfg.Options.DebugLSP {
 		slog.Debug("Closing file", "file", filepath)
 	}
-	if err := c.Notify(ctx, "textDocument/didClose", params); err != nil {
+	if err := c.DidClose(ctx, params); err != nil {
 		return err
 	}
 
diff --git a/internal/lsp/transport.go b/internal/lsp/transport.go
index b468101d..483281d2 100644
--- a/internal/lsp/transport.go
+++ b/internal/lsp/transport.go
@@ -188,9 +188,12 @@ func (c *Client) handleMessages() {
 
 // Call makes a request and waits for the response
 func (c *Client) Call(ctx context.Context, method string, params any, result any) error {
-	cfg := config.Get()
+	if !c.IsMethodSupported(method) {
+		return fmt.Errorf("method not supported by server: %s", method)
+	}
 	id := c.nextID.Add(1)
 
+	cfg := config.Get()
 	if cfg.Options.DebugLSP {
 		slog.Debug("Making call", "method", method, "id", id)
 	}
@@ -253,6 +256,12 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
 // Notify sends a notification (a request without an ID that doesn't expect a response)
 func (c *Client) Notify(ctx context.Context, method string, params any) error {
 	cfg := config.Get()
+	if !c.IsMethodSupported(method) {
+		if cfg.Options.DebugLSP {
+			slog.Debug("Skipping notification: method not supported by server", "method", method)
+		}
+		return nil
+	}
 	if cfg.Options.DebugLSP {
 		slog.Debug("Sending notification", "method", method)
 	}
diff --git a/internal/lsp/watcher/watcher.go b/internal/lsp/watcher/watcher.go
index 476c4936..ad03099a 100644
--- a/internal/lsp/watcher/watcher.go
+++ b/internal/lsp/watcher/watcher.go
@@ -623,18 +623,11 @@ func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPatt
 	if basePath == "" {
 		return false
 	}
-	// For relative patterns
-	if basePath, err = protocol.DocumentURI(basePath).Path(); err != nil {
-		// XXX: Do we want to return here, or send the error up the stack?
-		slog.Error("Error converting base path to URI", "basePath", basePath, "error", err)
-	}
-
-	basePath = filepath.ToSlash(basePath)
 
 	// Make path relative to basePath for matching
 	relPath, err := filepath.Rel(basePath, path)
 	if err != nil {
-		slog.Error("Error getting relative path", "path", path, "basePath", basePath, "error", err)
+		slog.Error("Error getting relative path", "path", path, "basePath", basePath, "error", err, "server", w.name)
 		return false
 	}
 	relPath = filepath.ToSlash(relPath)
diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go
index eeabeac3..236c5d2e 100644
--- a/internal/tui/components/chat/sidebar/sidebar.go
+++ b/internal/tui/components/chat/sidebar/sidebar.go
@@ -3,7 +3,6 @@ package sidebar
 import (
 	"context"
 	"fmt"
-	"os"
 	"slices"
 	"strings"
 
@@ -14,6 +13,7 @@ import (
 	"github.com/charmbracelet/crush/internal/diff"
 	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/history"
+	"github.com/charmbracelet/crush/internal/home"
 	"github.com/charmbracelet/crush/internal/lsp"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/charmbracelet/crush/internal/session"
@@ -609,11 +609,5 @@ func (m *sidebarCmp) SetCompactMode(compact bool) {
 func cwd() string {
 	cwd := config.Get().WorkingDir()
 	t := styles.CurrentTheme()
-	// Replace home directory with ~, unless we're at the top level of the
-	// home directory).
-	homeDir, err := os.UserHomeDir()
-	if err == nil && cwd != homeDir {
-		cwd = strings.ReplaceAll(cwd, homeDir, "~")
-	}
-	return t.S().Muted.Render(cwd)
+	return t.S().Muted.Render(home.Short(cwd))
 }
diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go
index 2416888f..7fa46cdd 100644
--- a/internal/tui/components/chat/splash/splash.go
+++ b/internal/tui/components/chat/splash/splash.go
@@ -2,7 +2,6 @@ package splash
 
 import (
 	"fmt"
-	"os"
 	"strings"
 	"time"
 
@@ -11,6 +10,7 @@ import (
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/home"
 	"github.com/charmbracelet/crush/internal/llm/prompt"
 	"github.com/charmbracelet/crush/internal/tui/components/chat"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
@@ -648,12 +648,7 @@ func (s *splashCmp) cwdPart() string {
 }
 
 func (s *splashCmp) cwd() string {
-	cwd := config.Get().WorkingDir()
-	homeDir, err := os.UserHomeDir()
-	if err == nil && cwd != homeDir {
-		cwd = strings.ReplaceAll(cwd, homeDir, "~")
-	}
-	return cwd
+	return home.Short(config.Get().WorkingDir())
 }
 
 func LSPList(maxWidth int) []string {
diff --git a/internal/tui/components/dialogs/commands/loader.go b/internal/tui/components/dialogs/commands/loader.go
index 9aee528e..74d9c7e4 100644
--- a/internal/tui/components/dialogs/commands/loader.go
+++ b/internal/tui/components/dialogs/commands/loader.go
@@ -10,6 +10,7 @@ import (
 
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/home"
 	"github.com/charmbracelet/crush/internal/tui/util"
 )
 
@@ -54,7 +55,7 @@ func buildCommandSources(cfg *config.Config) []commandSource {
 	}
 
 	// Home directory
-	if home, err := os.UserHomeDir(); err == nil {
+	if home := home.Dir(); home != "" {
 		sources = append(sources, commandSource{
 			path:   filepath.Join(home, ".crush", "commands"),
 			prefix: UserCommandPrefix,
@@ -73,7 +74,7 @@ func buildCommandSources(cfg *config.Config) []commandSource {
 func getXDGCommandsDir() string {
 	xdgHome := os.Getenv("XDG_CONFIG_HOME")
 	if xdgHome == "" {
-		if home, err := os.UserHomeDir(); err == nil {
+		if home := home.Dir(); home != "" {
 			xdgHome = filepath.Join(home, ".config")
 		}
 	}
diff --git a/internal/tui/components/dialogs/filepicker/filepicker.go b/internal/tui/components/dialogs/filepicker/filepicker.go
index fd853cdc..fcec2fc8 100644
--- a/internal/tui/components/dialogs/filepicker/filepicker.go
+++ b/internal/tui/components/dialogs/filepicker/filepicker.go
@@ -11,6 +11,7 @@ import (
 	"github.com/charmbracelet/bubbles/v2/help"
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/home"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
@@ -60,7 +61,7 @@ func NewFilePickerCmp(workingDir string) FilePicker {
 		if cwd, err := os.Getwd(); err == nil {
 			fp.CurrentDirectory = cwd
 		} else {
-			fp.CurrentDirectory, _ = os.UserHomeDir()
+			fp.CurrentDirectory = home.Dir()
 		}
 	}
 
@@ -106,8 +107,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 		if key.Matches(msg, m.filePicker.KeyMap.Back) {
 			// make sure we don't go back if we are at the home directory
-			homeDir, _ := os.UserHomeDir()
-			if m.filePicker.CurrentDirectory == homeDir {
+			if m.filePicker.CurrentDirectory == home.Dir() {
 				return m, nil
 			}
 		}
diff --git a/internal/tui/components/dialogs/models/apikey.go b/internal/tui/components/dialogs/models/apikey.go
index 80f812cd..0490335f 100644
--- a/internal/tui/components/dialogs/models/apikey.go
+++ b/internal/tui/components/dialogs/models/apikey.go
@@ -2,13 +2,12 @@ package models
 
 import (
 	"fmt"
-	"strings"
 
 	"github.com/charmbracelet/bubbles/v2/spinner"
 	"github.com/charmbracelet/bubbles/v2/textinput"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/home"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/lipgloss/v2"
 )
@@ -145,7 +144,7 @@ func (a *APIKeyInput) View() string {
 	inputView := a.input.View()
 
 	dataPath := config.GlobalConfigData()
-	dataPath = strings.Replace(dataPath, fsext.HomeDir(), "~", 1)
+	dataPath = home.Short(dataPath)
 	helpText := styles.CurrentTheme().S().Muted.
 		Render(fmt.Sprintf("This will be written to the global configuration: %s", dataPath))
 



Test patch:
diff --git a/internal/home/home_test.go b/internal/home/home_test.go
new file mode 100644
index 00000000..e5775c31
--- /dev/null
+++ b/internal/home/home_test.go
@@ -0,0 +1,26 @@
+package home
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestDir(t *testing.T) {
+	require.NotEmpty(t, Dir())
+}
+
+func TestShort(t *testing.T) {
+	d := filepath.Join(Dir(), "documents", "file.txt")
+	require.Equal(t, filepath.FromSlash("~/documents/file.txt"), Short(d))
+	ad := filepath.FromSlash("/absolute/path/file.txt")
+	require.Equal(t, ad, Short(ad))
+}
+
+func TestLong(t *testing.T) {
+	d := filepath.FromSlash("~/documents/file.txt")
+	require.Equal(t, filepath.Join(Dir(), "documents", "file.txt"), Long(d))
+	ad := filepath.FromSlash("/absolute/path/file.txt")
+	require.Equal(t, ad, Long(ad))
+}
diff --git a/internal/llm/prompt/prompt_test.go b/internal/llm/prompt/prompt_test.go
index e4289595..66f9d438 100644
--- a/internal/llm/prompt/prompt_test.go
+++ b/internal/llm/prompt/prompt_test.go
@@ -2,10 +2,10 @@ package prompt
 
 import (
 	"os"
-	"path/filepath"
-	"runtime"
 	"strings"
 	"testing"
+
+	"github.com/charmbracelet/crush/internal/home"
 )
 
 func TestExpandPath(t *testing.T) {
@@ -25,16 +25,14 @@ func TestExpandPath(t *testing.T) {
 			name:  "tilde expansion",
 			input: "~/documents",
 			expected: func() string {
-				home, _ := os.UserHomeDir()
-				return filepath.Join(home, "documents")
+				return home.Dir() + "/documents"
 			},
 		},
 		{
 			name:  "tilde only",
 			input: "~",
 			expected: func() string {
-				home, _ := os.UserHomeDir()
-				return home
+				return home.Dir()
 			},
 		},
 		{
@@ -69,55 +67,3 @@ func TestExpandPath(t *testing.T) {
 		})
 	}
 }
-
-func TestProcessContextPaths(t *testing.T) {
-	// Create a temporary directory and file for testing
-	tmpDir := t.TempDir()
-	testFile := filepath.Join(tmpDir, "test.txt")
-	testContent := "test content"
-
-	err := os.WriteFile(testFile, []byte(testContent), 0o644)
-	if err != nil {
-		t.Fatalf("Failed to create test file: %v", err)
-	}
-
-	// Test with absolute path to file
-	result := processContextPaths("", []string{testFile})
-	expected := "# From:" + testFile + "\n" + testContent
-
-	if result != expected {
-		t.Errorf("processContextPaths with absolute path failed.\nGot: %q\nWant: %q", result, expected)
-	}
-
-	// Test with directory path (should process all files in directory)
-	result = processContextPaths("", []string{tmpDir})
-	if !strings.Contains(result, testContent) {
-		t.Errorf("processContextPaths with directory path failed to include file content")
-	}
-
-	// Test with tilde expansion (if we can create a file in home directory)
-	tmpDir = t.TempDir()
-	setHomeEnv(t, tmpDir)
-	homeTestFile := filepath.Join(tmpDir, "crush_test_file.txt")
-	err = os.WriteFile(homeTestFile, []byte(testContent), 0o644)
-	if err == nil {
-		defer os.Remove(homeTestFile) // Clean up
-
-		tildeFile := "~/crush_test_file.txt"
-		result = processContextPaths("", []string{tildeFile})
-		expected = "# From:" + homeTestFile + "\n" + testContent
-
-		if result != expected {
-			t.Errorf("processContextPaths with tilde expansion failed.\nGot: %q\nWant: %q", result, expected)
-		}
-	}
-}
-
-func setHomeEnv(tb testing.TB, path string) {
-	tb.Helper()
-	key := "HOME"
-	if runtime.GOOS == "windows" {
-		key = "USERPROFILE"
-	}
-	tb.Setenv(key, path)
-}
diff --git a/internal/permission/permission_test.go b/internal/permission/permission_test.go
index c3c646ec..d1ccd286 100644
--- a/internal/permission/permission_test.go
+++ b/internal/permission/permission_test.go
@@ -154,12 +154,10 @@ func TestPermissionService_SequentialProperties(t *testing.T) {
 		events := service.Subscribe(t.Context())
 		var result1 bool
 		var wg sync.WaitGroup
-		wg.Add(1)
 
-		go func() {
-			defer wg.Done()
+		wg.Go(func() {
 			result1 = service.Request(req)
-		}()
+		})
 
 		var permissionReq PermissionRequest
 		event := <-events
@@ -170,12 +168,10 @@ func TestPermissionService_SequentialProperties(t *testing.T) {
 		assert.True(t, result1, "First request should be granted")
 
 		var result2 bool
-		wg.Add(1)
 
-		go func() {
-			defer wg.Done()
+		wg.Go(func() {
 			result2 = service.Request(req)
-		}()
+		})
 
 		event = <-events
 		permissionReq = event.Payload