on main help sign in
Merge remote-tracking branch 'origin/main' into list-select
976d49cKujtim Hoxha
.github/cla-signatures.json+24
@@ -223,6 +223,30 @@
223 "created_at": "2025-08-04T14:01:30Z",
223 "created_at": "2025-08-04T14:01:30Z",
224 "repoId": 987670088,
224 "repoId": 987670088,
225 "pullRequestNo": 546
225 "pullRequestNo": 546
226+ },
227+ {
228+ "name": "zloeber",
229+ "id": 4702624,
230+ "comment_id": 3152513500,
231+ "created_at": "2025-08-04T21:55:42Z",
232+ "repoId": 987670088,
233+ "pullRequestNo": 564
234+ },
235+ {
236+ "name": "nelsenm2",
237+ "id": 197524521,
238+ "comment_id": 3152872109,
239+ "created_at": "2025-08-05T00:24:50Z",
240+ "repoId": 987670088,
241+ "pullRequestNo": 569
242+ },
243+ {
244+ "name": "mohseenrm",
245+ "id": 10768371,
246+ "comment_id": 3153159347,
247+ "created_at": "2025-08-05T03:39:12Z",
248+ "repoId": 987670088,
249+ "pullRequestNo": 574
226 }
250 }
227 ]
251 ]
228}
252}
CRUSH.md+2
@@ -4,6 +4,8 @@
4
4
5- **Build**: `go build .` or `go run .`
5- **Build**: `go build .` or `go run .`
6- **Test**: `task test` or `go test ./...` (run single test: `go test ./internal/llm/prompt -run TestGetContextFromPaths`)
6- **Test**: `task test` or `go test ./...` (run single test: `go test ./internal/llm/prompt -run TestGetContextFromPaths`)
7+- **Update Golden Files**: `go test ./... -update` (regenerates .golden files when test output changes)
8+ - Update specific package: `go test ./internal/tui/components/core -update` (in this case, we're updating "core")
7- **Lint**: `task lint-fix`
9- **Lint**: `task lint-fix`
8- **Format**: `task fmt` (gofumpt -w .)
10- **Format**: `task fmt` (gofumpt -w .)
9- **Dev**: `task dev` (runs with profiling enabled)
11- **Dev**: `task dev` (runs with profiling enabled)
README.md+107−8
@@ -39,7 +39,7 @@ nix run github:numtide/nix-ai-tools#crush
39
39
40<details>
40<details>
41<summary><strong>Nix (NUR)</strong></summary>
41<summary><strong>Nix (NUR)</strong></summary>
42−
42+
43Crush is available via [NUR](https://github.com/nix-community/NUR) in `nur.repos.charmbracelet.crush`.
43Crush is available via [NUR](https://github.com/nix-community/NUR) in `nur.repos.charmbracelet.crush`.
44
44
45You can also try out Crush via `nix-shell`:
45You can also try out Crush via `nix-shell`:
@@ -138,19 +138,29 @@ Crush runs great with no configuration. That said, if you do need or want to
138customize Crush, configuration can be added either local to the project itself,
138customize Crush, configuration can be added either local to the project itself,
139or globally, with the following priority:
139or globally, with the following priority:
140
140
141−1. `./.crush.json`
141+1. `.crush.json`
142−2. `./crush.json`
142+2. `crush.json`
143−3. `$HOME/.config/crush/crush.json`
143+3. `$HOME/.config/crush/crush.json` (Windows: `%USERPROFILE%\AppData\Local\crush\crush.json`)
144
144
145Configuration itself is stored as a JSON object:
145Configuration itself is stored as a JSON object:
146
146
147```json
147```json
148{
148{
149− "this-setting": { }
149+ "this-setting": {"this": "that"},
150− "that-setting": { }
150+ "that-setting": ["ceci", "cela"]
151}
151}
152```
152```
153
153
154+As an additional note, Crush also stores ephemeral data, such as application state, in one additional location:
155+
156+```bash
157+# Unix
158+$HOME/.local/shared/crush/crush.json
159+
160+# Windows
161+%LOCALAPPDATA%\crush\crush.json
162+```
163+
154### LSPs
164### LSPs
155
165
156Crush can use LSPs for additional context to help inform its decisions, just
166Crush can use LSPs for additional context to help inform its decisions, just
@@ -221,10 +231,10 @@ control but don't want Crush to consider when providing context.
221The `.crushignore` file uses the same syntax as `.gitignore` and can be placed
231The `.crushignore` file uses the same syntax as `.gitignore` and can be placed
222in the root of your project or in subdirectories.
232in the root of your project or in subdirectories.
223
233
224−### Whitelisting Tools
234+### Allowing Tools
225
235
226By default, Crush will ask you for permission before running tool calls. If
236By default, Crush will ask you for permission before running tool calls. If
227−you'd like, you can whitelist tools to be executed without prompting you for
237+you'd like, you can allow tools to be executed without prompting you for
228permissions. Use this with care.
238permissions. Use this with care.
229
239
230```json
240```json
@@ -245,6 +255,53 @@ permissions. Use this with care.
245You can also skip all permission prompts entirely by running Crush with the
255You can also skip all permission prompts entirely by running Crush with the
246`--yolo` flag. Be very, very careful with this feature.
256`--yolo` flag. Be very, very careful with this feature.
247
257
258+### Local Models
259+
260+Local models can also be configured via OpenAI-compatible API. Here are two common examples:
261+
262+#### Ollama
263+
264+```json
265+{
266+ "providers": {
267+ "ollama": {
268+ "name": "Ollama",
269+ "base_url": "http://localhost:11434/v1/",
270+ "type": "openai",
271+ "models": [
272+ {
273+ "name": "Qwen 3 30B",
274+ "id": "qwen3:30b",
275+ "context_window": 256000,
276+ "default_max_tokens": 20000
277+ }
278+ ]
279+ }
280+}
281+```
282+
283+#### LM Studio
284+
285+```json
286+{
287+ "providers": {
288+ "lmstudio": {
289+ "name": "LM Studio",
290+ "base_url": "http://localhost:1234/v1/",
291+ "type": "openai",
292+ "models": [
293+ {
294+ "name": "Qwen 3 30B",
295+ "id": "qwen/qwen3-30b-a3b-2507",
296+ "context_window": 256000,
297+ "default_max_tokens": 20000
298+ }
299+ ]
300+ }
301+ }
302+}
303+```
304+
248### Custom Providers
305### Custom Providers
249
306
250Crush supports custom provider configurations for both OpenAI-compatible and
307Crush supports custom provider configurations for both OpenAI-compatible and
@@ -314,6 +371,48 @@ Custom Anthropic-compatible providers follow this format:
314}
371}
315```
372```
316
373
374+### Amazon Bedrock
375+
376+Crush currently supports running Anthropic models through Bedrock, with caching disabled.
377+
378+* A Bedrock provider will appear once you have AWS configured, i.e. `aws configure`
379+* Crush also expects the `AWS_REGION` or `AWS_DEFAULT_REGION` to be set
380+* To use a specific AWS profile set `AWS_PROFILE` in your environment, i.e. `AWS_PROFILE=myprofile crush`
381+
382+### Vertex AI Platform
383+
384+Vertex AI will appear in the list of available providers when `VERTEXAI_PROJECT` and `VERTEXAI_LOCATION` are set. You will also need to be authenticated:
385+
386+```bash
387+gcloud auth application-default login
388+```
389+
390+To add specific models to the configuration, configure as such:
391+
392+```json
393+{
394+ "$schema": "https://charm.land/crush.json",
395+ "providers": {
396+ "vertexai": {
397+ "models": [
398+ {
399+ "id": "claude-sonnet-4@20250514",
400+ "name": "VertexAI Sonnet 4",
401+ "cost_per_1m_in": 3,
402+ "cost_per_1m_out": 15,
403+ "cost_per_1m_in_cached": 3.75,
404+ "cost_per_1m_out_cached": 0.3,
405+ "context_window": 200000,
406+ "default_max_tokens": 50000,
407+ "can_reason": true,
408+ "supports_attachments": true
409+ }
410+ ]
411+ }
412+ }
413+}
414+```
415+
317## Logging
416## Logging
318
417
319Sometimes you need to look at logs. Luckily, Crush logs all sorts of
418Sometimes you need to look at logs. Luckily, Crush logs all sorts of
go.mod+3−3
@@ -14,7 +14,7 @@ require (
14 github.com/charlievieth/fastwalk v1.0.11
14 github.com/charlievieth/fastwalk v1.0.11
15 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5
15 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5
16 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250730165737-56ff7146d52d
16 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250730165737-56ff7146d52d
17− github.com/charmbracelet/catwalk v0.3.5
17+ github.com/charmbracelet/catwalk v0.4.5
18 github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674
18 github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674
19 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
19 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
20 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250721205738-ea66aa652ee0
20 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250721205738-ea66aa652ee0
@@ -27,7 +27,7 @@ require (
27 github.com/google/uuid v1.6.0
27 github.com/google/uuid v1.6.0
28 github.com/invopop/jsonschema v0.13.0
28 github.com/invopop/jsonschema v0.13.0
29 github.com/joho/godotenv v1.5.1
29 github.com/joho/godotenv v1.5.1
30− github.com/mark3labs/mcp-go v0.36.0
30+ github.com/mark3labs/mcp-go v0.37.0
31 github.com/muesli/termenv v0.16.0
31 github.com/muesli/termenv v0.16.0
32 github.com/ncruces/go-sqlite3 v0.25.0
32 github.com/ncruces/go-sqlite3 v0.25.0
33 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
33 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
@@ -135,7 +135,7 @@ require (
135 golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
135 golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
136 golang.org/x/image v0.26.0 // indirect
136 golang.org/x/image v0.26.0 // indirect
137 golang.org/x/net v0.40.0 // indirect
137 golang.org/x/net v0.40.0 // indirect
138− golang.org/x/oauth2 v0.25.0 // indirect
138+ golang.org/x/oauth2 v0.30.0 // indirect
139 golang.org/x/sync v0.16.0 // indirect
139 golang.org/x/sync v0.16.0 // indirect
140 golang.org/x/sys v0.34.0
140 golang.org/x/sys v0.34.0
141 golang.org/x/term v0.32.0 // indirect
141 golang.org/x/term v0.32.0 // indirect
go.sum+6−6
@@ -78,8 +78,8 @@ github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5
78github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
78github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
79github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250730165737-56ff7146d52d h1:YMXLZHSo8DjytVY/b5dK8LDuyQsVUmBK3ydQMpu2Ui4=
79github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250730165737-56ff7146d52d h1:YMXLZHSo8DjytVY/b5dK8LDuyQsVUmBK3ydQMpu2Ui4=
80github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250730165737-56ff7146d52d/go.mod h1:XIQ1qQfRph6Z5o2EikCydjumo0oDInQySRHuPATzbZc=
80github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250730165737-56ff7146d52d/go.mod h1:XIQ1qQfRph6Z5o2EikCydjumo0oDInQySRHuPATzbZc=
81−github.com/charmbracelet/catwalk v0.3.5 h1:ChMvA5ooTNZhDKFagmGNQgIZvZp8XjpdaJ+cDmhgCgA=
81+github.com/charmbracelet/catwalk v0.4.5 h1:Kv3PadDe8IF8gpcYTfAJdCee5Bv4HufvtNT61FXtq5g=
82−github.com/charmbracelet/catwalk v0.3.5/go.mod h1:gUUCqqZ8bk4D7ZzGTu3I77k7cC2x4exRuJBN1H2u2pc=
82+github.com/charmbracelet/catwalk v0.4.5/go.mod h1:WnKgNPmQHuMyk7GtwAQwl+ezHusfH40IvzML2qwUGwc=
83github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
83github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
84github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
84github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
85github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674 h1:+Cz+VfxD5DO+JT1LlswXWhre0HYLj6l2HW8HVGfMuC0=
85github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674 h1:+Cz+VfxD5DO+JT1LlswXWhre0HYLj6l2HW8HVGfMuC0=
@@ -180,8 +180,8 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69
180github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
180github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
181github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
181github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
182github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
182github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
183−github.com/mark3labs/mcp-go v0.36.0 h1:rIZaijrRYPeSbJG8/qNDe0hWlGrCJ7FWHNMz2SQpTis=
183+github.com/mark3labs/mcp-go v0.37.0 h1:BywvZLPRT6Zx6mMG/MJfxLSZQkTGIcJSEGKsvr4DsoQ=
184−github.com/mark3labs/mcp-go v0.36.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g=
184+github.com/mark3labs/mcp-go v0.37.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g=
185github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
185github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
186github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
186github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
187github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
187github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
@@ -332,8 +332,8 @@ golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
332golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
332golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
333golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
333golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
334golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
334golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
335−golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
335+golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
336−golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
336+golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
337golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
337golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
338golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
338golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
339golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
339golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
internal/llm/agent/agent.go+37−31
@@ -159,11 +159,12 @@ func NewAgent(
159 if err != nil {
159 if err != nil {
160 return nil, err
160 return nil, err
161 }
161 }
162+
162 summarizeOpts := []provider.ProviderClientOption{
163 summarizeOpts := []provider.ProviderClientOption{
163− provider.WithModel(config.SelectedModelTypeSmall),
164+ provider.WithModel(config.SelectedModelTypeLarge),
164− provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, smallModelProviderCfg.ID)),
165+ provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, providerCfg.ID)),
165 }
166 }
166− summarizeProvider, err := provider.NewProvider(*smallModelProviderCfg, summarizeOpts...)
167+ summarizeProvider, err := provider.NewProvider(*providerCfg, summarizeOpts...)
167 if err != nil {
168 if err != nil {
168 return nil, err
169 return nil, err
169 }
170 }
@@ -224,7 +225,7 @@ func NewAgent(
224 sessions: sessions,
225 sessions: sessions,
225 titleProvider: titleProvider,
226 titleProvider: titleProvider,
226 summarizeProvider: summarizeProvider,
227 summarizeProvider: summarizeProvider,
227− summarizeProviderID: string(smallModelProviderCfg.ID),
228+ summarizeProviderID: string(providerCfg.ID),
228 activeRequests: csync.NewMap[string, context.CancelFunc](),
229 activeRequests: csync.NewMap[string, context.CancelFunc](),
229 tools: csync.NewLazySlice(toolFn),
230 tools: csync.NewLazySlice(toolFn),
230 }, nil
231 }, nil
@@ -904,54 +905,59 @@ func (a *agent) UpdateModel() error {
904 a.providerID = string(currentProviderCfg.ID)
905 a.providerID = string(currentProviderCfg.ID)
905 }
906 }
906
907
907− // Check if small model provider has changed (affects title and summarize providers)
908+ // Check if providers have changed for title (small) and summarize (large)
908 smallModelCfg := cfg.Models[config.SelectedModelTypeSmall]
909 smallModelCfg := cfg.Models[config.SelectedModelTypeSmall]
909 var smallModelProviderCfg config.ProviderConfig
910 var smallModelProviderCfg config.ProviderConfig
910−
911 for p := range cfg.Providers.Seq() {
911 for p := range cfg.Providers.Seq() {
912 if p.ID == smallModelCfg.Provider {
912 if p.ID == smallModelCfg.Provider {
913 smallModelProviderCfg = p
913 smallModelProviderCfg = p
914 break
914 break
915 }
915 }
916 }
916 }
917−
918 if smallModelProviderCfg.ID == "" {
917 if smallModelProviderCfg.ID == "" {
919 return fmt.Errorf("provider %s not found in config", smallModelCfg.Provider)
918 return fmt.Errorf("provider %s not found in config", smallModelCfg.Provider)
920 }
919 }
921
920
922− // Check if summarize provider has changed
921+ largeModelCfg := cfg.Models[config.SelectedModelTypeLarge]
923− if string(smallModelProviderCfg.ID) != a.summarizeProviderID {
922+ var largeModelProviderCfg config.ProviderConfig
924− smallModel := cfg.GetModelByType(config.SelectedModelTypeSmall)
923+ for p := range cfg.Providers.Seq() {
925− if smallModel == nil {
924+ if p.ID == largeModelCfg.Provider {
926− return fmt.Errorf("model %s not found in provider %s", smallModelCfg.Model, smallModelProviderCfg.ID)
925+ largeModelProviderCfg = p
926+ break
927 }
927 }
928+ }
929+ if largeModelProviderCfg.ID == "" {
930+ return fmt.Errorf("provider %s not found in config", largeModelCfg.Provider)
931+ }
928
932
929− // Recreate title provider
933+ // Recreate title provider
930− titleOpts := []provider.ProviderClientOption{
934+ titleOpts := []provider.ProviderClientOption{
931− provider.WithModel(config.SelectedModelTypeSmall),
935+ provider.WithModel(config.SelectedModelTypeSmall),
932− provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptTitle, smallModelProviderCfg.ID)),
936+ provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptTitle, smallModelProviderCfg.ID)),
933− // We want the title to be short, so we limit the max tokens
934− provider.WithMaxTokens(40),
937+ provider.WithMaxTokens(40),
935− }
938+ }
936− newTitleProvider, err := provider.NewProvider(smallModelProviderCfg, titleOpts...)
939+ newTitleProvider, err := provider.NewProvider(smallModelProviderCfg, titleOpts...)
937− if err != nil {
940+ if err != nil {
938− return fmt.Errorf("failed to create new title provider: %w", err)
941+ return fmt.Errorf("failed to create new title provider: %w", err)
939− }
942+ }
943+ a.titleProvider = newTitleProvider
940
944
941− // Recreate summarize provider
945+ // Recreate summarize provider if provider changed (now large model)
946+ if string(largeModelProviderCfg.ID) != a.summarizeProviderID {
947+ largeModel := cfg.GetModelByType(config.SelectedModelTypeLarge)
948+ if largeModel == nil {
949+ return fmt.Errorf("model %s not found in provider %s", largeModelCfg.Model, largeModelProviderCfg.ID)
950+ }
942 summarizeOpts := []provider.ProviderClientOption{
951 summarizeOpts := []provider.ProviderClientOption{
943− provider.WithModel(config.SelectedModelTypeSmall),
952+ provider.WithModel(config.SelectedModelTypeLarge),
944− provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, smallModelProviderCfg.ID)),
953+ provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, largeModelProviderCfg.ID)),
945 }
954 }
946− newSummarizeProvider, err := provider.NewProvider(smallModelProviderCfg, summarizeOpts...)
955+ newSummarizeProvider, err := provider.NewProvider(largeModelProviderCfg, summarizeOpts...)
947 if err != nil {
956 if err != nil {
948 return fmt.Errorf("failed to create new summarize provider: %w", err)
957 return fmt.Errorf("failed to create new summarize provider: %w", err)
949 }
958 }
950−
951− // Update the providers and provider ID
952− a.titleProvider = newTitleProvider
953 a.summarizeProvider = newSummarizeProvider
959 a.summarizeProvider = newSummarizeProvider
954− a.summarizeProviderID = string(smallModelProviderCfg.ID)
960+ a.summarizeProviderID = string(largeModelProviderCfg.ID)
955 }
961 }
956
962
957 return nil
963 return nil
internal/llm/agent/mcp-tools.go+9−7
@@ -308,29 +308,31 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con
308func createMcpClient(m config.MCPConfig) (*client.Client, error) {
308func createMcpClient(m config.MCPConfig) (*client.Client, error) {
309 switch m.Type {
309 switch m.Type {
310 case config.MCPStdio:
310 case config.MCPStdio:
311− return client.NewStdioMCPClient(
311+ return client.NewStdioMCPClientWithOptions(
312 m.Command,
312 m.Command,
313 m.ResolvedEnv(),
313 m.ResolvedEnv(),
314− m.Args...,
314+ m.Args,
315+ transport.WithCommandLogger(mcpLogger{}),
315 )
316 )
316 case config.MCPHttp:
317 case config.MCPHttp:
317 return client.NewStreamableHttpClient(
318 return client.NewStreamableHttpClient(
318 m.URL,
319 m.URL,
319 transport.WithHTTPHeaders(m.ResolvedHeaders()),
320 transport.WithHTTPHeaders(m.ResolvedHeaders()),
320− transport.WithLogger(mcpHTTPLogger{}),
321+ transport.WithHTTPLogger(mcpLogger{}),
321 )
322 )
322 case config.MCPSse:
323 case config.MCPSse:
323 return client.NewSSEMCPClient(
324 return client.NewSSEMCPClient(
324 m.URL,
325 m.URL,
325 client.WithHeaders(m.ResolvedHeaders()),
326 client.WithHeaders(m.ResolvedHeaders()),
327+ transport.WithSSELogger(mcpLogger{}),
326 )
328 )
327 default:
329 default:
328 return nil, fmt.Errorf("unsupported mcp type: %s", m.Type)
330 return nil, fmt.Errorf("unsupported mcp type: %s", m.Type)
329 }
331 }
330}
332}
331
333
332−// for MCP's HTTP client.
334+// for MCP's clients.
333−type mcpHTTPLogger struct{}
335+type mcpLogger struct{}
334
336
335−func (l mcpHTTPLogger) Errorf(format string, v ...any) { slog.Error(fmt.Sprintf(format, v...)) }
337+func (l mcpLogger) Errorf(format string, v ...any) { slog.Error(fmt.Sprintf(format, v...)) }
336−func (l mcpHTTPLogger) Infof(format string, v ...any) { slog.Info(fmt.Sprintf(format, v...)) }
338+func (l mcpLogger) Infof(format string, v ...any) { slog.Info(fmt.Sprintf(format, v...)) }
internal/llm/provider/openai.go+71−67
@@ -2,10 +2,12 @@ package provider
2
2
3import (
3import (
4 "context"
4 "context"
5+ "encoding/json"
5 "errors"
6 "errors"
6 "fmt"
7 "fmt"
7 "io"
8 "io"
8 "log/slog"
9 "log/slog"
10+ "slices"
9 "strings"
11 "strings"
10 "time"
12 "time"
11
13
@@ -14,6 +16,7 @@ import (
14 "github.com/charmbracelet/crush/internal/llm/tools"
16 "github.com/charmbracelet/crush/internal/llm/tools"
15 "github.com/charmbracelet/crush/internal/log"
17 "github.com/charmbracelet/crush/internal/log"
16 "github.com/charmbracelet/crush/internal/message"
18 "github.com/charmbracelet/crush/internal/message"
19+ "github.com/google/uuid"
17 "github.com/openai/openai-go"
20 "github.com/openai/openai-go"
18 "github.com/openai/openai-go/option"
21 "github.com/openai/openai-go/option"
19 "github.com/openai/openai-go/packages/param"
22 "github.com/openai/openai-go/packages/param"
@@ -70,8 +73,9 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
70 systemMessage = o.providerOptions.systemPromptPrefix + "\n" + systemMessage
73 systemMessage = o.providerOptions.systemPromptPrefix + "\n" + systemMessage
71 }
74 }
72
75
73− systemTextBlock := openai.ChatCompletionContentPartTextParam{Text: systemMessage}
76+ system := openai.SystemMessage(systemMessage)
74 if isAnthropicModel && !o.providerOptions.disableCache {
77 if isAnthropicModel && !o.providerOptions.disableCache {
78+ systemTextBlock := openai.ChatCompletionContentPartTextParam{Text: systemMessage}
75 systemTextBlock.SetExtraFields(
79 systemTextBlock.SetExtraFields(
76 map[string]any{
80 map[string]any{
77 "cache_control": map[string]string{
81 "cache_control": map[string]string{
@@ -79,10 +83,10 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
79 },
83 },
80 },
84 },
81 )
85 )
86+ var content []openai.ChatCompletionContentPartTextParam
87+ content = append(content, systemTextBlock)
88+ system = openai.SystemMessage(content)
82 }
89 }
83− var content []openai.ChatCompletionContentPartTextParam
84− content = append(content, systemTextBlock)
85− system := openai.SystemMessage(content)
86 openaiMessages = append(openaiMessages, system)
90 openaiMessages = append(openaiMessages, system)
87
91
88 for i, msg := range messages {
92 for i, msg := range messages {
@@ -93,9 +97,12 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
93 switch msg.Role {
97 switch msg.Role {
94 case message.User:
98 case message.User:
95 var content []openai.ChatCompletionContentPartUnionParam
99 var content []openai.ChatCompletionContentPartUnionParam
100+
96 textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()}
101 textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()}
97 content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock})
102 content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock})
103+ hasBinaryContent := false
98 for _, binaryContent := range msg.BinaryContent() {
104 for _, binaryContent := range msg.BinaryContent() {
105+ hasBinaryContent = true
99 imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(catwalk.InferenceProviderOpenAI)}
106 imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(catwalk.InferenceProviderOpenAI)}
100 imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL}
107 imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL}
101
108
@@ -108,8 +115,11 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
108 },
115 },
109 })
116 })
110 }
117 }
111−
118+ if hasBinaryContent || (isAnthropicModel && !o.providerOptions.disableCache) {
112− openaiMessages = append(openaiMessages, openai.UserMessage(content))
119+ openaiMessages = append(openaiMessages, openai.UserMessage(content))
120+ } else {
121+ openaiMessages = append(openaiMessages, openai.UserMessage(msg.Content().String()))
122+ }
113
123
114 case message.Assistant:
124 case message.Assistant:
115 assistantMsg := openai.ChatCompletionAssistantMessageParam{
125 assistantMsg := openai.ChatCompletionAssistantMessageParam{
@@ -134,13 +144,15 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
134 },
144 },
135 },
145 },
136 }
146 }
147+ if !isAnthropicModel {
148+ assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{
149+ OfString: param.NewOpt(msg.Content().String()),
150+ }
151+ }
137 }
152 }
138
153
139 if len(msg.ToolCalls()) > 0 {
154 if len(msg.ToolCalls()) > 0 {
140 hasContent = true
155 hasContent = true
141− assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{
142− OfString: param.NewOpt(msg.Content().String()),
143− }
144 assistantMsg.ToolCalls = make([]openai.ChatCompletionMessageToolCallParam, len(msg.ToolCalls()))
156 assistantMsg.ToolCalls = make([]openai.ChatCompletionMessageToolCallParam, len(msg.ToolCalls()))
145 for i, call := range msg.ToolCalls() {
157 for i, call := range msg.ToolCalls() {
146 assistantMsg.ToolCalls[i] = openai.ChatCompletionMessageToolCallParam{
158 assistantMsg.ToolCalls[i] = openai.ChatCompletionMessageToolCallParam{
@@ -329,21 +341,26 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
329 acc := openai.ChatCompletionAccumulator{}
341 acc := openai.ChatCompletionAccumulator{}
330 currentContent := ""
342 currentContent := ""
331 toolCalls := make([]message.ToolCall, 0)
343 toolCalls := make([]message.ToolCall, 0)
332−
333− var currentToolCallID string
334− var currentToolCall openai.ChatCompletionMessageToolCall
335 var msgToolCalls []openai.ChatCompletionMessageToolCall
344 var msgToolCalls []openai.ChatCompletionMessageToolCall
336− currentToolIndex := 0
337 for openaiStream.Next() {
345 for openaiStream.Next() {
338 chunk := openaiStream.Current()
346 chunk := openaiStream.Current()
339 // Kujtim: this is an issue with openrouter qwen, its sending -1 for the tool index
347 // Kujtim: this is an issue with openrouter qwen, its sending -1 for the tool index
340 if len(chunk.Choices) > 0 && len(chunk.Choices[0].Delta.ToolCalls) > 0 && chunk.Choices[0].Delta.ToolCalls[0].Index == -1 {
348 if len(chunk.Choices) > 0 && len(chunk.Choices[0].Delta.ToolCalls) > 0 && chunk.Choices[0].Delta.ToolCalls[0].Index == -1 {
341− chunk.Choices[0].Delta.ToolCalls[0].Index = int64(currentToolIndex)
349+ chunk.Choices[0].Delta.ToolCalls[0].Index = 0
342− currentToolIndex++
343 }
350 }
344 acc.AddChunk(chunk)
351 acc.AddChunk(chunk)
345− // This fixes multiple tool calls for some providers
346− for _, choice := range chunk.Choices {
352+ for i, choice := range chunk.Choices {
353+ reasoning, ok := choice.Delta.JSON.ExtraFields["reasoning"]
354+ if ok && reasoning.Raw() != "" {
355+ reasoningStr := ""
356+ json.Unmarshal([]byte(reasoning.Raw()), &reasoningStr)
357+ if reasoningStr != "" {
358+ eventChan <- ProviderEvent{
359+ Type: EventThinkingDelta,
360+ Thinking: reasoningStr,
361+ }
362+ }
363+ }
347 if choice.Delta.Content != "" {
364 if choice.Delta.Content != "" {
348 eventChan <- ProviderEvent{
365 eventChan <- ProviderEvent{
349 Type: EventContentDelta,
366 Type: EventContentDelta,
@@ -352,63 +369,50 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
352 currentContent += choice.Delta.Content
369 currentContent += choice.Delta.Content
353 } else if len(choice.Delta.ToolCalls) > 0 {
370 } else if len(choice.Delta.ToolCalls) > 0 {
354 toolCall := choice.Delta.ToolCalls[0]
371 toolCall := choice.Delta.ToolCalls[0]
355− // Detect tool use start
372+ newToolCall := false
356− if currentToolCallID == "" {
373+ if len(msgToolCalls)-1 >= int(toolCall.Index) { // tool call exists
374+ existingToolCall := msgToolCalls[toolCall.Index]
375+ if toolCall.ID != "" && toolCall.ID != existingToolCall.ID {
376+ found := false
377+ // try to find the tool based on the ID
378+ for i, tool := range msgToolCalls {
357− if toolCall.ID != "" {
379+ if tool.ID == toolCall.ID {
358− currentToolCallID = toolCall.ID
380+ msgToolCalls[i].Function.Arguments += toolCall.Function.Arguments
359− eventChan <- ProviderEvent{
381+ found = true
360− Type: EventToolUseStart,
361− ToolCall: &message.ToolCall{
362− ID: toolCall.ID,
363− Name: toolCall.Function.Name,
364− Finished: false,
365− },
382+ }
366 }
383 }
367− currentToolCall = openai.ChatCompletionMessageToolCall{
384+ if !found {
368− ID: toolCall.ID,
385+ newToolCall = true
369− Type: "function",
370− Function: openai.ChatCompletionMessageToolCallFunction{
371− Name: toolCall.Function.Name,
372− Arguments: toolCall.Function.Arguments,
373− },
374 }
386 }
375− }
376− } else {
377− // Delta tool use
378− if toolCall.ID == "" || toolCall.ID == currentToolCallID {
379− currentToolCall.Function.Arguments += toolCall.Function.Arguments
380 } else {
387 } else {
381− // Detect new tool use
388+ msgToolCalls[toolCall.Index].Function.Arguments += toolCall.Function.Arguments
382− if toolCall.ID != currentToolCallID {
383− msgToolCalls = append(msgToolCalls, currentToolCall)
384− currentToolCallID = toolCall.ID
385− eventChan <- ProviderEvent{
386− Type: EventToolUseStart,
387− ToolCall: &message.ToolCall{
388− ID: toolCall.ID,
389− Name: toolCall.Function.Name,
390− Finished: false,
391− },
392− }
393− currentToolCall = openai.ChatCompletionMessageToolCall{
394− ID: toolCall.ID,
395− Type: "function",
396− Function: openai.ChatCompletionMessageToolCallFunction{
397− Name: toolCall.Function.Name,
398− Arguments: toolCall.Function.Arguments,
399− },
400− }
401− }
402 }
389 }
390+ } else {
391+ newToolCall = true
403 }
392 }
404− }
393+ if newToolCall { // new tool call
405− // Kujtim: some models send finish stop even for tool calls
394+ if toolCall.ID == "" {
406− if choice.FinishReason == "tool_calls" || (choice.FinishReason == "stop" && currentToolCallID != "") {
395+ toolCall.ID = uuid.NewString()
396+ }
397+ eventChan <- ProviderEvent{
398+ Type: EventToolUseStart,
399+ ToolCall: &message.ToolCall{
400+ ID: toolCall.ID,
401+ Name: toolCall.Function.Name,
402+ Finished: false,
403+ },
404+ }
407− msgToolCalls = append(msgToolCalls, currentToolCall)
405+ msgToolCalls = append(msgToolCalls, openai.ChatCompletionMessageToolCall{
408− if len(acc.Choices) > 0 {
406+ ID: toolCall.ID,
409− acc.Choices[0].Message.ToolCalls = msgToolCalls
407+ Type: "function",
408+ Function: openai.ChatCompletionMessageToolCallFunction{
409+ Name: toolCall.Function.Name,
410+ Arguments: toolCall.Function.Arguments,
411+ },
412+ })
410 }
413 }
411 }
414 }
415+ acc.Choices[i].Message.ToolCalls = slices.Clone(msgToolCalls)
412 }
416 }
413 }
417 }
414
418
internal/llm/tools/tools.go+74−1
@@ -3,6 +3,8 @@ package tools
3import (
3import (
4 "context"
4 "context"
5 "encoding/json"
5 "encoding/json"
6+ "fmt"
7+ "strings"
6)
8)
7
9
8type ToolInfo struct {
10type ToolInfo struct {
@@ -25,6 +27,10 @@ const (
25
27
26 SessionIDContextKey sessionIDContextKey = "session_id"
28 SessionIDContextKey sessionIDContextKey = "session_id"
27 MessageIDContextKey messageIDContextKey = "message_id"
29 MessageIDContextKey messageIDContextKey = "message_id"
30+
31+ maxResponseWidth = 3000
32+ maxResponseHeight = 5000
33+ maxResponseChars = 50000
28)
34)
29
35
30type ToolResponse struct {
36type ToolResponse struct {
@@ -37,10 +43,77 @@ type ToolResponse struct {
37func NewTextResponse(content string) ToolResponse {
43func NewTextResponse(content string) ToolResponse {
38 return ToolResponse{
44 return ToolResponse{
39 Type: ToolResponseTypeText,
45 Type: ToolResponseTypeText,
40− Content: content,
46+ Content: truncateContent(content),
41 }
47 }
42}
48}
43
49
50+func truncateContent(content string) string {
51+ if len(content) <= maxResponseChars {
52+ return truncateWidthAndHeight(content)
53+ }
54+
55+ truncated := content[:maxResponseChars]
56+
57+ if lastNewline := strings.LastIndex(truncated, "\n"); lastNewline > maxResponseChars/2 {
58+ truncated = truncated[:lastNewline]
59+ }
60+
61+ truncated += "\n\n... [Content truncated due to length] ..."
62+
63+ return truncateWidthAndHeight(truncated)
64+}
65+
66+func truncateWidthAndHeight(content string) string {
67+ lines := strings.Split(content, "\n")
68+
69+ heightTruncated := false
70+ if len(lines) > maxResponseHeight {
71+ keepLines := maxResponseHeight - 3
72+ firstHalf := keepLines / 2
73+ secondHalf := keepLines - firstHalf
74+
75+ truncatedLines := make([]string, 0, maxResponseHeight)
76+ truncatedLines = append(truncatedLines, lines[:firstHalf]...)
77+ truncatedLines = append(truncatedLines, "")
78+ truncatedLines = append(truncatedLines, fmt.Sprintf("... [%d lines truncated] ...", len(lines)-keepLines))
79+ truncatedLines = append(truncatedLines, "")
80+ truncatedLines = append(truncatedLines, lines[len(lines)-secondHalf:]...)
81+
82+ lines = truncatedLines
83+ heightTruncated = true
84+ }
85+
86+ widthTruncated := false
87+ for i, line := range lines {
88+ if len(line) > maxResponseWidth {
89+ if maxResponseWidth > 20 {
90+ keepChars := maxResponseWidth - 10
91+ firstHalf := keepChars / 2
92+ secondHalf := keepChars - firstHalf
93+ lines[i] = line[:firstHalf] + " ... " + line[len(line)-secondHalf:]
94+ } else {
95+ lines[i] = line[:maxResponseWidth]
96+ }
97+ widthTruncated = true
98+ }
99+ }
100+
101+ result := strings.Join(lines, "\n")
102+
103+ if heightTruncated || widthTruncated {
104+ notices := make([]string, 0, 2)
105+ if heightTruncated {
106+ notices = append(notices, "height")
107+ }
108+ if widthTruncated {
109+ notices = append(notices, "width")
110+ }
111+ result += fmt.Sprintf("\n\n[Note: Content truncated by %s to fit response limits]", strings.Join(notices, " and "))
112+ }
113+
114+ return result
115+}
116+
44func WithResponseMetadata(response ToolResponse, metadata any) ToolResponse {
117func WithResponseMetadata(response ToolResponse, metadata any) ToolResponse {
45 if metadata != nil {
118 if metadata != nil {
46 metadataBytes, err := json.Marshal(metadata)
119 metadataBytes, err := json.Marshal(metadata)
internal/tui/components/chat/messages/messages.go+3−1
@@ -274,11 +274,13 @@ func (m *messageCmp) renderThinkingContent() string {
274 if reasoningContent.StartedAt > 0 {
274 if reasoningContent.StartedAt > 0 {
275 duration := m.message.ThinkingDuration()
275 duration := m.message.ThinkingDuration()
276 if reasoningContent.FinishedAt > 0 {
276 if reasoningContent.FinishedAt > 0 {
277+ if duration.String() == "0s" {
278+ return ""
279+ }
277 m.anim.SetLabel("")
280 m.anim.SetLabel("")
278 opts := core.StatusOpts{
281 opts := core.StatusOpts{
279 Title: "Thought for",
282 Title: "Thought for",
280 Description: duration.String(),
283 Description: duration.String(),
281− NoIcon: true,
282 }
284 }
283 return t.S().Base.PaddingLeft(1).Render(core.Status(opts, m.textWidth()-1))
285 return t.S().Base.PaddingLeft(1).Render(core.Status(opts, m.textWidth()-1))
284 } else if finishReason != nil && finishReason.Reason == message.FinishReasonCanceled {
286 } else if finishReason != nil && finishReason.Reason == message.FinishReasonCanceled {
internal/tui/components/chat/splash/splash.go−22
@@ -3,7 +3,6 @@ package splash
3import (
3import (
4 "fmt"
4 "fmt"
5 "os"
5 "os"
6− "slices"
7 "strings"
6 "strings"
8 "time"
7 "time"
9
8
@@ -103,27 +102,6 @@ func New() Splash {
103
102
104func (s *splashCmp) SetOnboarding(onboarding bool) {
103func (s *splashCmp) SetOnboarding(onboarding bool) {
105 s.isOnboarding = onboarding
104 s.isOnboarding = onboarding
106− if onboarding {
107− providers, err := config.Providers()
108− if err != nil {
109− return
110− }
111− filteredProviders := []catwalk.Provider{}
112− simpleProviders := []string{
113− "anthropic",
114− "openai",
115− "gemini",
116− "xai",
117− "groq",
118− "openrouter",
119− }
120− for _, p := range providers {
121− if slices.Contains(simpleProviders, string(p.ID)) {
122− filteredProviders = append(filteredProviders, p)
123− }
124− }
125− s.modelList.SetProviders(filteredProviders)
126− }
127}
105}
128
106
129func (s *splashCmp) SetProjectInit(needsInit bool) {
107func (s *splashCmp) SetProjectInit(needsInit bool) {
internal/tui/components/core/core.go+14−25
@@ -82,41 +82,30 @@ func Title(title string, width int) string {
82}
82}
83
83
84type StatusOpts struct {
84type StatusOpts struct {
85− Icon string
85+ Icon string // if empty no icon will be shown
86− IconColor color.Color
87− NoIcon bool // If true, no icon will be displayed
88 Title string
86 Title string
89 TitleColor color.Color
87 TitleColor color.Color
90 Description string
88 Description string
91 DescriptionColor color.Color
89 DescriptionColor color.Color
92− ExtraContent string // Additional content to append after the description
90+ ExtraContent string // additional content to append after the description
93}
91}
94
92
95−func Status(ops StatusOpts, width int) string {
93+func Status(opts StatusOpts, width int) string {
96 t := styles.CurrentTheme()
94 t := styles.CurrentTheme()
97− icon := "●"
95+ icon := opts.Icon
98− iconColor := t.Success
99− if ops.Icon != "" {
100− icon = ops.Icon
101− } else if ops.NoIcon {
102− icon = ""
103− }
104− if ops.IconColor != nil {
105− iconColor = ops.IconColor
106− }
107− title := ops.Title
96+ title := opts.Title
108 titleColor := t.FgMuted
97 titleColor := t.FgMuted
109− if ops.TitleColor != nil {
98+ if opts.TitleColor != nil {
110− titleColor = ops.TitleColor
99+ titleColor = opts.TitleColor
111 }
100 }
112− description := ops.Description
101+ description := opts.Description
113 descriptionColor := t.FgSubtle
102 descriptionColor := t.FgSubtle
114− if ops.DescriptionColor != nil {
103+ if opts.DescriptionColor != nil {
115− descriptionColor = ops.DescriptionColor
104+ descriptionColor = opts.DescriptionColor
116 }
105 }
117 title = t.S().Base.Foreground(titleColor).Render(title)
106 title = t.S().Base.Foreground(titleColor).Render(title)
118 if description != "" {
107 if description != "" {
119− extraContentWidth := lipgloss.Width(ops.ExtraContent)
108+ extraContentWidth := lipgloss.Width(opts.ExtraContent)
120 if extraContentWidth > 0 {
109 if extraContentWidth > 0 {
121 extraContentWidth += 1
110 extraContentWidth += 1
122 }
111 }
@@ -126,11 +115,11 @@ func Status(ops StatusOpts, width int) string {
126
115
127 content := []string{}
116 content := []string{}
128 if icon != "" {
117 if icon != "" {
129− content = append(content, t.S().Base.Foreground(iconColor).Render(icon))
118+ content = append(content, icon)
130 }
119 }
131 content = append(content, title, description)
120 content = append(content, title, description)
132− if ops.ExtraContent != "" {
121+ if opts.ExtraContent != "" {
133− content = append(content, ops.ExtraContent)
122+ content = append(content, opts.ExtraContent)
134 }
123 }
135
124
136 return strings.Join(content, " ")
125 return strings.Join(content, " ")
internal/tui/components/core/status_test.go−3
@@ -37,7 +37,6 @@ func TestStatus(t *testing.T) {
37 {
37 {
38 name: "NoIcon",
38 name: "NoIcon",
39 opts: core.StatusOpts{
39 opts: core.StatusOpts{
40− NoIcon: true,
41 Title: "Info",
40 Title: "Info",
42 Description: "This status has no icon",
41 Description: "This status has no icon",
43 },
42 },
@@ -47,7 +46,6 @@ func TestStatus(t *testing.T) {
47 name: "WithColors",
46 name: "WithColors",
48 opts: core.StatusOpts{
47 opts: core.StatusOpts{
49 Icon: "⚠",
48 Icon: "⚠",
50− IconColor: color.RGBA{255, 165, 0, 255}, // Orange
51 Title: "Warning",
49 Title: "Warning",
52 TitleColor: color.RGBA{255, 255, 0, 255}, // Yellow
50 TitleColor: color.RGBA{255, 255, 0, 255}, // Yellow
53 Description: "This is a warning message",
51 Description: "This is a warning message",
@@ -102,7 +100,6 @@ func TestStatus(t *testing.T) {
102 name: "AllFieldsWithExtraContent",
100 name: "AllFieldsWithExtraContent",
103 opts: core.StatusOpts{
101 opts: core.StatusOpts{
104 Icon: "🚀",
102 Icon: "🚀",
105− IconColor: color.RGBA{0, 255, 0, 255}, // Green
106 Title: "Deployment",
103 Title: "Deployment",
107 TitleColor: color.RGBA{0, 0, 255, 255}, // Blue
104 TitleColor: color.RGBA{0, 0, 255, 255}, // Blue
108 Description: "Deploying to production environment",
105 Description: "Deploying to production environment",
internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.golden+1−1
@@ -1 +1 @@
1−🚀 Deployment Deploying to production environment v1.2.3
1+🚀 Deployment Deploying to production environment v1.2.3
internal/tui/components/core/testdata/TestStatus/Default.golden+1−1
@@ -1 +1 @@
1−● Status Everything is working fine
1+Status Everything is working fine
internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden+1−1
@@ -1 +1 @@
1−● Title Only 
1+● Title Only 
internal/tui/components/core/testdata/TestStatus/LongDescription.golden+1−1
@@ -1 +1 @@
1−● Processing This is a very long description that should be…
1+Processing This is a very long description that should be …
internal/tui/components/core/testdata/TestStatus/NarrowWidth.golden+1−1
@@ -1 +1 @@
1−● Status Short message
1+● Status Short message
internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.golden+1−1
@@ -1 +1 @@
1−● Test This will be…
1+● Test This will be…
internal/tui/components/core/testdata/TestStatus/WithColors.golden+1−1
@@ -1 +1 @@
1−⚠ Warning This is a warning message
1+⚠ Warning This is a warning message
internal/tui/components/core/testdata/TestStatus/WithCustomIcon.golden+1−1
@@ -1 +1 @@
1−✓ Success Operation completed successfully
1+✓ Success Operation completed successfully
internal/tui/components/core/testdata/TestStatus/WithExtraContent.golden+1−1
@@ -1 +1 @@
1−● Build Building project [2/5]
1+Build Building project [2/5]
internal/tui/components/core/testdata/TestStatusTruncation/Width20.golden+1−1
@@ -1 +1 @@
1−● Very Long Title  [extra]
1+● Very Long Title  [extra]
internal/tui/components/core/testdata/TestStatusTruncation/Width30.golden+1−1
@@ -1 +1 @@
1−● Very Long Title Thi… [extra]
1+● Very Long Title Thi… [extra]
internal/tui/components/core/testdata/TestStatusTruncation/Width40.golden+1−1
@@ -1 +1 @@
1−● Very Long Title This is an ex… [extra]
1+● Very Long Title This is an ex… [extra]
internal/tui/components/core/testdata/TestStatusTruncation/Width50.golden+1−1
@@ -1 +1 @@
1−● Very Long Title This is an extremely lo… [extra]
1+● Very Long Title This is an extremely lo… [extra]
internal/tui/components/core/testdata/TestStatusTruncation/Width60.golden+1−1
@@ -1 +1 @@
1−● Very Long Title This is an extremely long descrip… [extra]
1+● Very Long Title This is an extremely long descrip… [extra]
internal/tui/components/dialogs/commands/commands.go+4−2
@@ -119,7 +119,10 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
119 case tea.WindowSizeMsg:
119 case tea.WindowSizeMsg:
120 c.wWidth = msg.Width
120 c.wWidth = msg.Width
121 c.wHeight = msg.Height
121 c.wHeight = msg.Height
122+ return c, tea.Batch(
123+ c.SetCommandType(c.commandType),
122− return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
124+ c.commandList.SetSize(c.listWidth(), c.listHeight()),
125+ )
123 case tea.KeyPressMsg:
126 case tea.KeyPressMsg:
124 switch {
127 switch {
125 case key.Matches(msg, c.keyMap.Select):
128 case key.Matches(msg, c.keyMap.Select):
@@ -318,7 +321,6 @@ func (c *commandDialogCmp) defaultCommands() []Command {
318 })
321 })
319 }
322 }
320 }
323 }
321−
322 // Only show toggle compact mode command if window width is larger than compact breakpoint (90)
324 // Only show toggle compact mode command if window width is larger than compact breakpoint (90)
323 if c.wWidth > 120 && c.sessionID != "" {
325 if c.wWidth > 120 && c.sessionID != "" {
324 commands = append(commands, Command{
326 commands = append(commands, Command{
internal/tui/components/dialogs/models/list.go+10−5
@@ -3,6 +3,7 @@ package models
3import (
3import (
4 "fmt"
4 "fmt"
5 "slices"
5 "slices"
6+ "strings"
6
7
7 tea "github.com/charmbracelet/bubbletea/v2"
8 tea "github.com/charmbracelet/bubbletea/v2"
8 "github.com/charmbracelet/catwalk/pkg/catwalk"
9 "github.com/charmbracelet/catwalk/pkg/catwalk"
@@ -49,7 +50,15 @@ func (m *ModelListComponent) Init() tea.Cmd {
49 var cmds []tea.Cmd
50 var cmds []tea.Cmd
50 if len(m.providers) == 0 {
51 if len(m.providers) == 0 {
51 providers, err := config.Providers()
52 providers, err := config.Providers()
53+ filteredProviders := []catwalk.Provider{}
54+ for _, p := range providers {
55+ hasAPIKeyEnv := strings.HasPrefix(p.APIKey, "$")
56+ if hasAPIKeyEnv && p.ID != catwalk.InferenceProviderAzure {
57+ filteredProviders = append(filteredProviders, p)
58+ }
59+ }
60+
52− m.providers = providers
61+ m.providers = filteredProviders
53 if err != nil {
62 if err != nil {
54 cmds = append(cmds, util.ReportError(err))
63 cmds = append(cmds, util.ReportError(err))
55 }
64 }
@@ -242,7 +251,3 @@ func (m *ModelListComponent) GetModelType() int {
242func (m *ModelListComponent) SetInputPlaceholder(placeholder string) {
251func (m *ModelListComponent) SetInputPlaceholder(placeholder string) {
243 m.list.SetInputPlaceholder(placeholder)
252 m.list.SetInputPlaceholder(placeholder)
244}
253}
245−
246−func (m *ModelListComponent) SetProviders(providers []catwalk.Provider) {
247− m.providers = providers
248−}
internal/tui/components/dialogs/models/models.go−19
@@ -2,7 +2,6 @@ package models
2
2
3import (
3import (
4 "fmt"
4 "fmt"
5− "slices"
6 "time"
5 "time"
7
6
8 "github.com/charmbracelet/bubbles/v2/help"
7 "github.com/charmbracelet/bubbles/v2/help"
@@ -96,24 +95,6 @@ func NewModelDialogCmp() ModelDialog {
96}
95}
97
96
98func (m *modelDialogCmp) Init() tea.Cmd {
97func (m *modelDialogCmp) Init() tea.Cmd {
99− providers, err := config.Providers()
100− if err == nil {
101− filteredProviders := []catwalk.Provider{}
102− simpleProviders := []string{
103− "anthropic",
104− "openai",
105− "gemini",
106− "xai",
107− "groq",
108− "openrouter",
109− }
110− for _, p := range providers {
111− if slices.Contains(simpleProviders, string(p.ID)) {
112− filteredProviders = append(filteredProviders, p)
113− }
114− }
115− m.modelList.SetProviders(filteredProviders)
116− }
117 return tea.Batch(m.modelList.Init(), m.apiKeyInput.Init())
98 return tea.Batch(m.modelList.Init(), m.apiKeyInput.Init())
118}
99}
119
100
internal/tui/components/files/files.go+3−2
@@ -59,6 +59,9 @@ func RenderFileList(fileSlice []SessionFile, opts RenderOptions) []string {
59
59
60 // Sort files by the latest version's created time
60 // Sort files by the latest version's created time
61 sort.Slice(fileSlice, func(i, j int) bool {
61 sort.Slice(fileSlice, func(i, j int) bool {
62+ if fileSlice[i].History.LatestVersion.CreatedAt == fileSlice[j].History.LatestVersion.CreatedAt {
63+ return strings.Compare(fileSlice[i].FilePath, fileSlice[j].FilePath) < 0
64+ }
62 return fileSlice[i].History.LatestVersion.CreatedAt > fileSlice[j].History.LatestVersion.CreatedAt
65 return fileSlice[i].History.LatestVersion.CreatedAt > fileSlice[j].History.LatestVersion.CreatedAt
63 })
66 })
64
67
@@ -95,8 +98,6 @@ func RenderFileList(fileSlice []SessionFile, opts RenderOptions) []string {
95 fileList = append(fileList,
98 fileList = append(fileList,
96 core.Status(
99 core.Status(
97 core.StatusOpts{
100 core.StatusOpts{
98− IconColor: t.FgMuted,
99− NoIcon: true,
100 Title: filePath,
101 Title: filePath,
101 ExtraContent: extraContent,
102 ExtraContent: extraContent,
102 },
103 },
internal/tui/components/lsp/lsp.go+5−6
@@ -57,22 +57,21 @@ func RenderLSPList(lspClients map[string]*lsp.Client, opts RenderOptions) []stri
57 }
57 }
58
58
59 // Determine icon color and description based on state
59 // Determine icon color and description based on state
60− iconColor := t.FgMuted
60+ icon := t.ItemOfflineIcon
61 description := l.LSP.Command
61 description := l.LSP.Command
62
62
63 if l.LSP.Disabled {
63 if l.LSP.Disabled {
64− iconColor = t.FgMuted
65 description = t.S().Subtle.Render("disabled")
64 description = t.S().Subtle.Render("disabled")
66 } else if state, exists := lspStates[l.Name]; exists {
65 } else if state, exists := lspStates[l.Name]; exists {
67 switch state.State {
66 switch state.State {
68 case lsp.StateStarting:
67 case lsp.StateStarting:
69− iconColor = t.Yellow
68+ icon = t.ItemBusyIcon
70 description = t.S().Subtle.Render("starting...")
69 description = t.S().Subtle.Render("starting...")
71 case lsp.StateReady:
70 case lsp.StateReady:
72− iconColor = t.Success
71+ icon = t.ItemOnlineIcon
73 description = l.LSP.Command
72 description = l.LSP.Command
74 case lsp.StateError:
73 case lsp.StateError:
75− iconColor = t.Red
74+ icon = t.ItemErrorIcon
76 if state.Error != nil {
75 if state.Error != nil {
77 description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error()))
76 description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error()))
78 } else {
77 } else {
@@ -119,7 +118,7 @@ func RenderLSPList(lspClients map[string]*lsp.Client, opts RenderOptions) []stri
119 lspList = append(lspList,
118 lspList = append(lspList,
120 core.Status(
119 core.Status(
121 core.StatusOpts{
120 core.StatusOpts{
122− IconColor: iconColor,
121+ Icon: icon.String(),
123 Title: l.Name,
122 Title: l.Name,
124 Description: description,
123 Description: description,
125 ExtraContent: extraContent,
124 ExtraContent: extraContent,
internal/tui/components/mcp/mcp.go+5−7
@@ -54,25 +54,24 @@ func RenderMCPList(opts RenderOptions) []string {
54 }
54 }
55
55
56 // Determine icon and color based on state
56 // Determine icon and color based on state
57− iconColor := t.FgMuted
57+ icon := t.ItemOfflineIcon
58 description := l.MCP.Command
58 description := l.MCP.Command
59 extraContent := ""
59 extraContent := ""
60
60
61 if state, exists := mcpStates[l.Name]; exists {
61 if state, exists := mcpStates[l.Name]; exists {
62 switch state.State {
62 switch state.State {
63 case agent.MCPStateDisabled:
63 case agent.MCPStateDisabled:
64− iconColor = t.FgMuted
65 description = t.S().Subtle.Render("disabled")
64 description = t.S().Subtle.Render("disabled")
66 case agent.MCPStateStarting:
65 case agent.MCPStateStarting:
67− iconColor = t.Yellow
66+ icon = t.ItemBusyIcon
68 description = t.S().Subtle.Render("starting...")
67 description = t.S().Subtle.Render("starting...")
69 case agent.MCPStateConnected:
68 case agent.MCPStateConnected:
70− iconColor = t.Success
69+ icon = t.ItemOnlineIcon
71 if state.ToolCount > 0 {
70 if state.ToolCount > 0 {
72 extraContent = t.S().Subtle.Render(fmt.Sprintf("(%d tools)", state.ToolCount))
71 extraContent = t.S().Subtle.Render(fmt.Sprintf("(%d tools)", state.ToolCount))
73 }
72 }
74 case agent.MCPStateError:
73 case agent.MCPStateError:
75− iconColor = t.Red
74+ icon = t.ItemErrorIcon
76 if state.Error != nil {
75 if state.Error != nil {
77 description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error()))
76 description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error()))
78 } else {
77 } else {
@@ -80,14 +79,13 @@ func RenderMCPList(opts RenderOptions) []string {
80 }
79 }
81 }
80 }
82 } else if l.MCP.Disabled {
81 } else if l.MCP.Disabled {
83− iconColor = t.FgMuted
84 description = t.S().Subtle.Render("disabled")
82 description = t.S().Subtle.Render("disabled")
85 }
83 }
86
84
87 mcpList = append(mcpList,
85 mcpList = append(mcpList,
88 core.Status(
86 core.Status(
89 core.StatusOpts{
87 core.StatusOpts{
90− IconColor: iconColor,
88+ Icon: icon.String(),
91 Title: l.Name,
89 Title: l.Name,
92 Description: description,
90 Description: description,
93 ExtraContent: extraContent,
91 ExtraContent: extraContent,
internal/tui/exp/list/list.go+9−2
@@ -4,6 +4,7 @@ import (
4 "log/slog"
4 "log/slog"
5 "slices"
5 "slices"
6 "strings"
6 "strings"
7+ "sync"
7
8
8 "github.com/charmbracelet/bubbles/v2/key"
9 "github.com/charmbracelet/bubbles/v2/key"
9 tea "github.com/charmbracelet/bubbletea/v2"
10 tea "github.com/charmbracelet/bubbletea/v2"
@@ -94,6 +95,7 @@ type list[T Item] struct {
94
95
95 renderedItems *csync.Map[string, renderedItem]
96 renderedItems *csync.Map[string, renderedItem]
96
97
98+ renderMu sync.Mutex
97 rendered string
99 rendered string
98
100
99 movingByItem bool
101 movingByItem bool
@@ -462,7 +464,9 @@ func (l *list[T]) render() tea.Cmd {
462 // we are not rendering the first time
464 // we are not rendering the first time
463 if l.rendered != "" {
465 if l.rendered != "" {
464 // rerender everything will mostly hit cache
466 // rerender everything will mostly hit cache
467+ l.renderMu.Lock()
465 l.rendered, _ = l.renderIterator(0, false, "")
468 l.rendered, _ = l.renderIterator(0, false, "")
469+ l.renderMu.Unlock()
466 if l.direction == DirectionBackward {
470 if l.direction == DirectionBackward {
467 l.recalculateItemPositions()
471 l.recalculateItemPositions()
468 }
472 }
@@ -472,9 +476,10 @@ func (l *list[T]) render() tea.Cmd {
472 }
476 }
473 return focusChangeCmd
477 return focusChangeCmd
474 }
478 }
479+ l.renderMu.Lock()
475 rendered, finishIndex := l.renderIterator(0, true, "")
480 rendered, finishIndex := l.renderIterator(0, true, "")
476 l.rendered = rendered
481 l.rendered = rendered
477−
482+ l.renderMu.Unlock()
478 // recalculate for the initial items
483 // recalculate for the initial items
479 if l.direction == DirectionBackward {
484 if l.direction == DirectionBackward {
480 l.recalculateItemPositions()
485 l.recalculateItemPositions()
@@ -482,7 +487,10 @@ func (l *list[T]) render() tea.Cmd {
482 renderCmd := func() tea.Msg {
487 renderCmd := func() tea.Msg {
483 l.offset = 0
488 l.offset = 0
484 // render the rest
489 // render the rest
490+
491+ l.renderMu.Lock()
485 l.rendered, _ = l.renderIterator(finishIndex, false, l.rendered)
492 l.rendered, _ = l.renderIterator(finishIndex, false, l.rendered)
493+ l.renderMu.Unlock()
486 // needed for backwards
494 // needed for backwards
487 if l.direction == DirectionBackward {
495 if l.direction == DirectionBackward {
488 l.recalculateItemPositions()
496 l.recalculateItemPositions()
@@ -491,7 +499,6 @@ func (l *list[T]) render() tea.Cmd {
491 if l.focused {
499 if l.focused {
492 l.scrollToSelection()
500 l.scrollToSelection()
493 }
501 }
494−
495 return nil
502 return nil
496 }
503 }
497 return tea.Batch(focusChangeCmd, renderCmd)
504 return tea.Batch(focusChangeCmd, renderCmd)
internal/tui/styles/crush.go+10−1
@@ -1,11 +1,12 @@
1package styles
1package styles
2
2
3import (
3import (
4+ "github.com/charmbracelet/lipgloss/v2"
4 "github.com/charmbracelet/x/exp/charmtone"
5 "github.com/charmbracelet/x/exp/charmtone"
5)
6)
6
7
7func NewCrushTheme() *Theme {
8func NewCrushTheme() *Theme {
8− return &Theme{
9+ t := &Theme{
9 Name: "crush",
10 Name: "crush",
10 IsDark: true,
11 IsDark: true,
11
12
@@ -54,4 +55,12 @@ func NewCrushTheme() *Theme {
54 RedLight: charmtone.Salmon,
55 RedLight: charmtone.Salmon,
55 Cherry: charmtone.Cherry,
56 Cherry: charmtone.Cherry,
56 }
57 }
58+
59+ // LSP and MCP status.
60+ t.ItemOfflineIcon = lipgloss.NewStyle().Foreground(charmtone.Squid).SetString("●")
61+ t.ItemBusyIcon = t.ItemOfflineIcon.Foreground(charmtone.Citron)
62+ t.ItemErrorIcon = t.ItemOfflineIcon.Foreground(charmtone.Coral)
63+ t.ItemOnlineIcon = t.ItemOfflineIcon.Foreground(charmtone.Guac)
64+
65+ return t
57}
66}
internal/tui/styles/theme.go+6
@@ -74,6 +74,12 @@ type Theme struct {
74 RedLight color.Color
74 RedLight color.Color
75 Cherry color.Color
75 Cherry color.Color
76
76
77+ // LSP and MCP status indicators.
78+ ItemOfflineIcon lipgloss.Style
79+ ItemBusyIcon lipgloss.Style
80+ ItemErrorIcon lipgloss.Style
81+ ItemOnlineIcon lipgloss.Style
82+
77 styles *Styles
83 styles *Styles
78}
84}
79
85