Merge branch 'main' into server-client

Ayman Bagabas created

Change summary

README.md                                                  |  34 +
Taskfile.yaml                                              |  17 
go.mod                                                     |   3 
go.sum                                                     |   6 
internal/app/lsp.go                                        |   2 
internal/cmd/root.go                                       |  30 +
internal/config/config.go                                  |   2 
internal/config/load.go                                    |   7 
internal/config/load_test.go                               |  12 
internal/event/all.go                                      |  59 ++
internal/event/event.go                                    | 115 +++
internal/event/logger.go                                   |  27 
internal/fsext/ls.go                                       |  11 
internal/gorust/pipeline.go                                | 233 --------
internal/llm/agent/agent.go                                |  44 
internal/llm/agent/errors.go                               |  15 
internal/llm/agent/event.go                                |  51 +
internal/llm/agent/mcp-tools.go                            |  20 
internal/llm/provider/anthropic.go                         |  14 
internal/llm/provider/gemini.go                            |   9 
internal/llm/provider/openai.go                            |  11 
internal/llm/provider/provider.go                          |   2 
internal/log/log.go                                        |   3 
internal/lsp/client.go                                     |  13 
internal/lsp/client_test.go                                |   9 
internal/session/session.go                                |   3 
internal/tui/components/chat/editor/editor.go              |   2 
internal/tui/components/chat/editor/keys.go                |   2 
internal/tui/components/chat/messages/messages.go          |   2 
internal/tui/components/chat/splash/keys.go                |   2 
internal/tui/components/completions/keys.go                |   2 
internal/tui/components/dialogs/commands/keys.go           |   2 
internal/tui/components/dialogs/compact/keys.go            |   2 
internal/tui/components/dialogs/filepicker/keys.go         |   2 
internal/tui/components/dialogs/keys.go                    |   2 
internal/tui/components/dialogs/models/keys.go             |   2 
internal/tui/components/dialogs/permissions/permissions.go |  30 +
internal/tui/components/dialogs/quit/keys.go               |   2 
internal/tui/components/dialogs/sessions/keys.go           |   2 
internal/tui/components/dialogs/sessions/sessions.go       |   2 
internal/tui/page/chat/chat.go                             |  14 
internal/tui/page/chat/keys.go                             |   2 
internal/tui/tui.go                                        |   3 
main.go                                                    |   2 
schema.json                                                |   5 
45 files changed, 523 insertions(+), 311 deletions(-)

Detailed changes

README.md πŸ”—

@@ -545,7 +545,7 @@ config:
 }
 ```
 
-## Disabling Provider Auto-Updates
+## Provider Auto-Updates
 
 By default, Crush automatically checks for the latest and greatest list of
 providers and models from [Catwalk](https://github.com/charmbracelet/catwalk),
@@ -553,6 +553,8 @@ the open source Crush provider database. This means that when new providers and
 models are available, or when model metadata changes, Crush automatically
 updates your local configuration.
 
+### Disabling automatic provider updates
+
 For those with restricted internet access, or those who prefer to work in
 air-gapped environments, this might not be want you want, and this feature can
 be disabled.
@@ -597,6 +599,36 @@ crush update-providers embedded
 crush update-providers --help
 ```
 
