on main help sign in
Merge branch 'main' into tools-bg
commit50f07eb229c49036345075525d212b1bab9565c3 authorCarlos Alexandro Becker <caarlos0@users.noreply.github.com> dateThu 24 Jul 2025, 10:42 (1 year ago) parentf36edc66ffdc05
55 files changed+682−482
.github/workflows/build.yml+1−1 .github/workflows/release.yml+1 .goreleaser.yml+8−1 README.md+35−10 go.mod+4−2 go.sum+8−3 internal/ansiext/ansi.go+25 internal/app/app.go+14−6 internal/cmd/root.go+7−4 internal/config/config.go+25−19 internal/config/load.go+18−16 internal/config/load_test.go+80−80 internal/config/provider.go+10−11 internal/config/provider_empty_test.go+4−4 internal/config/provider_test.go+4−4 internal/fur/client/client.go−63 internal/fur/provider/provider.go−75 internal/llm/agent/agent.go+10−7 internal/llm/prompt/coder.go+3−3 internal/llm/provider/anthropic.go+5−5 internal/llm/provider/bedrock.go+3−3 internal/llm/provider/gemini.go+4−4 internal/llm/provider/openai.go+5−5 internal/llm/provider/openai_test.go+5−5 internal/llm/provider/provider.go+13−13 internal/llm/tools/bash.go+16−17 internal/llm/tools/glob.go+1−1 internal/lsp/watcher/watcher.go+1−1 internal/message/content.go+3−3 internal/permission/permission.go+9−1 internal/permission/permission_test.go+92 internal/shell/coreutils.go+59 internal/shell/shell.go+1−1 internal/tui/components/anim/anim.go+1−1 internal/tui/components/chat/editor/editor.go+5−3 internal/tui/components/chat/messages/messages.go+4−4 internal/tui/components/chat/messages/renderer.go+14−21 internal/tui/components/chat/sidebar/sidebar.go+4−4 internal/tui/components/chat/splash/splash.go+3−3 internal/tui/components/completions/completions.go+26−1 internal/tui/components/completions/keys.go+10 internal/tui/components/core/status/status.go+4−9 internal/tui/components/dialogs/commands/commands.go+2−2 internal/tui/components/dialogs/models/list.go+11−11 internal/tui/components/dialogs/models/models.go+4−4 internal/tui/exp/diffview/chroma.go+7−2 internal/tui/exp/diffview/diffview.go+9−4 internal/tui/exp/diffview/diffview_test.go+26 internal/tui/exp/diffview/testdata/TestDiffViewLineBreakIssue/Split.golden+9 internal/tui/exp/diffview/testdata/TestDiffViewLineBreakIssue/Unified.golden+12 internal/tui/exp/diffview/testdata/TestLineBreakIssue.after+8 internal/tui/exp/diffview/testdata/TestLineBreakIssue.before+6 internal/tui/page/chat/chat.go+1−1 internal/tui/tui.go+41−23 main.go+1−21
.github/workflows/build.yml+1−1
@@ -2,7 +2,7 @@ name: build
22on: [push, pull_request]
33
44jobs:
5− build-go:
5+ build:
66 uses: charmbracelet/meta/.github/workflows/build.yml@main
77 with:
88 go-version: ""
.github/workflows/release.yml+1
@@ -20,3 +20,4 @@ jobs:
2020 fury_token: ${{ secrets.FURY_TOKEN }}
2121 nfpm_gpg_key: ${{ secrets.NFPM_GPG_KEY }}
2222 nfpm_passphrase: ${{ secrets.NFPM_PASSPHRASE }}
23+ npm_token: ${{ secrets.NPM_TOKEN }}
.goreleaser.yml+8−1
@@ -45,7 +45,7 @@ builds:
4545 goos:
4646 - linux
4747 - darwin
48− # - windows
48+ - windows
4949 goarch:
5050 - amd64
5151 - arm64
@@ -111,8 +111,15 @@ homebrew_casks:
111111 owner: charmbracelet
112112 name: homebrew-tap
113113
114+npms:
115+ - name: "@charmland/crush"
116+ repository: "git+https://github.com/charmbracelet/crush.git"
117+ bugs: https://github.com/charmbracelet/crush/issues
118+ access: public
119+
114120nfpms:
115121 - formats:
122+ - apk
116123 - deb
117124 - rpm
118125 - archlinux
README.md+35−10
@@ -12,10 +12,12 @@ Crush is a tool for building software with AI.
1212
1313## Installation
1414
15+Crush has first class support for macOS, Linux, and Windows.
16+
1517Nightly builds are available while Crush is in development.
1618
17−- [Packages](https://github.com/charmbracelet/crush/releases/tag/nightly) are available in Debian and RPM formats
18−- [Binaries](https://github.com/charmbracelet/crush/releases/tag/nightly) are available for Linux and macOS
19+- [Packages](https://github.com/charmbracelet/crush/releases/tag/nightly) are available in Debian, RPM, APK, and PKG formats
20+- [Binaries](https://github.com/charmbracelet/crush/releases/tag/nightly) are available for Linux, macOS and Windows
1921
2022You can also just install it with go:
2123
@@ -28,7 +30,7 @@ go install
2830<details>
2931<summary>Not a developer? Here’s a quick how-to.</summary>
3032
31−Download the latest [nightly release](https://github.com/charmbracelet/crush/releases) for your system. The [macOS ARM64](https://github.com/charmbracelet/crush/releases/download/nightly/crush_0.1.0-nightly_Darwin_arm64.tar.gz) is most likely what you want.
33+Download the latest [nightly release](https://github.com/charmbracelet/crush/releases) for your system. The [macOS ARM64 one](https://github.com/charmbracelet/crush/releases/download/nightly/crush_0.1.0-nightly_Darwin_arm64.tar.gz) is most likely what you want.
3234
3335Next, open a terminal and run the following commands:
3436
@@ -36,17 +38,15 @@ Next, open a terminal and run the following commands:
3638cd ~/Downloads
3739tar -xvzf crush_0.1.0-nightly_Darwin_arm64.tar.gz -C crush
3840sudo mv ./crush/crush /usr/local/bin/crush
39−rm -rf crush
41+rm -rf ./crush
4042```
4143
4244Then, run Crush by typing `crush`.
4345
44−***
46+---
4547
4648</details>
4749
48−Note that Crush doesn't support Windows yet, however Windows support is planned and in progress.
49−
5050## Getting Started
5151
5252The quickest way to get started to grab an API key for your preferred
@@ -108,7 +108,7 @@ Crush supports Model Context Protocol (MCP) servers through three transport type
108108 "mcp": {
109109 "filesystem": {
110110 "type": "stdio",
111− "command": "node",
111+ "command": "node",
112112 "args": ["/path/to/mcp-server.js"],
113113 "env": {
114114 "NODE_ENV": "production"
@@ -143,7 +143,7 @@ crush -d
143143# View last 1000 lines
144144crush logs
145145
146−# Follow logs in real-time
146+# Follow logs in real-time
147147crush logs -f
148148
149149# Show last 500 lines
@@ -161,6 +161,31 @@ Add to your `crush.json` config file:
161161}
162162```
163163
164+### Configurable Default Permissions
165+
166+Crush includes a permission system to control which tools can be executed without prompting. You can configure allowed tools in your `crush.json` config file:
167+
168+```json
169+{
170+ "permissions": {
171+ "allowed_tools": [
172+ "view",
173+ "ls",
174+ "grep",
175+ "edit:write",
176+ "mcp_context7_get-library-doc"
177+ ]
178+ }
179+}
180+```
181+
182+The `allowed_tools` array accepts:
183+
184+- Tool names (e.g., `"view"`) - allows all actions for that tool
185+- Tool:action combinations (e.g., `"edit:write"`) - allows only specific actions
186+
187+You can also skip all permission prompts entirely by running Crush with the `--yolo` flag.
188+
164189### OpenAI-Compatible APIs
165190
166191Crush supports all OpenAI-compatible APIs. Here's an example configuration for Deepseek, which uses an OpenAI-compatible API. Don't forget to set `DEEPSEEK_API_KEY` in your environment.
@@ -174,7 +199,7 @@ Crush supports all OpenAI-compatible APIs. Here's an example configuration for D
174199 "models": [
175200 {
176201 "id": "deepseek-chat",
177− "model": "Deepseek V3",
202+ "name": "Deepseek V3",
178203 "cost_per_1m_in": 0.27,
179204 "cost_per_1m_out": 1.1,
180205 "cost_per_1m_in_cached": 0.07,
go.mod+4−2
@@ -13,6 +13,7 @@ require (
1313 github.com/charlievieth/fastwalk v1.0.11
1414 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5
1515 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac
16+ github.com/charmbracelet/catwalk v0.3.1
1617 github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674
1718 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
1819 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250716211347-10c048e36112
@@ -39,6 +40,7 @@ require (
3940 github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
4041 github.com/stretchr/testify v1.10.0
4142 github.com/tidwall/sjson v1.2.5
43+ github.com/u-root/u-root v0.14.1-0.20250722142936-bf4e78a90dfc
4244 github.com/zeebo/xxh3 v1.0.2
4345 golang.org/x/exp v0.0.0-20250305212735-054e65f0b394
4446 gopkg.in/natefinch/lumberjack.v2 v2.2.1
@@ -70,7 +72,7 @@ require (
7072 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
7173 github.com/aymerick/douceur v0.2.0 // indirect
7274 github.com/charmbracelet/colorprofile v0.3.1 // indirect
73− github.com/charmbracelet/ultraviolet v0.0.0-20250721205647-f6ac6eda5d42 // indirect
75+ github.com/charmbracelet/ultraviolet v0.0.0-20250723145313-809e6f5b43a1 // indirect
7476 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect
7577 github.com/charmbracelet/x/exp/slice v0.0.0-20250611152503-f53cdd7e01ef
7678 github.com/charmbracelet/x/term v0.2.1
@@ -108,7 +110,7 @@ require (
108110 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
109111 github.com/sethvargo/go-retry v0.3.0 // indirect
110112 github.com/spf13/cast v1.7.1 // indirect
111− github.com/spf13/pflag v1.0.6 // indirect
113+ github.com/spf13/pflag v1.0.7 // indirect
112114 github.com/tetratelabs/wazero v1.9.0 // indirect
113115 github.com/tidwall/gjson v1.18.0 // indirect
114116 github.com/tidwall/match v1.1.1 // indirect
go.sum+8−3
@@ -72,6 +72,8 @@ github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5
7272github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
7373github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac h1:murtkvFYxZ/73vk4Z/tpE4biB+WDZcFmmBp8je/yV6M=
7474github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac/go.mod h1:m240IQxo1/eDQ7klblSzOCAUyc3LddHcV3Rc/YEGAgw=
75+github.com/charmbracelet/catwalk v0.3.1 h1:MkGWspcMyE659zDkqS+9wsaCMTKRFEDBFY2A2sap6+U=
76+github.com/charmbracelet/catwalk v0.3.1/go.mod h1:gUUCqqZ8bk4D7ZzGTu3I77k7cC2x4exRuJBN1H2u2pc=
7577github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
7678github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
7779github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674 h1:+Cz+VfxD5DO+JT1LlswXWhre0HYLj6l2HW8HVGfMuC0=
@@ -82,8 +84,8 @@ github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250716211347-10c048e36112
8284github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250716211347-10c048e36112/go.mod h1:BXY7j7rZgAprFwzNcO698++5KTd6GKI6lU83Pr4o0r0=
8385github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706 h1:WkwO6Ks3mSIGnGuSdKl9qDSyfbYK50z2wc2gGMggegE=
8486github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706/go.mod h1:mjJGp00cxcfvD5xdCa+bso251Jt4owrQvuimJtVmEmM=
85−github.com/charmbracelet/ultraviolet v0.0.0-20250721205647-f6ac6eda5d42 h1:Zqw2oP9Wo8VzMijVJbtIJcAaZviYyU07stvmCFCfn0Y=
86−github.com/charmbracelet/ultraviolet v0.0.0-20250721205647-f6ac6eda5d42/go.mod h1:XrrgNFfXLrFAyd9DUmrqVc3yQFVv8Uk+okj4PsNNzpc=
87+github.com/charmbracelet/ultraviolet v0.0.0-20250723145313-809e6f5b43a1 h1:tsw1mOuIEIKlmm614bXctvJ3aavaFhyPG+y+wrKtuKQ=
88+github.com/charmbracelet/ultraviolet v0.0.0-20250723145313-809e6f5b43a1/go.mod h1:XrrgNFfXLrFAyd9DUmrqVc3yQFVv8Uk+okj4PsNNzpc=
8789github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
8890github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
8991github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz0Z3rsiOtMYiz8axkT24i9yFiueDhJbzyNUADmME=
@@ -234,8 +236,9 @@ github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
234236github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
235237github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
236238github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
237−github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
238239github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
240+github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
241+github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
239242github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
240243github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
241244github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
@@ -258,6 +261,8 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
258261github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
259262github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
260263github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
264+github.com/u-root/u-root v0.14.1-0.20250722142936-bf4e78a90dfc h1:HjI/UCF4dRyzizePQrhGUSQvuU7z4tOqMqz6GRGlFCM=
265+github.com/u-root/u-root v0.14.1-0.20250722142936-bf4e78a90dfc/go.mod h1:/0Qr7qJeDwWxoKku2xKQ4Szc+SwBE3g9VE8jNiamsmc=
261266github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
262267github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
263268github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
internal/ansiext/ansi.gonew file+25
@@ -0,0 +1,25 @@
1+package ansiext
2+
3+import (
4+ "strings"
5+
6+ "github.com/charmbracelet/x/ansi"
7+)
8+
9+// Escape replaces control characters with their Unicode Control Picture
10+// representations to ensure they are displayed correctly in the UI.
11+func Escape(content string) string {
12+ var sb strings.Builder
13+ sb.Grow(len(content))
14+ for _, r := range content {
15+ switch {
16+ case r >= 0 && r <= 0x1f: // Control characters 0x00-0x1F
17+ sb.WriteRune('\u2400' + r)
18+ case r == ansi.DEL:
19+ sb.WriteRune('\u2421')
20+ default:
21+ sb.WriteRune(r)
22+ }
23+ }
24+ return sb.String()
25+}
internal/app/app.go+14−6
@@ -59,13 +59,17 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
5959 sessions := session.NewService(q)
6060 messages := message.NewService(q)
6161 files := history.NewService(q, conn)
62− skipPermissionsRequests := cfg.Options != nil && cfg.Options.SkipPermissionsRequests
62+ skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests
63+ allowedTools := []string{}
64+ if cfg.Permissions != nil && cfg.Permissions.AllowedTools != nil {
65+ allowedTools = cfg.Permissions.AllowedTools
66+ }
6367
6468 app := &App{
6569 Sessions: sessions,
6670 Messages: messages,
6771 History: files,
68− Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests),
72+ Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools),
6973 LSPClients: make(map[string]*lsp.Client),
7074
7175 globalCtx: ctx,
@@ -157,16 +161,20 @@ func (app *App) RunNonInteractive(ctx context.Context, prompt string, quiet bool
157161
158162 if result.Error != nil {
159163 if errors.Is(result.Error, context.Canceled) || errors.Is(result.Error, agent.ErrRequestCancelled) {
160− slog.Info("Agent processing cancelled", "session_id", sess.ID)
164+ slog.Info("Non-interactive: agent processing cancelled", "session_id", sess.ID)
161165 return nil
162166 }
163167 return fmt.Errorf("agent processing failed: %w", result.Error)
164168 }
165169
166− part := result.Message.Content().String()[readBts:]
167− fmt.Println(part)
170+ msgContent := result.Message.Content().String()
171+ if len(msgContent) < readBts {
172+ slog.Error("Non-interactive: message content is shorter than read bytes", "message_length", len(msgContent), "read_bytes", readBts)
173+ return fmt.Errorf("message content is shorter than read bytes: %d < %d", len(msgContent), readBts)
174+ }
175+ fmt.Println(msgContent[readBts:])
168176
169− slog.Info("Non-interactive run completed", "session_id", sess.ID)
177+ slog.Info("Non-interactive: run completed", "session_id", sess.ID)
170178 return nil
171179
172180 case event := <-messageEvents:
internal/cmd/root.go+7−4
@@ -73,7 +73,10 @@ to assist developers in writing, debugging, and understanding code directly from
7373 if err != nil {
7474 return err
7575 }
76− cfg.Options.SkipPermissionsRequests = yolo
76+ if cfg.Permissions == nil {
77+ cfg.Permissions = &config.Permissions{}
78+ }
79+ cfg.Permissions.SkipRequests = yolo
7780
7881 ctx := cmd.Context()
7982
@@ -85,14 +88,14 @@ to assist developers in writing, debugging, and understanding code directly from
8588
8689 app, err := app.New(ctx, conn, cfg)
8790 if err != nil {
88− slog.Error(fmt.Sprintf("Failed to create app instance: %v", err))
91+ slog.Error("Failed to create app instance", "error", err)
8992 return err
9093 }
9194 defer app.Shutdown()
9295
9396 prompt, err = maybePrependStdin(prompt)
9497 if err != nil {
95− slog.Error(fmt.Sprintf("Failed to read from stdin: %v", err))
98+ slog.Error("Failed to read from stdin", "error", err)
9699 return err
97100 }
98101
@@ -114,7 +117,7 @@ to assist developers in writing, debugging, and understanding code directly from
114117 go app.Subscribe(program)
115118
116119 if _, err := program.Run(); err != nil {
117− slog.Error(fmt.Sprintf("TUI run error: %v", err))
120+ slog.Error("TUI run error", "error", err)
118121 return fmt.Errorf("TUI error: %v", err)
119122 }
120123 return nil
internal/config/config.go+25−19
@@ -9,9 +9,9 @@ import (
99 "strings"
1010 "time"
1111
12+ "github.com/charmbracelet/catwalk/pkg/catwalk"
1213 "github.com/charmbracelet/crush/internal/csync"
1314 "github.com/charmbracelet/crush/internal/env"
14− "github.com/charmbracelet/crush/internal/fur/provider"
1515 "github.com/tidwall/sjson"
1616 "golang.org/x/exp/slog"
1717)
@@ -71,7 +71,7 @@ type ProviderConfig struct {
7171 // The provider's API endpoint.
7272 BaseURL string `json:"base_url,omitempty"`
7373 // The provider type, e.g. "openai", "anthropic", etc. if empty it defaults to openai.
74− Type provider.Type `json:"type,omitempty"`
74+ Type catwalk.Type `json:"type,omitempty"`
7575 // The provider's API key.
7676 APIKey string `json:"api_key,omitempty"`
7777 // Marks the provider as disabled.
@@ -86,7 +86,7 @@ type ProviderConfig struct {
8686 ExtraParams map[string]string `json:"-"`
8787
8888 // The provider models
89− Models []provider.Model `json:"models,omitempty"`
89+ Models []catwalk.Model `json:"models,omitempty"`
9090}
9191
9292type MCPType string
@@ -121,14 +121,18 @@ type TUIOptions struct {
121121 // Here we can add themes later or any TUI related options
122122}
123123
124+type Permissions struct {
125+ AllowedTools []string `json:"allowed_tools,omitempty"` // Tools that don't require permission prompts
126+ SkipRequests bool `json:"-"` // Automatically accept all permissions (YOLO mode)
127+}
128+
124129type Options struct {
125− ContextPaths []string `json:"context_paths,omitempty"`
126− TUI *TUIOptions `json:"tui,omitempty"`
127− Debug bool `json:"debug,omitempty"`
128− DebugLSP bool `json:"debug_lsp,omitempty"`
129− DisableAutoSummarize bool `json:"disable_auto_summarize,omitempty"`
130− DataDirectory string `json:"data_directory,omitempty"` // Relative to the cwd
131− SkipPermissionsRequests bool `json:"-"` // Automatically accept all permissions (YOLO mode)
130+ ContextPaths []string `json:"context_paths,omitempty"`
131+ TUI *TUIOptions `json:"tui,omitempty"`
132+ Debug bool `json:"debug,omitempty"`
133+ DebugLSP bool `json:"debug_lsp,omitempty"`
134+ DisableAutoSummarize bool `json:"disable_auto_summarize,omitempty"`
135+ DataDirectory string `json:"data_directory,omitempty"` // Relative to the cwd
132136}
133137
134138type MCPs map[string]MCPConfig
@@ -245,14 +249,16 @@ type Config struct {
245249
246250 Options *Options `json:"options,omitempty"`
247251
252+ Permissions *Permissions `json:"permissions,omitempty"`
253+
248254 // Internal
249255 workingDir string `json:"-"`
250256 // TODO: most likely remove this concept when I come back to it
251257 Agents map[string]Agent `json:"-"`
252258 // TODO: find a better way to do this this should probably not be part of the config
253259 resolver VariableResolver
254− dataConfigDir string `json:"-"`
255− knownProviders []provider.Provider `json:"-"`
260+ dataConfigDir string `json:"-"`
261+ knownProviders []catwalk.Provider `json:"-"`
256262}
257263
258264func (c *Config) WorkingDir() string {
@@ -274,7 +280,7 @@ func (c *Config) IsConfigured() bool {
274280 return len(c.EnabledProviders()) > 0
275281}
276282
277−func (c *Config) GetModel(provider, model string) *provider.Model {
283+func (c *Config) GetModel(provider, model string) *catwalk.Model {
278284 if providerConfig, ok := c.Providers.Get(provider); ok {
279285 for _, m := range providerConfig.Models {
280286 if m.ID == model {
@@ -296,7 +302,7 @@ func (c *Config) GetProviderForModel(modelType SelectedModelType) *ProviderConfi
296302 return nil
297303}
298304
299−func (c *Config) GetModelByType(modelType SelectedModelType) *provider.Model {
305+func (c *Config) GetModelByType(modelType SelectedModelType) *catwalk.Model {
300306 model, ok := c.Models[modelType]
301307 if !ok {
302308 return nil
@@ -304,7 +310,7 @@ func (c *Config) GetModelByType(modelType SelectedModelType) *provider.Model {
304310 return c.GetModel(model.Provider, model.Model)
305311}
306312
307−func (c *Config) LargeModel() *provider.Model {
313+func (c *Config) LargeModel() *catwalk.Model {
308314 model, ok := c.Models[SelectedModelTypeLarge]
309315 if !ok {
310316 return nil
@@ -312,7 +318,7 @@ func (c *Config) LargeModel() *provider.Model {
312318 return c.GetModel(model.Provider, model.Model)
313319}
314320
315−func (c *Config) SmallModel() *provider.Model {
321+func (c *Config) SmallModel() *catwalk.Model {
316322 model, ok := c.Models[SelectedModelTypeSmall]
317323 if !ok {
318324 return nil
@@ -378,7 +384,7 @@ func (c *Config) SetProviderAPIKey(providerID, apiKey string) error {
378384 return nil
379385 }
380386
381− var foundProvider *provider.Provider
387+ var foundProvider *catwalk.Provider
382388 for _, p := range c.knownProviders {
383389 if string(p.ID) == providerID {
384390 foundProvider = &p
@@ -447,14 +453,14 @@ func (c *ProviderConfig) TestConnection(resolver VariableResolver) error {
447453 headers := make(map[string]string)
448454 apiKey, _ := resolver.ResolveValue(c.APIKey)
449455 switch c.Type {
450− case provider.TypeOpenAI:
456+ case catwalk.TypeOpenAI:
451457 baseURL, _ := resolver.ResolveValue(c.BaseURL)
452458 if baseURL == "" {
453459 baseURL = "https://api.openai.com/v1"
454460 }
455461 testURL = baseURL + "/models"
456462 headers["Authorization"] = "Bearer " + apiKey
457− case provider.TypeAnthropic:
463+ case catwalk.TypeAnthropic:
458464 baseURL, _ := resolver.ResolveValue(c.BaseURL)
459465 if baseURL == "" {
460466 baseURL = "https://api.anthropic.com/v1"
internal/config/load.go+18−16
@@ -12,12 +12,14 @@ import (
1212 "strings"
1313 "sync"
1414
15+ "github.com/charmbracelet/catwalk/pkg/catwalk"
1516 "github.com/charmbracelet/crush/internal/csync"
1617 "github.com/charmbracelet/crush/internal/env"
17− "github.com/charmbracelet/crush/internal/fur/provider"
1818 "github.com/charmbracelet/crush/internal/log"
1919)
2020
21+const catwalkURL = "https://catwalk.charm.sh"
22+
2123// LoadReader config via io.Reader.
2224func LoadReader(fd io.Reader) (*Config, error) {
2325 data, err := io.ReadAll(fd)
@@ -61,7 +63,7 @@ func Load(workingDir string, debug bool) (*Config, error) {
6163 cfg.Options.Debug,
6264 )
6365
64− // Load known providers, this loads the config from fur
66+ // Load known providers, this loads the config from catwalk
6567 providers, err := Providers()
6668 if err != nil || len(providers) == 0 {
6769 return nil, fmt.Errorf("failed to load providers: %w", err)
@@ -97,7 +99,7 @@ func (c *Config) removeUnresponsiveProviders() {
9799 slog.Info("Testing provider connections")
98100 defer slog.Info("Provider connection tests completed")
99101 for _, p := range c.Providers.Seq2() {
100− if p.Type == provider.TypeOpenAI || p.Type == provider.TypeAnthropic {
102+ if p.Type == catwalk.TypeOpenAI || p.Type == catwalk.TypeAnthropic {
101103 wg.Add(1)
102104 go func(provider ProviderConfig) {
103105 defer wg.Done()
@@ -122,7 +124,7 @@ func (c *Config) removeUnresponsiveProviders() {
122124 })
123125}
124126
125−func (c *Config) configureProviders(env env.Env, resolver VariableResolver, knownProviders []provider.Provider) error {
127+func (c *Config) configureProviders(env env.Env, resolver VariableResolver, knownProviders []catwalk.Provider) error {
126128 knownProviderNames := make(map[string]bool)
127129 for _, p := range knownProviders {
128130 knownProviderNames[string(p.ID)] = true
@@ -141,7 +143,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
141143 p.APIKey = config.APIKey
142144 }
143145 if len(config.Models) > 0 {
144− models := []provider.Model{}
146+ models := []catwalk.Model{}
145147 seen := make(map[string]bool)
146148
147149 for _, model := range config.Models {
@@ -149,8 +151,8 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
149151 continue
150152 }
151153 seen[model.ID] = true
152− if model.Model == "" {
153− model.Model = model.ID
154+ if model.Name == "" {
155+ model.Name = model.ID
154156 }
155157 models = append(models, model)
156158 }
@@ -159,8 +161,8 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
159161 continue
160162 }
161163 seen[model.ID] = true
162− if model.Model == "" {
163− model.Model = model.ID
164+ if model.Name == "" {
165+ model.Name = model.ID
164166 }
165167 models = append(models, model)
166168 }
@@ -183,7 +185,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
183185
184186 switch p.ID {
185187 // Handle specific providers that require additional configuration
186− case provider.InferenceProviderVertexAI:
188+ case catwalk.InferenceProviderVertexAI:
187189 if !hasVertexCredentials(env) {
188190 if configExists {
189191 slog.Warn("Skipping Vertex AI provider due to missing credentials")
@@ -193,7 +195,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
193195 }
194196 prepared.ExtraParams["project"] = env.Get("GOOGLE_CLOUD_PROJECT")
195197 prepared.ExtraParams["location"] = env.Get("GOOGLE_CLOUD_LOCATION")
196− case provider.InferenceProviderAzure:
198+ case catwalk.InferenceProviderAzure:
197199 endpoint, err := resolver.ResolveValue(p.APIEndpoint)
198200 if err != nil || endpoint == "" {
199201 if configExists {
@@ -204,7 +206,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
204206 }
205207 prepared.BaseURL = endpoint
206208 prepared.ExtraParams["apiVersion"] = env.Get("AZURE_OPENAI_API_VERSION")
207− case provider.InferenceProviderBedrock:
209+ case catwalk.InferenceProviderBedrock:
208210 if !hasAWSCredentials(env) {
209211 if configExists {
210212 slog.Warn("Skipping Bedrock provider due to missing AWS credentials")
@@ -244,7 +246,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
244246 }
245247 // default to OpenAI if not set
246248 if providerConfig.Type == "" {
247− providerConfig.Type = provider.TypeOpenAI
249+ providerConfig.Type = catwalk.TypeOpenAI
248250 }
249251
250252 if providerConfig.Disable {
@@ -265,7 +267,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
265267 c.Providers.Del(id)
266268 continue
267269 }
268− if providerConfig.Type != provider.TypeOpenAI {
270+ if providerConfig.Type != catwalk.TypeOpenAI {
269271 slog.Warn("Skipping custom provider because the provider type is not supported", "provider", id, "type", providerConfig.Type)
270272 c.Providers.Del(id)
271273 continue
@@ -320,7 +322,7 @@ func (c *Config) setDefaults(workingDir string) {
320322 c.Options.ContextPaths = slices.Compact(c.Options.ContextPaths)
321323}
322324
323−func (c *Config) defaultModelSelection(knownProviders []provider.Provider) (largeModel SelectedModel, smallModel SelectedModel, err error) {
325+func (c *Config) defaultModelSelection(knownProviders []catwalk.Provider) (largeModel SelectedModel, smallModel SelectedModel, err error) {
324326 if len(knownProviders) == 0 && c.Providers.Len() == 0 {
325327 err = fmt.Errorf("no providers configured, please configure at least one provider")
326328 return
@@ -389,7 +391,7 @@ func (c *Config) defaultModelSelection(knownProviders []provider.Provider) (larg
389391 return
390392}
391393
392−func (c *Config) configureSelectedModels(knownProviders []provider.Provider) error {
394+func (c *Config) configureSelectedModels(knownProviders []catwalk.Provider) error {
393395 defaultLarge, defaultSmall, err := c.defaultModelSelection(knownProviders)
394396 if err != nil {
395397 return fmt.Errorf("failed to select default models: %w", err)
internal/config/load_test.go+80−80
@@ -8,9 +8,9 @@ import (
88 "strings"
99 "testing"
1010
11+ "github.com/charmbracelet/catwalk/pkg/catwalk"
1112 "github.com/charmbracelet/crush/internal/csync"
1213 "github.com/charmbracelet/crush/internal/env"
13− "github.com/charmbracelet/crush/internal/fur/provider"
1414 "github.com/stretchr/testify/assert"
1515)
1616
@@ -56,12 +56,12 @@ func TestConfig_setDefaults(t *testing.T) {
5656}
5757
5858func TestConfig_configureProviders(t *testing.T) {
59− knownProviders := []provider.Provider{
59+ knownProviders := []catwalk.Provider{
6060 {
6161 ID: "openai",
6262 APIKey: "$OPENAI_API_KEY",
6363 APIEndpoint: "https://api.openai.com/v1",
64− Models: []provider.Model{{
64+ Models: []catwalk.Model{{
6565 ID: "test-model",
6666 }},
6767 },
@@ -83,12 +83,12 @@ func TestConfig_configureProviders(t *testing.T) {
8383}
8484
8585func TestConfig_configureProvidersWithOverride(t *testing.T) {
86− knownProviders := []provider.Provider{
86+ knownProviders := []catwalk.Provider{
8787 {
8888 ID: "openai",
8989 APIKey: "$OPENAI_API_KEY",
9090 APIEndpoint: "https://api.openai.com/v1",
91− Models: []provider.Model{{
91+ Models: []catwalk.Model{{
9292 ID: "test-model",
9393 }},
9494 },
@@ -100,10 +100,10 @@ func TestConfig_configureProvidersWithOverride(t *testing.T) {
100100 cfg.Providers.Set("openai", ProviderConfig{
101101 APIKey: "xyz",
102102 BaseURL: "https://api.openai.com/v2",
103− Models: []provider.Model{
103+ Models: []catwalk.Model{
104104 {
105− ID: "test-model",
106− Model: "Updated",
105+ ID: "test-model",
106+ Name: "Updated",
107107 },
108108 {
109109 ID: "another-model",
@@ -125,16 +125,16 @@ func TestConfig_configureProvidersWithOverride(t *testing.T) {
125125 assert.Equal(t, "xyz", pc.APIKey)
126126 assert.Equal(t, "https://api.openai.com/v2", pc.BaseURL)
127127 assert.Len(t, pc.Models, 2)
128− assert.Equal(t, "Updated", pc.Models[0].Model)
128+ assert.Equal(t, "Updated", pc.Models[0].Name)
129129}
130130
131131func TestConfig_configureProvidersWithNewProvider(t *testing.T) {
132− knownProviders := []provider.Provider{
132+ knownProviders := []catwalk.Provider{
133133 {
134134 ID: "openai",
135135 APIKey: "$OPENAI_API_KEY",
136136 APIEndpoint: "https://api.openai.com/v1",
137− Models: []provider.Model{{
137+ Models: []catwalk.Model{{
138138 ID: "test-model",
139139 }},
140140 },
@@ -145,7 +145,7 @@ func TestConfig_configureProvidersWithNewProvider(t *testing.T) {
145145 "custom": {
146146 APIKey: "xyz",
147147 BaseURL: "https://api.someendpoint.com/v2",
148− Models: []provider.Model{
148+ Models: []catwalk.Model{
149149 {
150150 ID: "test-model",
151151 },
@@ -176,12 +176,12 @@ func TestConfig_configureProvidersWithNewProvider(t *testing.T) {
176176}
177177
178178func TestConfig_configureProvidersBedrockWithCredentials(t *testing.T) {
179− knownProviders := []provider.Provider{
179+ knownProviders := []catwalk.Provider{
180180 {
181− ID: provider.InferenceProviderBedrock,
181+ ID: catwalk.InferenceProviderBedrock,
182182 APIKey: "",
183183 APIEndpoint: "",
184− Models: []provider.Model{{
184+ Models: []catwalk.Model{{
185185 ID: "anthropic.claude-sonnet-4-20250514-v1:0",
186186 }},
187187 },
@@ -205,12 +205,12 @@ func TestConfig_configureProvidersBedrockWithCredentials(t *testing.T) {
205205}
206206
207207func TestConfig_configureProvidersBedrockWithoutCredentials(t *testing.T) {
208− knownProviders := []provider.Provider{
208+ knownProviders := []catwalk.Provider{
209209 {
210− ID: provider.InferenceProviderBedrock,
210+ ID: catwalk.InferenceProviderBedrock,
211211 APIKey: "",
212212 APIEndpoint: "",
213− Models: []provider.Model{{
213+ Models: []catwalk.Model{{
214214 ID: "anthropic.claude-sonnet-4-20250514-v1:0",
215215 }},
216216 },
@@ -227,12 +227,12 @@ func TestConfig_configureProvidersBedrockWithoutCredentials(t *testing.T) {
227227}
228228
229229func TestConfig_configureProvidersBedrockWithoutUnsupportedModel(t *testing.T) {
230− knownProviders := []provider.Provider{
230+ knownProviders := []catwalk.Provider{
231231 {
232− ID: provider.InferenceProviderBedrock,
232+ ID: catwalk.InferenceProviderBedrock,
233233 APIKey: "",
234234 APIEndpoint: "",
235− Models: []provider.Model{{
235+ Models: []catwalk.Model{{
236236 ID: "some-random-model",
237237 }},
238238 },
@@ -250,12 +250,12 @@ func TestConfig_configureProvidersBedrockWithoutUnsupportedModel(t *testing.T) {
250250}
251251
252252func TestConfig_configureProvidersVertexAIWithCredentials(t *testing.T) {
253− knownProviders := []provider.Provider{
253+ knownProviders := []catwalk.Provider{
254254 {
255− ID: provider.InferenceProviderVertexAI,
255+ ID: catwalk.InferenceProviderVertexAI,
256256 APIKey: "",
257257 APIEndpoint: "",
258− Models: []provider.Model{{
258+ Models: []catwalk.Model{{
259259 ID: "gemini-pro",
260260 }},
261261 },
@@ -282,12 +282,12 @@ func TestConfig_configureProvidersVertexAIWithCredentials(t *testing.T) {
282282}
283283
284284func TestConfig_configureProvidersVertexAIWithoutCredentials(t *testing.T) {
285− knownProviders := []provider.Provider{
285+ knownProviders := []catwalk.Provider{
286286 {
287− ID: provider.InferenceProviderVertexAI,
287+ ID: catwalk.InferenceProviderVertexAI,
288288 APIKey: "",
289289 APIEndpoint: "",
290− Models: []provider.Model{{
290+ Models: []catwalk.Model{{
291291 ID: "gemini-pro",
292292 }},
293293 },
@@ -308,12 +308,12 @@ func TestConfig_configureProvidersVertexAIWithoutCredentials(t *testing.T) {
308308}
309309
310310func TestConfig_configureProvidersVertexAIMissingProject(t *testing.T) {
311− knownProviders := []provider.Provider{
311+ knownProviders := []catwalk.Provider{
312312 {
313− ID: provider.InferenceProviderVertexAI,
313+ ID: catwalk.InferenceProviderVertexAI,
314314 APIKey: "",
315315 APIEndpoint: "",
316− Models: []provider.Model{{
316+ Models: []catwalk.Model{{
317317 ID: "gemini-pro",
318318 }},
319319 },
@@ -333,12 +333,12 @@ func TestConfig_configureProvidersVertexAIMissingProject(t *testing.T) {
333333}
334334
335335func TestConfig_configureProvidersSetProviderID(t *testing.T) {
336− knownProviders := []provider.Provider{
336+ knownProviders := []catwalk.Provider{
337337 {
338338 ID: "openai",
339339 APIKey: "$OPENAI_API_KEY",
340340 APIEndpoint: "https://api.openai.com/v1",
341− Models: []provider.Model{{
341+ Models: []catwalk.Model{{
342342 ID: "test-model",
343343 }},
344344 },
@@ -455,12 +455,12 @@ func TestConfig_IsConfigured(t *testing.T) {
455455}
456456
457457func TestConfig_configureProvidersWithDisabledProvider(t *testing.T) {
458− knownProviders := []provider.Provider{
458+ knownProviders := []catwalk.Provider{
459459 {
460460 ID: "openai",
461461 APIKey: "$OPENAI_API_KEY",
462462 APIEndpoint: "https://api.openai.com/v1",
463− Models: []provider.Model{{
463+ Models: []catwalk.Model{{
464464 ID: "test-model",
465465 }},
466466 },
@@ -494,7 +494,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
494494 Providers: csync.NewMapFrom(map[string]ProviderConfig{
495495 "custom": {
496496 BaseURL: "https://api.custom.com/v1",
497− Models: []provider.Model{{
497+ Models: []catwalk.Model{{
498498 ID: "test-model",
499499 }},
500500 },
@@ -507,7 +507,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
507507
508508 env := env.NewFromMap(map[string]string{})
509509 resolver := NewEnvironmentVariableResolver(env)
510− err := cfg.configureProviders(env, resolver, []provider.Provider{})
510+ err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
511511 assert.NoError(t, err)
512512
513513 assert.Equal(t, cfg.Providers.Len(), 1)
@@ -520,7 +520,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
520520 Providers: csync.NewMapFrom(map[string]ProviderConfig{
521521 "custom": {
522522 APIKey: "test-key",
523− Models: []provider.Model{{
523+ Models: []catwalk.Model{{
524524 ID: "test-model",
525525 }},
526526 },
@@ -530,7 +530,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
530530
531531 env := env.NewFromMap(map[string]string{})
532532 resolver := NewEnvironmentVariableResolver(env)
533− err := cfg.configureProviders(env, resolver, []provider.Provider{})
533+ err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
534534 assert.NoError(t, err)
535535
536536 assert.Equal(t, cfg.Providers.Len(), 0)
@@ -544,7 +544,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
544544 "custom": {
545545 APIKey: "test-key",
546546 BaseURL: "https://api.custom.com/v1",
547− Models: []provider.Model{},
547+ Models: []catwalk.Model{},
548548 },
549549 }),
550550 }
@@ -552,7 +552,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
552552
553553 env := env.NewFromMap(map[string]string{})
554554 resolver := NewEnvironmentVariableResolver(env)
555− err := cfg.configureProviders(env, resolver, []provider.Provider{})
555+ err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
556556 assert.NoError(t, err)
557557
558558 assert.Equal(t, cfg.Providers.Len(), 0)
@@ -567,7 +567,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
567567 APIKey: "test-key",
568568 BaseURL: "https://api.custom.com/v1",
569569 Type: "unsupported",
570− Models: []provider.Model{{
570+ Models: []catwalk.Model{{
571571 ID: "test-model",
572572 }},
573573 },
@@ -577,7 +577,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
577577
578578 env := env.NewFromMap(map[string]string{})
579579 resolver := NewEnvironmentVariableResolver(env)
580− err := cfg.configureProviders(env, resolver, []provider.Provider{})
580+ err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
581581 assert.NoError(t, err)
582582
583583 assert.Equal(t, cfg.Providers.Len(), 0)
@@ -591,8 +591,8 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
591591 "custom": {
592592 APIKey: "test-key",
593593 BaseURL: "https://api.custom.com/v1",
594− Type: provider.TypeOpenAI,
595− Models: []provider.Model{{
594+ Type: catwalk.TypeOpenAI,
595+ Models: []catwalk.Model{{
596596 ID: "test-model",
597597 }},
598598 },
@@ -602,7 +602,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
602602
603603 env := env.NewFromMap(map[string]string{})
604604 resolver := NewEnvironmentVariableResolver(env)
605− err := cfg.configureProviders(env, resolver, []provider.Provider{})
605+ err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
606606 assert.NoError(t, err)
607607
608608 assert.Equal(t, cfg.Providers.Len(), 1)
@@ -619,9 +619,9 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
619619 "custom": {
620620 APIKey: "test-key",
621621 BaseURL: "https://api.custom.com/v1",
622− Type: provider.TypeOpenAI,
622+ Type: catwalk.TypeOpenAI,
623623 Disable: true,
624− Models: []provider.Model{{
624+ Models: []catwalk.Model{{
625625 ID: "test-model",
626626 }},
627627 },
@@ -631,7 +631,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
631631
632632 env := env.NewFromMap(map[string]string{})
633633 resolver := NewEnvironmentVariableResolver(env)
634− err := cfg.configureProviders(env, resolver, []provider.Provider{})
634+ err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
635635 assert.NoError(t, err)
636636
637637 assert.Equal(t, cfg.Providers.Len(), 0)
@@ -642,12 +642,12 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
642642
643643func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
644644 t.Run("VertexAI provider removed when credentials missing with existing config", func(t *testing.T) {
645− knownProviders := []provider.Provider{
645+ knownProviders := []catwalk.Provider{
646646 {
647− ID: provider.InferenceProviderVertexAI,
647+ ID: catwalk.InferenceProviderVertexAI,
648648 APIKey: "",
649649 APIEndpoint: "",
650− Models: []provider.Model{{
650+ Models: []catwalk.Model{{
651651 ID: "gemini-pro",
652652 }},
653653 },
@@ -675,12 +675,12 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
675675 })
676676
677677 t.Run("Bedrock provider removed when AWS credentials missing with existing config", func(t *testing.T) {
678− knownProviders := []provider.Provider{
678+ knownProviders := []catwalk.Provider{
679679 {
680− ID: provider.InferenceProviderBedrock,
680+ ID: catwalk.InferenceProviderBedrock,
681681 APIKey: "",
682682 APIEndpoint: "",
683− Models: []provider.Model{{
683+ Models: []catwalk.Model{{
684684 ID: "anthropic.claude-sonnet-4-20250514-v1:0",
685685 }},
686686 },
@@ -706,12 +706,12 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
706706 })
707707
708708 t.Run("provider removed when API key missing with existing config", func(t *testing.T) {
709− knownProviders := []provider.Provider{
709+ knownProviders := []catwalk.Provider{
710710 {
711711 ID: "openai",
712712 APIKey: "$MISSING_API_KEY",
713713 APIEndpoint: "https://api.openai.com/v1",
714− Models: []provider.Model{{
714+ Models: []catwalk.Model{{
715715 ID: "test-model",
716716 }},
717717 },
@@ -737,12 +737,12 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
737737 })
738738
739739 t.Run("known provider should still be added if the endpoint is missing the client will use default endpoints", func(t *testing.T) {
740− knownProviders := []provider.Provider{
740+ knownProviders := []catwalk.Provider{
741741 {
742742 ID: "openai",
743743 APIKey: "$OPENAI_API_KEY",
744744 APIEndpoint: "$MISSING_ENDPOINT",
745− Models: []provider.Model{{
745+ Models: []catwalk.Model{{
746746 ID: "test-model",
747747 }},
748748 },
@@ -772,13 +772,13 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
772772
773773func TestConfig_defaultModelSelection(t *testing.T) {
774774 t.Run("default behavior uses the default models for given provider", func(t *testing.T) {
775− knownProviders := []provider.Provider{
775+ knownProviders := []catwalk.Provider{
776776 {
777777 ID: "openai",
778778 APIKey: "abc",
779779 DefaultLargeModelID: "large-model",
780780 DefaultSmallModelID: "small-model",
781− Models: []provider.Model{
781+ Models: []catwalk.Model{
782782 {
783783 ID: "large-model",
784784 DefaultMaxTokens: 1000,
@@ -808,13 +808,13 @@ func TestConfig_defaultModelSelection(t *testing.T) {
808808 assert.Equal(t, int64(500), small.MaxTokens)
809809 })
810810 t.Run("should error if no providers configured", func(t *testing.T) {
811− knownProviders := []provider.Provider{
811+ knownProviders := []catwalk.Provider{
812812 {
813813 ID: "openai",
814814 APIKey: "$MISSING_KEY",
815815 DefaultLargeModelID: "large-model",
816816 DefaultSmallModelID: "small-model",
817− Models: []provider.Model{
817+ Models: []catwalk.Model{
818818 {
819819 ID: "large-model",
820820 DefaultMaxTokens: 1000,
@@ -838,13 +838,13 @@ func TestConfig_defaultModelSelection(t *testing.T) {
838838 assert.Error(t, err)
839839 })
840840 t.Run("should error if model is missing", func(t *testing.T) {
841− knownProviders := []provider.Provider{
841+ knownProviders := []catwalk.Provider{
842842 {
843843 ID: "openai",
844844 APIKey: "abc",
845845 DefaultLargeModelID: "large-model",
846846 DefaultSmallModelID: "small-model",
847− Models: []provider.Model{
847+ Models: []catwalk.Model{
848848 {
849849 ID: "not-large-model",
850850 DefaultMaxTokens: 1000,
@@ -868,13 +868,13 @@ func TestConfig_defaultModelSelection(t *testing.T) {
868868 })
869869
870870 t.Run("should configure the default models with a custom provider", func(t *testing.T) {
871− knownProviders := []provider.Provider{
871+ knownProviders := []catwalk.Provider{
872872 {
873873 ID: "openai",
874874 APIKey: "$MISSING", // will not be included in the config
875875 DefaultLargeModelID: "large-model",
876876 DefaultSmallModelID: "small-model",
877− Models: []provider.Model{
877+ Models: []catwalk.Model{
878878 {
879879 ID: "not-large-model",
880880 DefaultMaxTokens: 1000,
@@ -892,7 +892,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
892892 "custom": {
893893 APIKey: "test-key",
894894 BaseURL: "https://api.custom.com/v1",
895− Models: []provider.Model{
895+ Models: []catwalk.Model{
896896 {
897897 ID: "model",
898898 DefaultMaxTokens: 600,
@@ -917,13 +917,13 @@ func TestConfig_defaultModelSelection(t *testing.T) {
917917 })
918918
919919 t.Run("should fail if no model configured", func(t *testing.T) {
920− knownProviders := []provider.Provider{
920+ knownProviders := []catwalk.Provider{
921921 {
922922 ID: "openai",
923923 APIKey: "$MISSING", // will not be included in the config
924924 DefaultLargeModelID: "large-model",
925925 DefaultSmallModelID: "small-model",
926− Models: []provider.Model{
926+ Models: []catwalk.Model{
927927 {
928928 ID: "not-large-model",
929929 DefaultMaxTokens: 1000,
@@ -941,7 +941,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
941941 "custom": {
942942 APIKey: "test-key",
943943 BaseURL: "https://api.custom.com/v1",
944− Models: []provider.Model{},
944+ Models: []catwalk.Model{},
945945 },
946946 }),
947947 }
@@ -954,13 +954,13 @@ func TestConfig_defaultModelSelection(t *testing.T) {
954954 assert.Error(t, err)
955955 })
956956 t.Run("should use the default provider first", func(t *testing.T) {
957− knownProviders := []provider.Provider{
957+ knownProviders := []catwalk.Provider{
958958 {
959959 ID: "openai",
960960 APIKey: "set",
961961 DefaultLargeModelID: "large-model",
962962 DefaultSmallModelID: "small-model",
963− Models: []provider.Model{
963+ Models: []catwalk.Model{
964964 {
965965 ID: "large-model",
966966 DefaultMaxTokens: 1000,
@@ -978,7 +978,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
978978 "custom": {
979979 APIKey: "test-key",
980980 BaseURL: "https://api.custom.com/v1",
981− Models: []provider.Model{
981+ Models: []catwalk.Model{
982982 {
983983 ID: "large-model",
984984 DefaultMaxTokens: 1000,
@@ -1005,13 +1005,13 @@ func TestConfig_defaultModelSelection(t *testing.T) {
10051005
10061006func TestConfig_configureSelectedModels(t *testing.T) {
10071007 t.Run("should override defaults", func(t *testing.T) {
1008− knownProviders := []provider.Provider{
1008+ knownProviders := []catwalk.Provider{
10091009 {
10101010 ID: "openai",
10111011 APIKey: "abc",
10121012 DefaultLargeModelID: "large-model",
10131013 DefaultSmallModelID: "small-model",
1014− Models: []provider.Model{
1014+ Models: []catwalk.Model{
10151015 {
10161016 ID: "larger-model",
10171017 DefaultMaxTokens: 2000,
@@ -1053,13 +1053,13 @@ func TestConfig_configureSelectedModels(t *testing.T) {
10531053 assert.Equal(t, int64(500), small.MaxTokens)
10541054 })
10551055 t.Run("should be possible to use multiple providers", func(t *testing.T) {
1056− knownProviders := []provider.Provider{
1056+ knownProviders := []catwalk.Provider{
10571057 {
10581058 ID: "openai",
10591059 APIKey: "abc",
10601060 DefaultLargeModelID: "large-model",
10611061 DefaultSmallModelID: "small-model",
1062− Models: []provider.Model{
1062+ Models: []catwalk.Model{
10631063 {
10641064 ID: "large-model",
10651065 DefaultMaxTokens: 1000,
@@ -1075,7 +1075,7 @@ func TestConfig_configureSelectedModels(t *testing.T) {
10751075 APIKey: "abc",
10761076 DefaultLargeModelID: "a-large-model",
10771077 DefaultSmallModelID: "a-small-model",
1078− Models: []provider.Model{
1078+ Models: []catwalk.Model{
10791079 {
10801080 ID: "a-large-model",
10811081 DefaultMaxTokens: 1000,
@@ -1116,13 +1116,13 @@ func TestConfig_configureSelectedModels(t *testing.T) {
11161116 })
11171117
11181118 t.Run("should override the max tokens only", func(t *testing.T) {
1119− knownProviders := []provider.Provider{
1119+ knownProviders := []catwalk.Provider{
11201120 {
11211121 ID: "openai",
11221122 APIKey: "abc",
11231123 DefaultLargeModelID: "large-model",
11241124 DefaultSmallModelID: "small-model",
1125− Models: []provider.Model{
1125+ Models: []catwalk.Model{
11261126 {
11271127 ID: "large-model",
11281128 DefaultMaxTokens: 1000,
internal/config/provider.go+10−11
@@ -10,17 +10,16 @@ import (
1010 "sync"
1111 "time"
1212
13− "github.com/charmbracelet/crush/internal/fur/client"
14− "github.com/charmbracelet/crush/internal/fur/provider"
13+ "github.com/charmbracelet/catwalk/pkg/catwalk"
1514)
1615
1716type ProviderClient interface {
18− GetProviders() ([]provider.Provider, error)
17+ GetProviders() ([]catwalk.Provider, error)
1918}
2019
2120var (
2221 providerOnce sync.Once
23− providerList []provider.Provider
22+ providerList []catwalk.Provider
2423)
2524
2625// file to cache provider data
@@ -44,7 +43,7 @@ func providerCacheFileData() string {
4443 return filepath.Join(os.Getenv("HOME"), ".local", "share", appName, "providers.json")
4544}
4645
47−func saveProvidersInCache(path string, providers []provider.Provider) error {
46+func saveProvidersInCache(path string, providers []catwalk.Provider) error {
4847 slog.Info("Caching provider data")
4948 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
5049 return fmt.Errorf("failed to create directory for provider cache: %w", err)
@@ -61,26 +60,26 @@ func saveProvidersInCache(path string, providers []provider.Provider) error {
6160 return nil
6261}
6362
64−func loadProvidersFromCache(path string) ([]provider.Provider, error) {
63+func loadProvidersFromCache(path string) ([]catwalk.Provider, error) {
6564 data, err := os.ReadFile(path)
6665 if err != nil {
6766 return nil, fmt.Errorf("failed to read provider cache file: %w", err)
6867 }
6968
70− var providers []provider.Provider
69+ var providers []catwalk.Provider
7170 if err := json.Unmarshal(data, &providers); err != nil {
7271 return nil, fmt.Errorf("failed to unmarshal provider data from cache: %w", err)
7372 }
7473 return providers, nil
7574}
7675
77−func Providers() ([]provider.Provider, error) {
78− client := client.New()
76+func Providers() ([]catwalk.Provider, error) {
77+ client := catwalk.NewWithURL(catwalkURL)
7978 path := providerCacheFileData()
8079 return loadProvidersOnce(client, path)
8180}
8281
83−func loadProvidersOnce(client ProviderClient, path string) ([]provider.Provider, error) {
82+func loadProvidersOnce(client ProviderClient, path string) ([]catwalk.Provider, error) {
8483 var err error
8584 providerOnce.Do(func() {
8685 providerList, err = loadProviders(client, path)
@@ -91,7 +90,7 @@ func loadProvidersOnce(client ProviderClient, path string) ([]provider.Provider,
9190 return providerList, nil
9291}
9392
94−func loadProviders(client ProviderClient, path string) (providerList []provider.Provider, err error) {
93+func loadProviders(client ProviderClient, path string) (providerList []catwalk.Provider, err error) {
9594 // if cache is not stale, load from it
9695 stale, exists := isCacheStale(path)
9796 if !stale {
internal/config/provider_empty_test.go+4−4
@@ -5,14 +5,14 @@ import (
55 "os"
66 "testing"
77
8− "github.com/charmbracelet/crush/internal/fur/provider"
8+ "github.com/charmbracelet/catwalk/pkg/catwalk"
99 "github.com/stretchr/testify/require"
1010)
1111
1212type emptyProviderClient struct{}
1313
14−func (m *emptyProviderClient) GetProviders() ([]provider.Provider, error) {
15− return []provider.Provider{}, nil
14+func (m *emptyProviderClient) GetProviders() ([]catwalk.Provider, error) {
15+ return []catwalk.Provider{}, nil
1616}
1717
1818func TestProvider_loadProvidersEmptyResult(t *testing.T) {
@@ -33,7 +33,7 @@ func TestProvider_loadProvidersEmptyCache(t *testing.T) {
3333 tmpPath := t.TempDir() + "/providers.json"
3434
3535 // Create an empty cache file
36− emptyProviders := []provider.Provider{}
36+ emptyProviders := []catwalk.Provider{}
3737 data, err := json.Marshal(emptyProviders)
3838 require.NoError(t, err)
3939 require.NoError(t, os.WriteFile(tmpPath, data, 0o644))
internal/config/provider_test.go+4−4
@@ -6,7 +6,7 @@ import (
66 "os"
77 "testing"
88
9− "github.com/charmbracelet/crush/internal/fur/provider"
9+ "github.com/charmbracelet/catwalk/pkg/catwalk"
1010 "github.com/stretchr/testify/assert"
1111)
1212
@@ -14,11 +14,11 @@ type mockProviderClient struct {
1414 shouldFail bool
1515}
1616
17−func (m *mockProviderClient) GetProviders() ([]provider.Provider, error) {
17+func (m *mockProviderClient) GetProviders() ([]catwalk.Provider, error) {
1818 if m.shouldFail {
1919 return nil, errors.New("failed to load providers")
2020 }
21− return []provider.Provider{
21+ return []catwalk.Provider{
2222 {
2323 Name: "Mock",
2424 },
@@ -43,7 +43,7 @@ func TestProvider_loadProvidersWithIssues(t *testing.T) {
4343 client := &mockProviderClient{shouldFail: true}
4444 tmpPath := t.TempDir() + "/providers.json"
4545 // store providers to a temporary file
46− oldProviders := []provider.Provider{
46+ oldProviders := []catwalk.Provider{
4747 {
4848 Name: "OldProvider",
4949 },
internal/fur/client/client.godeleted−63
@@ -1,63 +0,0 @@
1−// Package client provides a client for interacting with the fur service.
2−package client
3−
4−import (
5− "encoding/json"
6− "fmt"
7− "net/http"
8− "os"
9−
10− "github.com/charmbracelet/crush/internal/fur/provider"
11−)
12−
13−const defaultURL = "https://fur.charm.sh"
14−
15−// Client represents a client for the fur service.
16−type Client struct {
17− baseURL string
18− httpClient *http.Client
19−}
20−
21−// New creates a new client instance
22−// Uses FUR_URL environment variable or falls back to localhost:8080.
23−func New() *Client {
24− baseURL := os.Getenv("FUR_URL")
25− if baseURL == "" {
26− baseURL = defaultURL
27− }
28−
29− return &Client{
30− baseURL: baseURL,
31− httpClient: &http.Client{},
32− }
33−}
34−
35−// NewWithURL creates a new client with a specific URL.
36−func NewWithURL(url string) *Client {
37− return &Client{
38− baseURL: url,
39− httpClient: &http.Client{},
40− }
41−}
42−
43−// GetProviders retrieves all available providers from the service.
44−func (c *Client) GetProviders() ([]provider.Provider, error) {
45− url := fmt.Sprintf("%s/providers", c.baseURL)
46−
47− resp, err := c.httpClient.Get(url) //nolint:noctx
48− if err != nil {
49− return nil, fmt.Errorf("failed to make request: %w", err)
50− }
51− defer resp.Body.Close() //nolint:errcheck
52−
53− if resp.StatusCode != http.StatusOK {
54− return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
55− }
56−
57− var providers []provider.Provider
58− if err := json.NewDecoder(resp.Body).Decode(&providers); err != nil {
59− return nil, fmt.Errorf("failed to decode response: %w", err)
60− }
61−
62− return providers, nil
63−}
internal/fur/provider/provider.godeleted−75
@@ -1,75 +0,0 @@
1−// Package provider provides types and constants for AI providers.
2−package provider
3−
4−// Type represents the type of AI provider.
5−type Type string
6−
7−// All the supported AI provider types.
8−const (
9− TypeOpenAI Type = "openai"
10− TypeAnthropic Type = "anthropic"
11− TypeGemini Type = "gemini"
12− TypeAzure Type = "azure"
13− TypeBedrock Type = "bedrock"
14− TypeVertexAI Type = "vertexai"
15− TypeXAI Type = "xai"
16−)
17−
18−// InferenceProvider represents the inference provider identifier.
19−type InferenceProvider string
20−
21−// All the inference providers supported by the system.
22−const (
23− InferenceProviderOpenAI InferenceProvider = "openai"
24− InferenceProviderAnthropic InferenceProvider = "anthropic"
25− InferenceProviderGemini InferenceProvider = "gemini"
26− InferenceProviderAzure InferenceProvider = "azure"
27− InferenceProviderBedrock InferenceProvider = "bedrock"
28− InferenceProviderVertexAI InferenceProvider = "vertexai"
29− InferenceProviderXAI InferenceProvider = "xai"
30− InferenceProviderGROQ InferenceProvider = "groq"
31− InferenceProviderOpenRouter InferenceProvider = "openrouter"
32−)
33−
34−// Provider represents an AI provider configuration.
35−type Provider struct {
36− Name string `json:"name"`
37− ID InferenceProvider `json:"id"`
38− APIKey string `json:"api_key,omitempty"`
39− APIEndpoint string `json:"api_endpoint,omitempty"`
40− Type Type `json:"type,omitempty"`
41− DefaultLargeModelID string `json:"default_large_model_id,omitempty"`
42− DefaultSmallModelID string `json:"default_small_model_id,omitempty"`
43− Models []Model `json:"models,omitempty"`
44−}
45−
46−// Model represents an AI model configuration.
47−type Model struct {
48− ID string `json:"id"`
49− Model string `json:"model"`
50− CostPer1MIn float64 `json:"cost_per_1m_in"`
51− CostPer1MOut float64 `json:"cost_per_1m_out"`
52− CostPer1MInCached float64 `json:"cost_per_1m_in_cached"`
53− CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"`
54− ContextWindow int64 `json:"context_window"`
55− DefaultMaxTokens int64 `json:"default_max_tokens"`
56− CanReason bool `json:"can_reason"`
57− HasReasoningEffort bool `json:"has_reasoning_efforts"`
58− DefaultReasoningEffort string `json:"default_reasoning_effort,omitempty"`
59− SupportsImages bool `json:"supports_attachments"`
60−}
61−
62−// KnownProviders returns all the known inference providers.
63−func KnownProviders() []InferenceProvider {
64− return []InferenceProvider{
65− InferenceProviderOpenAI,
66− InferenceProviderAnthropic,
67− InferenceProviderGemini,
68− InferenceProviderAzure,
69− InferenceProviderBedrock,
70− InferenceProviderVertexAI,
71− InferenceProviderXAI,
72− InferenceProviderGROQ,
73− InferenceProviderOpenRouter,
74− }
75−}
internal/llm/agent/agent.go+10−7
@@ -10,9 +10,9 @@ import (
1010 "sync"
1111 "time"
1212
13+ "github.com/charmbracelet/catwalk/pkg/catwalk"
1314 "github.com/charmbracelet/crush/internal/config"
1415 "github.com/charmbracelet/crush/internal/csync"
15− fur "github.com/charmbracelet/crush/internal/fur/provider"
1616 "github.com/charmbracelet/crush/internal/history"
1717 "github.com/charmbracelet/crush/internal/llm/prompt"
1818 "github.com/charmbracelet/crush/internal/llm/provider"
@@ -23,6 +23,7 @@ import (
2323 "github.com/charmbracelet/crush/internal/permission"
2424 "github.com/charmbracelet/crush/internal/pubsub"
2525 "github.com/charmbracelet/crush/internal/session"
26+ "github.com/charmbracelet/crush/internal/shell"
2627)
2728
2829// Common errors
@@ -52,7 +53,7 @@ type AgentEvent struct {
5253
5354type Service interface {
5455 pubsub.Suscriber[AgentEvent]
55− Model() fur.Model
56+ Model() catwalk.Model
5657 Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error)
5758 Cancel(sessionID string)
5859 CancelAll()
@@ -226,7 +227,7 @@ func NewAgent(
226227 }, nil
227228}
228229
229−func (a *agent) Model() fur.Model {
230+func (a *agent) Model() catwalk.Model {
230231 return *config.Get().GetModelByType(a.agentCfg.Model)
231232}
232233
@@ -234,7 +235,7 @@ func (a *agent) Cancel(sessionID string) {
234235 // Cancel regular requests
235236 if cancelFunc, exists := a.activeRequests.LoadAndDelete(sessionID); exists {
236237 if cancel, ok := cancelFunc.(context.CancelFunc); ok {
237− slog.Info(fmt.Sprintf("Request cancellation initiated for session: %s", sessionID))
238+ slog.Info("Request cancellation initiated", "session_id", sessionID)
238239 cancel()
239240 }
240241 }
@@ -242,7 +243,7 @@ func (a *agent) Cancel(sessionID string) {
242243 // Also check for summarize requests
243244 if cancelFunc, exists := a.activeRequests.LoadAndDelete(sessionID + "-summarize"); exists {
244245 if cancel, ok := cancelFunc.(context.CancelFunc); ok {
245− slog.Info(fmt.Sprintf("Summarize cancellation initiated for session: %s", sessionID))
246+ slog.Info("Summarize cancellation initiated", "session_id", sessionID)
246247 cancel()
247248 }
248249 }
@@ -372,7 +373,7 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string
372373 })
373374 titleErr := a.generateTitle(context.Background(), sessionID, content)
374375 if titleErr != nil && !errors.Is(titleErr, context.Canceled) && !errors.Is(titleErr, context.DeadlineExceeded) {
375− slog.Error(fmt.Sprintf("failed to generate title: %v", titleErr))
376+ slog.Error("failed to generate title", "error", titleErr)
376377 }
377378 }()
378379 }
@@ -645,7 +646,7 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
645646 return nil
646647}
647648
648−func (a *agent) TrackUsage(ctx context.Context, sessionID string, model fur.Model, usage provider.TokenUsage) error {
649+func (a *agent) TrackUsage(ctx context.Context, sessionID string, model catwalk.Model, usage provider.TokenUsage) error {
649650 sess, err := a.sessions.Get(ctx, sessionID)
650651 if err != nil {
651652 return fmt.Errorf("failed to get session: %w", err)
@@ -770,6 +771,8 @@ func (a *agent) Summarize(ctx context.Context, sessionID string) error {
770771 a.Publish(pubsub.CreatedEvent, event)
771772 return
772773 }
774+ shell := shell.GetPersistentShell(config.Get().WorkingDir())
775+ summary += "\n\n**Current working directory of the persistent shell**\n\n" + shell.GetWorkingDir()
773776 event = AgentEvent{
774777 Type: AgentEventTypeSummarize,
775778 Progress: "Creating new session...",
internal/llm/prompt/coder.go+3−3
@@ -9,17 +9,17 @@ import (
99 "runtime"
1010 "time"
1111
12+ "github.com/charmbracelet/catwalk/pkg/catwalk"
1213 "github.com/charmbracelet/crush/internal/config"
13− "github.com/charmbracelet/crush/internal/fur/provider"
1414 "github.com/charmbracelet/crush/internal/llm/tools"
1515)
1616
1717func CoderPrompt(p string, contextFiles ...string) string {
1818 var basePrompt string
1919 switch p {
20− case string(provider.InferenceProviderOpenAI):
20+ case string(catwalk.InferenceProviderOpenAI):
2121 basePrompt = baseOpenAICoderPrompt
22− case string(provider.InferenceProviderGemini), string(provider.InferenceProviderVertexAI):
22+ case string(catwalk.InferenceProviderGemini), string(catwalk.InferenceProviderVertexAI):
2323 basePrompt = baseGeminiCoderPrompt
2424 default:
2525 basePrompt = baseAnthropicCoderPrompt
internal/llm/provider/anthropic.go+5−5
@@ -15,8 +15,8 @@ import (
1515 "github.com/anthropics/anthropic-sdk-go"
1616 "github.com/anthropics/anthropic-sdk-go/bedrock"
1717 "github.com/anthropics/anthropic-sdk-go/option"
18+ "github.com/charmbracelet/catwalk/pkg/catwalk"
1819 "github.com/charmbracelet/crush/internal/config"
19− "github.com/charmbracelet/crush/internal/fur/provider"
2020 "github.com/charmbracelet/crush/internal/llm/tools"
2121 "github.com/charmbracelet/crush/internal/message"
2222)
@@ -71,7 +71,7 @@ func (a *anthropicClient) convertMessages(messages []message.Message) (anthropic
7171 var contentBlocks []anthropic.ContentBlockParamUnion
7272 contentBlocks = append(contentBlocks, content)
7373 for _, binaryContent := range msg.BinaryContent() {
74− base64Image := binaryContent.String(provider.InferenceProviderAnthropic)
74+ base64Image := binaryContent.String(catwalk.InferenceProviderAnthropic)
7575 imageBlock := anthropic.NewImageBlockBase64(binaryContent.MIMEType, base64Image)
7676 contentBlocks = append(contentBlocks, imageBlock)
7777 }
@@ -248,7 +248,7 @@ func (a *anthropicClient) send(ctx context.Context, messages []message.Message,
248248 return nil, retryErr
249249 }
250250 if retry {
251− slog.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
251+ slog.Warn("Retrying due to rate limit", "attempt", attempts, "max_retries", maxRetries)
252252 select {
253253 case <-ctx.Done():
254254 return nil, ctx.Err()
@@ -401,7 +401,7 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
401401 return
402402 }
403403 if retry {
404− slog.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
404+ slog.Warn("Retrying due to rate limit", "attempt", attempts, "max_retries", maxRetries)
405405 select {
406406 case <-ctx.Done():
407407 // context cancelled
@@ -529,6 +529,6 @@ func (a *anthropicClient) usage(msg anthropic.Message) TokenUsage {
529529 }
530530}
531531
532−func (a *anthropicClient) Model() provider.Model {
532+func (a *anthropicClient) Model() catwalk.Model {
533533 return a.providerOptions.model(a.providerOptions.modelType)
534534}
internal/llm/provider/bedrock.go+3−3
@@ -6,8 +6,8 @@ import (
66 "fmt"
77 "strings"
88
9+ "github.com/charmbracelet/catwalk/pkg/catwalk"
910 "github.com/charmbracelet/crush/internal/config"
10− "github.com/charmbracelet/crush/internal/fur/provider"
1111 "github.com/charmbracelet/crush/internal/llm/tools"
1212 "github.com/charmbracelet/crush/internal/message"
1313)
@@ -32,7 +32,7 @@ func newBedrockClient(opts providerClientOptions) BedrockClient {
3232 }
3333 }
3434
35− opts.model = func(modelType config.SelectedModelType) provider.Model {
35+ opts.model = func(modelType config.SelectedModelType) catwalk.Model {
3636 model := config.Get().GetModelByType(modelType)
3737
3838 // Prefix the model name with region
@@ -88,6 +88,6 @@ func (b *bedrockClient) stream(ctx context.Context, messages []message.Message,
8888 return b.childProvider.stream(ctx, messages, tools)
8989}
9090
91−func (b *bedrockClient) Model() provider.Model {
91+func (b *bedrockClient) Model() catwalk.Model {
9292 return b.providerOptions.model(b.providerOptions.modelType)
9393}
internal/llm/provider/gemini.go+4−4
@@ -10,8 +10,8 @@ import (
1010 "strings"
1111 "time"
1212
13+ "github.com/charmbracelet/catwalk/pkg/catwalk"
1314 "github.com/charmbracelet/crush/internal/config"
14− "github.com/charmbracelet/crush/internal/fur/provider"
1515 "github.com/charmbracelet/crush/internal/llm/tools"
1616 "github.com/charmbracelet/crush/internal/message"
1717 "github.com/google/uuid"
@@ -210,7 +210,7 @@ func (g *geminiClient) send(ctx context.Context, messages []message.Message, too
210210 return nil, retryErr
211211 }
212212 if retry {
213− slog.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
213+ slog.Warn("Retrying due to rate limit", "attempt", attempts, "max_retries", maxRetries)
214214 select {
215215 case <-ctx.Done():
216216 return nil, ctx.Err()
@@ -323,7 +323,7 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
323323 return
324324 }
325325 if retry {
326− slog.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
326+ slog.Warn("Retrying due to rate limit", "attempt", attempts, "max_retries", maxRetries)
327327 select {
328328 case <-ctx.Done():
329329 if ctx.Err() != nil {
@@ -463,7 +463,7 @@ func (g *geminiClient) usage(resp *genai.GenerateContentResponse) TokenUsage {
463463 }
464464}
465465
466−func (g *geminiClient) Model() provider.Model {
466+func (g *geminiClient) Model() catwalk.Model {
467467 return g.providerOptions.model(g.providerOptions.modelType)
468468}
469469
internal/llm/provider/openai.go+5−5
@@ -9,8 +9,8 @@ import (
99 "log/slog"
1010 "time"
1111
12+ "github.com/charmbracelet/catwalk/pkg/catwalk"
1213 "github.com/charmbracelet/crush/internal/config"
13− "github.com/charmbracelet/crush/internal/fur/provider"
1414 "github.com/charmbracelet/crush/internal/llm/tools"
1515 "github.com/charmbracelet/crush/internal/message"
1616 "github.com/openai/openai-go"
@@ -66,7 +66,7 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
6666 textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()}
6767 content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock})
6868 for _, binaryContent := range msg.BinaryContent() {
69− imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(provider.InferenceProviderOpenAI)}
69+ imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(catwalk.InferenceProviderOpenAI)}
7070 imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL}
7171
7272 content = append(content, openai.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock})
@@ -222,7 +222,7 @@ func (o *openaiClient) send(ctx context.Context, messages []message.Message, too
222222 return nil, retryErr
223223 }
224224 if retry {
225− slog.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
225+ slog.Warn("Retrying due to rate limit", "attempt", attempts, "max_retries", maxRetries)
226226 select {
227227 case <-ctx.Done():
228228 return nil, ctx.Err()
@@ -395,7 +395,7 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
395395 return
396396 }
397397 if retry {
398− slog.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
398+ slog.Warn("Retrying due to rate limit", "attempt", attempts, "max_retries", maxRetries)
399399 select {
400400 case <-ctx.Done():
401401 // context cancelled
@@ -486,6 +486,6 @@ func (o *openaiClient) usage(completion openai.ChatCompletion) TokenUsage {
486486 }
487487}
488488
489−func (o *openaiClient) Model() provider.Model {
489+func (o *openaiClient) Model() catwalk.Model {
490490 return o.providerOptions.model(o.providerOptions.modelType)
491491}
internal/llm/provider/openai_test.go+5−5
@@ -9,8 +9,8 @@ import (
99 "testing"
1010 "time"
1111
12+ "github.com/charmbracelet/catwalk/pkg/catwalk"
1213 "github.com/charmbracelet/crush/internal/config"
13− "github.com/charmbracelet/crush/internal/fur/provider"
1414 "github.com/charmbracelet/crush/internal/llm/tools"
1515 "github.com/charmbracelet/crush/internal/message"
1616 "github.com/openai/openai-go"
@@ -55,10 +55,10 @@ func TestOpenAIClientStreamChoices(t *testing.T) {
5555 modelType: config.SelectedModelTypeLarge,
5656 apiKey: "test-key",
5757 systemMessage: "test",
58− model: func(config.SelectedModelType) provider.Model {
59− return provider.Model{
60− ID: "test-model",
61− Model: "test-model",
58+ model: func(config.SelectedModelType) catwalk.Model {
59+ return catwalk.Model{
60+ ID: "test-model",
61+ Name: "test-model",
6262 }
6363 },
6464 },
internal/llm/provider/provider.go+13−13
@@ -4,8 +4,8 @@ import (
44 "context"
55 "fmt"
66
7+ "github.com/charmbracelet/catwalk/pkg/catwalk"
78 "github.com/charmbracelet/crush/internal/config"
8− "github.com/charmbracelet/crush/internal/fur/provider"
99 "github.com/charmbracelet/crush/internal/llm/tools"
1010 "github.com/charmbracelet/crush/internal/message"
1111)
@@ -57,7 +57,7 @@ type Provider interface {
5757
5858 StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
5959
60− Model() provider.Model
60+ Model() catwalk.Model
6161}
6262
6363type providerClientOptions struct {
@@ -65,7 +65,7 @@ type providerClientOptions struct {
6565 config config.ProviderConfig
6666 apiKey string
6767 modelType config.SelectedModelType
68− model func(config.SelectedModelType) provider.Model
68+ model func(config.SelectedModelType) catwalk.Model
6969 disableCache bool
7070 systemMessage string
7171 maxTokens int64
@@ -80,7 +80,7 @@ type ProviderClient interface {
8080 send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error)
8181 stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
8282
83− Model() provider.Model
83+ Model() catwalk.Model
8484}
8585
8686type baseProvider[C ProviderClient] struct {
@@ -109,7 +109,7 @@ func (p *baseProvider[C]) StreamResponse(ctx context.Context, messages []message
109109 return p.client.stream(ctx, messages, tools)
110110}
111111
112−func (p *baseProvider[C]) Model() provider.Model {
112+func (p *baseProvider[C]) Model() catwalk.Model {
113113 return p.client.Model()
114114}
115115
@@ -149,7 +149,7 @@ func NewProvider(cfg config.ProviderConfig, opts ...ProviderClientOption) (Provi
149149 apiKey: resolvedAPIKey,
150150 extraHeaders: cfg.ExtraHeaders,
151151 extraBody: cfg.ExtraBody,
152− model: func(tp config.SelectedModelType) provider.Model {
152+ model: func(tp config.SelectedModelType) catwalk.Model {
153153 return *config.Get().GetModelByType(tp)
154154 },
155155 }
@@ -157,37 +157,37 @@ func NewProvider(cfg config.ProviderConfig, opts ...ProviderClientOption) (Provi
157157 o(&clientOptions)
158158 }
159159 switch cfg.Type {
160− case provider.TypeAnthropic:
160+ case catwalk.TypeAnthropic:
161161 return &baseProvider[AnthropicClient]{
162162 options: clientOptions,
163163 client: newAnthropicClient(clientOptions, false),
164164 }, nil
165− case provider.TypeOpenAI:
165+ case catwalk.TypeOpenAI:
166166 return &baseProvider[OpenAIClient]{
167167 options: clientOptions,
168168 client: newOpenAIClient(clientOptions),
169169 }, nil
170− case provider.TypeGemini:
170+ case catwalk.TypeGemini:
171171 return &baseProvider[GeminiClient]{
172172 options: clientOptions,
173173 client: newGeminiClient(clientOptions),
174174 }, nil
175− case provider.TypeBedrock:
175+ case catwalk.TypeBedrock:
176176 return &baseProvider[BedrockClient]{
177177 options: clientOptions,
178178 client: newBedrockClient(clientOptions),
179179 }, nil
180− case provider.TypeAzure:
180+ case catwalk.TypeAzure:
181181 return &baseProvider[AzureClient]{
182182 options: clientOptions,
183183 client: newAzureClient(clientOptions),
184184 }, nil
185− case provider.TypeVertexAI:
185+ case catwalk.TypeVertexAI:
186186 return &baseProvider[VertexAIClient]{
187187 options: clientOptions,
188188 client: newVertexAIClient(clientOptions),
189189 }, nil
190− case provider.TypeXAI:
190+ case catwalk.TypeXAI:
191191 clientOptions.baseURL = "https://api.x.ai/v1"
192192 return &baseProvider[OpenAIClient]{
193193 options: clientOptions,
internal/llm/tools/bash.go+16−17
@@ -4,7 +4,6 @@ import (
44 "context"
55 "encoding/json"
66 "fmt"
7− "log/slog"
87 "strings"
98 "time"
109
@@ -23,8 +22,10 @@ type BashPermissionsParams struct {
2322}
2423
2524type BashResponseMetadata struct {
26− StartTime int64 `json:"start_time"`
27− EndTime int64 `json:"end_time"`
25+ StartTime int64 `json:"start_time"`
26+ EndTime int64 `json:"end_time"`
27+ Output string `json:"output"`
28+ WorkingDirectory string `json:"working_directory"`
2829}
2930type bashTool struct {
3031 permissions permission.Service
@@ -146,6 +147,7 @@ Before executing the command, please follow these steps:
1461475. Return Result:
147148 - Provide the processed output of the command.
148149 - If any errors occurred during execution, include those in the output.
150+ - The result will also have metadata like the cwd (current working directory) at the end, included with <cwd></cwd> tags.
149151
150152Usage notes:
151153- The command argument is required.
@@ -389,9 +391,12 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
389391 ctx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Millisecond)
390392 defer cancel()
391393 }
392− stdout, stderr, err := shell.
393− GetPersistentShell(b.workingDir).
394− Exec(ctx, params.Command)
394+
395+ persistentShell := shell.GetPersistentShell(b.workingDir)
396+ stdout, stderr, err := persistentShell.Exec(ctx, params.Command)
397+
398+ // Get the current working directory after command execution
399+ currentWorkingDir := persistentShell.GetWorkingDir()
395400 interrupted := shell.IsInterrupt(err)
396401 exitCode := shell.ExitCode(err)
397402 if exitCode == 0 && !interrupted && err != nil {
@@ -401,15 +406,6 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
401406 stdout = truncateOutput(stdout)
402407 stderr = truncateOutput(stderr)
403408
404− slog.Info("Bash command executed",
405− "command", params.Command,
406− "stdout", stdout,
407− "stderr", stderr,
408− "exit_code", exitCode,
409− "interrupted", interrupted,
410− "err", err,
411− )
412−
413409 errorMessage := stderr
414410 if errorMessage == "" && err != nil {
415411 errorMessage = err.Error()
@@ -438,9 +434,12 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
438434 }
439435
440436 metadata := BashResponseMetadata{
441− StartTime: startTime.UnixMilli(),
442− EndTime: time.Now().UnixMilli(),
437+ StartTime: startTime.UnixMilli(),
438+ EndTime: time.Now().UnixMilli(),
439+ Output: stdout,
440+ WorkingDirectory: currentWorkingDir,
443441 }
442+ stdout += fmt.Sprintf("\n\n<cwd>%s</cwd>", currentWorkingDir)
444443 if stdout == "" {
445444 return WithResponseMetadata(NewTextResponse(BashNoOutput), metadata), nil
446445 }
internal/llm/tools/glob.go+1−1
@@ -146,7 +146,7 @@ func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
146146 if err == nil {
147147 return matches, len(matches) >= limit && limit > 0, nil
148148 }
149− slog.Warn(fmt.Sprintf("Ripgrep execution failed: %v. Falling back to doublestar.", err))
149+ slog.Warn("Ripgrep execution failed, falling back to doublestar", "error", err)
150150 }
151151
152152 return fsext.GlobWithDoubleStar(pattern, searchPath, limit)
internal/lsp/watcher/watcher.go+1−1
@@ -90,7 +90,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
9090 slog.Debug("BaseURI", "baseURI", u)
9191 }
9292 default:
93− slog.Debug("GlobPattern", "unknown type", fmt.Sprintf("%T", v))
93+ slog.Debug("GlobPattern unknown type", "type", fmt.Sprintf("%T", v))
9494 }
9595
9696 // Log WatchKind
internal/message/content.go+3−3
@@ -5,7 +5,7 @@ import (
55 "slices"
66 "time"
77
8− "github.com/charmbracelet/crush/internal/fur/provider"
8+ "github.com/charmbracelet/catwalk/pkg/catwalk"
99)
1010
1111type MessageRole string
@@ -74,9 +74,9 @@ type BinaryContent struct {
7474 Data []byte
7575}
7676
77−func (bc BinaryContent) String(p provider.InferenceProvider) string {
77+func (bc BinaryContent) String(p catwalk.InferenceProvider) string {
7878 base64Encoded := base64.StdEncoding.EncodeToString(bc.Data)
79− if p == provider.InferenceProviderOpenAI {
79+ if p == catwalk.InferenceProviderOpenAI {
8080 return "data:" + bc.MIMEType + ";base64," + base64Encoded
8181 }
8282 return base64Encoded
internal/permission/permission.go+9−1
@@ -50,6 +50,7 @@ type permissionService struct {
5050 autoApproveSessions []string
5151 autoApproveSessionsMu sync.RWMutex
5252 skip bool
53+ allowedTools []string
5354}
5455
5556func (s *permissionService) GrantPersistent(permission PermissionRequest) {
@@ -82,6 +83,12 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
8283 return true
8384 }
8485
86+ // Check if the tool/action combination is in the allowlist
87+ commandKey := opts.ToolName + ":" + opts.Action
88+ if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) {
89+ return true
90+ }
91+
8592 s.autoApproveSessionsMu.RLock()
8693 autoApprove := slices.Contains(s.autoApproveSessions, opts.SessionID)
8794 s.autoApproveSessionsMu.RUnlock()
@@ -130,11 +137,12 @@ func (s *permissionService) AutoApproveSession(sessionID string) {
130137 s.autoApproveSessionsMu.Unlock()
131138}
132139
133−func NewPermissionService(workingDir string, skip bool) Service {
140+func NewPermissionService(workingDir string, skip bool, allowedTools []string) Service {
134141 return &permissionService{
135142 Broker: pubsub.NewBroker[PermissionRequest](),
136143 workingDir: workingDir,
137144 sessionPermissions: make([]PermissionRequest, 0),
138145 skip: skip,
146+ allowedTools: allowedTools,
139147 }
140148}
internal/permission/permission_test.gonew file+92
@@ -0,0 +1,92 @@
1+package permission
2+
3+import (
4+ "testing"
5+)
6+
7+func TestPermissionService_AllowedCommands(t *testing.T) {
8+ tests := []struct {
9+ name string
10+ allowedTools []string
11+ toolName string
12+ action string
13+ expected bool
14+ }{
15+ {
16+ name: "tool in allowlist",
17+ allowedTools: []string{"bash", "view"},
18+ toolName: "bash",
19+ action: "execute",
20+ expected: true,
21+ },
22+ {
23+ name: "tool:action in allowlist",
24+ allowedTools: []string{"bash:execute", "edit:create"},
25+ toolName: "bash",
26+ action: "execute",
27+ expected: true,
28+ },
29+ {
30+ name: "tool not in allowlist",
31+ allowedTools: []string{"view", "ls"},
32+ toolName: "bash",
33+ action: "execute",
34+ expected: false,
35+ },
36+ {
37+ name: "tool:action not in allowlist",
38+ allowedTools: []string{"bash:read", "edit:create"},
39+ toolName: "bash",
40+ action: "execute",
41+ expected: false,
42+ },
43+ {
44+ name: "empty allowlist",
45+ allowedTools: []string{},
46+ toolName: "bash",
47+ action: "execute",
48+ expected: false,
49+ },
50+ }
51+
52+ for _, tt := range tests {
53+ t.Run(tt.name, func(t *testing.T) {
54+ service := NewPermissionService("/tmp", false, tt.allowedTools)
55+
56+ // Create a channel to capture the permission request
57+ // Since we're testing the allowlist logic, we need to simulate the request
58+ ps := service.(*permissionService)
59+
60+ // Test the allowlist logic directly
61+ commandKey := tt.toolName + ":" + tt.action
62+ allowed := false
63+ for _, cmd := range ps.allowedTools {
64+ if cmd == commandKey || cmd == tt.toolName {
65+ allowed = true
66+ break
67+ }
68+ }
69+
70+ if allowed != tt.expected {
71+ t.Errorf("expected %v, got %v for tool %s action %s with allowlist %v",
72+ tt.expected, allowed, tt.toolName, tt.action, tt.allowedTools)
73+ }
74+ })
75+ }
76+}
77+
78+func TestPermissionService_SkipMode(t *testing.T) {
79+ service := NewPermissionService("/tmp", true, []string{})
80+
81+ result := service.Request(CreatePermissionRequest{
82+ SessionID: "test-session",
83+ ToolName: "bash",
84+ Action: "execute",
85+ Description: "test command",
86+ Path: "/tmp",
87+ })
88+
89+ if !result {
90+ t.Error("expected permission to be granted in skip mode")
91+ }
92+}
internal/shell/coreutils.gonew file+59
@@ -0,0 +1,59 @@
1+package shell
2+
3+import (
4+ "context"
5+
6+ "github.com/u-root/u-root/pkg/core"
7+ "github.com/u-root/u-root/pkg/core/cat"
8+ "github.com/u-root/u-root/pkg/core/chmod"
9+ "github.com/u-root/u-root/pkg/core/cp"
10+ "github.com/u-root/u-root/pkg/core/find"
11+ "github.com/u-root/u-root/pkg/core/ls"
12+ "github.com/u-root/u-root/pkg/core/mkdir"
13+ "github.com/u-root/u-root/pkg/core/mv"
14+ "github.com/u-root/u-root/pkg/core/rm"
15+ "github.com/u-root/u-root/pkg/core/touch"
16+ "github.com/u-root/u-root/pkg/core/xargs"
17+ "mvdan.cc/sh/v3/interp"
18+)
19+
20+var coreUtils = map[string]func() core.Command{
21+ "cat": func() core.Command { return cat.New() },
22+ "chmod": func() core.Command { return chmod.New() },
23+ "cp": func() core.Command { return cp.New() },
24+ "find": func() core.Command { return find.New() },
25+ "ls": func() core.Command { return ls.New() },
26+ "mkdir": func() core.Command { return mkdir.New() },
27+ "mv": func() core.Command { return mv.New() },
28+ "rm": func() core.Command { return rm.New() },
29+ "touch": func() core.Command { return touch.New() },
30+ "xargs": func() core.Command { return xargs.New() },
31+}
32+
33+func (s *Shell) coreUtilsHandler() func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
34+ return func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
35+ return func(ctx context.Context, args []string) error {
36+ if len(args) == 0 {
37+ return next(ctx, args)
38+ }
39+
40+ program, programArgs := args[0], args[1:]
41+
42+ newCoreUtil, ok := coreUtils[program]
43+ if !ok {
44+ return next(ctx, args)
45+ }
46+
47+ c := interp.HandlerCtx(ctx)
48+
49+ cmd := newCoreUtil()
50+ cmd.SetIO(c.Stdin, c.Stdout, c.Stderr)
51+ cmd.SetWorkingDir(c.Dir)
52+ cmd.SetLookupEnv(func(key string) (string, bool) {
53+ v := c.Env.Get(key)
54+ return v.Str, v.Set
55+ })
56+ return cmd.RunContext(ctx, programArgs...)
57+ }
58+ }
59+}
internal/shell/shell.go+1−1
@@ -221,7 +221,7 @@ func (s *Shell) execPOSIX(ctx context.Context, command string) (string, string,
221221 interp.Interactive(false),
222222 interp.Env(expand.ListEnviron(s.env...)),
223223 interp.Dir(s.cwd),
224− interp.ExecHandlers(s.blockHandler()),
224+ interp.ExecHandlers(s.blockHandler(), s.coreUtilsHandler()),
225225 )
226226 if err != nil {
227227 return "", "", fmt.Errorf("could not run command: %w", err)
internal/tui/components/anim/anim.go+1−1
@@ -289,7 +289,7 @@ func (a Anim) View() string {
289289 var b strings.Builder
290290 for i := range a.width {
291291 switch {
292− case !a.initialized && time.Since(a.startTime) < a.birthOffsets[i]:
292+ case !a.initialized && i < len(a.birthOffsets) && time.Since(a.startTime) < a.birthOffsets[i]:
293293 // Birth offset not reached: render initial character.
294294 b.WriteString(a.initialFrames[a.step][i])
295295 case i < a.cyclingCharWidth:
internal/tui/components/chat/editor/editor.go+5−3
@@ -187,9 +187,11 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
187187 value = value[:m.completionsStartIndex]
188188 value += item.Path
189189 m.textarea.SetValue(value)
190− m.isCompletionsOpen = false
191− m.currentQuery = ""
192− m.completionsStartIndex = 0
190+ if !msg.Insert {
191+ m.isCompletionsOpen = false
192+ m.currentQuery = ""
193+ m.completionsStartIndex = 0
194+ }
193195 return m, nil
194196 }
195197 case openEditorMsg:
internal/tui/components/chat/messages/messages.go+4−4
@@ -8,11 +8,11 @@ import (
88
99 "github.com/charmbracelet/bubbles/v2/viewport"
1010 tea "github.com/charmbracelet/bubbletea/v2"
11+ "github.com/charmbracelet/catwalk/pkg/catwalk"
1112 "github.com/charmbracelet/lipgloss/v2"
1213 "github.com/charmbracelet/x/ansi"
1314
1415 "github.com/charmbracelet/crush/internal/config"
15− "github.com/charmbracelet/crush/internal/fur/provider"
1616 "github.com/charmbracelet/crush/internal/message"
1717 "github.com/charmbracelet/crush/internal/tui/components/anim"
1818 "github.com/charmbracelet/crush/internal/tui/components/core"
@@ -369,11 +369,11 @@ func (m *assistantSectionModel) View() string {
369369 model := config.Get().GetModel(m.message.Provider, m.message.Model)
370370 if model == nil {
371371 // This means the model is not configured anymore
372− model = &provider.Model{
373− Model: "Unknown Model",
372+ model = &catwalk.Model{
373+ Name: "Unknown Model",
374374 }
375375 }
376− modelFormatted := t.S().Muted.Render(model.Model)
376+ modelFormatted := t.S().Muted.Render(model.Name)
377377 assistant := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg)
378378 return t.S().Base.PaddingLeft(2).Render(
379379 core.Section(assistant, m.width-2),
internal/tui/components/chat/messages/renderer.go+14−21
@@ -6,6 +6,7 @@ import (
66 "strings"
77 "time"
88
9+ "github.com/charmbracelet/crush/internal/ansiext"
910 "github.com/charmbracelet/crush/internal/fsext"
1011 "github.com/charmbracelet/crush/internal/llm/agent"
1112 "github.com/charmbracelet/crush/internal/llm/tools"
@@ -212,10 +213,19 @@ func (br bashRenderer) Render(v *toolCallCmp) string {
212213 args := newParamBuilder().addMain(cmd).build()
213214
214215 return br.renderWithParams(v, "Bash", args, func() string {
215− if v.result.Content == tools.BashNoOutput {
216+ var meta tools.BashResponseMetadata
217+ if err := br.unmarshalParams(v.result.Metadata, &meta); err != nil {
218+ return renderPlainContent(v, v.result.Content)
219+ }
220+ // for backwards compatibility with older tool calls.
221+ if meta.Output == "" && v.result.Content != tools.BashNoOutput {
222+ meta.Output = v.result.Content
223+ }
224+
225+ if meta.Output == "" {
216226 return ""
217227 }
218− return renderPlainContent(v, v.result.Content)
228+ return renderPlainContent(v, meta.Output)
219229 })
220230}
221231
@@ -693,7 +703,7 @@ func renderPlainContent(v *toolCallCmp, content string) string {
693703 if i >= responseContextHeight {
694704 break
695705 }
696− ln = escapeContent(ln)
706+ ln = ansiext.Escape(ln)
697707 ln = " " + ln // left padding
698708 if len(ln) > width {
699709 ln = v.fit(ln, width)
@@ -731,7 +741,7 @@ func renderCodeContent(v *toolCallCmp, path, content string, offset int) string
731741
732742 lines := strings.Split(truncated, "\n")
733743 for i, ln := range lines {
734− lines[i] = escapeContent(ln)
744+ lines[i] = ansiext.Escape(ln)
735745 }
736746
737747 highlighted, _ := highlight.SyntaxHighlight(strings.Join(lines, "\n"), path, t.BgBase)
@@ -807,20 +817,3 @@ func prettifyToolName(name string) string {
807817 return name
808818 }
809819}
810−
811−// escapeContent replaces control characters with their Unicode Control Picture
812−// representations to ensure they are displayed correctly in the UI.
813−func escapeContent(content string) string {
814− var sb strings.Builder
815− for _, r := range content {
816− switch {
817− case r >= 0 && r <= 0x1f: // Control characters 0x00-0x1F
818− sb.WriteRune('\u2400' + r)
819− case r == ansi.DEL:
820− sb.WriteRune('\u2421')
821− default:
822− sb.WriteRune(r)
823− }
824− }
825− return sb.String()
826−}
internal/tui/components/chat/sidebar/sidebar.go+4−4
@@ -9,10 +9,10 @@ import (
99 "sync"
1010
1111 tea "github.com/charmbracelet/bubbletea/v2"
12+ "github.com/charmbracelet/catwalk/pkg/catwalk"
1213 "github.com/charmbracelet/crush/internal/config"
1314 "github.com/charmbracelet/crush/internal/diff"
1415 "github.com/charmbracelet/crush/internal/fsext"
15− "github.com/charmbracelet/crush/internal/fur/provider"
1616 "github.com/charmbracelet/crush/internal/history"
1717 "github.com/charmbracelet/crush/internal/lsp"
1818 "github.com/charmbracelet/crush/internal/lsp/protocol"
@@ -897,7 +897,7 @@ func (s *sidebarCmp) currentModelBlock() string {
897897 t := styles.CurrentTheme()
898898
899899 modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
900− modelName := t.S().Text.Render(model.Model)
900+ modelName := t.S().Text.Render(model.Name)
901901 modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
902902 parts := []string{
903903 modelInfo,
@@ -905,14 +905,14 @@ func (s *sidebarCmp) currentModelBlock() string {
905905 if model.CanReason {
906906 reasoningInfoStyle := t.S().Subtle.PaddingLeft(2)
907907 switch modelProvider.Type {
908− case provider.TypeOpenAI:
908+ case catwalk.TypeOpenAI:
909909 reasoningEffort := model.DefaultReasoningEffort
910910 if selectedModel.ReasoningEffort != "" {
911911 reasoningEffort = selectedModel.ReasoningEffort
912912 }
913913 formatter := cases.Title(language.English, cases.NoLower)
914914 parts = append(parts, reasoningInfoStyle.Render(formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort))))
915− case provider.TypeAnthropic:
915+ case catwalk.TypeAnthropic:
916916 formatter := cases.Title(language.English, cases.NoLower)
917917 if selectedModel.Think {
918918 parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking on")))
internal/tui/components/chat/splash/splash.go+3−3
@@ -10,8 +10,8 @@ import (
1010 "github.com/charmbracelet/bubbles/v2/key"
1111 "github.com/charmbracelet/bubbles/v2/spinner"
1212 tea "github.com/charmbracelet/bubbletea/v2"
13+ "github.com/charmbracelet/catwalk/pkg/catwalk"
1314 "github.com/charmbracelet/crush/internal/config"
14− "github.com/charmbracelet/crush/internal/fur/provider"
1515 "github.com/charmbracelet/crush/internal/llm/prompt"
1616 "github.com/charmbracelet/crush/internal/tui/components/chat"
1717 "github.com/charmbracelet/crush/internal/tui/components/completions"
@@ -109,7 +109,7 @@ func (s *splashCmp) SetOnboarding(onboarding bool) {
109109 if err != nil {
110110 return
111111 }
112− filteredProviders := []provider.Provider{}
112+ filteredProviders := []catwalk.Provider{}
113113 simpleProviders := []string{
114114 "anthropic",
115115 "openai",
@@ -407,7 +407,7 @@ func (s *splashCmp) setPreferredModel(selectedItem models.ModelOption) tea.Cmd {
407407 return nil
408408}
409409
410−func (s *splashCmp) getProvider(providerID provider.InferenceProvider) (*provider.Provider, error) {
410+func (s *splashCmp) getProvider(providerID catwalk.InferenceProvider) (*catwalk.Provider, error) {
411411 providers, err := config.Providers()
412412 if err != nil {
413413 return nil, err
internal/tui/components/completions/completions.go+26−1
@@ -36,7 +36,8 @@ type CompletionsOpenedMsg struct{}
3636type CloseCompletionsMsg struct{}
3737
3838type SelectCompletionMsg struct {
39− Value any // The value of the selected completion item
39+ Value any // The value of the selected completion item
40+ Insert bool
4041}
4142
4243type Completions interface {
@@ -115,6 +116,30 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
115116 d, cmd := c.list.Update(msg)
116117 c.list = d.(list.ListModel)
117118 return c, cmd
119+ case key.Matches(msg, c.keyMap.UpInsert):
120+ selectedItemInx := c.list.SelectedIndex() - 1
121+ items := c.list.Items()
122+ if selectedItemInx == list.NoSelection || selectedItemInx < 0 {
123+ return c, nil // No item selected, do nothing
124+ }
125+ selectedItem := items[selectedItemInx].(CompletionItem).Value()
126+ c.list.SetSelected(selectedItemInx)
127+ return c, util.CmdHandler(SelectCompletionMsg{
128+ Value: selectedItem,
129+ Insert: true,
130+ })
131+ case key.Matches(msg, c.keyMap.DownInsert):
132+ selectedItemInx := c.list.SelectedIndex() + 1
133+ items := c.list.Items()
134+ if selectedItemInx == list.NoSelection || selectedItemInx >= len(items) {
135+ return c, nil // No item selected, do nothing
136+ }
137+ selectedItem := items[selectedItemInx].(CompletionItem).Value()
138+ c.list.SetSelected(selectedItemInx)
139+ return c, util.CmdHandler(SelectCompletionMsg{
140+ Value: selectedItem,
141+ Insert: true,
142+ })
118143 case key.Matches(msg, c.keyMap.Select):
119144 selectedItemInx := c.list.SelectedIndex()
120145 if selectedItemInx == list.NoSelection {
internal/tui/components/completions/keys.go+10
@@ -9,6 +9,8 @@ type KeyMap struct {
99 Up,
1010 Select,
1111 Cancel key.Binding
12+ DownInsert,
13+ UpInsert key.Binding
1214}
1315
1416func DefaultKeyMap() KeyMap {
@@ -29,6 +31,14 @@ func DefaultKeyMap() KeyMap {
2931 key.WithKeys("esc"),
3032 key.WithHelp("esc", "cancel"),
3133 ),
34+ DownInsert: key.NewBinding(
35+ key.WithKeys("ctrl+n"),
36+ key.WithHelp("ctrl+n", "insert next"),
37+ ),
38+ UpInsert: key.NewBinding(
39+ key.WithKeys("ctrl+p"),
40+ key.WithHelp("ctrl+p", "insert previous"),
41+ ),
3242 }
3343}
3444
internal/tui/components/core/status/status.go+4−9
@@ -1,14 +1,12 @@
11package status
22
33import (
4− "strings"
54 "time"
65
76 "github.com/charmbracelet/bubbles/v2/help"
87 tea "github.com/charmbracelet/bubbletea/v2"
98 "github.com/charmbracelet/crush/internal/tui/styles"
109 "github.com/charmbracelet/crush/internal/tui/util"
11− "github.com/charmbracelet/lipgloss/v2"
1210 "github.com/charmbracelet/x/ansi"
1311)
1412
@@ -74,18 +72,15 @@ func (m *statusCmp) infoMsg() string {
7472 switch m.info.Type {
7573 case util.InfoTypeError:
7674 infoType = t.S().Base.Background(t.Red).Padding(0, 1).Render("ERROR")
77− width := m.width - lipgloss.Width(infoType)
78− message = t.S().Base.Background(t.Error).Foreground(t.White).Padding(0, 1).Width(width).Render(ansi.Truncate(m.info.Msg, width, "…"))
75+ message = t.S().Base.Background(t.Error).Width(m.width).Foreground(t.White).Padding(0, 1).Render(m.info.Msg)
7976 case util.InfoTypeWarn:
8077 infoType = t.S().Base.Foreground(t.BgOverlay).Background(t.Yellow).Padding(0, 1).Render("WARNING")
81− width := m.width - lipgloss.Width(infoType)
82− message = t.S().Base.Foreground(t.BgOverlay).Background(t.Warning).Padding(0, 1).Width(width).Render(ansi.Truncate(m.info.Msg, width, "…"))
78+ message = t.S().Base.Foreground(t.BgOverlay).Width(m.width).Background(t.Warning).Padding(0, 1).Render(m.info.Msg)
8379 default:
8480 infoType = t.S().Base.Foreground(t.BgOverlay).Background(t.Green).Padding(0, 1).Render("OKAY!")
85− width := m.width - lipgloss.Width(infoType)
86− message = t.S().Base.Background(t.Success).Foreground(t.White).Padding(0, 1).Width(width).Render(ansi.Truncate(m.info.Msg, width, "…"))
81+ message = t.S().Base.Background(t.Success).Width(m.width).Foreground(t.White).Padding(0, 1).Render(m.info.Msg)
8782 }
88− return strings.Join([]string{infoType, message}, "")
83+ return ansi.Truncate(infoType+message, m.width, "…")
8984}
9085
9186func (m *statusCmp) ToggleFullHelp() {
internal/tui/components/dialogs/commands/commands.go+2−2
@@ -4,10 +4,10 @@ import (
44 "github.com/charmbracelet/bubbles/v2/help"
55 "github.com/charmbracelet/bubbles/v2/key"
66 tea "github.com/charmbracelet/bubbletea/v2"
7+ "github.com/charmbracelet/catwalk/pkg/catwalk"
78 "github.com/charmbracelet/lipgloss/v2"
89
910 "github.com/charmbracelet/crush/internal/config"
10− "github.com/charmbracelet/crush/internal/fur/provider"
1111 "github.com/charmbracelet/crush/internal/llm/prompt"
1212 "github.com/charmbracelet/crush/internal/tui/components/chat"
1313 "github.com/charmbracelet/crush/internal/tui/components/completions"
@@ -270,7 +270,7 @@ func (c *commandDialogCmp) defaultCommands() []Command {
270270 providerCfg := cfg.GetProviderForModel(agentCfg.Model)
271271 model := cfg.GetModelByType(agentCfg.Model)
272272 if providerCfg != nil && model != nil &&
273− providerCfg.Type == provider.TypeAnthropic && model.CanReason {
273+ providerCfg.Type == catwalk.TypeAnthropic && model.CanReason {
274274 selectedModel := cfg.Models[agentCfg.Model]
275275 status := "Enable"
276276 if selectedModel.Think {
internal/tui/components/dialogs/models/list.go+11−11
@@ -5,8 +5,8 @@ import (
55 "slices"
66
77 tea "github.com/charmbracelet/bubbletea/v2"
8+ "github.com/charmbracelet/catwalk/pkg/catwalk"
89 "github.com/charmbracelet/crush/internal/config"
9− "github.com/charmbracelet/crush/internal/fur/provider"
1010 "github.com/charmbracelet/crush/internal/tui/components/completions"
1111 "github.com/charmbracelet/crush/internal/tui/components/core/list"
1212 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
@@ -18,7 +18,7 @@ import (
1818type ModelListComponent struct {
1919 list list.ListModel
2020 modelType int
21− providers []provider.Provider
21+ providers []catwalk.Provider
2222}
2323
2424func NewModelListComponent(keyMap list.KeyMap, inputStyle lipgloss.Style, inputPlaceholder string) *ModelListComponent {
@@ -109,19 +109,19 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
109109 }
110110
111111 // Check if this provider is not in the known providers list
112− if !slices.ContainsFunc(knownProviders, func(p provider.Provider) bool { return p.ID == provider.InferenceProvider(providerID) }) {
112+ if !slices.ContainsFunc(knownProviders, func(p catwalk.Provider) bool { return p.ID == catwalk.InferenceProvider(providerID) }) {
113113 // Convert config provider to provider.Provider format
114− configProvider := provider.Provider{
114+ configProvider := catwalk.Provider{
115115 Name: providerConfig.Name,
116− ID: provider.InferenceProvider(providerID),
117− Models: make([]provider.Model, len(providerConfig.Models)),
116+ ID: catwalk.InferenceProvider(providerID),
117+ Models: make([]catwalk.Model, len(providerConfig.Models)),
118118 }
119119
120120 // Convert models
121121 for i, model := range providerConfig.Models {
122− configProvider.Models[i] = provider.Model{
122+ configProvider.Models[i] = catwalk.Model{
123123 ID: model.ID,
124− Model: model.Model,
124+ Name: model.Name,
125125 CostPer1MIn: model.CostPer1MIn,
126126 CostPer1MOut: model.CostPer1MOut,
127127 CostPer1MInCached: model.CostPer1MInCached,
@@ -144,7 +144,7 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
144144 section.SetInfo(configured)
145145 modelItems = append(modelItems, section)
146146 for _, model := range configProvider.Models {
147− modelItems = append(modelItems, completions.NewCompletionItem(model.Model, ModelOption{
147+ modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{
148148 Provider: configProvider,
149149 Model: model,
150150 }))
@@ -179,7 +179,7 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
179179 }
180180 modelItems = append(modelItems, section)
181181 for _, model := range provider.Models {
182− modelItems = append(modelItems, completions.NewCompletionItem(model.Model, ModelOption{
182+ modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{
183183 Provider: provider,
184184 Model: model,
185185 }))
@@ -201,6 +201,6 @@ func (m *ModelListComponent) SetInputPlaceholder(placeholder string) {
201201 m.list.SetFilterPlaceholder(placeholder)
202202}
203203
204−func (m *ModelListComponent) SetProviders(providers []provider.Provider) {
204+func (m *ModelListComponent) SetProviders(providers []catwalk.Provider) {
205205 m.providers = providers
206206}
internal/tui/components/dialogs/models/models.go+4−4
@@ -8,8 +8,8 @@ import (
88 "github.com/charmbracelet/bubbles/v2/key"
99 "github.com/charmbracelet/bubbles/v2/spinner"
1010 tea "github.com/charmbracelet/bubbletea/v2"
11+ "github.com/charmbracelet/catwalk/pkg/catwalk"
1112 "github.com/charmbracelet/crush/internal/config"
12− "github.com/charmbracelet/crush/internal/fur/provider"
1313 "github.com/charmbracelet/crush/internal/tui/components/completions"
1414 "github.com/charmbracelet/crush/internal/tui/components/core"
1515 "github.com/charmbracelet/crush/internal/tui/components/core/list"
@@ -48,8 +48,8 @@ type ModelDialog interface {
4848}
4949
5050type ModelOption struct {
51− Provider provider.Provider
52− Model provider.Model
51+ Provider catwalk.Provider
52+ Model catwalk.Model
5353}
5454
5555type modelDialogCmp struct {
@@ -363,7 +363,7 @@ func (m *modelDialogCmp) isProviderConfigured(providerID string) bool {
363363 return false
364364}
365365
366−func (m *modelDialogCmp) getProvider(providerID provider.InferenceProvider) (*provider.Provider, error) {
366+func (m *modelDialogCmp) getProvider(providerID catwalk.InferenceProvider) (*catwalk.Provider, error) {
367367 providers, err := config.Providers()
368368 if err != nil {
369369 return nil, err
internal/tui/exp/diffview/chroma.go+7−2
@@ -4,8 +4,10 @@ import (
44 "fmt"
55 "image/color"
66 "io"
7+ "strings"
78
89 "github.com/alecthomas/chroma/v2"
10+ "github.com/charmbracelet/crush/internal/ansiext"
911 "github.com/charmbracelet/lipgloss/v2"
1012)
1113
@@ -20,9 +22,12 @@ type chromaFormatter struct {
2022// Format implements the chroma.Formatter interface.
2123func (c chromaFormatter) Format(w io.Writer, style *chroma.Style, it chroma.Iterator) error {
2224 for token := it(); token != chroma.EOF; token = it() {
25+ value := strings.TrimRight(token.Value, "\n")
26+ value = ansiext.Escape(value)
27+
2328 entry := style.Get(token.Type)
2429 if entry.IsZero() {
25− if _, err := fmt.Fprint(w, token.Value); err != nil {
30+ if _, err := fmt.Fprint(w, value); err != nil {
2631 return err
2732 }
2833 continue
@@ -44,7 +49,7 @@ func (c chromaFormatter) Format(w io.Writer, style *chroma.Style, it chroma.Iter
4449 s = s.Foreground(lipgloss.Color(entry.Colour.String()))
4550 }
4651
47− if _, err := fmt.Fprint(w, s.Render(token.Value)); err != nil {
52+ if _, err := fmt.Fprint(w, s.Render(value)); err != nil {
4853 return err
4954 }
5055 }
internal/tui/exp/diffview/diffview.go+9−4
@@ -193,6 +193,7 @@ func (dv *DiffView) clearSyntaxCache() {
193193
194194// String returns the string representation of the DiffView.
195195func (dv *DiffView) String() string {
196+ dv.normalizeLineEndings()
196197 dv.replaceTabs()
197198 if err := dv.computeDiff(); err != nil {
198199 return err.Error()
@@ -227,6 +228,12 @@ func (dv *DiffView) String() string {
227228 }
228229}
229230
231+// normalizeLineEndings ensures the file contents use Unix-style line endings.
232+func (dv *DiffView) normalizeLineEndings() {
233+ dv.before.content = strings.ReplaceAll(dv.before.content, "\r\n", "\n")
234+ dv.after.content = strings.ReplaceAll(dv.after.content, "\r\n", "\n")
235+}
236+
230237// replaceTabs replaces tabs in the before and after file contents with spaces
231238// according to the specified tab width.
232239func (dv *DiffView) replaceTabs() {
@@ -396,8 +403,7 @@ func (dv *DiffView) renderUnified() string {
396403 shouldWrite := func() bool { return printedLines >= 0 }
397404
398405 getContent := func(in string, ls LineStyle) (content string, leadingEllipsis bool) {
399− content = strings.ReplaceAll(in, "\r\n", "\n")
400− content = strings.TrimSuffix(content, "\n")
406+ content = strings.TrimSuffix(in, "\n")
401407 content = dv.hightlightCode(content, ls.Code.GetBackground())
402408 content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
403409 content = ansi.Truncate(content, dv.codeWidth, "…")
@@ -520,8 +526,7 @@ func (dv *DiffView) renderSplit() string {
520526 shouldWrite := func() bool { return printedLines >= 0 }
521527
522528 getContent := func(in string, ls LineStyle) (content string, leadingEllipsis bool) {
523− content = strings.ReplaceAll(in, "\r\n", "\n")
524− content = strings.TrimSuffix(content, "\n")
529+ content = strings.TrimSuffix(in, "\n")
525530 content = dv.hightlightCode(content, ls.Code.GetBackground())
526531 content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
527532 content = ansi.Truncate(content, dv.codeWidth, "…")
internal/tui/exp/diffview/diffview_test.go+26
@@ -36,6 +36,12 @@ var TestTabsBefore string
3636//go:embed testdata/TestTabs.after
3737var TestTabsAfter string
3838
39+//go:embed testdata/TestLineBreakIssue.before
40+var TestLineBreakIssueBefore string
41+
42+//go:embed testdata/TestLineBreakIssue.after
43+var TestLineBreakIssueAfter string
44+
3945type (
4046 TestFunc func(dv *diffview.DiffView) *diffview.DiffView
4147 TestFuncs map[string]TestFunc
@@ -177,6 +183,26 @@ func TestDiffViewTabs(t *testing.T) {
177183 }
178184}
179185
186+func TestDiffViewLineBreakIssue(t *testing.T) {
187+ t.Parallel()
188+
189+ for layoutName, layoutFunc := range LayoutFuncs {
190+ t.Run(layoutName, func(t *testing.T) {
191+ t.Parallel()
192+
193+ dv := diffview.New().
194+ Before("index.js", TestLineBreakIssueBefore).
195+ After("index.js", TestLineBreakIssueAfter).
196+ Style(diffview.DefaultLightStyle()).
197+ ChromaStyle(styles.Get("catppuccin-latte"))
198+ dv = layoutFunc(dv)
199+
200+ output := dv.String()
201+ golden.RequireEqual(t, []byte(output))
202+ })
203+ }
204+}
205+
180206func TestDiffViewWidth(t *testing.T) {
181207 for layoutName, layoutFunc := range LayoutFuncs {
182208 t.Run(layoutName, func(t *testing.T) {
internal/tui/exp/diffview/testdata/TestDiffViewLineBreakIssue/Split.goldennew file+9
@@ -0,0 +1,9 @@
1+  …  @@ -1,6 +1,8 @@    …   
2+  1 - // this is   1 + /** 
3+       2 +  * this is 
4+  2 - // a regular   3 +  * a block 
5+  3 - // comment   4 +  * comment 
6+       5 +  */ 
7+  4  $(function() {   6  $(function() { 
8+  5   console.log("Hello, world!");   7   console.log("Hello, world!"); 
9+  6  });   8  }); 
internal/tui/exp/diffview/testdata/TestDiffViewLineBreakIssue/Unified.goldennew file+12
@@ -0,0 +1,12 @@
1+  …   …  @@ -1,6 +1,8 @@  
2+  1    - // this is 
3+     1 + /** 
4+     2 +  * this is 
5+  2    - // a regular 
6+     3 +  * a block 
7+  3    - // comment 
8+     4 +  * comment 
9+     5 +  */ 
10+  4   6  $(function() { 
11+  5   7   console.log("Hello, world!"); 
12+  6   8  }); 
internal/tui/exp/diffview/testdata/TestLineBreakIssue.afternew file+8
@@ -0,0 +1,8 @@
1+/**
2+ * this is
3+ * a block
4+ * comment
5+ */
6+$(function() {
7+ console.log("Hello, world!");
8+});
internal/tui/exp/diffview/testdata/TestLineBreakIssue.beforenew file+6
@@ -0,0 +1,6 @@
1+// this is
2+// a regular
3+// comment
4+$(function() {
5+ console.log("Hello, world!");
6+});
internal/tui/page/chat/chat.go+1−1
@@ -279,7 +279,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
279279 if model.SupportsImages {
280280 return p, util.CmdHandler(OpenFilePickerMsg{})
281281 } else {
282− return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Model)
282+ return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
283283 }
284284 case key.Matches(msg, p.keyMap.Tab):
285285 if p.session.ID == "" {
internal/tui/tui.go+41−23
@@ -3,6 +3,7 @@ package tui
33import (
44 "context"
55 "fmt"
6+ "strings"
67
78 "github.com/charmbracelet/bubbles/v2/key"
89 tea "github.com/charmbracelet/bubbletea/v2"
@@ -112,6 +113,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
112113 }
113114 return a, tea.Batch(cmds...)
114115 case tea.WindowSizeMsg:
116+ a.wWidth, a.wHeight = msg.Width, msg.Height
115117 a.completions.Update(msg)
116118 return a, a.handleWindowResize(msg.Width, msg.Height)
117119
@@ -290,7 +292,6 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
290292// handleWindowResize processes window resize events and updates all components.
291293func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
292294 var cmds []tea.Cmd
293− a.wWidth, a.wHeight = width, height
294295 if a.showingFullHelp {
295296 height -= 5
296297 } else {
@@ -319,26 +320,20 @@ func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
319320
320321// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
321322func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
323+ if a.completions.Open() {
324+ // completions
325+ keyMap := a.completions.KeyMap()
326+ switch {
327+ case key.Matches(msg, keyMap.Up), key.Matches(msg, keyMap.Down),
328+ key.Matches(msg, keyMap.Select), key.Matches(msg, keyMap.Cancel),
329+ key.Matches(msg, keyMap.UpInsert), key.Matches(msg, keyMap.DownInsert):
330+ u, cmd := a.completions.Update(msg)
331+ a.completions = u.(completions.Completions)
332+ return cmd
333+ }
334+ }
322335 switch {
323− // completions
324− case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Up):
325− u, cmd := a.completions.Update(msg)
326− a.completions = u.(completions.Completions)
327− return cmd
328−
329− case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Down):
330− u, cmd := a.completions.Update(msg)
331− a.completions = u.(completions.Completions)
332− return cmd
333− case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Select):
334− u, cmd := a.completions.Update(msg)
335− a.completions = u.(completions.Completions)
336− return cmd
337− case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Cancel):
338− u, cmd := a.completions.Update(msg)
339− a.completions = u.(completions.Completions)
340− return cmd
341− // help
336+ // help
342337 case key.Matches(msg, a.keyMap.Help):
343338 a.status.ToggleFullHelp()
344339 a.showingFullHelp = !a.showingFullHelp
@@ -429,6 +424,27 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
429424
430425// View renders the complete application interface including pages, dialogs, and overlays.
431426func (a *appModel) View() tea.View {
427+ var view tea.View
428+ t := styles.CurrentTheme()
429+ view.BackgroundColor = t.BgBase
430+ if a.wWidth < 25 || a.wHeight < 15 {
431+ view.Layer = lipgloss.NewCanvas(
432+ lipgloss.NewLayer(
433+ t.S().Base.Width(a.wWidth).Height(a.wHeight).
434+ Align(lipgloss.Center, lipgloss.Center).
435+ Render(
436+ t.S().Base.
437+ Padding(1, 4).
438+ Foreground(t.White).
439+ BorderStyle(lipgloss.RoundedBorder()).
440+ BorderForeground(t.Primary).
441+ Render("Window too small!"),
442+ ),
443+ ),
444+ )
445+ return view
446+ }
447+
432448 page := a.pages[a.currentPage]
433449 if withHelp, ok := page.(core.KeyMapHelp); ok {
434450 a.status.SetKeyMap(withHelp.Help())
@@ -453,6 +469,11 @@ func (a *appModel) View() tea.View {
453469 var cursor *tea.Cursor
454470 if v, ok := page.(util.Cursor); ok {
455471 cursor = v.Cursor()
472+ // Hide the cursor if it's positioned outside the textarea
473+ statusHeight := a.height - strings.Count(pageView, "\n") + 1
474+ if cursor != nil && cursor.Y+statusHeight+chat.EditorHeight-2 <= a.height { // 2 for the top and bottom app padding
475+ cursor = nil
476+ }
456477 }
457478 activeView := a.dialog.ActiveModel()
458479 if activeView != nil {
@@ -475,10 +496,7 @@ func (a *appModel) View() tea.View {
475496 layers...,
476497 )
477498
478− var view tea.View
479− t := styles.CurrentTheme()
480499 view.Layer = canvas
481− view.BackgroundColor = t.BgBase
482500 view.Cursor = cursor
483501 return view
484502}
main.go+1−21
@@ -1,12 +1,9 @@
11package main
22
33import (
4− "fmt"
54 "log/slog"
65 "net/http"
76 "os"
8− "runtime"
9− "strings"
107
118 _ "net/http/pprof" // profiling
129
@@ -14,14 +11,9 @@ import (
1411
1512 "github.com/charmbracelet/crush/internal/cmd"
1613 "github.com/charmbracelet/crush/internal/log"
17− "github.com/charmbracelet/lipgloss/v2"
1814)
1915
2016func main() {
21− if runtime.GOOS == "windows" {
22− showWindowsWarning()
23− }
24−
2517 defer log.RecoverPanic("main", func() {
2618 slog.Error("Application terminated due to unhandled panic")
2719 })
@@ -30,22 +22,10 @@ func main() {
3022 go func() {
3123 slog.Info("Serving pprof at localhost:6060")
3224 if httpErr := http.ListenAndServe("localhost:6060", nil); httpErr != nil {
33− slog.Error(fmt.Sprintf("Failed to pprof listen: %v", httpErr))
25+ slog.Error("Failed to pprof listen", "error", httpErr)
3426 }
3527 }()
3628 }
3729
3830 cmd.Execute()
3931}
40−
41−func showWindowsWarning() {
42− content := strings.Join([]string{
43− lipgloss.NewStyle().Bold(true).Render("WARNING:") + " Crush is experimental on Windows!",
44− "While we work on it, we recommend WSL2 for a better experience.",
45− lipgloss.NewStyle().Italic(true).Render("Press Enter to continue..."),
46− }, "\n")
47− fmt.Print(content)
48−
49− var input string
50− fmt.Scanln(&input)
51−}