on main help sign in
Merge remote-tracking branch 'origin/main' into list-select
commit976d49c223633624afd1ad3852dda6ab024b3679 authorKujtim Hoxha <kujtimii.h@gmail.com> dateTue 5 Aug 2025, 16:02 (1 year ago) parent55e584bdc704c1
36 files changed+416−232
.github/cla-signatures.json+24 CRUSH.md+2 README.md+107−8 go.mod+3−3 go.sum+6−6 internal/llm/agent/agent.go+37−31 internal/llm/agent/mcp-tools.go+9−7 internal/llm/provider/openai.go+71−67 internal/llm/tools/tools.go+74−1 internal/tui/components/chat/messages/messages.go+3−1 internal/tui/components/chat/splash/splash.go−22 internal/tui/components/core/core.go+14−25 internal/tui/components/core/status_test.go−3 internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.golden+1−1 internal/tui/components/core/testdata/TestStatus/Default.golden+1−1 internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden+1−1 internal/tui/components/core/testdata/TestStatus/LongDescription.golden+1−1 internal/tui/components/core/testdata/TestStatus/NarrowWidth.golden+1−1 internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.golden+1−1 internal/tui/components/core/testdata/TestStatus/WithColors.golden+1−1 internal/tui/components/core/testdata/TestStatus/WithCustomIcon.golden+1−1 internal/tui/components/core/testdata/TestStatus/WithExtraContent.golden+1−1 internal/tui/components/core/testdata/TestStatusTruncation/Width20.golden+1−1 internal/tui/components/core/testdata/TestStatusTruncation/Width30.golden+1−1 internal/tui/components/core/testdata/TestStatusTruncation/Width40.golden+1−1 internal/tui/components/core/testdata/TestStatusTruncation/Width50.golden+1−1 internal/tui/components/core/testdata/TestStatusTruncation/Width60.golden+1−1 internal/tui/components/dialogs/commands/commands.go+4−2 internal/tui/components/dialogs/models/list.go+10−5 internal/tui/components/dialogs/models/models.go−19 internal/tui/components/files/files.go+3−2 internal/tui/components/lsp/lsp.go+5−6 internal/tui/components/mcp/mcp.go+5−7 internal/tui/exp/list/list.go+9−2 internal/tui/styles/crush.go+10−1 internal/tui/styles/theme.go+6
.github/cla-signatures.json+24
@@ -223,6 +223,30 @@
223223 "created_at": "2025-08-04T14:01:30Z",
224224 "repoId": 987670088,
225225 "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
226250 }
227251 ]
228252}
CRUSH.md+2
@@ -4,6 +4,8 @@
44
55- **Build**: `go build .` or `go run .`
66- **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")
79- **Lint**: `task lint-fix`
810- **Format**: `task fmt` (gofumpt -w .)
911- **Dev**: `task dev` (runs with profiling enabled)
README.md+107−8
@@ -39,7 +39,7 @@ nix run github:numtide/nix-ai-tools#crush
3939
4040<details>
4141<summary><strong>Nix (NUR)</strong></summary>
42−
42+
4343Crush is available via [NUR](https://github.com/nix-community/NUR) in `nur.repos.charmbracelet.crush`.
4444
4545You 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
138138customize Crush, configuration can be added either local to the project itself,
139139or globally, with the following priority:
140140
141−1. `./.crush.json`
142−2. `./crush.json`
143−3. `$HOME/.config/crush/crush.json`
141+1. `.crush.json`
142+2. `crush.json`
143+3. `$HOME/.config/crush/crush.json` (Windows: `%USERPROFILE%\AppData\Local\crush\crush.json`)
144144
145145Configuration itself is stored as a JSON object:
146146
147147```json
148148{
149− "this-setting": { }
150− "that-setting": { }
149+ "this-setting": {"this": "that"},
150+ "that-setting": ["ceci", "cela"]
151151}
152152```
153153
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+
154164### LSPs
155165
156166Crush 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.
221231The `.crushignore` file uses the same syntax as `.gitignore` and can be placed
222232in the root of your project or in subdirectories.
223233
224−### Whitelisting Tools
234+### Allowing Tools
225235
226236By 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
228238permissions. Use this with care.
229239
230240```json
@@ -245,6 +255,53 @@ permissions. Use this with care.
245255You can also skip all permission prompts entirely by running Crush with the
246256`--yolo` flag. Be very, very careful with this feature.
247257
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+
248305### Custom Providers
249306
250307Crush supports custom provider configurations for both OpenAI-compatible and
@@ -314,6 +371,48 @@ Custom Anthropic-compatible providers follow this format:
314371}
315372```
316373
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+
317416## Logging
318417
319418Sometimes you need to look at logs. Luckily, Crush logs all sorts of
go.mod+3−3
@@ -14,7 +14,7 @@ require (
1414 github.com/charlievieth/fastwalk v1.0.11
1515 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5
1616 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
1818 github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674
1919 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
2020 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250721205738-ea66aa652ee0
@@ -27,7 +27,7 @@ require (
2727 github.com/google/uuid v1.6.0
2828 github.com/invopop/jsonschema v0.13.0
2929 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
3131 github.com/muesli/termenv v0.16.0
3232 github.com/ncruces/go-sqlite3 v0.25.0
3333 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
@@ -135,7 +135,7 @@ require (
135135 golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
136136 golang.org/x/image v0.26.0 // indirect
137137 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
139139 golang.org/x/sync v0.16.0 // indirect
140140 golang.org/x/sys v0.34.0
141141 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
7878github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
7979github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250730165737-56ff7146d52d h1:YMXLZHSo8DjytVY/b5dK8LDuyQsVUmBK3ydQMpu2Ui4=
8080github.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=
82−github.com/charmbracelet/catwalk v0.3.5/go.mod h1:gUUCqqZ8bk4D7ZzGTu3I77k7cC2x4exRuJBN1H2u2pc=
81+github.com/charmbracelet/catwalk v0.4.5 h1:Kv3PadDe8IF8gpcYTfAJdCee5Bv4HufvtNT61FXtq5g=
82+github.com/charmbracelet/catwalk v0.4.5/go.mod h1:WnKgNPmQHuMyk7GtwAQwl+ezHusfH40IvzML2qwUGwc=
8383github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
8484github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
8585github.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
180180github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
181181github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
182182github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
183−github.com/mark3labs/mcp-go v0.36.0 h1:rIZaijrRYPeSbJG8/qNDe0hWlGrCJ7FWHNMz2SQpTis=
184−github.com/mark3labs/mcp-go v0.36.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g=
183+github.com/mark3labs/mcp-go v0.37.0 h1:BywvZLPRT6Zx6mMG/MJfxLSZQkTGIcJSEGKsvr4DsoQ=
184+github.com/mark3labs/mcp-go v0.37.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g=
185185github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
186186github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
187187github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
@@ -332,8 +332,8 @@ golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
332332golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
333333golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
334334golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
335−golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
336−golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
335+golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
336+golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
337337golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
338338golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
339339golang.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(
159159 if err != nil {
160160 return nil, err
161161 }
162+
162163 summarizeOpts := []provider.ProviderClientOption{
163− provider.WithModel(config.SelectedModelTypeSmall),
164− provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, smallModelProviderCfg.ID)),
164+ provider.WithModel(config.SelectedModelTypeLarge),
165+ provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, providerCfg.ID)),
165166 }
166− summarizeProvider, err := provider.NewProvider(*smallModelProviderCfg, summarizeOpts...)
167+ summarizeProvider, err := provider.NewProvider(*providerCfg, summarizeOpts...)
167168 if err != nil {
168169 return nil, err
169170 }
@@ -224,7 +225,7 @@ func NewAgent(
224225 sessions: sessions,
225226 titleProvider: titleProvider,
226227 summarizeProvider: summarizeProvider,
227− summarizeProviderID: string(smallModelProviderCfg.ID),
228+ summarizeProviderID: string(providerCfg.ID),
228229 activeRequests: csync.NewMap[string, context.CancelFunc](),
229230 tools: csync.NewLazySlice(toolFn),
230231 }, nil
@@ -904,54 +905,59 @@ func (a *agent) UpdateModel() error {
904905 a.providerID = string(currentProviderCfg.ID)
905906 }
906907
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)
908909 smallModelCfg := cfg.Models[config.SelectedModelTypeSmall]
909910 var smallModelProviderCfg config.ProviderConfig
910−
911911 for p := range cfg.Providers.Seq() {
912912 if p.ID == smallModelCfg.Provider {
913913 smallModelProviderCfg = p
914914 break
915915 }
916916 }
917−
918917 if smallModelProviderCfg.ID == "" {
919918 return fmt.Errorf("provider %s not found in config", smallModelCfg.Provider)
920919 }
921920
922− // Check if summarize provider has changed
923− if string(smallModelProviderCfg.ID) != a.summarizeProviderID {
924− smallModel := cfg.GetModelByType(config.SelectedModelTypeSmall)
925− if smallModel == nil {
926− return fmt.Errorf("model %s not found in provider %s", smallModelCfg.Model, smallModelProviderCfg.ID)
921+ largeModelCfg := cfg.Models[config.SelectedModelTypeLarge]
922+ var largeModelProviderCfg config.ProviderConfig
923+ for p := range cfg.Providers.Seq() {
924+ if p.ID == largeModelCfg.Provider {
925+ largeModelProviderCfg = p
926+ break
927927 }
928+ }
929+ if largeModelProviderCfg.ID == "" {
930+ return fmt.Errorf("provider %s not found in config", largeModelCfg.Provider)
931+ }
928932
929− // Recreate title provider
930− titleOpts := []provider.ProviderClientOption{
931− provider.WithModel(config.SelectedModelTypeSmall),
932− 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),
935− }
936− newTitleProvider, err := provider.NewProvider(smallModelProviderCfg, titleOpts...)
937− if err != nil {
938− return fmt.Errorf("failed to create new title provider: %w", err)
939− }
933+ // Recreate title provider
934+ titleOpts := []provider.ProviderClientOption{
935+ provider.WithModel(config.SelectedModelTypeSmall),
936+ provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptTitle, smallModelProviderCfg.ID)),
937+ provider.WithMaxTokens(40),
938+ }
939+ newTitleProvider, err := provider.NewProvider(smallModelProviderCfg, titleOpts...)
940+ if err != nil {
941+ return fmt.Errorf("failed to create new title provider: %w", err)
942+ }
943+ a.titleProvider = newTitleProvider
940944
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+ }
942951 summarizeOpts := []provider.ProviderClientOption{
943− provider.WithModel(config.SelectedModelTypeSmall),
944− provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, smallModelProviderCfg.ID)),
952+ provider.WithModel(config.SelectedModelTypeLarge),
953+ provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, largeModelProviderCfg.ID)),
945954 }
946− newSummarizeProvider, err := provider.NewProvider(smallModelProviderCfg, summarizeOpts...)
955+ newSummarizeProvider, err := provider.NewProvider(largeModelProviderCfg, summarizeOpts...)
947956 if err != nil {
948957 return fmt.Errorf("failed to create new summarize provider: %w", err)
949958 }
950−
951− // Update the providers and provider ID
952− a.titleProvider = newTitleProvider
953959 a.summarizeProvider = newSummarizeProvider
954− a.summarizeProviderID = string(smallModelProviderCfg.ID)
960+ a.summarizeProviderID = string(largeModelProviderCfg.ID)
955961 }
956962
957963 return nil
internal/llm/agent/mcp-tools.go+9−7
@@ -308,29 +308,31 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con
308308func createMcpClient(m config.MCPConfig) (*client.Client, error) {
309309 switch m.Type {
310310 case config.MCPStdio:
311− return client.NewStdioMCPClient(
311+ return client.NewStdioMCPClientWithOptions(
312312 m.Command,
313313 m.ResolvedEnv(),
314− m.Args...,
314+ m.Args,
315+ transport.WithCommandLogger(mcpLogger{}),
315316 )
316317 case config.MCPHttp:
317318 return client.NewStreamableHttpClient(
318319 m.URL,
319320 transport.WithHTTPHeaders(m.ResolvedHeaders()),
320− transport.WithLogger(mcpHTTPLogger{}),
321+ transport.WithHTTPLogger(mcpLogger{}),
321322 )
322323 case config.MCPSse:
323324 return client.NewSSEMCPClient(
324325 m.URL,
325326 client.WithHeaders(m.ResolvedHeaders()),
327+ transport.WithSSELogger(mcpLogger{}),
326328 )
327329 default:
328330 return nil, fmt.Errorf("unsupported mcp type: %s", m.Type)
329331 }
330332}
331333
332−// for MCP's HTTP client.
333−type mcpHTTPLogger struct{}
334+// for MCP's clients.
335+type mcpLogger struct{}
334336
335−func (l mcpHTTPLogger) 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...)) }
337+func (l mcpLogger) Errorf(format string, v ...any) { slog.Error(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
22
33import (
44 "context"
5+ "encoding/json"
56 "errors"
67 "fmt"
78 "io"
89 "log/slog"
10+ "slices"
911 "strings"
1012 "time"
1113
@@ -14,6 +16,7 @@ import (
1416 "github.com/charmbracelet/crush/internal/llm/tools"
1517 "github.com/charmbracelet/crush/internal/log"
1618 "github.com/charmbracelet/crush/internal/message"
19+ "github.com/google/uuid"
1720 "github.com/openai/openai-go"
1821 "github.com/openai/openai-go/option"
1922 "github.com/openai/openai-go/packages/param"
@@ -70,8 +73,9 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
7073 systemMessage = o.providerOptions.systemPromptPrefix + "\n" + systemMessage
7174 }
7275
73− systemTextBlock := openai.ChatCompletionContentPartTextParam{Text: systemMessage}
76+ system := openai.SystemMessage(systemMessage)
7477 if isAnthropicModel && !o.providerOptions.disableCache {
78+ systemTextBlock := openai.ChatCompletionContentPartTextParam{Text: systemMessage}
7579 systemTextBlock.SetExtraFields(
7680 map[string]any{
7781 "cache_control": map[string]string{
@@ -79,10 +83,10 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
7983 },
8084 },
8185 )
86+ var content []openai.ChatCompletionContentPartTextParam
87+ content = append(content, systemTextBlock)
88+ system = openai.SystemMessage(content)
8289 }
83− var content []openai.ChatCompletionContentPartTextParam
84− content = append(content, systemTextBlock)
85− system := openai.SystemMessage(content)
8690 openaiMessages = append(openaiMessages, system)
8791
8892 for i, msg := range messages {
@@ -93,9 +97,12 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
9397 switch msg.Role {
9498 case message.User:
9599 var content []openai.ChatCompletionContentPartUnionParam
100+
96101 textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()}
97102 content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock})
103+ hasBinaryContent := false
98104 for _, binaryContent := range msg.BinaryContent() {
105+ hasBinaryContent = true
99106 imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(catwalk.InferenceProviderOpenAI)}
100107 imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL}
101108
@@ -108,8 +115,11 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
108115 },
109116 })
110117 }
111−
112− openaiMessages = append(openaiMessages, openai.UserMessage(content))
118+ if hasBinaryContent || (isAnthropicModel && !o.providerOptions.disableCache) {
119+ openaiMessages = append(openaiMessages, openai.UserMessage(content))
120+ } else {
121+ openaiMessages = append(openaiMessages, openai.UserMessage(msg.Content().String()))
122+ }
113123
114124 case message.Assistant:
115125 assistantMsg := openai.ChatCompletionAssistantMessageParam{
@@ -134,13 +144,15 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
134144 },
135145 },
136146 }
147+ if !isAnthropicModel {
148+ assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{
149+ OfString: param.NewOpt(msg.Content().String()),
150+ }
151+ }
137152 }
138153
139154 if len(msg.ToolCalls()) > 0 {
140155 hasContent = true
141− assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{
142− OfString: param.NewOpt(msg.Content().String()),
143− }
144156 assistantMsg.ToolCalls = make([]openai.ChatCompletionMessageToolCallParam, len(msg.ToolCalls()))
145157 for i, call := range msg.ToolCalls() {
146158 assistantMsg.ToolCalls[i] = openai.ChatCompletionMessageToolCallParam{
@@ -329,21 +341,26 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
329341 acc := openai.ChatCompletionAccumulator{}
330342 currentContent := ""
331343 toolCalls := make([]message.ToolCall, 0)
332−
333− var currentToolCallID string
334− var currentToolCall openai.ChatCompletionMessageToolCall
335344 var msgToolCalls []openai.ChatCompletionMessageToolCall
336− currentToolIndex := 0
337345 for openaiStream.Next() {
338346 chunk := openaiStream.Current()
339347 // Kujtim: this is an issue with openrouter qwen, its sending -1 for the tool index
340348 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)
342− currentToolIndex++
349+ chunk.Choices[0].Delta.ToolCalls[0].Index = 0
343350 }
344351 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+ }
347364 if choice.Delta.Content != "" {
348365 eventChan <- ProviderEvent{
349366 Type: EventContentDelta,
@@ -352,63 +369,50 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
352369 currentContent += choice.Delta.Content
353370 } else if len(choice.Delta.ToolCalls) > 0 {
354371 toolCall := choice.Delta.ToolCalls[0]
355− // Detect tool use start
356− if currentToolCallID == "" {
357− if toolCall.ID != "" {
358− currentToolCallID = toolCall.ID
359− eventChan <- ProviderEvent{
360− Type: EventToolUseStart,
361− ToolCall: &message.ToolCall{
362− ID: toolCall.ID,
363− Name: toolCall.Function.Name,
364− Finished: false,
365− },
372+ newToolCall := false
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 {
379+ if tool.ID == toolCall.ID {
380+ msgToolCalls[i].Function.Arguments += toolCall.Function.Arguments
381+ found = true
382+ }
366383 }
367− currentToolCall = openai.ChatCompletionMessageToolCall{
368− ID: toolCall.ID,
369− Type: "function",
370− Function: openai.ChatCompletionMessageToolCallFunction{
371− Name: toolCall.Function.Name,
372− Arguments: toolCall.Function.Arguments,
373− },
384+ if !found {
385+ newToolCall = true
374386 }
375− }
376− } else {
377− // Delta tool use
378− if toolCall.ID == "" || toolCall.ID == currentToolCallID {
379− currentToolCall.Function.Arguments += toolCall.Function.Arguments
380387 } else {
381− // Detect new tool use
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− }
388+ msgToolCalls[toolCall.Index].Function.Arguments += toolCall.Function.Arguments
402389 }
390+ } else {
391+ newToolCall = true
403392 }
404− }
405− // Kujtim: some models send finish stop even for tool calls
406− if choice.FinishReason == "tool_calls" || (choice.FinishReason == "stop" && currentToolCallID != "") {
407− msgToolCalls = append(msgToolCalls, currentToolCall)
408− if len(acc.Choices) > 0 {
409− acc.Choices[0].Message.ToolCalls = msgToolCalls
393+ if newToolCall { // new tool call
394+ if toolCall.ID == "" {
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+ }
405+ msgToolCalls = append(msgToolCalls, openai.ChatCompletionMessageToolCall{
406+ ID: toolCall.ID,
407+ Type: "function",
408+ Function: openai.ChatCompletionMessageToolCallFunction{
409+ Name: toolCall.Function.Name,
410+ Arguments: toolCall.Function.Arguments,
411+ },
412+ })
410413 }
411414 }
415+ acc.Choices[i].Message.ToolCalls = slices.Clone(msgToolCalls)
412416 }
413417 }
414418
internal/llm/tools/tools.go+74−1
@@ -3,6 +3,8 @@ package tools
33import (
44 "context"
55 "encoding/json"
6+ "fmt"
7+ "strings"
68)
79
810type ToolInfo struct {
@@ -25,6 +27,10 @@ const (
2527
2628 SessionIDContextKey sessionIDContextKey = "session_id"
2729 MessageIDContextKey messageIDContextKey = "message_id"
30+
31+ maxResponseWidth = 3000
32+ maxResponseHeight = 5000
33+ maxResponseChars = 50000
2834)
2935
3036type ToolResponse struct {
@@ -37,10 +43,77 @@ type ToolResponse struct {
3743func NewTextResponse(content string) ToolResponse {
3844 return ToolResponse{
3945 Type: ToolResponseTypeText,
40− Content: content,
46+ Content: truncateContent(content),
4147 }
4248}
4349
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+
44117func WithResponseMetadata(response ToolResponse, metadata any) ToolResponse {
45118 if metadata != nil {
46119 metadataBytes, err := json.Marshal(metadata)
internal/tui/components/chat/messages/messages.go+3−1
@@ -274,11 +274,13 @@ func (m *messageCmp) renderThinkingContent() string {
274274 if reasoningContent.StartedAt > 0 {
275275 duration := m.message.ThinkingDuration()
276276 if reasoningContent.FinishedAt > 0 {
277+ if duration.String() == "0s" {
278+ return ""
279+ }
277280 m.anim.SetLabel("")
278281 opts := core.StatusOpts{
279282 Title: "Thought for",
280283 Description: duration.String(),
281− NoIcon: true,
282284 }
283285 return t.S().Base.PaddingLeft(1).Render(core.Status(opts, m.textWidth()-1))
284286 } else if finishReason != nil && finishReason.Reason == message.FinishReasonCanceled {
internal/tui/components/chat/splash/splash.go−22
@@ -3,7 +3,6 @@ package splash
33import (
44 "fmt"
55 "os"
6− "slices"
76 "strings"
87 "time"
98
@@ -103,27 +102,6 @@ func New() Splash {
103102
104103func (s *splashCmp) SetOnboarding(onboarding bool) {
105104 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− }
127105}
128106
129107func (s *splashCmp) SetProjectInit(needsInit bool) {
internal/tui/components/core/core.go+14−25
@@ -82,41 +82,30 @@ func Title(title string, width int) string {
8282}
8383
8484type StatusOpts struct {
85− Icon string
86− IconColor color.Color
87− NoIcon bool // If true, no icon will be displayed
85+ Icon string // if empty no icon will be shown
8886 Title string
8987 TitleColor color.Color
9088 Description string
9189 DescriptionColor color.Color
92− ExtraContent string // Additional content to append after the description
90+ ExtraContent string // additional content to append after the description
9391}
9492
95−func Status(ops StatusOpts, width int) string {
93+func Status(opts StatusOpts, width int) string {
9694 t := styles.CurrentTheme()
97− 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
95+ icon := opts.Icon
96+ title := opts.Title
10897 titleColor := t.FgMuted
109− if ops.TitleColor != nil {
110− titleColor = ops.TitleColor
98+ if opts.TitleColor != nil {
99+ titleColor = opts.TitleColor
111100 }
112− description := ops.Description
101+ description := opts.Description
113102 descriptionColor := t.FgSubtle
114− if ops.DescriptionColor != nil {
115− descriptionColor = ops.DescriptionColor
103+ if opts.DescriptionColor != nil {
104+ descriptionColor = opts.DescriptionColor
116105 }
117106 title = t.S().Base.Foreground(titleColor).Render(title)
118107 if description != "" {
119− extraContentWidth := lipgloss.Width(ops.ExtraContent)
108+ extraContentWidth := lipgloss.Width(opts.ExtraContent)
120109 if extraContentWidth > 0 {
121110 extraContentWidth += 1
122111 }
@@ -126,11 +115,11 @@ func Status(ops StatusOpts, width int) string {
126115
127116 content := []string{}
128117 if icon != "" {
129− content = append(content, t.S().Base.Foreground(iconColor).Render(icon))
118+ content = append(content, icon)
130119 }
131120 content = append(content, title, description)
132− if ops.ExtraContent != "" {
133− content = append(content, ops.ExtraContent)
121+ if opts.ExtraContent != "" {
122+ content = append(content, opts.ExtraContent)
134123 }
135124
136125 return strings.Join(content, " ")
internal/tui/components/core/status_test.go−3
@@ -37,7 +37,6 @@ func TestStatus(t *testing.T) {
3737 {
3838 name: "NoIcon",
3939 opts: core.StatusOpts{
40− NoIcon: true,
4140 Title: "Info",
4241 Description: "This status has no icon",
4342 },
@@ -47,7 +46,6 @@ func TestStatus(t *testing.T) {
4746 name: "WithColors",
4847 opts: core.StatusOpts{
4948 Icon: "⚠",
50− IconColor: color.RGBA{255, 165, 0, 255}, // Orange
5149 Title: "Warning",
5250 TitleColor: color.RGBA{255, 255, 0, 255}, // Yellow
5351 Description: "This is a warning message",
@@ -102,7 +100,6 @@ func TestStatus(t *testing.T) {
102100 name: "AllFieldsWithExtraContent",
103101 opts: core.StatusOpts{
104102 Icon: "🚀",
105− IconColor: color.RGBA{0, 255, 0, 255}, // Green
106103 Title: "Deployment",
107104 TitleColor: color.RGBA{0, 0, 255, 255}, // Blue
108105 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) {
119119 case tea.WindowSizeMsg:
120120 c.wWidth = msg.Width
121121 c.wHeight = msg.Height
122− return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
122+ return c, tea.Batch(
123+ c.SetCommandType(c.commandType),
124+ c.commandList.SetSize(c.listWidth(), c.listHeight()),
125+ )
123126 case tea.KeyPressMsg:
124127 switch {
125128 case key.Matches(msg, c.keyMap.Select):
@@ -318,7 +321,6 @@ func (c *commandDialogCmp) defaultCommands() []Command {
318321 })
319322 }
320323 }
321−
322324 // Only show toggle compact mode command if window width is larger than compact breakpoint (90)
323325 if c.wWidth > 120 && c.sessionID != "" {
324326 commands = append(commands, Command{
internal/tui/components/dialogs/models/list.go+10−5
@@ -3,6 +3,7 @@ package models
33import (
44 "fmt"
55 "slices"
6+ "strings"
67
78 tea "github.com/charmbracelet/bubbletea/v2"
89 "github.com/charmbracelet/catwalk/pkg/catwalk"
@@ -49,7 +50,15 @@ func (m *ModelListComponent) Init() tea.Cmd {
4950 var cmds []tea.Cmd
5051 if len(m.providers) == 0 {
5152 providers, err := config.Providers()
52− m.providers = 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+
61+ m.providers = filteredProviders
5362 if err != nil {
5463 cmds = append(cmds, util.ReportError(err))
5564 }
@@ -242,7 +251,3 @@ func (m *ModelListComponent) GetModelType() int {
242251func (m *ModelListComponent) SetInputPlaceholder(placeholder string) {
243252 m.list.SetInputPlaceholder(placeholder)
244253}
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
22
33import (
44 "fmt"
5− "slices"
65 "time"
76
87 "github.com/charmbracelet/bubbles/v2/help"
@@ -96,24 +95,6 @@ func NewModelDialogCmp() ModelDialog {
9695}
9796
9897func (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− }
11798 return tea.Batch(m.modelList.Init(), m.apiKeyInput.Init())
11899}
119100
internal/tui/components/files/files.go+3−2
@@ -59,6 +59,9 @@ func RenderFileList(fileSlice []SessionFile, opts RenderOptions) []string {
5959
6060 // Sort files by the latest version's created time
6161 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+ }
6265 return fileSlice[i].History.LatestVersion.CreatedAt > fileSlice[j].History.LatestVersion.CreatedAt
6366 })
6467
@@ -95,8 +98,6 @@ func RenderFileList(fileSlice []SessionFile, opts RenderOptions) []string {
9598 fileList = append(fileList,
9699 core.Status(
97100 core.StatusOpts{
98− IconColor: t.FgMuted,
99− NoIcon: true,
100101 Title: filePath,
101102 ExtraContent: extraContent,
102103 },
internal/tui/components/lsp/lsp.go+5−6
@@ -57,22 +57,21 @@ func RenderLSPList(lspClients map[string]*lsp.Client, opts RenderOptions) []stri
5757 }
5858
5959 // Determine icon color and description based on state
60− iconColor := t.FgMuted
60+ icon := t.ItemOfflineIcon
6161 description := l.LSP.Command
6262
6363 if l.LSP.Disabled {
64− iconColor = t.FgMuted
6564 description = t.S().Subtle.Render("disabled")
6665 } else if state, exists := lspStates[l.Name]; exists {
6766 switch state.State {
6867 case lsp.StateStarting:
69− iconColor = t.Yellow
68+ icon = t.ItemBusyIcon
7069 description = t.S().Subtle.Render("starting...")
7170 case lsp.StateReady:
72− iconColor = t.Success
71+ icon = t.ItemOnlineIcon
7372 description = l.LSP.Command
7473 case lsp.StateError:
75− iconColor = t.Red
74+ icon = t.ItemErrorIcon
7675 if state.Error != nil {
7776 description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error()))
7877 } else {
@@ -119,7 +118,7 @@ func RenderLSPList(lspClients map[string]*lsp.Client, opts RenderOptions) []stri
119118 lspList = append(lspList,
120119 core.Status(
121120 core.StatusOpts{
122− IconColor: iconColor,
121+ Icon: icon.String(),
123122 Title: l.Name,
124123 Description: description,
125124 ExtraContent: extraContent,
internal/tui/components/mcp/mcp.go+5−7
@@ -54,25 +54,24 @@ func RenderMCPList(opts RenderOptions) []string {
5454 }
5555
5656 // Determine icon and color based on state
57− iconColor := t.FgMuted
57+ icon := t.ItemOfflineIcon
5858 description := l.MCP.Command
5959 extraContent := ""
6060
6161 if state, exists := mcpStates[l.Name]; exists {
6262 switch state.State {
6363 case agent.MCPStateDisabled:
64− iconColor = t.FgMuted
6564 description = t.S().Subtle.Render("disabled")
6665 case agent.MCPStateStarting:
67− iconColor = t.Yellow
66+ icon = t.ItemBusyIcon
6867 description = t.S().Subtle.Render("starting...")
6968 case agent.MCPStateConnected:
70− iconColor = t.Success
69+ icon = t.ItemOnlineIcon
7170 if state.ToolCount > 0 {
7271 extraContent = t.S().Subtle.Render(fmt.Sprintf("(%d tools)", state.ToolCount))
7372 }
7473 case agent.MCPStateError:
75− iconColor = t.Red
74+ icon = t.ItemErrorIcon
7675 if state.Error != nil {
7776 description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error()))
7877 } else {
@@ -80,14 +79,13 @@ func RenderMCPList(opts RenderOptions) []string {
8079 }
8180 }
8281 } else if l.MCP.Disabled {
83− iconColor = t.FgMuted
8482 description = t.S().Subtle.Render("disabled")
8583 }
8684
8785 mcpList = append(mcpList,
8886 core.Status(
8987 core.StatusOpts{
90− IconColor: iconColor,
88+ Icon: icon.String(),
9189 Title: l.Name,
9290 Description: description,
9391 ExtraContent: extraContent,
internal/tui/exp/list/list.go+9−2
@@ -4,6 +4,7 @@ import (
44 "log/slog"
55 "slices"
66 "strings"
7+ "sync"
78
89 "github.com/charmbracelet/bubbles/v2/key"
910 tea "github.com/charmbracelet/bubbletea/v2"
@@ -94,6 +95,7 @@ type list[T Item] struct {
9495
9596 renderedItems *csync.Map[string, renderedItem]
9697
98+ renderMu sync.Mutex
9799 rendered string
98100
99101 movingByItem bool
@@ -462,7 +464,9 @@ func (l *list[T]) render() tea.Cmd {
462464 // we are not rendering the first time
463465 if l.rendered != "" {
464466 // rerender everything will mostly hit cache
467+ l.renderMu.Lock()
465468 l.rendered, _ = l.renderIterator(0, false, "")
469+ l.renderMu.Unlock()
466470 if l.direction == DirectionBackward {
467471 l.recalculateItemPositions()
468472 }
@@ -472,9 +476,10 @@ func (l *list[T]) render() tea.Cmd {
472476 }
473477 return focusChangeCmd
474478 }
479+ l.renderMu.Lock()
475480 rendered, finishIndex := l.renderIterator(0, true, "")
476481 l.rendered = rendered
477−
482+ l.renderMu.Unlock()
478483 // recalculate for the initial items
479484 if l.direction == DirectionBackward {
480485 l.recalculateItemPositions()
@@ -482,7 +487,10 @@ func (l *list[T]) render() tea.Cmd {
482487 renderCmd := func() tea.Msg {
483488 l.offset = 0
484489 // render the rest
490+
491+ l.renderMu.Lock()
485492 l.rendered, _ = l.renderIterator(finishIndex, false, l.rendered)
493+ l.renderMu.Unlock()
486494 // needed for backwards
487495 if l.direction == DirectionBackward {
488496 l.recalculateItemPositions()
@@ -491,7 +499,6 @@ func (l *list[T]) render() tea.Cmd {
491499 if l.focused {
492500 l.scrollToSelection()
493501 }
494−
495502 return nil
496503 }
497504 return tea.Batch(focusChangeCmd, renderCmd)
internal/tui/styles/crush.go+10−1
@@ -1,11 +1,12 @@
11package styles
22
33import (
4+ "github.com/charmbracelet/lipgloss/v2"
45 "github.com/charmbracelet/x/exp/charmtone"
56)
67
78func NewCrushTheme() *Theme {
8− return &Theme{
9+ t := &Theme{
910 Name: "crush",
1011 IsDark: true,
1112
@@ -54,4 +55,12 @@ func NewCrushTheme() *Theme {
5455 RedLight: charmtone.Salmon,
5556 Cherry: charmtone.Cherry,
5657 }
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
5766}
internal/tui/styles/theme.go+6
@@ -74,6 +74,12 @@ type Theme struct {
7474 RedLight color.Color
7575 Cherry color.Color
7676
77+ // LSP and MCP status indicators.
78+ ItemOfflineIcon lipgloss.Style
79+ ItemBusyIcon lipgloss.Style
80+ ItemErrorIcon lipgloss.Style
81+ ItemOnlineIcon lipgloss.Style
82+
7783 styles *Styles
7884}
7985