Detailed changes
@@ -863,6 +863,14 @@
"created_at": "2025-11-16T20:55:47Z",
"repoId": 987670088,
"pullRequestNo": 1457
+ },
+ {
+ "name": "micahwalter",
+ "id": 47419,
+ "comment_id": 3553482361,
+ "created_at": "2025-11-19T15:59:07Z",
+ "repoId": 987670088,
+ "pullRequestNo": 1478
}
]
}
@@ -186,11 +186,11 @@ That said, you can also set environment variables for preferred providers.
| `VERTEXAI_PROJECT` | Google Cloud VertexAI (Gemini) |
| `VERTEXAI_LOCATION` | Google Cloud VertexAI (Gemini) |
| `GROQ_API_KEY` | Groq |
-| `AWS_ACCESS_KEY_ID` | AWS Bedrock (Claude) |
-| `AWS_SECRET_ACCESS_KEY` | AWS Bedrock (Claude) |
-| `AWS_REGION` | AWS Bedrock (Claude) |
-| `AWS_PROFILE` | AWS Bedrock (Custom Profile) |
-| `AWS_BEARER_TOKEN_BEDROCK` | AWS Bedrock |
+| `AWS_ACCESS_KEY_ID` | Amazon Bedrock (Claude) |
+| `AWS_SECRET_ACCESS_KEY` | Amazon Bedrock (Claude) |
+| `AWS_REGION` | Amazon Bedrock (Claude) |
+| `AWS_PROFILE` | Amazon Bedrock (Custom Profile) |
+| `AWS_BEARER_TOKEN_BEDROCK` | Amazon Bedrock |
| `AZURE_OPENAI_API_ENDPOINT` | Azure OpenAI models |
| `AZURE_OPENAI_API_KEY` | Azure OpenAI models (optional when using Entra ID) |
| `AZURE_OPENAI_API_VERSION` | Azure OpenAI models |
@@ -5,7 +5,7 @@ go 1.25.0
require (
charm.land/bubbles/v2 v2.0.0-rc.1
charm.land/bubbletea/v2 v2.0.0-rc.1.0.20251117161017-15f884bd2973
- charm.land/fantasy v0.3.1
+ charm.land/fantasy v0.3.2
charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410
charm.land/x/vcr v0.1.1
github.com/JohannesKaufmann/html-to-markdown v1.6.0
@@ -26,7 +26,7 @@ require (
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
github.com/charmbracelet/x/exp/ordered v0.1.0
- github.com/charmbracelet/x/exp/slice v0.0.0-20251113172435-cef867b85f6a
+ github.com/charmbracelet/x/exp/slice v0.0.0-20251118172736-77d017256798
github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4
github.com/charmbracelet/x/term v0.2.2
github.com/denisbrodbeck/machineid v1.0.1
@@ -116,7 +116,8 @@ require (
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kaptinlin/go-i18n v0.2.0 // indirect
- github.com/kaptinlin/jsonschema v0.5.2 // indirect
+ github.com/kaptinlin/jsonpointer v0.4.6 // indirect
+ github.com/kaptinlin/jsonschema v0.6.1 // indirect
github.com/kaptinlin/messageformat-go v0.4.6 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.0.9 // indirect
@@ -2,8 +2,8 @@ charm.land/bubbles/v2 v2.0.0-rc.1 h1:EiIFVAc3Zi/yY86td+79mPhHR7AqZ1OxF+6ztpOCRaM
charm.land/bubbles/v2 v2.0.0-rc.1/go.mod h1:5AbN6cEd/47gkEf8TgiQ2O3RZ5QxMS14l9W+7F9fPC4=
charm.land/bubbletea/v2 v2.0.0-rc.1.0.20251117161017-15f884bd2973 h1:Ay8VWyn/CbwltswomzWXj0m5KKfSJavFfCDCxI+j8qo=
charm.land/bubbletea/v2 v2.0.0-rc.1.0.20251117161017-15f884bd2973/go.mod h1:IXFmnCnMLTWw/KQ9rEatSYqbAPAYi8kA3Yqwa1SFnLk=
-charm.land/fantasy v0.3.1 h1:YeMoLnaOHM3hdXq+SByxIKZxdm/2CHgKIS7HE0k/G6I=
-charm.land/fantasy v0.3.1/go.mod h1:sV8Ns/JTJHOaYOHPgVRDugMheAyxsW/nmdpVGrycYEk=
+charm.land/fantasy v0.3.2 h1:yHTsSZ25LcICMRw3xzdz3OkaZtDQch+B5ljJo17HxgU=
+charm.land/fantasy v0.3.2/go.mod h1:sV8Ns/JTJHOaYOHPgVRDugMheAyxsW/nmdpVGrycYEk=
charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 h1:D9PbaszZYpB4nj+d6HTWr1onlmlyuGVNfL9gAi8iB3k=
charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU=
charm.land/x/vcr v0.1.1 h1:PXCFMUG0rPtyk35rhfzYCJEduOzWXCIbrXTFq4OF/9Q=
@@ -108,8 +108,8 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6g
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
-github.com/charmbracelet/x/exp/slice v0.0.0-20251113172435-cef867b85f6a h1:+mXWbAiS5wNq8VvUd+/P4STqdu2dLtCe9sFr9IqdPDk=
-github.com/charmbracelet/x/exp/slice v0.0.0-20251113172435-cef867b85f6a/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
+github.com/charmbracelet/x/exp/slice v0.0.0-20251118172736-77d017256798 h1:EkOQR1G3MhyPxA39njT7E33V1Y/bDbF1XxEcMmM6Ox8=
+github.com/charmbracelet/x/exp/slice v0.0.0-20251118172736-77d017256798/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 h1:i/XilBPYK4L1Yo/mc9FPx0SyJzIsN0y4sj1MWq9Sscc=
@@ -197,8 +197,10 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kaptinlin/go-i18n v0.2.0 h1:8iwjAERQbCVF78c3HxC4MxUDxDRFvQVQlMDvlsO43hU=
github.com/kaptinlin/go-i18n v0.2.0/go.mod h1:gRHEMrTHtQLsAFwulPbJG71TwHjXxkagn88O8FI8FuA=
-github.com/kaptinlin/jsonschema v0.5.2 h1:ipUBEv1/RnT+ErwdqXZ3Xtwkwp6uqp/Q9lFILrwhUfc=
-github.com/kaptinlin/jsonschema v0.5.2/go.mod h1:HuWb90460GwFxRe0i9Ni3Z7YXwkjpqjeccWTB9gTZZE=
+github.com/kaptinlin/jsonpointer v0.4.6 h1:hAett1YROLwxAOKZS08hsJueXr1w0fTMSvWq2x1IoUA=
+github.com/kaptinlin/jsonpointer v0.4.6/go.mod h1:5pHXLIYd2FgV0rUEsChp6xTOvcC2OFk7kF/cjhHzL4g=
+github.com/kaptinlin/jsonschema v0.6.1 h1:RNUQ11ZCHTtM80YcVwRm033H5OJS+MpO06d9x7Yk25o=
+github.com/kaptinlin/jsonschema v0.6.1/go.mod h1:T8SNWNTRLDS1w+ogMZpGYqIfUXn/8DK9r06mf8XbNLE=
github.com/kaptinlin/messageformat-go v0.4.6 h1:57DUC9en40mGZR7MvqOS+5EYogAl465fjo+loAA1KPg=
github.com/kaptinlin/messageformat-go v0.4.6/go.mod h1:r0PH7FsxJX8jS/n6LAYZon5w3X+yfCLUrquqYd2H7ks=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
@@ -273,7 +273,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
}
if googleData, ok := reasoning.ProviderMetadata[google.Name]; ok {
if reasoning, ok := googleData.(*google.ReasoningMetadata); ok {
- currentAssistant.AppendReasoningSignature(reasoning.Signature)
+ currentAssistant.AppendThoughtSignature(reasoning.Signature, reasoning.ToolID)
}
}
if openaiData, ok := reasoning.ProviderMetadata[openai.Name]; ok {
@@ -33,6 +33,8 @@ import (
"github.com/charmbracelet/crush/internal/term"
"github.com/charmbracelet/crush/internal/tui/components/anim"
"github.com/charmbracelet/crush/internal/tui/styles"
+ "github.com/charmbracelet/crush/internal/update"
+ "github.com/charmbracelet/crush/internal/version"
"github.com/charmbracelet/x/ansi"
"github.com/charmbracelet/x/exp/charmtone"
)
@@ -92,6 +94,9 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
// Initialize LSP clients in the background.
app.initLSPClients(ctx)
+ // Check for updates in the background.
+ go app.checkForUpdates(ctx)
+
go func() {
slog.Info("Initializing MCP clients")
mcp.Initialize(ctx, app.Permissions, cfg)
@@ -390,3 +395,19 @@ func (app *App) Shutdown() {
}
}
}
+
+// checkForUpdates checks for available updates.
+func (app *App) checkForUpdates(ctx context.Context) {
+ checkCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
+ defer cancel()
+
+ info, err := update.Check(checkCtx, version.Version, update.Default)
+ if err != nil || !info.Available() {
+ return
+ }
+ app.events <- pubsub.UpdateAvailableMsg{
+ CurrentVersion: info.Current,
+ LatestVersion: info.Latest,
+ IsDevelopment: info.IsDevelopment(),
+ }
+}
@@ -89,7 +89,7 @@ type ProviderConfig struct {
// The provider's API endpoint.
BaseURL string `json:"base_url,omitempty" jsonschema:"description=Base URL for the provider's API,format=uri,example=https://api.openai.com/v1"`
// The provider type, e.g. "openai", "anthropic", etc. if empty it defaults to openai.
- Type catwalk.Type `json:"type,omitempty" jsonschema:"description=Provider type that determines the API format,enum=openai,enum=anthropic,enum=gemini,enum=azure,enum=vertexai,default=openai"`
+ Type catwalk.Type `json:"type,omitempty" jsonschema:"description=Provider type that determines the API format,enum=openai,enum=openai-compat,enum=anthropic,enum=gemini,enum=azure,enum=vertexai,default=openai"`
// The provider's API key.
APIKey string `json:"api_key,omitempty" jsonschema:"description=API key for authentication with the provider,example=$OPENAI_API_KEY"`
// Marks the provider as disabled.
@@ -635,6 +635,10 @@ func (c *ProviderConfig) TestConnection(resolver VariableResolver) error {
baseURL = "https://api.anthropic.com/v1"
}
testURL = baseURL + "/models"
+ // TODO: replace with const when catwalk is released
+ if c.ID == "kimi-coding" {
+ testURL = baseURL + "/v1/models"
+ }
headers["x-api-key"] = apiKey
headers["anthropic-version"] = "2023-06-01"
case catwalk.TypeGoogle:
@@ -45,6 +45,7 @@ type ReasoningContent struct {
Thinking string `json:"thinking"`
Signature string `json:"signature"`
ThoughtSignature string `json:"thought_signature"` // Used for google
+ ToolID string `json:"tool_id"` // Used for openrouter google models
ResponsesData *openai.ResponsesReasoningMetadata `json:"responses_data"`
StartedAt int64 `json:"started_at,omitempty"`
FinishedAt int64 `json:"finished_at,omitempty"`
@@ -261,12 +262,13 @@ func (m *Message) AppendReasoningContent(delta string) {
}
}
-func (m *Message) AppendThoughtSignature(signature string) {
+func (m *Message) AppendThoughtSignature(signature string, toolCallID string) {
for i, part := range m.Parts {
if c, ok := part.(ReasoningContent); ok {
m.Parts[i] = ReasoningContent{
Thinking: c.Thinking,
ThoughtSignature: c.ThoughtSignature + signature,
+ ToolID: toolCallID,
Signature: c.Signature,
StartedAt: c.StartedAt,
FinishedAt: c.FinishedAt,
@@ -464,6 +466,7 @@ func (m *Message) ToAIMessage() []fantasy.Message {
if reasoning.ThoughtSignature != "" {
reasoningPart.ProviderOptions[google.Name] = &google.ReasoningMetadata{
Signature: reasoning.ThoughtSignature,
+ ToolID: reasoning.ToolID,
}
}
parts = append(parts, reasoningPart)
@@ -26,3 +26,10 @@ type (
Publish(EventType, T)
}
)
+
+// UpdateAvailableMsg is sent when a new version is available.
+type UpdateAvailableMsg struct {
+ CurrentVersion string
+ LatestVersion string
+ IsDevelopment bool
+}
@@ -8,6 +8,7 @@ import (
)
func TestBackgroundShellManager_Start(t *testing.T) {
+ t.Skip("Skipping this until I figure out why its flaky")
t.Parallel()
ctx := context.Background()
@@ -82,10 +82,10 @@ func (m *statusCmp) infoMsg() string {
info := ansi.Truncate(m.info.Msg, widthLeft, "…")
message = t.S().Base.Foreground(t.BgOverlay).Width(widthLeft+2).Background(t.Warning).Padding(0, 1).Render(info)
default:
- infoType = t.S().Base.Foreground(t.BgOverlay).Background(t.Green).Padding(0, 1).Render("OKAY!")
+ infoType = t.S().Base.Foreground(t.BgSubtle).Background(t.Green).Padding(0, 1).Bold(true).Render("HEY!")
widthLeft := m.width - (lipgloss.Width(infoType) + 2)
info := ansi.Truncate(m.info.Msg, widthLeft, "…")
- message = t.S().Base.Background(t.Success).Width(widthLeft+2).Foreground(t.White).Padding(0, 1).Render(info)
+ message = t.S().Base.Background(t.GreenDark).Width(widthLeft+2).Foreground(t.BgSubtle).Padding(0, 1).Render(info)
}
return ansi.Truncate(infoType+message, m.width, "…")
}
@@ -372,6 +372,20 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, pageCmd)
}
return a, tea.Batch(cmds...)
+ // Update Available
+ case pubsub.UpdateAvailableMsg:
+ // Show update notification in status bar
+ statusMsg := fmt.Sprintf("Crush update available: v%s → v%s.", msg.CurrentVersion, msg.LatestVersion)
+ if msg.IsDevelopment {
+ statusMsg = fmt.Sprintf("This is a development version of Crush. The latest version is v%s.", msg.LatestVersion)
+ }
+ s, statusCmd := a.status.Update(util.InfoMsg{
+ Type: util.InfoTypeInfo,
+ Msg: statusMsg,
+ TTL: 30 * time.Second,
+ })
+ a.status = s.(status.StatusCmp)
+ return a, statusCmd
}
s, _ := a.status.Update(msg)
a.status = s.(status.StatusCmp)
@@ -35,6 +35,7 @@ type InfoType int
const (
InfoTypeInfo InfoType = iota
+ InfoTypeSuccess
InfoTypeWarn
InfoTypeError
)
@@ -0,0 +1,118 @@
+package update
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "regexp"
+ "strings"
+ "time"
+)
+
+const (
+ githubApiUrl = "https://api.github.com/repos/charmbracelet/crush/releases/latest"
+ userAgent = "crush/1.0"
+)
+
+// Default is the default [Client].
+var Default Client = &github{}
+
+// Info contains information about an available update.
+type Info struct {
+ Current string
+ Latest string
+ URL string
+}
+
+// Matches a version string like:
+// v0.0.0-0.20251231235959-06c807842604
+var goInstallRegexp = regexp.MustCompile(`^v?\d+\.\d+\.\d+-\d+\.\d{14}-[0-9a-f]{12}$`)
+
+func (i Info) IsDevelopment() bool {
+ return i.Current == "devel" || i.Current == "unknown" || strings.Contains(i.Current, "dirty") || goInstallRegexp.MatchString(i.Current)
+}
+
+// Available returns true if there's an update available.
+//
+// If both current and latest are stable versions, returns true if versions are
+// different.
+// If current is a pre-release and latest isn't, returns true.
+// If latest is a pre-release and current isn't, returns false.
+func (i Info) Available() bool {
+ cpr := strings.Contains(i.Current, "-")
+ lpr := strings.Contains(i.Latest, "-")
+ // current is pre release && latest isn't a prerelease
+ if cpr && !lpr {
+ return true
+ }
+ // latest is pre release && current isn't a prerelease
+ if lpr && !cpr {
+ return false
+ }
+ return i.Current != i.Latest
+}
+
+// Check checks if a new version is available.
+func Check(ctx context.Context, current string, client Client) (Info, error) {
+ info := Info{
+ Current: current,
+ Latest: current,
+ }
+
+ release, err := client.Latest(ctx)
+ if err != nil {
+ return info, fmt.Errorf("failed to fetch latest release: %w", err)
+ }
+
+ info.Latest = strings.TrimPrefix(release.TagName, "v")
+ info.Current = strings.TrimPrefix(info.Current, "v")
+ info.URL = release.HTMLURL
+ return info, nil
+}
+
+// Release represents a GitHub release.
+type Release struct {
+ TagName string `json:"tag_name"`
+ HTMLURL string `json:"html_url"`
+}
+
+// Client is a client that can get the latest release.
+type Client interface {
+ Latest(ctx context.Context) (*Release, error)
+}
+
+type github struct{}
+
+// Latest implements [Client].
+func (c *github) Latest(ctx context.Context) (*Release, error) {
+ client := &http.Client{
+ Timeout: 30 * time.Second,
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "GET", githubApiUrl, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("User-Agent", userAgent)
+ req.Header.Set("Accept", "application/vnd.github.v3+json")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("github api returned status %d: %s", resp.StatusCode, string(body))
+ }
+
+ var release Release
+ if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
+ return nil, err
+ }
+
+ return &release, nil
+}
@@ -0,0 +1,48 @@
+package update
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestCheckForUpdate_Old(t *testing.T) {
+ info, err := Check(t.Context(), "v0.10.0", testClient{"v0.11.0"})
+ require.NoError(t, err)
+ require.NotNil(t, info)
+ require.True(t, info.Available())
+}
+
+func TestCheckForUpdate_Beta(t *testing.T) {
+ t.Run("current is stable", func(t *testing.T) {
+ info, err := Check(t.Context(), "v0.10.0", testClient{"v0.11.0-beta.1"})
+ require.NoError(t, err)
+ require.NotNil(t, info)
+ require.False(t, info.Available())
+ })
+
+ t.Run("current is also beta", func(t *testing.T) {
+ info, err := Check(t.Context(), "v0.11.0-beta.1", testClient{"v0.11.0-beta.2"})
+ require.NoError(t, err)
+ require.NotNil(t, info)
+ require.True(t, info.Available())
+ })
+
+ t.Run("current is beta, latest isn't", func(t *testing.T) {
+ info, err := Check(t.Context(), "v0.11.0-beta.1", testClient{"v0.11.0"})
+ require.NoError(t, err)
+ require.NotNil(t, info)
+ require.True(t, info.Available())
+ })
+}
+
+type testClient struct{ tag string }
+
+// Latest implements Client.
+func (t testClient) Latest(ctx context.Context) (*Release, error) {
+ return &Release{
+ TagName: t.tag,
+ HTMLURL: "https://example.org",
+ }, nil
+}
@@ -4,7 +4,7 @@ import "runtime/debug"
// Build-time parameters set via -ldflags
-var Version = "unknown"
+var Version = "devel"
// A user may install crush using `go install github.com/charmbracelet/crush@latest`.
// without -ldflags, in which case the version above is unset. As a workaround
@@ -13,14 +13,10 @@ var Version = "unknown"
func init() {
info, ok := debug.ReadBuildInfo()
if !ok {
- // < go v1.18
return
}
mainVersion := info.Main.Version
- if mainVersion == "" || mainVersion == "(devel)" {
- // bin not built using `go install`
- return
+ if mainVersion != "" && mainVersion != "(devel)" {
+ Version = mainVersion
}
- // bin built using `go install`
- Version = mainVersion
}
@@ -468,6 +468,7 @@
"type": "string",
"enum": [
"openai",
+ "openai-compat",
"anthropic",
"gemini",
"azure",