+## Metrics
+
+Crush records pseudonymous usage metrics (tied to a device-specific hash),
+which maintainers rely on to inform development and support priorities. The
+metrics include solely usage metadata; prompts and responses are NEVER
+collected.
+
+Details on exactly what’s collected are in the source code ([here](https://github.com/charmbracelet/crush/tree/main/internal/event)
+and [here](https://github.com/charmbracelet/crush/blob/main/internal/llm/agent/event.go)).
+
+You can opt out of metrics collection at any time by setting the environment
+variable by setting the following in your environment:
+
+```bash
+export CRUSH_DISABLE_METRICS=1
+```
+
+Or by setting the following in your config:
+
+```json
+{
+  "options": {
+    "disable_metrics": true
+  }
+}
+```
+
+Crush also respects the [`DO_NOT_TRACK`](https://consoledonottrack.com)
+convention which can be enabled via `export DO_NOT_TRACK=1`.
+
 ## A Note on Claude Max and GitHub Copilot
 
 Crush only supports model providers through official, compliant APIs. We do not

Taskfile.yaml πŸ”—

@@ -84,3 +84,20 @@ tasks:
       - echo "Generated schema.json"
     generates:
       - schema.json
+
+  release:
+    desc: Create and push a new tag following semver
+    vars:
+      NEXT:
+        sh: go run github.com/caarlos0/svu/v3@latest next --always
+    prompt: "This will release {{.NEXT}}. Continue?"
+    preconditions:
+      - sh: '[ $(git symbolic-ref --short HEAD) = "main" ]'
+        msg: Not on main branch
+      - sh: "[ $(git status --porcelain=2 | wc -l) = 0 ]"
+        msg: "Git is dirty"
+    cmds:
+      - git tag -d nightly
+      - git tag --sign {{.NEXT}} {{.CLI_ARGS}}
+      - echo "pushing {{.NEXT}}..."
+      - git push origin --tags

go.mod πŸ”—

@@ -82,6 +82,7 @@ require (
 	github.com/charmbracelet/x/termios v0.1.1 // indirect
 	github.com/charmbracelet/x/windows v0.2.2 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/denisbrodbeck/machineid v1.0.1
 	github.com/disintegration/gift v1.1.2 // indirect
 	github.com/dlclark/regexp2 v1.11.5 // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect
@@ -96,6 +97,7 @@ require (
 	github.com/googleapis/gax-go/v2 v2.14.1 // indirect
 	github.com/gorilla/css v1.0.1 // indirect
 	github.com/gorilla/websocket v1.5.3 // indirect
+	github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/klauspost/compress v1.18.0 // indirect
 	github.com/klauspost/cpuid/v2 v2.0.9 // indirect
@@ -115,6 +117,7 @@ require (
 	github.com/ncruces/julianday v1.0.0 // indirect
 	github.com/pierrec/lz4/v4 v4.1.22 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
+	github.com/posthog/posthog-go v1.6.10
 	github.com/rivo/uniseg v0.4.7
 	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
 	github.com/sethvargo/go-retry v0.3.0 // indirect

go.sum πŸ”—

@@ -120,6 +120,8 @@ github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfv
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ=
+github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI=
 github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs=
 github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
@@ -164,6 +166,8 @@ github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A
 github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
 github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
 github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
 github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -235,6 +239,8 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjL
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/posthog/posthog-go v1.6.10 h1:OA6bkiUg89rI7f5cSXbcrH5+wLinyS6hHplnD92Pu/M=
+github.com/posthog/posthog-go v1.6.10/go.mod h1:LcC1Nu4AgvV22EndTtrMXTy+7RGVC0MhChSw7Qk5XkY=
 github.com/pressly/goose/v3 v3.25.0 h1:6WeYhMWGRCzpyd89SpODFnCBCKz41KrVbRT58nVjGng=
 github.com/pressly/goose/v3 v3.25.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
 github.com/qjebbs/go-jsons v1.0.0-alpha.4 h1:Qsb4ohRUHQODIUAsJKdKJ/SIDbsO7oGOzsfy+h1yQZs=

internal/app/lsp.go πŸ”—

@@ -36,7 +36,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, lspCfg
 	app.updateLSPState(name, lsp.StateStarting, nil, 0)
 
 	// Create LSP client.
-	lspClient, err := lsp.New(ctx, app.config, name, lspCfg)
+	lspClient, err := lsp.New(ctx, app.config, name, lspCfg, app.config.Resolver())
 	if err != nil {
 		slog.Error("Failed to create LSP client for", name, err)
 		app.updateLSPState(name, lsp.StateError, err, 0)

internal/cmd/root.go πŸ”—

@@ -12,11 +12,13 @@ import (
 	"os/exec"
 	"path/filepath"
 	"regexp"
+	"strconv"
 	"time"
 
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/client"
 	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/event"
 	"github.com/charmbracelet/crush/internal/log"
 	"github.com/charmbracelet/crush/internal/proto"
 	"github.com/charmbracelet/crush/internal/server"
@@ -134,6 +136,8 @@ crush -y
 
 		defer func() { c.DeleteInstance(cmd.Context(), c.ID()) }()
 
+		event.AppInitialized()
+
 		// Set up the TUI.
 		program := tea.NewProgram(
 			m,
@@ -151,11 +155,15 @@ crush -y
 		go streamEvents(cmd.Context(), evc, program)
 
 		if _, err := program.Run(); err != nil {
+			event.Error(err)
 			slog.Error("TUI run error", "error", err)
 			return fmt.Errorf("TUI error: %v", err)
 		}
 		return nil
 	},
+	PostRun: func(cmd *cobra.Command, args []string) {
+		event.AppExited()
+	},
 }
 
 func Execute() {
@@ -220,6 +228,15 @@ func setupApp(cmd *cobra.Command, hostURL *url.URL) (*client.Client, error) {
 
 	c.SetID(ins.ID)
 
+	cfg, err := c.GetGlobalConfig()
+	if err != nil {
+		return nil, fmt.Errorf("failed to get global config: %v", err)
+	}
+
+	if shouldEnableMetrics(cfg) {
+		event.Init()
+	}
+
 	return c, nil
 }
 
@@ -268,6 +285,19 @@ func startDetachedServer(cmd *cobra.Command) error {
 	return nil
 }
 
+func shouldEnableMetrics(cfg *config.Config) bool {
+	if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
+		return false
+	}
+	if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
+		return false
+	}
+	if cfg.Options.DisableMetrics {
+		return false
+	}
+	return true
+}
+
 func MaybePrependStdin(prompt string) (string, error) {
 	if term.IsTerminal(os.Stdin.Fd()) {
 		return prompt, nil

internal/config/config.go πŸ”—

@@ -153,6 +153,7 @@ type Options struct {
 	DisabledTools             []string     `json:"disabled_tools" jsonschema:"description=Tools to disable"`
 	DisableProviderAutoUpdate bool         `json:"disable_provider_auto_update,omitempty" jsonschema:"description=Disable providers auto-update,default=false"`
 	Attribution               *Attribution `json:"attribution,omitempty" jsonschema:"description=Attribution settings for generated content"`
+	DisableMetrics            bool         `json:"disable_metrics,omitempty" jsonschema:"description=Disable sending metrics,default=false"`
 }
 
 type MCPs map[string]MCPConfig
@@ -427,6 +428,7 @@ func (c *Config) SetProviderAPIKey(providerID, apiKey string) error {
 
 func allToolNames() []string {
 	return []string{
+		"agent",
 		"bash",
 		"download",
 		"edit",

internal/config/load.go πŸ”—

@@ -119,11 +119,6 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
 		config, configExists := c.Providers.Get(string(p.ID))
 		// if the user configured a known provider we need to allow it to override a couple of parameters
 		if configExists {
-			if config.Disable {
-				slog.Debug("Skipping provider due to disable flag", "provider", p.ID)
-				c.Providers.Del(string(p.ID))
-				continue
-			}
 			if config.BaseURL != "" {
 				p.APIEndpoint = config.BaseURL
 			}
@@ -268,7 +263,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
 			c.Providers.Del(id)
 			continue
 		}
-		if providerConfig.Type != catwalk.TypeOpenAI && providerConfig.Type != catwalk.TypeAnthropic {
+		if providerConfig.Type != catwalk.TypeOpenAI && providerConfig.Type != catwalk.TypeAnthropic && providerConfig.Type != catwalk.TypeGemini {
 			slog.Warn("Skipping custom provider because the provider type is not supported", "provider", id, "type", providerConfig.Type)
 			c.Providers.Del(id)
 			continue

internal/config/load_test.go πŸ”—

@@ -485,7 +485,7 @@ func TestConfig_setupAgentsWithDisabledTools(t *testing.T) {
 	cfg.SetupAgents()
 	coderAgent, ok := cfg.Agents["coder"]
 	require.True(t, ok)
-	assert.Equal(t, []string{"bash", "multiedit", "fetch", "glob", "ls", "sourcegraph", "view", "write"}, coderAgent.AllowedTools)
+	assert.Equal(t, []string{"agent", "bash", "multiedit", "fetch", "glob", "ls", "sourcegraph", "view", "write"}, coderAgent.AllowedTools)
 
 	taskAgent, ok := cfg.Agents["task"]
 	require.True(t, ok)
@@ -508,7 +508,7 @@ func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) {
 	cfg.SetupAgents()
 	coderAgent, ok := cfg.Agents["coder"]
 	require.True(t, ok)
-	assert.Equal(t, []string{"bash", "download", "edit", "multiedit", "fetch", "write"}, coderAgent.AllowedTools)
+	assert.Equal(t, []string{"agent", "bash", "download", "edit", "multiedit", "fetch", "write"}, coderAgent.AllowedTools)
 
 	taskAgent, ok := cfg.Agents["task"]
 	require.True(t, ok)
@@ -543,10 +543,10 @@ func TestConfig_configureProvidersWithDisabledProvider(t *testing.T) {
 	err := cfg.configureProviders(env, resolver, knownProviders)
 	require.NoError(t, err)
 
-	// Provider should be removed from config when disabled
-	require.Equal(t, cfg.Providers.Len(), 0)
-	_, exists := cfg.Providers.Get("openai")
-	require.False(t, exists)
+	require.Equal(t, cfg.Providers.Len(), 1)
+	prov, exists := cfg.Providers.Get("openai")
+	require.True(t, exists)
+	require.True(t, prov.Disable)
 }
 
 func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {

internal/event/all.go πŸ”—

@@ -0,0 +1,59 @@
+package event
+
+import (
+	"time"
+)
+
+var appStartTime time.Time
+
+func AppInitialized() {
+	appStartTime = time.Now()
+	send("app initialized")
+}
+
+func AppExited() {
+	duration := time.Since(appStartTime).Truncate(time.Second)
+	send(
+		"app exited",
+		"app duration pretty", duration.String(),
+		"app duration in seconds", int64(duration.Seconds()),
+	)
+	Flush()
+}
+
+func SessionCreated() {
+	send("session created")
+}
+
+func SessionDeleted() {
+	send("session deleted")
+}
+
+func SessionSwitched() {
+	send("session switched")
+}
+
+func FilePickerOpened() {
+	send("filepicker opened")
+}
+
+func PromptSent(props ...any) {
+	send(
+		"prompt sent",
+		props...,
+	)
+}
+
+func PromptResponded(props ...any) {
+	send(
+		"prompt responded",
+		props...,
+	)
+}
+
+func TokensUsed(props ...any) {
+	send(
+		"tokens used",
+		props...,
+	)
+}

internal/event/event.go πŸ”—

@@ -0,0 +1,115 @@
+package event
+
+import (
+	"fmt"
+	"log/slog"
+	"os"
+	"path/filepath"
+	"reflect"
+	"runtime"
+
+	"github.com/charmbracelet/crush/internal/version"
+	"github.com/denisbrodbeck/machineid"
+	"github.com/posthog/posthog-go"
+)
+
+const (
+	endpoint = "https://data.charm.land"
+	key      = "phc_4zt4VgDWLqbYnJYEwLRxFoaTL2noNrQij0C6E8k3I0V"
+)
+
+var (
+	client posthog.Client
+
+	baseProps = posthog.NewProperties().
+			Set("GOOS", runtime.GOOS).
+			Set("GOARCH", runtime.GOARCH).
+			Set("TERM", os.Getenv("TERM")).
+			Set("SHELL", filepath.Base(os.Getenv("SHELL"))).
+			Set("Version", version.Version).
+			Set("GoVersion", runtime.Version())
+)
+
+func Init() {
+	c, err := posthog.NewWithConfig(key, posthog.Config{
+		Endpoint: endpoint,
+		Logger:   logger{},
+	})
+	if err != nil {
+		slog.Error("Failed to initialize PostHog client", "error", err)
+	}
+	client = c
+}
+
+// send logs an event to PostHog with the given event name and properties.
+func send(event string, props ...any) {
+	if client == nil {
+		return
+	}
+	err := client.Enqueue(posthog.Capture{
+		DistinctId: distinctId(),
+		Event:      event,
+		Properties: pairsToProps(props...).Merge(baseProps),
+	})
+	if err != nil {
+		slog.Error("Failed to enqueue PostHog event", "event", event, "props", props, "error", err)
+		return
+	}
+}
+
+// Error logs an error event to PostHog with the error type and message.
+func Error(err any, props ...any) {
+	if client == nil {
+		return
+	}
+	// The PostHog Go client does not yet support sending exceptions.
+	// We're mimicking the behavior by sending the minimal info required
+	// for PostHog to recognize this as an exception event.
+	props = append(
+		[]any{
+			"$exception_list",
+			[]map[string]string{
+				{"type": reflect.TypeOf(err).String(), "value": fmt.Sprintf("%v", err)},
+			},
+		},
+		props...,
+	)
+	send("$exception", props...)
+}
+
+func Flush() {
+	if client == nil {
+		return
+	}
+	if err := client.Close(); err != nil {
+		slog.Error("Failed to flush PostHog events", "error", err)
+	}
+}
+
+func pairsToProps(props ...any) posthog.Properties {
+	p := posthog.NewProperties()
+
+	if !isEven(len(props)) {
+		slog.Error("Event properties must be provided as key-value pairs", "props", props)
+		return p
+	}
+
+	for i := 0; i < len(props); i += 2 {
+		key := props[i].(string)
+		value := props[i+1]
+		p = p.Set(key, value)
+	}
+	return p
+}
+
+func isEven(n int) bool {
+	return n%2 == 0
+}
+
+func distinctId() string {
+	id, err := machineid.ProtectedID("charm")
+	if err != nil {
+		return "crush-cli"
+	}
+	return id
+}

internal/event/logger.go πŸ”—

@@ -0,0 +1,27 @@
+package event
+
+import (
+	"log/slog"
+
+	"github.com/posthog/posthog-go"
+)
+
+var _ posthog.Logger = logger{}
+
+type logger struct{}
+
+func (logger) Debugf(format string, args ...any) {
+	slog.Debug(format, args...)
+}
+
+func (logger) Logf(format string, args ...any) {
+	slog.Info(format, args...)
+}
+
+func (logger) Warnf(format string, args ...any) {
+	slog.Warn(format, args...)
+}
+
+func (logger) Errorf(format string, args ...any) {
+	slog.Error(format, args...)
+}

internal/fsext/ls.go πŸ”—

@@ -4,6 +4,7 @@ import (
 	"log/slog"
 	"os"
 	"path/filepath"
+	"slices"
 	"strings"
 	"sync"
 
@@ -200,7 +201,7 @@ func (dl *directoryLister) getIgnore(path string) ignore.IgnoreParser {
 
 // ListDirectory lists files and directories in the specified path,
 func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
-	var results []string
+	results := csync.NewSlice[string]()
 	truncated := false
 	dl := NewDirectoryLister(initialPath)
 
@@ -227,19 +228,19 @@ func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]st
 			if d.IsDir() {
 				path = path + string(filepath.Separator)
 			}
-			results = append(results, path)
+			results.Append(path)
 		}
 
-		if limit > 0 && len(results) >= limit {
+		if limit > 0 && results.Len() >= limit {
 			truncated = true
 			return filepath.SkipAll
 		}
 
 		return nil
 	})
-	if err != nil && len(results) == 0 {
+	if err != nil && results.Len() == 0 {
 		return nil, truncated, err
 	}
 
-	return results, truncated, nil
+	return slices.Collect(results.Seq()), truncated, nil
 }

internal/gorust/pipeline.go πŸ”—

@@ -1,233 +0,0 @@
-package gorust
-
-import (
-	"context"
-	"fmt"
-	"sync"
-	"time"
-)
-
-type Stage[T, R any] interface {
-	Process(ctx context.Context, input T) (R, error)
-	Name() string
-}
-
-type Pipeline[T any] struct {
-	stages []Stage[any, any]
-	mu     sync.RWMutex
-	opts   PipelineOptions
-}
-
-type PipelineOptions struct {
-	MaxConcurrency int
-	Timeout        time.Duration
-	RetryAttempts  int
-	RetryDelay     time.Duration
-}
-
-type PipelineResult[T any] struct {
-	Output T
-	Error  error
-	Stage  string
-	Took   time.Duration
-}
-
-func NewPipeline[T any](opts PipelineOptions) *Pipeline[T] {
-	if opts.MaxConcurrency <= 0 {
-		opts.MaxConcurrency = 10
-	}
-	if opts.Timeout <= 0 {
-		opts.Timeout = 30 * time.Second
-	}
-	if opts.RetryAttempts <= 0 {
-		opts.RetryAttempts = 3
-	}
-	if opts.RetryDelay <= 0 {
-		opts.RetryDelay = time.Second
-	}
-
-	return &Pipeline[T]{
-		stages: make([]Stage[any, any], 0),
-		opts:   opts,
-	}
-}
-
-func (p *Pipeline[T]) AddStage(stage Stage[any, any]) *Pipeline[T] {
-	p.mu.Lock()
-	defer p.mu.Unlock()
-	p.stages = append(p.stages, stage)
-	return p
-}
-
-func (p *Pipeline[T]) Execute(ctx context.Context, input T) <-chan PipelineResult[any] {
-	results := make(chan PipelineResult[any], len(p.stages))
-	
-	go func() {
-		defer close(results)
-		
-		current := input
-		for _, stage := range p.stages {
-			select {
-			case <-ctx.Done():
-				results <- PipelineResult[any]{
-					Error: ctx.Err(),
-					Stage: stage.Name(),
-				}
-				return
-			default:
-				result := p.executeStageWithRetry(ctx, stage, current)
-				results <- result
-				
-				if result.Error != nil {
-					return
-				}
-				current = result.Output
-			}
-		}
-	}()
-	
-	return results
-}
-
-func (p *Pipeline[T]) executeStageWithRetry(ctx context.Context, stage Stage[any, any], input any) PipelineResult[any] {
-	var lastErr error
-	start := time.Now()
-	
-	for attempt := 0; attempt < p.opts.RetryAttempts; attempt++ {
-		if attempt > 0 {
-			select {
-			case <-ctx.Done():
-				return PipelineResult[any]{
-					Error: ctx.Err(),
-					Stage: stage.Name(),
-					Took:  time.Since(start),
-				}
-			case <-time.After(p.opts.RetryDelay):
-			}
-		}
-		
-		stageCtx, cancel := context.WithTimeout(ctx, p.opts.Timeout)
-		output, err := stage.Process(stageCtx, input)
-		cancel()
-		
-		if err == nil {
-			return PipelineResult[any]{
-				Output: output,
-				Stage:  stage.Name(),
-				Took:   time.Since(start),
-			}
-		}
-		
-		lastErr = err
-	}
-	
-	return PipelineResult[any]{
-		Error: fmt.Errorf("stage %s failed after %d attempts: %w", stage.Name(), p.opts.RetryAttempts, lastErr),
-		Stage: stage.Name(),
-		Took:  time.Since(start),
-	}
-}
-
-type TransformStage[T, R any] struct {
-	name      string
-	transform func(context.Context, T) (R, error)
-}
-
-func NewTransformStage[T, R any](name string, transform func(context.Context, T) (R, error)) *TransformStage[T, R] {
-	return &TransformStage[T, R]{
-		name:      name,
-		transform: transform,
-	}
-}
-
-func (s *TransformStage[T, R]) Name() string {
-	return s.name
-}
-
-func (s *TransformStage[T, R]) Process(ctx context.Context, input T) (R, error) {
-	return s.transform(ctx, input)
-}
-
-type FilterStage[T any] struct {
-	name      string
-	predicate func(context.Context, T) (bool, error)
-}
-
-func NewFilterStage[T any](name string, predicate func(context.Context, T) (bool, error)) *FilterStage[T] {
-	return &FilterStage[T]{
-		name:      name,
-		predicate: predicate,
-	}
-}
-
-func (s *FilterStage[T]) Name() string {
-	return s.name
-}
-
-func (s *FilterStage[T]) Process(ctx context.Context, input T) (T, error) {
-	keep, err := s.predicate(ctx, input)
-	if err != nil {
-		var zero T
-		return zero, err
-	}
-	
-	if !keep {
-		var zero T
-		return zero, fmt.Errorf("item filtered out")
-	}
-	
-	return input, nil
-}
-
-type BatchProcessor[T, R any] struct {
-	name      string
-	batchSize int
-	processor func(context.Context, []T) ([]R, error)
-	buffer    []T
-	mu        sync.Mutex
-}
-
-func NewBatchProcessor[T, R any](name string, batchSize int, processor func(context.Context, []T) ([]R, error)) *BatchProcessor[T, R] {
-	return &BatchProcessor[T, R]{
-		name:      name,
-		batchSize: batchSize,
-		processor: processor,
-		buffer:    make([]T, 0, batchSize),
-	}
-}
-
-func (b *BatchProcessor[T, R]) Name() string {
-	return b.name
-}
-
-func (b *BatchProcessor[T, R]) Process(ctx context.Context, input T) ([]R, error) {
-	b.mu.Lock()
-	defer b.mu.Unlock()
-	
-	b.buffer = append(b.buffer, input)
-	
-	if len(b.buffer) >= b.batchSize {
-		batch := make([]T, len(b.buffer))
-		copy(batch, b.buffer)
-		b.buffer = b.buffer[:0]
-		
-		return b.processor(ctx, batch)
-	}
-	
-	return nil, nil
-}
-
-func (b *BatchProcessor[T, R]) Flush(ctx context.Context) ([]R, error) {
-	b.mu.Lock()
-	defer b.mu.Unlock()
-	
-	if len(b.buffer) == 0 {
-		return nil, nil
-	}
-	
-	batch := make([]T, len(b.buffer))
-	copy(batch, b.buffer)
-	b.buffer = b.buffer[:0]
-	
-	return b.processor(ctx, batch)
-}

internal/llm/agent/agent.go πŸ”—

@@ -12,6 +12,7 @@ import (
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/event"
 	"github.com/charmbracelet/crush/internal/history"
 	"github.com/charmbracelet/crush/internal/llm/prompt"
 	"github.com/charmbracelet/crush/internal/llm/provider"
@@ -26,12 +27,6 @@ import (
 	"github.com/charmbracelet/crush/internal/shell"
 )
 
-// Common errors
-var (
-	ErrRequestCancelled = errors.New("request canceled by user")
-	ErrSessionBusy      = errors.New("session is currently processing another request")
-)
-
 type (
 	AgentEventType = proto.AgentEventType
 	AgentEvent     = proto.AgentEvent
@@ -59,11 +54,12 @@ type Service interface {
 
 type agent struct {
 	*pubsub.Broker[AgentEvent]
-	agentCfg config.Agent
-	sessions session.Service
-	messages message.Service
-	mcpTools []McpTool
-	cfg      *config.Config
+	agentCfg    config.Agent
+	sessions    session.Service
+	messages    message.Service
+	permissions permission.Service
+	mcpTools    []McpTool
+	cfg         *config.Config
 
 	tools *csync.LazySlice[tools.BaseTool]
 	// We need this to be able to update it when model changes
@@ -97,7 +93,7 @@ func NewAgent(
 	lspClients *csync.Map[string, *lsp.Client],
 ) (Service, error) {
 	var agentToolFn func() (tools.BaseTool, error)
-	if agentCfg.ID == "coder" {
+	if agentCfg.ID == "coder" && slices.Contains(agentCfg.AllowedTools, AgentToolName) {
 		agentToolFn = func() (tools.BaseTool, error) {
 			taskAgentCfg := cfg.Agents["task"]
 			if taskAgentCfg.ID == "" {
@@ -231,6 +227,7 @@ func NewAgent(
 		tools:               csync.NewLazySlice(toolFn),
 		promptQueue:         csync.NewMap[string, []string](),
 		cfg:                 cfg,
+		permissions:         permissions,
 	}, nil
 }
 
@@ -359,8 +356,9 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac
 	}
 
 	genCtx, cancel := context.WithCancel(ctx)
-
 	a.activeRequests.Set(sessionID, cancel)
+	startTime := time.Now()
+
 	go func() {
 		slog.Debug("Request started", "sessionID", sessionID)
 		defer log.RecoverPanic("agent.Run", func() {
@@ -371,16 +369,24 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac
 			attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
 		}
 		result := a.processGeneration(genCtx, sessionID, content, attachmentParts)
-		if result.Error != nil && !errors.Is(result.Error, ErrRequestCancelled) && !errors.Is(result.Error, context.Canceled) {
-			slog.Error(result.Error.Error())
+		if result.Error != nil {
+			if isCancelledErr(result.Error) {
+				slog.Error("Request canceled", "sessionID", sessionID)
+			} else {
+				slog.Error("Request errored", "sessionID", sessionID, "error", result.Error.Error())
+				event.Error(result.Error)
+			}
+		} else {
+			slog.Debug("Request completed", "sessionID", sessionID)
 		}
-		slog.Debug("Request completed", "sessionID", sessionID)
+		a.eventPromptResponded(sessionID, time.Since(startTime).Truncate(time.Second))
 		a.activeRequests.Del(sessionID)
 		cancel()
 		a.Publish(pubsub.CreatedEvent, result)
 		events <- result
 		close(events)
 	}()
+	a.eventPromptSent(sessionID)
 	return events, nil
 }
 
@@ -719,13 +725,13 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
 		if err := a.messages.Update(ctx, *assistantMsg); err != nil {
 			return fmt.Errorf("failed to update message: %w", err)
 		}
-		return a.TrackUsage(ctx, sessionID, a.Model(), event.Response.Usage)
+		return a.trackUsage(ctx, sessionID, a.Model(), event.Response.Usage)
 	}
 
 	return nil
 }
 
-func (a *agent) TrackUsage(ctx context.Context, sessionID string, model catwalk.Model, usage provider.TokenUsage) error {
+func (a *agent) trackUsage(ctx context.Context, sessionID string, model catwalk.Model, usage provider.TokenUsage) error {
 	sess, err := a.sessions.Get(ctx, sessionID)
 	if err != nil {
 		return fmt.Errorf("failed to get session: %w", err)
@@ -736,6 +742,8 @@ func (a *agent) TrackUsage(ctx context.Context, sessionID string, model catwalk.
 		model.CostPer1MIn/1e6*float64(usage.InputTokens) +
 		model.CostPer1MOut/1e6*float64(usage.OutputTokens)
 
+	a.eventTokensUsed(sessionID, usage, cost)
+
 	sess.Cost += cost
 	sess.CompletionTokens = usage.OutputTokens + usage.CacheReadTokens
 	sess.PromptTokens = usage.InputTokens + usage.CacheCreationTokens

internal/llm/agent/errors.go πŸ”—

@@ -0,0 +1,15 @@
+package agent
+
+import (
+	"context"
+	"errors"
+)
+
+var (
+	ErrRequestCancelled = errors.New("request canceled by user")
+	ErrSessionBusy      = errors.New("session is currently processing another request")
+)
+
+func isCancelledErr(err error) bool {
+	return errors.Is(err, context.Canceled) || errors.Is(err, ErrRequestCancelled)
+}

internal/llm/agent/event.go πŸ”—

@@ -0,0 +1,51 @@
+package agent
+
+import (
+	"time"
+
+	"github.com/charmbracelet/crush/internal/event"
+	"github.com/charmbracelet/crush/internal/llm/provider"
+)
+
+func (a *agent) eventPromptSent(sessionID string) {
+	event.PromptSent(
+		a.eventCommon(sessionID)...,
+	)
+}
+
+func (a *agent) eventPromptResponded(sessionID string, duration time.Duration) {
+	event.PromptResponded(
+		append(
+			a.eventCommon(sessionID),
+			"prompt duration pretty", duration.String(),
+			"prompt duration in seconds", int64(duration.Seconds()),
+		)...,
+	)
+}
+
+func (a *agent) eventTokensUsed(sessionID string, usage provider.TokenUsage, cost float64) {
+	event.TokensUsed(
+		append(
+			a.eventCommon(sessionID),
+			"input tokens", usage.InputTokens,
+			"output tokens", usage.OutputTokens,
+			"cache read tokens", usage.CacheReadTokens,
+			"cache creation tokens", usage.CacheCreationTokens,
+			"total tokens", usage.InputTokens+usage.OutputTokens+usage.CacheReadTokens+usage.CacheCreationTokens,
+			"cost", cost,
+		)...,
+	)
+}
+
+func (a *agent) eventCommon(sessionID string) []any {
+	currentModel := a.cfg.Models[a.cfg.Agents["coder"].Model]
+
+	return []any{
+		"session id", sessionID,
+		"provider", currentModel.Provider,
+		"model", currentModel.Model,
+		"reasoning effort", currentModel.ReasoningEffort,
+		"thinking mode", currentModel.Think,
+		"yolo mode", a.permissions.SkipRequests(),
+	}
+}

internal/llm/agent/mcp-tools.go πŸ”—

@@ -136,7 +136,7 @@ func getOrRenewClient(ctx context.Context, cfg *config.Config, name string) (*cl
 	}
 	updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, state.ToolCount)
 
-	c, err = createAndInitializeClient(ctx, name, m)
+	c, err = createAndInitializeClient(ctx, name, m, cfg.Resolver())
 	if err != nil {
 		return nil, err
 	}
@@ -151,7 +151,7 @@ func (b *McpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolRes
 	if sessionID == "" || messageID == "" {
 		return tools.ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
 	}
-	permissionDescription := fmt.Sprintf("execute %s with the following parameters: %s", b.Info().Name, params.Input)
+	permissionDescription := fmt.Sprintf("execute %s with the following parameters:", b.Info().Name)
 	p := b.permissions.Request(
 		permission.CreatePermissionRequest{
 			SessionID:   sessionID,
@@ -288,7 +288,7 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con
 
 			ctx, cancel := context.WithTimeout(ctx, mcpTimeout(m))
 			defer cancel()
-			c, err := createAndInitializeClient(ctx, name, m)
+			c, err := createAndInitializeClient(ctx, name, m, cfg.Resolver())
 			if err != nil {
 				return
 			}
@@ -303,8 +303,8 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con
 	return slices.Collect(result.Seq())
 }
 
-func createAndInitializeClient(ctx context.Context, name string, m config.MCPConfig) (*client.Client, error) {
-	c, err := createMcpClient(name, m)
+func createAndInitializeClient(ctx context.Context, name string, m config.MCPConfig, resolver config.VariableResolver) (*client.Client, error) {
+	c, err := createMcpClient(name, m, resolver)
 	if err != nil {
 		updateMCPState(name, MCPStateError, err, nil, 0)
 		slog.Error("error creating mcp client", "error", err, "name", name)
@@ -342,14 +342,18 @@ func maybeTimeoutErr(err error, timeout time.Duration) error {
 	return err
 }
 
-func createMcpClient(name string, m config.MCPConfig) (*client.Client, error) {
+func createMcpClient(name string, m config.MCPConfig, resolver config.VariableResolver) (*client.Client, error) {
 	switch m.Type {
 	case config.MCPStdio:
-		if strings.TrimSpace(m.Command) == "" {
+		command, err := resolver.ResolveValue(m.Command)
+		if err != nil {
+			return nil, fmt.Errorf("invalid mcp command: %w", err)
+		}
+		if strings.TrimSpace(command) == "" {
 			return nil, fmt.Errorf("mcp stdio config requires a non-empty 'command' field")
 		}
 		return client.NewStdioMCPClientWithOptions(
-			home.Long(m.Command),
+			home.Long(command),
 			m.ResolvedEnv(),
 			m.Args,
 			transport.WithCommandLogger(mcpLogger{name: name}),

internal/llm/provider/anthropic.go πŸ”—

@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"io"
 	"log/slog"
+	"net/http"
 	"regexp"
 	"strconv"
 	"strings"
@@ -492,17 +493,23 @@ func (a *anthropicClient) shouldRetry(attempts int, err error) (bool, int64, err
 		return false, 0, fmt.Errorf("maximum retry attempts reached for rate limit: %d retries", maxRetries)
 	}
 
-	if apiErr.StatusCode == 401 {
+	if apiErr.StatusCode == http.StatusUnauthorized {
+		prev := a.providerOptions.apiKey
+		// in case the key comes from a script, we try to re-evaluate it.
 		a.providerOptions.apiKey, err = a.providerOptions.cfg.Resolve(a.providerOptions.config.APIKey)
 		if err != nil {
 			return false, 0, fmt.Errorf("failed to resolve API key: %w", err)
 		}
+		// if it didn't change, do not retry.
+		if prev == a.providerOptions.apiKey {
+			return false, 0, err
+		}
 		a.client = createAnthropicClient(a.providerOptions, a.tp)
 		return true, 0, nil
 	}
 
 	// Handle context limit exceeded error (400 Bad Request)
-	if apiErr.StatusCode == 400 {
+	if apiErr.StatusCode == http.StatusBadRequest {
 		if adjusted, ok := a.handleContextLimitError(apiErr); ok {
 			a.adjustedMaxTokens = adjusted
 			slog.Debug("Adjusted max_tokens due to context limit", "new_max_tokens", adjusted)
@@ -511,7 +518,8 @@ func (a *anthropicClient) shouldRetry(attempts int, err error) (bool, int64, err
 	}
 
 	isOverloaded := strings.Contains(apiErr.Error(), "overloaded") || strings.Contains(apiErr.Error(), "rate limit exceeded")
-	if apiErr.StatusCode != 429 && apiErr.StatusCode != 529 && !isOverloaded {
+	// 529 (unofficial): The service is overloaded
+	if apiErr.StatusCode != http.StatusTooManyRequests && apiErr.StatusCode != 529 && !isOverloaded {
 		return false, 0, err
 	}
 

internal/llm/provider/gemini.go πŸ”—

@@ -43,6 +43,9 @@ func createGeminiClient(opts providerClientOptions) (*genai.Client, error) {
 	cc := &genai.ClientConfig{
 		APIKey:  opts.apiKey,
 		Backend: genai.BackendGeminiAPI,
+		HTTPOptions: genai.HTTPOptions{
+			BaseURL: opts.baseURL,
+		},
 	}
 	if opts.cfg.Options.Debug {
 		cc.HTTPClient = log.NewHTTPClient()
@@ -433,10 +436,16 @@ func (g *geminiClient) shouldRetry(attempts int, err error) (bool, int64, error)
 
 	// Check for token expiration (401 Unauthorized)
 	if contains(errMsg, "unauthorized", "invalid api key", "api key expired") {
+		prev := g.providerOptions.apiKey
+		// in case the key comes from a script, we try to re-evaluate it.
 		g.providerOptions.apiKey, err = g.providerOptions.cfg.Resolve(g.providerOptions.config.APIKey)
 		if err != nil {
 			return false, 0, fmt.Errorf("failed to resolve API key: %w", err)
 		}
+		// if it didn't change, do not retry.
+		if prev == g.providerOptions.apiKey {
+			return false, 0, err
+		}
 		g.client, err = createGeminiClient(g.providerOptions)
 		if err != nil {
 			return false, 0, fmt.Errorf("failed to create Gemini client after API key refresh: %w", err)

internal/llm/provider/openai.go πŸ”—

@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"io"
 	"log/slog"
+	"net/http"
 	"strings"
 	"time"
 
@@ -513,16 +514,22 @@ func (o *openaiClient) shouldRetry(attempts int, err error) (bool, int64, error)
 	retryAfterValues := []string{}
 	if errors.As(err, &apiErr) {
 		// Check for token expiration (401 Unauthorized)
-		if apiErr.StatusCode == 401 {
+		if apiErr.StatusCode == http.StatusUnauthorized {
+			prev := o.providerOptions.apiKey
+			// in case the key comes from a script, we try to re-evaluate it.
 			o.providerOptions.apiKey, err = o.providerOptions.cfg.Resolve(o.providerOptions.config.APIKey)
 			if err != nil {
 				return false, 0, fmt.Errorf("failed to resolve API key: %w", err)
 			}
+			// if it didn't change, do not retry.
+			if prev == o.providerOptions.apiKey {
+				return false, 0, err
+			}
 			o.client = createOpenAIClient(o.providerOptions)
 			return true, 0, nil
 		}
 
-		if apiErr.StatusCode != 429 && apiErr.StatusCode != 500 {
+		if apiErr.StatusCode != http.StatusTooManyRequests && apiErr.StatusCode != http.StatusInternalServerError {
 			return false, 0, err
 		}
 

internal/log/log.go πŸ”—

@@ -9,6 +9,7 @@ import (
 	"sync/atomic"
 	"time"
 
+	"github.com/charmbracelet/crush/internal/event"
 	"gopkg.in/natefinch/lumberjack.v2"
 )
 
@@ -48,6 +49,8 @@ func Initialized() bool {
 
 func RecoverPanic(name string, cleanup func()) {
 	if r := recover(); r != nil {
+		event.Error(r, "panic", true, "name", name)
+
 		// Create a timestamped panic log file
 		timestamp := time.Now().Format("20060102-150405")
 		filename := fmt.Sprintf("crush-panic-%s-%s.log", name, timestamp)

internal/lsp/client.go πŸ”—

@@ -46,7 +46,7 @@ type Client struct {
 }
 
 // New creates a new LSP client using the powernap implementation.
-func New(ctx context.Context, cfg *config.Config, name string, lspCfg config.LSPConfig) (*Client, error) {
+func New(ctx context.Context, cfg *config.Config, name string, lspCfg config.LSPConfig, resolver config.VariableResolver) (*Client, error) {
 	// Convert working directory to file URI
 	workDir, err := os.Getwd()
 	if err != nil {
@@ -55,9 +55,14 @@ func New(ctx context.Context, cfg *config.Config, name string, lspCfg config.LSP
 
 	rootURI := string(protocol.URIFromPath(workDir))
 
+	command, err := resolver.ResolveValue(lspCfg.Command)
+	if err != nil {
+		return nil, fmt.Errorf("invalid lsp command: %w", err)
+	}
+
 	// Create powernap client config
 	clientConfig := powernap.ClientConfig{
-		Command: home.Long(lspCfg.Command),
+		Command: home.Long(command),
 		Args:    lspCfg.Args,
 		RootURI: rootURI,
 		Environment: func() map[string]string {
@@ -78,7 +83,7 @@ func New(ctx context.Context, cfg *config.Config, name string, lspCfg config.LSP
 	// Create the powernap client
 	powernapClient, err := powernap.NewClient(clientConfig)
 	if err != nil {
-		return nil, fmt.Errorf("failed to create powernap client: %w", err)
+		return nil, fmt.Errorf("failed to create lsp client: %w", err)
 	}
 
 	client := &Client{
@@ -100,7 +105,7 @@ func New(ctx context.Context, cfg *config.Config, name string, lspCfg config.LSP
 // Initialize initializes the LSP client and returns the server capabilities.
 func (c *Client) Initialize(ctx context.Context, workspaceDir string) (*protocol.InitializeResult, error) {
 	if err := c.client.Initialize(ctx, false); err != nil {
-		return nil, fmt.Errorf("failed to initialize powernap client: %w", err)
+		return nil, fmt.Errorf("failed to initialize the lsp client: %w", err)
 	}
 
 	// Convert powernap capabilities to protocol capabilities

internal/lsp/client_test.go πŸ”—

@@ -5,15 +5,16 @@ import (
 	"testing"
 
 	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/env"
 )
 
-func TestPowernapClient(t *testing.T) {
+func TestClient(t *testing.T) {
 	ctx := context.Background()
 
 	// Create a simple config for testing
 	var cfg config.Config
 	lspCfg := config.LSPConfig{
-		Command:   "echo", // Use echo as a dummy command that won't fail
+		Command:   "$THE_CMD", // Use echo as a dummy command that won't fail
 		Args:      []string{"hello"},
 		FileTypes: []string{"go"},
 		Env:       map[string]string{},
@@ -21,7 +22,9 @@ func TestPowernapClient(t *testing.T) {
 
 	// Test creating a powernap client - this will likely fail with echo
 	// but we can still test the basic structure
-	client, err := New(ctx, &cfg, "test", lspCfg)
+	client, err := New(ctx, &cfg, "test", lspCfg, config.NewEnvironmentVariableResolver(env.NewFromMap(map[string]string{
+		"THE_CMD": "echo",
+	})))
 	if err != nil {
 		// Expected to fail with echo command, skip the rest
 		t.Skipf("Powernap client creation failed as expected with dummy command: %v", err)

internal/session/session.go πŸ”—

@@ -5,6 +5,7 @@ import (
 	"database/sql"
 
 	"github.com/charmbracelet/crush/internal/db"
+	"github.com/charmbracelet/crush/internal/event"
 	"github.com/charmbracelet/crush/internal/proto"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/google/uuid"
@@ -38,6 +39,7 @@ func (s *service) Create(ctx context.Context, title string) (Session, error) {
 	}
 	session := s.fromDBItem(dbSession)
 	s.Publish(pubsub.CreatedEvent, session)
+	event.SessionCreated()
 	return session, nil
 }
 
@@ -79,6 +81,7 @@ func (s *service) Delete(ctx context.Context, id string) error {
 		return err
 	}
 	s.Publish(pubsub.DeletedEvent, session)
+	event.SessionDeleted()
 	return nil
 }
 

internal/tui/components/chat/editor/editor.go πŸ”—

@@ -75,7 +75,7 @@ var DeleteKeyMaps = DeleteAttachmentKeyMaps{
 		key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
 	),
 	Escape: key.NewBinding(
-		key.WithKeys("esc"),
+		key.WithKeys("esc", "alt+esc"),
 		key.WithHelp("esc", "cancel delete mode"),
 	),
 	DeleteAllAttachments: key.NewBinding(

internal/tui/components/chat/editor/keys.go πŸ”—

@@ -61,7 +61,7 @@ var AttachmentsKeyMaps = DeleteAttachmentKeyMaps{
 		key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
 	),
 	Escape: key.NewBinding(
-		key.WithKeys("esc"),
+		key.WithKeys("esc", "alt+esc"),
 		key.WithHelp("esc", "cancel delete mode"),
 	),
 	DeleteAllAttachments: key.NewBinding(

internal/tui/components/chat/messages/messages.go πŸ”—

@@ -29,7 +29,7 @@ import (
 var CopyKey = key.NewBinding(key.WithKeys("c", "y", "C", "Y"), key.WithHelp("c/y", "copy"))
 
 // ClearSelectionKey is the key binding for clearing the current selection in the chat interface.
-var ClearSelectionKey = key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "clear selection"))
+var ClearSelectionKey = key.NewBinding(key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "clear selection"))
 
 // MessageCmp defines the interface for message components in the chat interface.
 // It combines standard UI model interfaces with message-specific functionality.

internal/tui/components/chat/splash/keys.go πŸ”—

@@ -46,7 +46,7 @@ func DefaultKeyMap() KeyMap {
 			key.WithHelp("←/β†’", "switch"),
 		),
 		Back: key.NewBinding(
-			key.WithKeys("esc"),
+			key.WithKeys("esc", "alt+esc"),
 			key.WithHelp("esc", "back"),
 		),
 	}

internal/tui/components/completions/keys.go πŸ”—

@@ -28,7 +28,7 @@ func DefaultKeyMap() KeyMap {
 			key.WithHelp("enter", "select"),
 		),
 		Cancel: key.NewBinding(
-			key.WithKeys("esc"),
+			key.WithKeys("esc", "alt+esc"),
 			key.WithHelp("esc", "cancel"),
 		),
 		DownInsert: key.NewBinding(

internal/tui/components/dialogs/commands/keys.go πŸ”—

@@ -31,7 +31,7 @@ func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap {
 			key.WithHelp("tab", "switch selection"),
 		),
 		Close: key.NewBinding(
-			key.WithKeys("esc"),
+			key.WithKeys("esc", "alt+esc"),
 			key.WithHelp("esc", "cancel"),
 		),
 	}

internal/tui/components/dialogs/permissions/permissions.go πŸ”—

@@ -1,6 +1,7 @@
 package permissions
 
 import (
+	"encoding/json"
 	"fmt"
 	"strings"
 
@@ -615,6 +616,35 @@ func (p *permissionDialogCmp) generateDefaultContent() string {
 
 	content := p.permission.Description
 
+	// Add pretty-printed JSON parameters for MCP tools
+	if p.permission.Params != nil {
+		var paramStr string
+
+		// Ensure params is a string
+		if str, ok := p.permission.Params.(string); ok {
+			paramStr = str
+		} else {
+			paramStr = fmt.Sprintf("%v", p.permission.Params)
+		}
+
+		// Try to parse as JSON for pretty printing
+		var parsed any
+		if err := json.Unmarshal([]byte(paramStr), &parsed); err == nil {
+			if b, err := json.MarshalIndent(parsed, "", "  "); err == nil {
+				if content != "" {
+					content += "\n\n"
+				}
+				content += string(b)
+			}
+		} else {
+			// Not JSON, show as-is
+			if content != "" {
+				content += "\n\n"
+			}
+			content += paramStr
+		}
+	}
+
 	content = strings.TrimSpace(content)
 	content = "\n" + content + "\n"
 	lines := strings.Split(content, "\n")

internal/tui/components/dialogs/quit/keys.go πŸ”—

@@ -37,7 +37,7 @@ func DefaultKeymap() KeyMap {
 			key.WithHelp("tab", "switch options"),
 		),
 		Close: key.NewBinding(
-			key.WithKeys("esc"),
+			key.WithKeys("esc", "alt+esc"),
 			key.WithHelp("esc", "cancel"),
 		),
 	}

internal/tui/components/dialogs/sessions/sessions.go πŸ”—

@@ -4,6 +4,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/event"
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/tui/components/chat"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
@@ -99,6 +100,7 @@ func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			selectedItem := s.sessionsList.SelectedItem()
 			if selectedItem != nil {
 				selected := *selectedItem
+				event.SessionSwitched()
 				return s, tea.Sequence(
 					util.CmdHandler(dialogs.CloseDialogMsg{}),
 					util.CmdHandler(

internal/tui/page/chat/chat.go πŸ”—

@@ -778,7 +778,7 @@ func (p *chatPage) Bindings() []key.Binding {
 		cancelBinding := p.keyMap.Cancel
 		if p.isCanceling {
 			cancelBinding = key.NewBinding(
-				key.WithKeys("esc"),
+				key.WithKeys("esc", "alt+esc"),
 				key.WithHelp("esc", "press again to cancel"),
 			)
 		}
@@ -847,7 +847,7 @@ func (p *chatPage) Help() help.KeyMap {
 			shortList = append(shortList,
 				// Go back
 				key.NewBinding(
-					key.WithKeys("esc"),
+					key.WithKeys("esc", "alt+esc"),
 					key.WithHelp("esc", "back"),
 				),
 			)
@@ -882,7 +882,7 @@ func (p *chatPage) Help() help.KeyMap {
 					key.WithHelp("tab/enter", "complete"),
 				),
 				key.NewBinding(
-					key.WithKeys("esc"),
+					key.WithKeys("esc", "alt+esc"),
 					key.WithHelp("esc", "cancel"),
 				),
 				key.NewBinding(
@@ -898,19 +898,19 @@ func (p *chatPage) Help() help.KeyMap {
 		info, err := p.app.GetAgentInfo(context.TODO())
 		if err == nil && info.IsBusy {
 			cancelBinding := key.NewBinding(
-				key.WithKeys("esc"),
+				key.WithKeys("esc", "alt+esc"),
 				key.WithHelp("esc", "cancel"),
 			)
 			if p.isCanceling {
 				cancelBinding = key.NewBinding(
-					key.WithKeys("esc"),
+					key.WithKeys("esc", "alt+esc"),
 					key.WithHelp("esc", "press again to cancel"),
 				)
 			}
 			queued, _ := p.app.GetAgentSessionQueuedPrompts(context.TODO(), p.session.ID)
 			if queued > 0 {
 				cancelBinding = key.NewBinding(
-					key.WithKeys("esc"),
+					key.WithKeys("esc", "alt+esc"),
 					key.WithHelp("esc", "clear queue"),
 				)
 			}
@@ -1056,7 +1056,7 @@ func (p *chatPage) Help() help.KeyMap {
 						key.WithHelp("ctrl+r+r", "delete all attachments"),
 					),
 					key.NewBinding(
-						key.WithKeys("esc"),
+						key.WithKeys("esc", "alt+esc"),
 						key.WithHelp("esc", "cancel delete mode"),
 					),
 				})

internal/tui/page/chat/keys.go πŸ”—

@@ -23,7 +23,7 @@ func DefaultKeyMap() KeyMap {
 			key.WithHelp("ctrl+f", "add attachment"),
 		),
 		Cancel: key.NewBinding(
-			key.WithKeys("esc"),
+			key.WithKeys("esc", "alt+esc"),
 			key.WithHelp("esc", "cancel"),
 		),
 		Tab: key.NewBinding(

internal/tui/tui.go πŸ”—

@@ -11,6 +11,7 @@ import (
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/client"
 	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/event"
 	"github.com/charmbracelet/crush/internal/llm/agent"
 	"github.com/charmbracelet/crush/internal/log"
 	"github.com/charmbracelet/crush/internal/permission"
@@ -225,6 +226,8 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 	// File Picker
 	case commands.OpenFilePickerMsg:
+		event.FilePickerOpened()
+
 		if a.dialog.ActiveDialogID() == filepicker.FilePickerID {
 			// If the commands dialog is already open, close it
 			return a, util.CmdHandler(dialogs.CloseDialogMsg{})

main.go πŸ”—

@@ -10,11 +10,13 @@ import (
 	_ "github.com/joho/godotenv/autoload" // automatically load .env files
 
 	"github.com/charmbracelet/crush/internal/cmd"
+	"github.com/charmbracelet/crush/internal/event"
 	"github.com/charmbracelet/crush/internal/log"
 )
 
 func main() {
 	defer log.RecoverPanic("main", func() {
+		event.Flush()
 		slog.Error("Application terminated due to unhandled panic")
 	})
 

schema.json πŸ”—

@@ -320,6 +320,11 @@
         "attribution": {
           "$ref": "#/$defs/Attribution",
           "description": "Attribution settings for generated content"
+        },
+        "disable_metrics": {
+          "type": "boolean",
+          "description": "Disable sending metrics",
+          "default": false
         }
       },
       "additionalProperties": false,