diff --git a/README.md b/README.md index a2b07093c1e45162018614411ba7a4300f9ef680..7f28c5c049cdb6c45bc83ec59f94f4310c13b7c5 100644 --- a/README.md +++ b/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 diff --git a/Taskfile.yaml b/Taskfile.yaml index 443531fa2435d5557536a4d2e6d88014ea4a5677..80d6bd86d1070e2f4e900660a7cab060ebdfbcea 100644 --- a/Taskfile.yaml +++ b/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 diff --git a/go.mod b/go.mod index 664edc3e113e3cbb53944caae29435d3d70f6ba1..22e36410c06ee50647ee9b3d49cf4bc29aa636f6 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 7536f8bef6e61d9205b4baf600f18285cf368a39..a30d51ad5d829ed92d8148df91a973a48926eab5 100644 --- a/go.sum +++ b/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= diff --git a/internal/app/lsp.go b/internal/app/lsp.go index 4c4885cd597a3b39cac9326765758028dd7ffb3a..2994f1f688c22d3b85d224c1389b38822bddb24e 100644 --- a/internal/app/lsp.go +++ b/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) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index c17fb3e8694b67a2d6537f50e87c81c54917d60a..6169e3afea160cf8f7e19c10533efcc30ace8b9e 100644 --- a/internal/cmd/root.go +++ b/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 diff --git a/internal/config/config.go b/internal/config/config.go index 3bff5f359684482894110bccb9fa36936d007870..450029399eac38cf4cb4d5868d7cff5e09e59e9f 100644 --- a/internal/config/config.go +++ b/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", diff --git a/internal/config/load.go b/internal/config/load.go index dc806e5372bcd28437edd2b86d21390a4e67ff14..ed12cddef00357509844344b37921a7abec1b6ef 100644 --- a/internal/config/load.go +++ b/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 diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 90276c96ad113f453ed699c8deeb30b4f5fef9d5..406fe07d523c8b0d5d7f038f8d94cc74a0b58f89 100644 --- a/internal/config/load_test.go +++ b/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) { diff --git a/internal/event/all.go b/internal/event/all.go new file mode 100644 index 0000000000000000000000000000000000000000..8caf98e62ff3f39b291e341959ebc943361eec05 --- /dev/null +++ b/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..., + ) +} diff --git a/internal/event/event.go b/internal/event/event.go new file mode 100644 index 0000000000000000000000000000000000000000..42272c7035638fee7167b5c3510c7975cb9c9394 --- /dev/null +++ b/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 +} diff --git a/internal/event/logger.go b/internal/event/logger.go new file mode 100644 index 0000000000000000000000000000000000000000..7648ae2c2cca91ed20535c0d65a677cd4db84500 --- /dev/null +++ b/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...) +} diff --git a/internal/fsext/ls.go b/internal/fsext/ls.go index 2c46416f28a2777ddc9092883686c8a3461a9f7d..2027f734c4156572b134c012b2e3c143c364bd29 100644 --- a/internal/fsext/ls.go +++ b/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 } diff --git a/internal/gorust/pipeline.go b/internal/gorust/pipeline.go deleted file mode 100644 index 4eb01ba5f270908cd82bed6836a376d8ba770512..0000000000000000000000000000000000000000 --- a/internal/gorust/pipeline.go +++ /dev/null @@ -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) -} \ No newline at end of file diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index 3e9d97980ef5eee5ab66bad1e8f5dbcd3be5caea..ccdad58adf0c574f8627d0c4b5eb78e919063c7c 100644 --- a/internal/llm/agent/agent.go +++ b/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 diff --git a/internal/llm/agent/errors.go b/internal/llm/agent/errors.go new file mode 100644 index 0000000000000000000000000000000000000000..0e2f983d64b42b93ad3a51f32ce0335b0374a613 --- /dev/null +++ b/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) +} diff --git a/internal/llm/agent/event.go b/internal/llm/agent/event.go new file mode 100644 index 0000000000000000000000000000000000000000..cee57f9f0f229354a8a817368f4ddaa8768b8951 --- /dev/null +++ b/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(), + } +} diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index def891ce75a047a8f4b75e02a7fed0829165af8d..41415c474d9b236f2e451b526396a6168012efa7 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/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}), diff --git a/internal/llm/provider/anthropic.go b/internal/llm/provider/anthropic.go index 52cb173232477ae118321c0343eaefdf70bccd58..f0fb6b06368afe465522ab20f10a9d49d5f315d4 100644 --- a/internal/llm/provider/anthropic.go +++ b/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 } diff --git a/internal/llm/provider/gemini.go b/internal/llm/provider/gemini.go index 12dd9c7736d0db2eb57497561cb33144852124e3..b0c53e75d3a2cad501e25184a8174391e0b2dbbd 100644 --- a/internal/llm/provider/gemini.go +++ b/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) diff --git a/internal/llm/provider/openai.go b/internal/llm/provider/openai.go index d190f1eb921174e196baf4b4da38944e753f185f..5ff2535ef112397e155c02bafd866da4b0bc8b6a 100644 --- a/internal/llm/provider/openai.go +++ b/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 } diff --git a/internal/llm/provider/provider.go b/internal/llm/provider/provider.go index 3c5dade89bf3d1046d22ff4fb932a9bdd4cc938d..37cae1e7aa2b021aaa3c06a2f1fb6054fbe91cb9 100644 --- a/internal/llm/provider/provider.go +++ b/internal/llm/provider/provider.go @@ -13,7 +13,7 @@ import ( type EventType string -const maxRetries = 8 +const maxRetries = 3 const ( EventContentStart EventType = "content_start" diff --git a/internal/log/log.go b/internal/log/log.go index bf99fe60fa9a5015029af171adfd6b3f9bf5596b..9463c3bd97956da3ab895b8600f79d1c05790844 100644 --- a/internal/log/log.go +++ b/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) diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 50eb11d1b9d9700c76218dc88672a3b8076df53f..07f14e9cbf2fb4170b0ca2c1b9f3a4bb6de25bfd 100644 --- a/internal/lsp/client.go +++ b/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 diff --git a/internal/lsp/client_test.go b/internal/lsp/client_test.go index e05d6cec25a4b60979b11fab4b422bd83bf5ccff..be87b679cf9b1b1839e4120af023f42d9c97afa1 100644 --- a/internal/lsp/client_test.go +++ b/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) diff --git a/internal/session/session.go b/internal/session/session.go index 31eaa7709dc1a73a97647eb4df1516591a174de6..96d4f887ffddaac821155bf261495a08765d7a42 100644 --- a/internal/session/session.go +++ b/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 } diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index 38c8309ad69ba6bc9a61d4af663527cd7f0afb1e..df0e4027e60ec48e91c4c2f8d4c62e96e8d5e14b 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/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( diff --git a/internal/tui/components/chat/editor/keys.go b/internal/tui/components/chat/editor/keys.go index 9d2274753b4667031bb43a76f54fce18c1decf51..8bc8b2354dfb72120d9e6173256635e903d012fd 100644 --- a/internal/tui/components/chat/editor/keys.go +++ b/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( diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index ba7297ba5d023fa522d148ca0f46dfc504b2b6bc..2eeb2537329cb4ec2388a2f47fb9ee7912650dfd 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/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. diff --git a/internal/tui/components/chat/splash/keys.go b/internal/tui/components/chat/splash/keys.go index 675c608a94af4aa72b701376f3983506166ac7d7..d36c8d8e7ee2231ef8bc27eb053a5745a0bd3885 100644 --- a/internal/tui/components/chat/splash/keys.go +++ b/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"), ), } diff --git a/internal/tui/components/completions/keys.go b/internal/tui/components/completions/keys.go index 82372358028aec2b1384f1b4b6bff90be4a05eb8..dec1059f8cde34b7a65faad279ebe551a2108a3a 100644 --- a/internal/tui/components/completions/keys.go +++ b/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( diff --git a/internal/tui/components/dialogs/commands/keys.go b/internal/tui/components/dialogs/commands/keys.go index 9685216817c02cdfaab682f94e0f89aa64af365f..7b79a29c28a024154a3b4d8c763969585409fd00 100644 --- a/internal/tui/components/dialogs/commands/keys.go +++ b/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"), ), } diff --git a/internal/tui/components/dialogs/compact/keys.go b/internal/tui/components/dialogs/compact/keys.go index c3dd98e13035085b7d46e7a2e94450b25a7f0d59..cec1486491e342c28f148a50d37f1129944c002e 100644 --- a/internal/tui/components/dialogs/compact/keys.go +++ b/internal/tui/components/dialogs/compact/keys.go @@ -33,7 +33,7 @@ func DefaultKeyMap() KeyMap { key.WithHelp("n", "no"), ), Close: key.NewBinding( - key.WithKeys("esc"), + key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "cancel"), ), } diff --git a/internal/tui/components/dialogs/filepicker/keys.go b/internal/tui/components/dialogs/filepicker/keys.go index 9f3b706e3cf677b66cbc3136a7b98a466470d949..72e32f2ab9dd07d8b7165aee74744e8be5fd78e8 100644 --- a/internal/tui/components/dialogs/filepicker/keys.go +++ b/internal/tui/components/dialogs/filepicker/keys.go @@ -38,7 +38,7 @@ func DefaultKeyMap() KeyMap { ), Close: key.NewBinding( - key.WithKeys("esc"), + key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "close/exit"), ), } diff --git a/internal/tui/components/dialogs/keys.go b/internal/tui/components/dialogs/keys.go index c382b7e09e15de04efb5b2520bc490ef9d57b985..264ce3d42f6a99f441f961128f109e6baebf4c1b 100644 --- a/internal/tui/components/dialogs/keys.go +++ b/internal/tui/components/dialogs/keys.go @@ -12,7 +12,7 @@ type KeyMap struct { func DefaultKeyMap() KeyMap { return KeyMap{ Close: key.NewBinding( - key.WithKeys("esc"), + key.WithKeys("esc", "alt+esc"), ), } } diff --git a/internal/tui/components/dialogs/models/keys.go b/internal/tui/components/dialogs/models/keys.go index df546863d87d3a68777e51938f58eee28a5c6473..ef4a6228b839c43a3862e251999dadf81dd6403f 100644 --- a/internal/tui/components/dialogs/models/keys.go +++ b/internal/tui/components/dialogs/models/keys.go @@ -34,7 +34,7 @@ func DefaultKeyMap() KeyMap { key.WithHelp("tab", "toggle type"), ), Close: key.NewBinding( - key.WithKeys("esc"), + key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "cancel"), ), } diff --git a/internal/tui/components/dialogs/permissions/permissions.go b/internal/tui/components/dialogs/permissions/permissions.go index 35ddc4fc2fc3118abc5736ba5571ca6a3d8181da..75d2d1cc85aa07f60112d8dcbd1d62df2751bc02 100644 --- a/internal/tui/components/dialogs/permissions/permissions.go +++ b/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") diff --git a/internal/tui/components/dialogs/quit/keys.go b/internal/tui/components/dialogs/quit/keys.go index 3268749b20c703ae1faf7640e253ce557f051c65..2e8dbc199264eb9221544319f81ef859d71e58b5 100644 --- a/internal/tui/components/dialogs/quit/keys.go +++ b/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"), ), } diff --git a/internal/tui/components/dialogs/sessions/keys.go b/internal/tui/components/dialogs/sessions/keys.go index a3ca4b31f0c04c491fa7990f7e69ac546f608a7d..bc7ec1ba9f83915caee9189504abf0b07bd4a24b 100644 --- a/internal/tui/components/dialogs/sessions/keys.go +++ b/internal/tui/components/dialogs/sessions/keys.go @@ -26,7 +26,7 @@ func DefaultKeyMap() KeyMap { key.WithHelp("↑", "previous item"), ), Close: key.NewBinding( - key.WithKeys("esc"), + key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "cancel"), ), } diff --git a/internal/tui/components/dialogs/sessions/sessions.go b/internal/tui/components/dialogs/sessions/sessions.go index 4e5cbdef7fdb42f4c667de7ac5bdd5066e7be4df..037eb5ebb727a24b8ab9bfda2e2c72943120e819 100644 --- a/internal/tui/components/dialogs/sessions/sessions.go +++ b/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( diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index c9c19e3051e316b0bafdb94a9d1a09d965c5df0d..d188782f3abdd696e2948bec1a953dfbc524b71e 100644 --- a/internal/tui/page/chat/chat.go +++ b/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"), ), }) diff --git a/internal/tui/page/chat/keys.go b/internal/tui/page/chat/keys.go index ef896aaab10fe36ee8ce88d3f70a3f03e3c61d3e..679a97c69522c0e831e59bddc7b0c1ddcc55fbb9 100644 --- a/internal/tui/page/chat/keys.go +++ b/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( diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 52b47ea3a4d4755eb544dbaf2dc1caac205d573b..3166ed7eedebe0476ed07fe3785f62214d682681 100644 --- a/internal/tui/tui.go +++ b/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{}) diff --git a/main.go b/main.go index 072e3b35d2a2f408d8ed6a09423712b324df8b96..49dbcd7d3c045ae1510d7ca2055fa480c6fadadf 100644 --- a/main.go +++ b/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") }) diff --git a/schema.json b/schema.json index f0cb2053e188d918e4c49168080026de5f0bffe5..deb65846fe30ca689779e36745b9a429082c452b 100644 --- a/schema.json +++ b/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,