Detailed changes
@@ -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
@@ -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
@@ -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
@@ -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=
@@ -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)
@@ -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
@@ -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",
@@ -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
@@ -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) {
@@ -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...,
+ )
+}
@@ -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
+}
@@ -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...)
+}
@@ -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
}
@@ -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)
-}
@@ -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
@@ -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)
+}
@@ -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(),
+ }
+}
@@ -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}),
@@ -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
}
@@ -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)
@@ -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
}
@@ -13,7 +13,7 @@ import (
type EventType string
-const maxRetries = 8
+const maxRetries = 3
const (
EventContentStart EventType = "content_start"
@@ -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)
@@ -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
@@ -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)
@@ -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
}
@@ -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(
@@ -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(
@@ -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.
@@ -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"),
),
}
@@ -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(
@@ -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"),
),
}
@@ -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"),
),
}
@@ -38,7 +38,7 @@ func DefaultKeyMap() KeyMap {
),
Close: key.NewBinding(
- key.WithKeys("esc"),
+ key.WithKeys("esc", "alt+esc"),
key.WithHelp("esc", "close/exit"),
),
}
@@ -12,7 +12,7 @@ type KeyMap struct {
func DefaultKeyMap() KeyMap {
return KeyMap{
Close: key.NewBinding(
- key.WithKeys("esc"),
+ key.WithKeys("esc", "alt+esc"),
),
}
}
@@ -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"),
),
}
@@ -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")
@@ -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"),
),
}
@@ -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"),
),
}
@@ -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(
@@ -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"),
),
})
@@ -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(
@@ -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{})
@@ -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")
})
@@ -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,