on main help sign in
Merge branch 'main' into ui
38605b2Ayman Bagabas
.github/cla-signatures.json+8
@@ -863,6 +863,14 @@
863 "created_at": "2025-11-16T20:55:47Z",
863 "created_at": "2025-11-16T20:55:47Z",
864 "repoId": 987670088,
864 "repoId": 987670088,
865 "pullRequestNo": 1457
865 "pullRequestNo": 1457
866+ },
867+ {
868+ "name": "micahwalter",
869+ "id": 47419,
870+ "comment_id": 3553482361,
871+ "created_at": "2025-11-19T15:59:07Z",
872+ "repoId": 987670088,
873+ "pullRequestNo": 1478
866 }
874 }
867 ]
875 ]
868}
876}
README.md+5−5
@@ -186,11 +186,11 @@ That said, you can also set environment variables for preferred providers.
186| `VERTEXAI_PROJECT` | Google Cloud VertexAI (Gemini) |
186| `VERTEXAI_PROJECT` | Google Cloud VertexAI (Gemini) |
187| `VERTEXAI_LOCATION` | Google Cloud VertexAI (Gemini) |
187| `VERTEXAI_LOCATION` | Google Cloud VertexAI (Gemini) |
188| `GROQ_API_KEY` | Groq |
188| `GROQ_API_KEY` | Groq |
189−| `AWS_ACCESS_KEY_ID` | AWS Bedrock (Claude) |
189+| `AWS_ACCESS_KEY_ID` | Amazon Bedrock (Claude) |
190−| `AWS_SECRET_ACCESS_KEY` | AWS Bedrock (Claude) |
190+| `AWS_SECRET_ACCESS_KEY` | Amazon Bedrock (Claude) |
191−| `AWS_REGION` | AWS Bedrock (Claude) |
191+| `AWS_REGION` | Amazon Bedrock (Claude) |
192−| `AWS_PROFILE` | AWS Bedrock (Custom Profile) |
192+| `AWS_PROFILE` | Amazon Bedrock (Custom Profile) |
193−| `AWS_BEARER_TOKEN_BEDROCK` | AWS Bedrock |
193+| `AWS_BEARER_TOKEN_BEDROCK` | Amazon Bedrock |
194| `AZURE_OPENAI_API_ENDPOINT` | Azure OpenAI models |
194| `AZURE_OPENAI_API_ENDPOINT` | Azure OpenAI models |
195| `AZURE_OPENAI_API_KEY` | Azure OpenAI models (optional when using Entra ID) |
195| `AZURE_OPENAI_API_KEY` | Azure OpenAI models (optional when using Entra ID) |
196| `AZURE_OPENAI_API_VERSION` | Azure OpenAI models |
196| `AZURE_OPENAI_API_VERSION` | Azure OpenAI models |
go.mod+4−3
@@ -5,7 +5,7 @@ go 1.25.0
5require (
5require (
6 charm.land/bubbles/v2 v2.0.0-rc.1
6 charm.land/bubbles/v2 v2.0.0-rc.1
7 charm.land/bubbletea/v2 v2.0.0-rc.1.0.20251117161017-15f884bd2973
7 charm.land/bubbletea/v2 v2.0.0-rc.1.0.20251117161017-15f884bd2973
8− charm.land/fantasy v0.3.1
8+ charm.land/fantasy v0.3.2
9 charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410
9 charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410
10 charm.land/x/vcr v0.1.1
10 charm.land/x/vcr v0.1.1
11 github.com/JohannesKaufmann/html-to-markdown v1.6.0
11 github.com/JohannesKaufmann/html-to-markdown v1.6.0
@@ -26,7 +26,7 @@ require (
26 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3
26 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3
27 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
27 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
28 github.com/charmbracelet/x/exp/ordered v0.1.0
28 github.com/charmbracelet/x/exp/ordered v0.1.0
29− github.com/charmbracelet/x/exp/slice v0.0.0-20251113172435-cef867b85f6a
29+ github.com/charmbracelet/x/exp/slice v0.0.0-20251118172736-77d017256798
30 github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4
30 github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4
31 github.com/charmbracelet/x/term v0.2.2
31 github.com/charmbracelet/x/term v0.2.2
32 github.com/denisbrodbeck/machineid v1.0.1
32 github.com/denisbrodbeck/machineid v1.0.1
@@ -116,7 +116,8 @@ require (
116 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
116 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
117 github.com/inconshreveable/mousetrap v1.1.0 // indirect
117 github.com/inconshreveable/mousetrap v1.1.0 // indirect
118 github.com/kaptinlin/go-i18n v0.2.0 // indirect
118 github.com/kaptinlin/go-i18n v0.2.0 // indirect
119+ github.com/kaptinlin/jsonpointer v0.4.6 // indirect
119− github.com/kaptinlin/jsonschema v0.5.2 // indirect
120+ github.com/kaptinlin/jsonschema v0.6.1 // indirect
120 github.com/kaptinlin/messageformat-go v0.4.6 // indirect
121 github.com/kaptinlin/messageformat-go v0.4.6 // indirect
121 github.com/klauspost/compress v1.18.0 // indirect
122 github.com/klauspost/compress v1.18.0 // indirect
122 github.com/klauspost/cpuid/v2 v2.0.9 // indirect
123 github.com/klauspost/cpuid/v2 v2.0.9 // indirect
go.sum+8−6
@@ -2,8 +2,8 @@ charm.land/bubbles/v2 v2.0.0-rc.1 h1:EiIFVAc3Zi/yY86td+79mPhHR7AqZ1OxF+6ztpOCRaM
2charm.land/bubbles/v2 v2.0.0-rc.1/go.mod h1:5AbN6cEd/47gkEf8TgiQ2O3RZ5QxMS14l9W+7F9fPC4=
2charm.land/bubbles/v2 v2.0.0-rc.1/go.mod h1:5AbN6cEd/47gkEf8TgiQ2O3RZ5QxMS14l9W+7F9fPC4=
3charm.land/bubbletea/v2 v2.0.0-rc.1.0.20251117161017-15f884bd2973 h1:Ay8VWyn/CbwltswomzWXj0m5KKfSJavFfCDCxI+j8qo=
3charm.land/bubbletea/v2 v2.0.0-rc.1.0.20251117161017-15f884bd2973 h1:Ay8VWyn/CbwltswomzWXj0m5KKfSJavFfCDCxI+j8qo=
4charm.land/bubbletea/v2 v2.0.0-rc.1.0.20251117161017-15f884bd2973/go.mod h1:IXFmnCnMLTWw/KQ9rEatSYqbAPAYi8kA3Yqwa1SFnLk=
4charm.land/bubbletea/v2 v2.0.0-rc.1.0.20251117161017-15f884bd2973/go.mod h1:IXFmnCnMLTWw/KQ9rEatSYqbAPAYi8kA3Yqwa1SFnLk=
5−charm.land/fantasy v0.3.1 h1:YeMoLnaOHM3hdXq+SByxIKZxdm/2CHgKIS7HE0k/G6I=
5+charm.land/fantasy v0.3.2 h1:yHTsSZ25LcICMRw3xzdz3OkaZtDQch+B5ljJo17HxgU=
6−charm.land/fantasy v0.3.1/go.mod h1:sV8Ns/JTJHOaYOHPgVRDugMheAyxsW/nmdpVGrycYEk=
6+charm.land/fantasy v0.3.2/go.mod h1:sV8Ns/JTJHOaYOHPgVRDugMheAyxsW/nmdpVGrycYEk=
7charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 h1:D9PbaszZYpB4nj+d6HTWr1onlmlyuGVNfL9gAi8iB3k=
7charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 h1:D9PbaszZYpB4nj+d6HTWr1onlmlyuGVNfL9gAi8iB3k=
8charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU=
8charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU=
9charm.land/x/vcr v0.1.1 h1:PXCFMUG0rPtyk35rhfzYCJEduOzWXCIbrXTFq4OF/9Q=
9charm.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
108github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
108github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
109github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
109github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
110github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
110github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
111−github.com/charmbracelet/x/exp/slice v0.0.0-20251113172435-cef867b85f6a h1:+mXWbAiS5wNq8VvUd+/P4STqdu2dLtCe9sFr9IqdPDk=
111+github.com/charmbracelet/x/exp/slice v0.0.0-20251118172736-77d017256798 h1:EkOQR1G3MhyPxA39njT7E33V1Y/bDbF1XxEcMmM6Ox8=
112−github.com/charmbracelet/x/exp/slice v0.0.0-20251113172435-cef867b85f6a/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
112+github.com/charmbracelet/x/exp/slice v0.0.0-20251118172736-77d017256798/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
113github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
113github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
114github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
114github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
115github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 h1:i/XilBPYK4L1Yo/mc9FPx0SyJzIsN0y4sj1MWq9Sscc=
115github.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
197github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
197github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
198github.com/kaptinlin/go-i18n v0.2.0 h1:8iwjAERQbCVF78c3HxC4MxUDxDRFvQVQlMDvlsO43hU=
198github.com/kaptinlin/go-i18n v0.2.0 h1:8iwjAERQbCVF78c3HxC4MxUDxDRFvQVQlMDvlsO43hU=
199github.com/kaptinlin/go-i18n v0.2.0/go.mod h1:gRHEMrTHtQLsAFwulPbJG71TwHjXxkagn88O8FI8FuA=
199github.com/kaptinlin/go-i18n v0.2.0/go.mod h1:gRHEMrTHtQLsAFwulPbJG71TwHjXxkagn88O8FI8FuA=
200−github.com/kaptinlin/jsonschema v0.5.2 h1:ipUBEv1/RnT+ErwdqXZ3Xtwkwp6uqp/Q9lFILrwhUfc=
200+github.com/kaptinlin/jsonpointer v0.4.6 h1:hAett1YROLwxAOKZS08hsJueXr1w0fTMSvWq2x1IoUA=
201+github.com/kaptinlin/jsonpointer v0.4.6/go.mod h1:5pHXLIYd2FgV0rUEsChp6xTOvcC2OFk7kF/cjhHzL4g=
202+github.com/kaptinlin/jsonschema v0.6.1 h1:RNUQ11ZCHTtM80YcVwRm033H5OJS+MpO06d9x7Yk25o=
201−github.com/kaptinlin/jsonschema v0.5.2/go.mod h1:HuWb90460GwFxRe0i9Ni3Z7YXwkjpqjeccWTB9gTZZE=
203+github.com/kaptinlin/jsonschema v0.6.1/go.mod h1:T8SNWNTRLDS1w+ogMZpGYqIfUXn/8DK9r06mf8XbNLE=
202github.com/kaptinlin/messageformat-go v0.4.6 h1:57DUC9en40mGZR7MvqOS+5EYogAl465fjo+loAA1KPg=
204github.com/kaptinlin/messageformat-go v0.4.6 h1:57DUC9en40mGZR7MvqOS+5EYogAl465fjo+loAA1KPg=
203github.com/kaptinlin/messageformat-go v0.4.6/go.mod h1:r0PH7FsxJX8jS/n6LAYZon5w3X+yfCLUrquqYd2H7ks=
205github.com/kaptinlin/messageformat-go v0.4.6/go.mod h1:r0PH7FsxJX8jS/n6LAYZon5w3X+yfCLUrquqYd2H7ks=
204github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
206github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
internal/agent/agent.go+1−1
@@ -273,7 +273,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
273 }
273 }
274 if googleData, ok := reasoning.ProviderMetadata[google.Name]; ok {
274 if googleData, ok := reasoning.ProviderMetadata[google.Name]; ok {
275 if reasoning, ok := googleData.(*google.ReasoningMetadata); ok {
275 if reasoning, ok := googleData.(*google.ReasoningMetadata); ok {
276− currentAssistant.AppendReasoningSignature(reasoning.Signature)
276+ currentAssistant.AppendThoughtSignature(reasoning.Signature, reasoning.ToolID)
277 }
277 }
278 }
278 }
279 if openaiData, ok := reasoning.ProviderMetadata[openai.Name]; ok {
279 if openaiData, ok := reasoning.ProviderMetadata[openai.Name]; ok {
internal/app/app.go+21
@@ -33,6 +33,8 @@ import (
33 "github.com/charmbracelet/crush/internal/term"
33 "github.com/charmbracelet/crush/internal/term"
34 "github.com/charmbracelet/crush/internal/tui/components/anim"
34 "github.com/charmbracelet/crush/internal/tui/components/anim"
35 "github.com/charmbracelet/crush/internal/tui/styles"
35 "github.com/charmbracelet/crush/internal/tui/styles"
36+ "github.com/charmbracelet/crush/internal/update"
37+ "github.com/charmbracelet/crush/internal/version"
36 "github.com/charmbracelet/x/ansi"
38 "github.com/charmbracelet/x/ansi"
37 "github.com/charmbracelet/x/exp/charmtone"
39 "github.com/charmbracelet/x/exp/charmtone"
38)
40)
@@ -92,6 +94,9 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
92 // Initialize LSP clients in the background.
94 // Initialize LSP clients in the background.
93 app.initLSPClients(ctx)
95 app.initLSPClients(ctx)
94
96
97+ // Check for updates in the background.
98+ go app.checkForUpdates(ctx)
99+
95 go func() {
100 go func() {
96 slog.Info("Initializing MCP clients")
101 slog.Info("Initializing MCP clients")
97 mcp.Initialize(ctx, app.Permissions, cfg)
102 mcp.Initialize(ctx, app.Permissions, cfg)
@@ -390,3 +395,19 @@ func (app *App) Shutdown() {
390 }
395 }
391 }
396 }
392}
397}
398+
399+// checkForUpdates checks for available updates.
400+func (app *App) checkForUpdates(ctx context.Context) {
401+ checkCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
402+ defer cancel()
403+
404+ info, err := update.Check(checkCtx, version.Version, update.Default)
405+ if err != nil || !info.Available() {
406+ return
407+ }
408+ app.events <- pubsub.UpdateAvailableMsg{
409+ CurrentVersion: info.Current,
410+ LatestVersion: info.Latest,
411+ IsDevelopment: info.IsDevelopment(),
412+ }
413+}
internal/config/config.go+5−1
@@ -89,7 +89,7 @@ type ProviderConfig struct {
89 // The provider's API endpoint.
89 // The provider's API endpoint.
90 BaseURL string `json:"base_url,omitempty" jsonschema:"description=Base URL for the provider's API,format=uri,example=https://api.openai.com/v1"`
90 BaseURL string `json:"base_url,omitempty" jsonschema:"description=Base URL for the provider's API,format=uri,example=https://api.openai.com/v1"`
91 // The provider type, e.g. "openai", "anthropic", etc. if empty it defaults to openai.
91 // The provider type, e.g. "openai", "anthropic", etc. if empty it defaults to openai.
92− 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"`
92+ 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"`
93 // The provider's API key.
93 // The provider's API key.
94 APIKey string `json:"api_key,omitempty" jsonschema:"description=API key for authentication with the provider,example=$OPENAI_API_KEY"`
94 APIKey string `json:"api_key,omitempty" jsonschema:"description=API key for authentication with the provider,example=$OPENAI_API_KEY"`
95 // Marks the provider as disabled.
95 // Marks the provider as disabled.
@@ -635,6 +635,10 @@ func (c *ProviderConfig) TestConnection(resolver VariableResolver) error {
635 baseURL = "https://api.anthropic.com/v1"
635 baseURL = "https://api.anthropic.com/v1"
636 }
636 }
637 testURL = baseURL + "/models"
637 testURL = baseURL + "/models"
638+ // TODO: replace with const when catwalk is released
639+ if c.ID == "kimi-coding" {
640+ testURL = baseURL + "/v1/models"
641+ }
638 headers["x-api-key"] = apiKey
642 headers["x-api-key"] = apiKey
639 headers["anthropic-version"] = "2023-06-01"
643 headers["anthropic-version"] = "2023-06-01"
640 case catwalk.TypeGoogle:
644 case catwalk.TypeGoogle:
internal/message/content.go+4−1
@@ -45,6 +45,7 @@ type ReasoningContent struct {
45 Thinking string `json:"thinking"`
45 Thinking string `json:"thinking"`
46 Signature string `json:"signature"`
46 Signature string `json:"signature"`
47 ThoughtSignature string `json:"thought_signature"` // Used for google
47 ThoughtSignature string `json:"thought_signature"` // Used for google
48+ ToolID string `json:"tool_id"` // Used for openrouter google models
48 ResponsesData *openai.ResponsesReasoningMetadata `json:"responses_data"`
49 ResponsesData *openai.ResponsesReasoningMetadata `json:"responses_data"`
49 StartedAt int64 `json:"started_at,omitempty"`
50 StartedAt int64 `json:"started_at,omitempty"`
50 FinishedAt int64 `json:"finished_at,omitempty"`
51 FinishedAt int64 `json:"finished_at,omitempty"`
@@ -261,12 +262,13 @@ func (m *Message) AppendReasoningContent(delta string) {
261 }
262 }
262}
263}
263
264
264−func (m *Message) AppendThoughtSignature(signature string) {
265+func (m *Message) AppendThoughtSignature(signature string, toolCallID string) {
265 for i, part := range m.Parts {
266 for i, part := range m.Parts {
266 if c, ok := part.(ReasoningContent); ok {
267 if c, ok := part.(ReasoningContent); ok {
267 m.Parts[i] = ReasoningContent{
268 m.Parts[i] = ReasoningContent{
268 Thinking: c.Thinking,
269 Thinking: c.Thinking,
269 ThoughtSignature: c.ThoughtSignature + signature,
270 ThoughtSignature: c.ThoughtSignature + signature,
271+ ToolID: toolCallID,
270 Signature: c.Signature,
272 Signature: c.Signature,
271 StartedAt: c.StartedAt,
273 StartedAt: c.StartedAt,
272 FinishedAt: c.FinishedAt,
274 FinishedAt: c.FinishedAt,
@@ -464,6 +466,7 @@ func (m *Message) ToAIMessage() []fantasy.Message {
464 if reasoning.ThoughtSignature != "" {
466 if reasoning.ThoughtSignature != "" {
465 reasoningPart.ProviderOptions[google.Name] = &google.ReasoningMetadata{
467 reasoningPart.ProviderOptions[google.Name] = &google.ReasoningMetadata{
466 Signature: reasoning.ThoughtSignature,
468 Signature: reasoning.ThoughtSignature,
469+ ToolID: reasoning.ToolID,
467 }
470 }
468 }
471 }
469 parts = append(parts, reasoningPart)
472 parts = append(parts, reasoningPart)
internal/pubsub/events.go+7
@@ -26,3 +26,10 @@ type (
26 Publish(EventType, T)
26 Publish(EventType, T)
27 }
27 }
28)
28)
29+
30+// UpdateAvailableMsg is sent when a new version is available.
31+type UpdateAvailableMsg struct {
32+ CurrentVersion string
33+ LatestVersion string
34+ IsDevelopment bool
35+}
internal/shell/background_test.go+1
@@ -8,6 +8,7 @@ import (
8)
8)
9
9
10func TestBackgroundShellManager_Start(t *testing.T) {
10func TestBackgroundShellManager_Start(t *testing.T) {
11+ t.Skip("Skipping this until I figure out why its flaky")
11 t.Parallel()
12 t.Parallel()
12
13
13 ctx := context.Background()
14 ctx := context.Background()
internal/tui/components/core/status/status.go+2−2
@@ -82,10 +82,10 @@ func (m *statusCmp) infoMsg() string {
82 info := ansi.Truncate(m.info.Msg, widthLeft, "…")
82 info := ansi.Truncate(m.info.Msg, widthLeft, "…")
83 message = t.S().Base.Foreground(t.BgOverlay).Width(widthLeft+2).Background(t.Warning).Padding(0, 1).Render(info)
83 message = t.S().Base.Foreground(t.BgOverlay).Width(widthLeft+2).Background(t.Warning).Padding(0, 1).Render(info)
84 default:
84 default:
85− infoType = t.S().Base.Foreground(t.BgOverlay).Background(t.Green).Padding(0, 1).Render("OKAY!")
85+ infoType = t.S().Base.Foreground(t.BgSubtle).Background(t.Green).Padding(0, 1).Bold(true).Render("HEY!")
86 widthLeft := m.width - (lipgloss.Width(infoType) + 2)
86 widthLeft := m.width - (lipgloss.Width(infoType) + 2)
87 info := ansi.Truncate(m.info.Msg, widthLeft, "…")
87 info := ansi.Truncate(m.info.Msg, widthLeft, "…")
88− message = t.S().Base.Background(t.Success).Width(widthLeft+2).Foreground(t.White).Padding(0, 1).Render(info)
88+ message = t.S().Base.Background(t.GreenDark).Width(widthLeft+2).Foreground(t.BgSubtle).Padding(0, 1).Render(info)
89 }
89 }
90 return ansi.Truncate(infoType+message, m.width, "…")
90 return ansi.Truncate(infoType+message, m.width, "…")
91}
91}
internal/tui/tui.go+14
@@ -372,6 +372,20 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
372 cmds = append(cmds, pageCmd)
372 cmds = append(cmds, pageCmd)
373 }
373 }
374 return a, tea.Batch(cmds...)
374 return a, tea.Batch(cmds...)
375+ // Update Available
376+ case pubsub.UpdateAvailableMsg:
377+ // Show update notification in status bar
378+ statusMsg := fmt.Sprintf("Crush update available: v%s → v%s.", msg.CurrentVersion, msg.LatestVersion)
379+ if msg.IsDevelopment {
380+ statusMsg = fmt.Sprintf("This is a development version of Crush. The latest version is v%s.", msg.LatestVersion)
381+ }
382+ s, statusCmd := a.status.Update(util.InfoMsg{
383+ Type: util.InfoTypeInfo,
384+ Msg: statusMsg,
385+ TTL: 30 * time.Second,
386+ })
387+ a.status = s.(status.StatusCmp)
388+ return a, statusCmd
375 }
389 }
376 s, _ := a.status.Update(msg)
390 s, _ := a.status.Update(msg)
377 a.status = s.(status.StatusCmp)
391 a.status = s.(status.StatusCmp)
internal/tui/util/util.go+1
@@ -35,6 +35,7 @@ type InfoType int
35
35
36const (
36const (
37 InfoTypeInfo InfoType = iota
37 InfoTypeInfo InfoType = iota
38+ InfoTypeSuccess
38 InfoTypeWarn
39 InfoTypeWarn
39 InfoTypeError
40 InfoTypeError
40)
41)
internal/update/update.gonew file+118
@@ -0,0 +1,118 @@
1+package update
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "net/http"
9+ "regexp"
10+ "strings"
11+ "time"
12+)
13+
14+const (
15+ githubApiUrl = "https://api.github.com/repos/charmbracelet/crush/releases/latest"
16+ userAgent = "crush/1.0"
17+)
18+
19+// Default is the default [Client].
20+var Default Client = &github{}
21+
22+// Info contains information about an available update.
23+type Info struct {
24+ Current string
25+ Latest string
26+ URL string
27+}
28+
29+// Matches a version string like:
30+// v0.0.0-0.20251231235959-06c807842604
31+var goInstallRegexp = regexp.MustCompile(`^v?\d+\.\d+\.\d+-\d+\.\d{14}-[0-9a-f]{12}$`)
32+
33+func (i Info) IsDevelopment() bool {
34+ return i.Current == "devel" || i.Current == "unknown" || strings.Contains(i.Current, "dirty") || goInstallRegexp.MatchString(i.Current)
35+}
36+
37+// Available returns true if there's an update available.
38+//
39+// If both current and latest are stable versions, returns true if versions are
40+// different.
41+// If current is a pre-release and latest isn't, returns true.
42+// If latest is a pre-release and current isn't, returns false.
43+func (i Info) Available() bool {
44+ cpr := strings.Contains(i.Current, "-")
45+ lpr := strings.Contains(i.Latest, "-")
46+ // current is pre release && latest isn't a prerelease
47+ if cpr && !lpr {
48+ return true
49+ }
50+ // latest is pre release && current isn't a prerelease
51+ if lpr && !cpr {
52+ return false
53+ }
54+ return i.Current != i.Latest
55+}
56+
57+// Check checks if a new version is available.
58+func Check(ctx context.Context, current string, client Client) (Info, error) {
59+ info := Info{
60+ Current: current,
61+ Latest: current,
62+ }
63+
64+ release, err := client.Latest(ctx)
65+ if err != nil {
66+ return info, fmt.Errorf("failed to fetch latest release: %w", err)
67+ }
68+
69+ info.Latest = strings.TrimPrefix(release.TagName, "v")
70+ info.Current = strings.TrimPrefix(info.Current, "v")
71+ info.URL = release.HTMLURL
72+ return info, nil
73+}
74+
75+// Release represents a GitHub release.
76+type Release struct {
77+ TagName string `json:"tag_name"`
78+ HTMLURL string `json:"html_url"`
79+}
80+
81+// Client is a client that can get the latest release.
82+type Client interface {
83+ Latest(ctx context.Context) (*Release, error)
84+}
85+
86+type github struct{}
87+
88+// Latest implements [Client].
89+func (c *github) Latest(ctx context.Context) (*Release, error) {
90+ client := &http.Client{
91+ Timeout: 30 * time.Second,
92+ }
93+
94+ req, err := http.NewRequestWithContext(ctx, "GET", githubApiUrl, nil)
95+ if err != nil {
96+ return nil, err
97+ }
98+ req.Header.Set("User-Agent", userAgent)
99+ req.Header.Set("Accept", "application/vnd.github.v3+json")
100+
101+ resp, err := client.Do(req)
102+ if err != nil {
103+ return nil, err
104+ }
105+ defer resp.Body.Close()
106+
107+ if resp.StatusCode != http.StatusOK {
108+ body, _ := io.ReadAll(resp.Body)
109+ return nil, fmt.Errorf("github api returned status %d: %s", resp.StatusCode, string(body))
110+ }
111+
112+ var release Release
113+ if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
114+ return nil, err
115+ }
116+
117+ return &release, nil
118+}
internal/update/update_test.gonew file+48
@@ -0,0 +1,48 @@
1+package update
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestCheckForUpdate_Old(t *testing.T) {
11+ info, err := Check(t.Context(), "v0.10.0", testClient{"v0.11.0"})
12+ require.NoError(t, err)
13+ require.NotNil(t, info)
14+ require.True(t, info.Available())
15+}
16+
17+func TestCheckForUpdate_Beta(t *testing.T) {
18+ t.Run("current is stable", func(t *testing.T) {
19+ info, err := Check(t.Context(), "v0.10.0", testClient{"v0.11.0-beta.1"})
20+ require.NoError(t, err)
21+ require.NotNil(t, info)
22+ require.False(t, info.Available())
23+ })
24+
25+ t.Run("current is also beta", func(t *testing.T) {
26+ info, err := Check(t.Context(), "v0.11.0-beta.1", testClient{"v0.11.0-beta.2"})
27+ require.NoError(t, err)
28+ require.NotNil(t, info)
29+ require.True(t, info.Available())
30+ })
31+
32+ t.Run("current is beta, latest isn't", func(t *testing.T) {
33+ info, err := Check(t.Context(), "v0.11.0-beta.1", testClient{"v0.11.0"})
34+ require.NoError(t, err)
35+ require.NotNil(t, info)
36+ require.True(t, info.Available())
37+ })
38+}
39+
40+type testClient struct{ tag string }
41+
42+// Latest implements Client.
43+func (t testClient) Latest(ctx context.Context) (*Release, error) {
44+ return &Release{
45+ TagName: t.tag,
46+ HTMLURL: "https://example.org",
47+ }, nil
48+}
internal/version/version.go+3−7
@@ -4,7 +4,7 @@ import "runtime/debug"
4
4
5// Build-time parameters set via -ldflags
5// Build-time parameters set via -ldflags
6
6
7−var Version = "unknown"
7+var Version = "devel"
8
8
9// A user may install crush using `go install github.com/charmbracelet/crush@latest`.
9// A user may install crush using `go install github.com/charmbracelet/crush@latest`.
10// without -ldflags, in which case the version above is unset. As a workaround
10// without -ldflags, in which case the version above is unset. As a workaround
@@ -13,14 +13,10 @@ var Version = "unknown"
13func init() {
13func init() {
14 info, ok := debug.ReadBuildInfo()
14 info, ok := debug.ReadBuildInfo()
15 if !ok {
15 if !ok {
16− // < go v1.18
17 return
16 return
18 }
17 }
19 mainVersion := info.Main.Version
18 mainVersion := info.Main.Version
20− if mainVersion == "" || mainVersion == "(devel)" {
19+ if mainVersion != "" && mainVersion != "(devel)" {
21− // bin not built using `go install`
20+ Version = mainVersion
22− return
23 }
21 }
24− // bin built using `go install`
25− Version = mainVersion
26}
22}
schema.json+1
@@ -468,6 +468,7 @@
468 "type": "string",
468 "type": "string",
469 "enum": [
469 "enum": [
470 "openai",
470 "openai",
471+ "openai-compat",
471 "anthropic",
472 "anthropic",
472 "gemini",
473 "gemini",
473 "azure",
474 "azure",