on main help sign in
Merge branch 'main' into tools-bg
50f07ebCarlos Alexandro Becker
.github/workflows/build.yml+1−1
@@ -2,7 +2,7 @@ name: build
2on: [push, pull_request]
2on: [push, pull_request]
3
3
4jobs:
4jobs:
5− build-go:
5+ build:
6 uses: charmbracelet/meta/.github/workflows/build.yml@main
6 uses: charmbracelet/meta/.github/workflows/build.yml@main
7 with:
7 with:
8 go-version: ""
8 go-version: ""
.github/workflows/release.yml+1
@@ -20,3 +20,4 @@ jobs:
20 fury_token: ${{ secrets.FURY_TOKEN }}
20 fury_token: ${{ secrets.FURY_TOKEN }}
21 nfpm_gpg_key: ${{ secrets.NFPM_GPG_KEY }}
21 nfpm_gpg_key: ${{ secrets.NFPM_GPG_KEY }}
22 nfpm_passphrase: ${{ secrets.NFPM_PASSPHRASE }}
22 nfpm_passphrase: ${{ secrets.NFPM_PASSPHRASE }}
23+ npm_token: ${{ secrets.NPM_TOKEN }}
.goreleaser.yml+8−1
@@ -45,7 +45,7 @@ builds:
45 goos:
45 goos:
46 - linux
46 - linux
47 - darwin
47 - darwin
48− # - windows
48+ - windows
49 goarch:
49 goarch:
50 - amd64
50 - amd64
51 - arm64
51 - arm64
@@ -111,8 +111,15 @@ homebrew_casks:
111 owner: charmbracelet
111 owner: charmbracelet
112 name: homebrew-tap
112 name: homebrew-tap
113
113
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+
114nfpms:
120nfpms:
115 - formats:
121 - formats:
122+ - apk
116 - deb
123 - deb
117 - rpm
124 - rpm
118 - archlinux
125 - archlinux
README.md+35−10
@@ -12,10 +12,12 @@ Crush is a tool for building software with AI.
12
12
13## Installation
13## Installation
14
14
15+Crush has first class support for macOS, Linux, and Windows.
16+
15Nightly builds are available while Crush is in development.
17Nightly builds are available while Crush is in development.
16
18
17−- [Packages](https://github.com/charmbracelet/crush/releases/tag/nightly) are available in Debian and RPM formats
19+- [Packages](https://github.com/charmbracelet/crush/releases/tag/nightly) are available in Debian, RPM, APK, and PKG formats
18−- [Binaries](https://github.com/charmbracelet/crush/releases/tag/nightly) are available for Linux and macOS
20+- [Binaries](https://github.com/charmbracelet/crush/releases/tag/nightly) are available for Linux, macOS and Windows
19
21
20You can also just install it with go:
22You can also just install it with go:
21
23
@@ -28,7 +30,7 @@ go install
28<details>
30<details>
29<summary>Not a developer? Here’s a quick how-to.</summary>
31<summary>Not a developer? Here’s a quick how-to.</summary>
30
32
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.
32
34
33Next, open a terminal and run the following commands:
35Next, open a terminal and run the following commands:
34
36
@@ -36,17 +38,15 @@ Next, open a terminal and run the following commands:
36cd ~/Downloads
38cd ~/Downloads
37tar -xvzf crush_0.1.0-nightly_Darwin_arm64.tar.gz -C crush
39tar -xvzf crush_0.1.0-nightly_Darwin_arm64.tar.gz -C crush
38sudo mv ./crush/crush /usr/local/bin/crush
40sudo mv ./crush/crush /usr/local/bin/crush
39−rm -rf crush
41+rm -rf ./crush
40```
42```
41
43
42Then, run Crush by typing `crush`.
44Then, run Crush by typing `crush`.
43
45
44−***
46+---
45
47
46</details>
48</details>
47
49
48−Note that Crush doesn't support Windows yet, however Windows support is planned and in progress.
49−
50## Getting Started
50## Getting Started
51
51
52The quickest way to get started to grab an API key for your preferred
52The 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
108 "mcp": {
108 "mcp": {
109 "filesystem": {
109 "filesystem": {
110 "type": "stdio",
110 "type": "stdio",
111− "command": "node",
111+ "command": "node",
112 "args": ["/path/to/mcp-server.js"],
112 "args": ["/path/to/mcp-server.js"],
113 "env": {
113 "env": {
114 "NODE_ENV": "production"
114 "NODE_ENV": "production"
@@ -143,7 +143,7 @@ crush -d
143# View last 1000 lines
143# View last 1000 lines
144crush logs
144crush logs
145
145
146−# Follow logs in real-time
146+# Follow logs in real-time
147crush logs -f
147crush logs -f
148
148
149# Show last 500 lines
149# Show last 500 lines
@@ -161,6 +161,31 @@ Add to your `crush.json` config file:
161}
161}
162```
162```
163
163
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+
164### OpenAI-Compatible APIs
189### OpenAI-Compatible APIs
165
190
166Crush 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.
191Crush 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
174 "models": [
199 "models": [
175 {
200 {
176 "id": "deepseek-chat",
201 "id": "deepseek-chat",
177− "model": "Deepseek V3",
202+ "name": "Deepseek V3",
178 "cost_per_1m_in": 0.27,
203 "cost_per_1m_in": 0.27,
179 "cost_per_1m_out": 1.1,
204 "cost_per_1m_out": 1.1,
180 "cost_per_1m_in_cached": 0.07,
205 "cost_per_1m_in_cached": 0.07,
go.mod+4−2
@@ -13,6 +13,7 @@ require (
13 github.com/charlievieth/fastwalk v1.0.11
13 github.com/charlievieth/fastwalk v1.0.11
14 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5
14 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5
15 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac
15 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac
16+ github.com/charmbracelet/catwalk v0.3.1
16 github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674
17 github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674
17 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
18 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
18 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250716211347-10c048e36112
19 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250716211347-10c048e36112
@@ -39,6 +40,7 @@ require (
39 github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
40 github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
40 github.com/stretchr/testify v1.10.0
41 github.com/stretchr/testify v1.10.0
41 github.com/tidwall/sjson v1.2.5
42 github.com/tidwall/sjson v1.2.5
43+ github.com/u-root/u-root v0.14.1-0.20250722142936-bf4e78a90dfc
42 github.com/zeebo/xxh3 v1.0.2
44 github.com/zeebo/xxh3 v1.0.2
43 golang.org/x/exp v0.0.0-20250305212735-054e65f0b394
45 golang.org/x/exp v0.0.0-20250305212735-054e65f0b394
44 gopkg.in/natefinch/lumberjack.v2 v2.2.1
46 gopkg.in/natefinch/lumberjack.v2 v2.2.1
@@ -70,7 +72,7 @@ require (
70 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
72 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
71 github.com/aymerick/douceur v0.2.0 // indirect
73 github.com/aymerick/douceur v0.2.0 // indirect
72 github.com/charmbracelet/colorprofile v0.3.1 // indirect
74 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
74 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect
76 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect
75 github.com/charmbracelet/x/exp/slice v0.0.0-20250611152503-f53cdd7e01ef
77 github.com/charmbracelet/x/exp/slice v0.0.0-20250611152503-f53cdd7e01ef
76 github.com/charmbracelet/x/term v0.2.1
78 github.com/charmbracelet/x/term v0.2.1
@@ -108,7 +110,7 @@ require (
108 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
110 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
109 github.com/sethvargo/go-retry v0.3.0 // indirect
111 github.com/sethvargo/go-retry v0.3.0 // indirect
110 github.com/spf13/cast v1.7.1 // indirect
112 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
112 github.com/tetratelabs/wazero v1.9.0 // indirect
114 github.com/tetratelabs/wazero v1.9.0 // indirect
113 github.com/tidwall/gjson v1.18.0 // indirect
115 github.com/tidwall/gjson v1.18.0 // indirect
114 github.com/tidwall/match v1.1.1 // indirect
116 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
72github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
72github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
73github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac h1:murtkvFYxZ/73vk4Z/tpE4biB+WDZcFmmBp8je/yV6M=
73github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac h1:murtkvFYxZ/73vk4Z/tpE4biB+WDZcFmmBp8je/yV6M=
74github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac/go.mod h1:m240IQxo1/eDQ7klblSzOCAUyc3LddHcV3Rc/YEGAgw=
74github.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=
75github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
77github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
76github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
78github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
77github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674 h1:+Cz+VfxD5DO+JT1LlswXWhre0HYLj6l2HW8HVGfMuC0=
79github.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
82github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250716211347-10c048e36112/go.mod h1:BXY7j7rZgAprFwzNcO698++5KTd6GKI6lU83Pr4o0r0=
84github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250716211347-10c048e36112/go.mod h1:BXY7j7rZgAprFwzNcO698++5KTd6GKI6lU83Pr4o0r0=
83github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706 h1:WkwO6Ks3mSIGnGuSdKl9qDSyfbYK50z2wc2gGMggegE=
85github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706 h1:WkwO6Ks3mSIGnGuSdKl9qDSyfbYK50z2wc2gGMggegE=
84github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706/go.mod h1:mjJGp00cxcfvD5xdCa+bso251Jt4owrQvuimJtVmEmM=
86github.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=
87+github.com/charmbracelet/ultraviolet v0.0.0-20250723145313-809e6f5b43a1 h1:tsw1mOuIEIKlmm614bXctvJ3aavaFhyPG+y+wrKtuKQ=
86−github.com/charmbracelet/ultraviolet v0.0.0-20250721205647-f6ac6eda5d42/go.mod h1:XrrgNFfXLrFAyd9DUmrqVc3yQFVv8Uk+okj4PsNNzpc=
88+github.com/charmbracelet/ultraviolet v0.0.0-20250723145313-809e6f5b43a1/go.mod h1:XrrgNFfXLrFAyd9DUmrqVc3yQFVv8Uk+okj4PsNNzpc=
87github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
89github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
88github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
90github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
89github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz0Z3rsiOtMYiz8axkT24i9yFiueDhJbzyNUADmME=
91github.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=
234github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
236github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
235github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
237github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
236github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
238github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
237−github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
238github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
239github.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=
239github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
242github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
240github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
243github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
241github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
244github.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=
258github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
261github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
259github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
262github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
260github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
263github.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=
261github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
266github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
262github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
267github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
263github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
268github.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) {
59 sessions := session.NewService(q)
59 sessions := session.NewService(q)
60 messages := message.NewService(q)
60 messages := message.NewService(q)
61 files := history.NewService(q, conn)
61 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+ }
63
67
64 app := &App{
68 app := &App{
65 Sessions: sessions,
69 Sessions: sessions,
66 Messages: messages,
70 Messages: messages,
67 History: files,
71 History: files,
68− Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests),
72+ Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools),
69 LSPClients: make(map[string]*lsp.Client),
73 LSPClients: make(map[string]*lsp.Client),
70
74
71 globalCtx: ctx,
75 globalCtx: ctx,
@@ -157,16 +161,20 @@ func (app *App) RunNonInteractive(ctx context.Context, prompt string, quiet bool
157
161
158 if result.Error != nil {
162 if result.Error != nil {
159 if errors.Is(result.Error, context.Canceled) || errors.Is(result.Error, agent.ErrRequestCancelled) {
163 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)
161 return nil
165 return nil
162 }
166 }
163 return fmt.Errorf("agent processing failed: %w", result.Error)
167 return fmt.Errorf("agent processing failed: %w", result.Error)
164 }
168 }
165
169
166− part := result.Message.Content().String()[readBts:]
170+ msgContent := result.Message.Content().String()
167− fmt.Println(part)
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:])
168
176
169− slog.Info("Non-interactive run completed", "session_id", sess.ID)
177+ slog.Info("Non-interactive: run completed", "session_id", sess.ID)
170 return nil
178 return nil
171
179
172 case event := <-messageEvents:
180 case event := <-messageEvents:
internal/cmd/root.go+7−4
@@ -73,7 +73,10 @@ to assist developers in writing, debugging, and understanding code directly from
73 if err != nil {
73 if err != nil {
74 return err
74 return err
75 }
75 }
76− cfg.Options.SkipPermissionsRequests = yolo
76+ if cfg.Permissions == nil {
77+ cfg.Permissions = &config.Permissions{}
78+ }
79+ cfg.Permissions.SkipRequests = yolo
77
80
78 ctx := cmd.Context()
81 ctx := cmd.Context()
79
82
@@ -85,14 +88,14 @@ to assist developers in writing, debugging, and understanding code directly from
85
88
86 app, err := app.New(ctx, conn, cfg)
89 app, err := app.New(ctx, conn, cfg)
87 if err != nil {
90 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)
89 return err
92 return err
90 }
93 }
91 defer app.Shutdown()
94 defer app.Shutdown()
92
95
93 prompt, err = maybePrependStdin(prompt)
96 prompt, err = maybePrependStdin(prompt)
94 if err != nil {
97 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)
96 return err
99 return err
97 }
100 }
98
101
@@ -114,7 +117,7 @@ to assist developers in writing, debugging, and understanding code directly from
114 go app.Subscribe(program)
117 go app.Subscribe(program)
115
118
116 if _, err := program.Run(); err != nil {
119 if _, err := program.Run(); err != nil {
117− slog.Error(fmt.Sprintf("TUI run error: %v", err))
120+ slog.Error("TUI run error", "error", err)
118 return fmt.Errorf("TUI error: %v", err)
121 return fmt.Errorf("TUI error: %v", err)
119 }
122 }
120 return nil
123 return nil
internal/config/config.go+25−19
@@ -9,9 +9,9 @@ import (
9 "strings"
9 "strings"
10 "time"
10 "time"
11
11
12+ "github.com/charmbracelet/catwalk/pkg/catwalk"
12 "github.com/charmbracelet/crush/internal/csync"
13 "github.com/charmbracelet/crush/internal/csync"
13 "github.com/charmbracelet/crush/internal/env"
14 "github.com/charmbracelet/crush/internal/env"
14− "github.com/charmbracelet/crush/internal/fur/provider"
15 "github.com/tidwall/sjson"
15 "github.com/tidwall/sjson"
16 "golang.org/x/exp/slog"
16 "golang.org/x/exp/slog"
17)
17)
@@ -71,7 +71,7 @@ type ProviderConfig struct {
71 // The provider's API endpoint.
71 // The provider's API endpoint.
72 BaseURL string `json:"base_url,omitempty"`
72 BaseURL string `json:"base_url,omitempty"`
73 // The provider type, e.g. "openai", "anthropic", etc. if empty it defaults to openai.
73 // 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"`
75 // The provider's API key.
75 // The provider's API key.
76 APIKey string `json:"api_key,omitempty"`
76 APIKey string `json:"api_key,omitempty"`
77 // Marks the provider as disabled.
77 // Marks the provider as disabled.
@@ -86,7 +86,7 @@ type ProviderConfig struct {
86 ExtraParams map[string]string `json:"-"`
86 ExtraParams map[string]string `json:"-"`
87
87
88 // The provider models
88 // The provider models
89− Models []provider.Model `json:"models,omitempty"`
89+ Models []catwalk.Model `json:"models,omitempty"`
90}
90}
91
91
92type MCPType string
92type MCPType string
@@ -121,14 +121,18 @@ type TUIOptions struct {
121 // Here we can add themes later or any TUI related options
121 // Here we can add themes later or any TUI related options
122}
122}
123
123
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+
124type Options struct {
129type Options struct {
125− ContextPaths []string `json:"context_paths,omitempty"`
130+ ContextPaths []string `json:"context_paths,omitempty"`
126− TUI *TUIOptions `json:"tui,omitempty"`
131+ TUI *TUIOptions `json:"tui,omitempty"`
127− Debug bool `json:"debug,omitempty"`
132+ Debug bool `json:"debug,omitempty"`
128− DebugLSP bool `json:"debug_lsp,omitempty"`
133+ DebugLSP bool `json:"debug_lsp,omitempty"`
129− DisableAutoSummarize bool `json:"disable_auto_summarize,omitempty"`
134+ DisableAutoSummarize bool `json:"disable_auto_summarize,omitempty"`
130− DataDirectory string `json:"data_directory,omitempty"` // Relative to the cwd
135+ DataDirectory string `json:"data_directory,omitempty"` // Relative to the cwd
131− SkipPermissionsRequests bool `json:"-"` // Automatically accept all permissions (YOLO mode)
132}
136}
133
137
134type MCPs map[string]MCPConfig
138type MCPs map[string]MCPConfig
@@ -245,14 +249,16 @@ type Config struct {
245
249
246 Options *Options `json:"options,omitempty"`
250 Options *Options `json:"options,omitempty"`
247
251
252+ Permissions *Permissions `json:"permissions,omitempty"`
253+
248 // Internal
254 // Internal
249 workingDir string `json:"-"`
255 workingDir string `json:"-"`
250 // TODO: most likely remove this concept when I come back to it
256 // TODO: most likely remove this concept when I come back to it
251 Agents map[string]Agent `json:"-"`
257 Agents map[string]Agent `json:"-"`
252 // TODO: find a better way to do this this should probably not be part of the config
258 // TODO: find a better way to do this this should probably not be part of the config
253 resolver VariableResolver
259 resolver VariableResolver
254− dataConfigDir string `json:"-"`
260+ dataConfigDir string `json:"-"`
255− knownProviders []provider.Provider `json:"-"`
261+ knownProviders []catwalk.Provider `json:"-"`
256}
262}
257
263
258func (c *Config) WorkingDir() string {
264func (c *Config) WorkingDir() string {
@@ -274,7 +280,7 @@ func (c *Config) IsConfigured() bool {
274 return len(c.EnabledProviders()) > 0
280 return len(c.EnabledProviders()) > 0
275}
281}
276
282
277−func (c *Config) GetModel(provider, model string) *provider.Model {
283+func (c *Config) GetModel(provider, model string) *catwalk.Model {
278 if providerConfig, ok := c.Providers.Get(provider); ok {
284 if providerConfig, ok := c.Providers.Get(provider); ok {
279 for _, m := range providerConfig.Models {
285 for _, m := range providerConfig.Models {
280 if m.ID == model {
286 if m.ID == model {
@@ -296,7 +302,7 @@ func (c *Config) GetProviderForModel(modelType SelectedModelType) *ProviderConfi
296 return nil
302 return nil
297}
303}
298
304
299−func (c *Config) GetModelByType(modelType SelectedModelType) *provider.Model {
305+func (c *Config) GetModelByType(modelType SelectedModelType) *catwalk.Model {
300 model, ok := c.Models[modelType]
306 model, ok := c.Models[modelType]
301 if !ok {
307 if !ok {
302 return nil
308 return nil
@@ -304,7 +310,7 @@ func (c *Config) GetModelByType(modelType SelectedModelType) *provider.Model {
304 return c.GetModel(model.Provider, model.Model)
310 return c.GetModel(model.Provider, model.Model)
305}
311}
306
312
307−func (c *Config) LargeModel() *provider.Model {
313+func (c *Config) LargeModel() *catwalk.Model {
308 model, ok := c.Models[SelectedModelTypeLarge]
314 model, ok := c.Models[SelectedModelTypeLarge]
309 if !ok {
315 if !ok {
310 return nil
316 return nil
@@ -312,7 +318,7 @@ func (c *Config) LargeModel() *provider.Model {
312 return c.GetModel(model.Provider, model.Model)
318 return c.GetModel(model.Provider, model.Model)
313}
319}
314
320
315−func (c *Config) SmallModel() *provider.Model {
321+func (c *Config) SmallModel() *catwalk.Model {
316 model, ok := c.Models[SelectedModelTypeSmall]
322 model, ok := c.Models[SelectedModelTypeSmall]
317 if !ok {
323 if !ok {
318 return nil
324 return nil
@@ -378,7 +384,7 @@ func (c *Config) SetProviderAPIKey(providerID, apiKey string) error {
378 return nil
384 return nil
379 }
385 }
380
386
381− var foundProvider *provider.Provider
387+ var foundProvider *catwalk.Provider
382 for _, p := range c.knownProviders {
388 for _, p := range c.knownProviders {
383 if string(p.ID) == providerID {
389 if string(p.ID) == providerID {
384 foundProvider = &p
390 foundProvider = &p
@@ -447,14 +453,14 @@ func (c *ProviderConfig) TestConnection(resolver VariableResolver) error {
447 headers := make(map[string]string)
453 headers := make(map[string]string)
448 apiKey, _ := resolver.ResolveValue(c.APIKey)
454 apiKey, _ := resolver.ResolveValue(c.APIKey)
449 switch c.Type {
455 switch c.Type {
450− case provider.TypeOpenAI:
456+ case catwalk.TypeOpenAI:
451 baseURL, _ := resolver.ResolveValue(c.BaseURL)
457 baseURL, _ := resolver.ResolveValue(c.BaseURL)
452 if baseURL == "" {
458 if baseURL == "" {
453 baseURL = "https://api.openai.com/v1"
459 baseURL = "https://api.openai.com/v1"
454 }
460 }
455 testURL = baseURL + "/models"
461 testURL = baseURL + "/models"
456 headers["Authorization"] = "Bearer " + apiKey
462 headers["Authorization"] = "Bearer " + apiKey
457− case provider.TypeAnthropic:
463+ case catwalk.TypeAnthropic:
458 baseURL, _ := resolver.ResolveValue(c.BaseURL)
464 baseURL, _ := resolver.ResolveValue(c.BaseURL)
459 if baseURL == "" {
465 if baseURL == "" {
460 baseURL = "https://api.anthropic.com/v1"
466 baseURL = "https://api.anthropic.com/v1"
internal/config/load.go+18−16
@@ -12,12 +12,14 @@ import (
12 "strings"
12 "strings"
13 "sync"
13 "sync"
14
14
15+ "github.com/charmbracelet/catwalk/pkg/catwalk"
15 "github.com/charmbracelet/crush/internal/csync"
16 "github.com/charmbracelet/crush/internal/csync"
16 "github.com/charmbracelet/crush/internal/env"
17 "github.com/charmbracelet/crush/internal/env"
17− "github.com/charmbracelet/crush/internal/fur/provider"
18 "github.com/charmbracelet/crush/internal/log"
18 "github.com/charmbracelet/crush/internal/log"
19)
19)
20
20
21+const catwalkURL = "https://catwalk.charm.sh"
22+
21// LoadReader config via io.Reader.
23// LoadReader config via io.Reader.
22func LoadReader(fd io.Reader) (*Config, error) {
24func LoadReader(fd io.Reader) (*Config, error) {
23 data, err := io.ReadAll(fd)
25 data, err := io.ReadAll(fd)
@@ -61,7 +63,7 @@ func Load(workingDir string, debug bool) (*Config, error) {
61 cfg.Options.Debug,
63 cfg.Options.Debug,
62 )
64 )
63
65
64− // Load known providers, this loads the config from fur
66+ // Load known providers, this loads the config from catwalk
65 providers, err := Providers()
67 providers, err := Providers()
66 if err != nil || len(providers) == 0 {
68 if err != nil || len(providers) == 0 {
67 return nil, fmt.Errorf("failed to load providers: %w", err)
69 return nil, fmt.Errorf("failed to load providers: %w", err)
@@ -97,7 +99,7 @@ func (c *Config) removeUnresponsiveProviders() {
97 slog.Info("Testing provider connections")
99 slog.Info("Testing provider connections")
98 defer slog.Info("Provider connection tests completed")
100 defer slog.Info("Provider connection tests completed")
99 for _, p := range c.Providers.Seq2() {
101 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 {
101 wg.Add(1)
103 wg.Add(1)
102 go func(provider ProviderConfig) {
104 go func(provider ProviderConfig) {
103 defer wg.Done()
105 defer wg.Done()
@@ -122,7 +124,7 @@ func (c *Config) removeUnresponsiveProviders() {
122 })
124 })
123}
125}
124
126
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 {
126 knownProviderNames := make(map[string]bool)
128 knownProviderNames := make(map[string]bool)
127 for _, p := range knownProviders {
129 for _, p := range knownProviders {
128 knownProviderNames[string(p.ID)] = true
130 knownProviderNames[string(p.ID)] = true
@@ -141,7 +143,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
141 p.APIKey = config.APIKey
143 p.APIKey = config.APIKey
142 }
144 }
143 if len(config.Models) > 0 {
145 if len(config.Models) > 0 {
144− models := []provider.Model{}
146+ models := []catwalk.Model{}
145 seen := make(map[string]bool)
147 seen := make(map[string]bool)
146
148
147 for _, model := range config.Models {
149 for _, model := range config.Models {
@@ -149,8 +151,8 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
149 continue
151 continue
150 }
152 }
151 seen[model.ID] = true
153 seen[model.ID] = true
152− if model.Model == "" {
154+ if model.Name == "" {
153− model.Model = model.ID
155+ model.Name = model.ID
154 }
156 }
155 models = append(models, model)
157 models = append(models, model)
156 }
158 }
@@ -159,8 +161,8 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
159 continue
161 continue
160 }
162 }
161 seen[model.ID] = true
163 seen[model.ID] = true
162− if model.Model == "" {
164+ if model.Name == "" {
163− model.Model = model.ID
165+ model.Name = model.ID
164 }
166 }
165 models = append(models, model)
167 models = append(models, model)
166 }
168 }
@@ -183,7 +185,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
183
185
184 switch p.ID {
186 switch p.ID {
185 // Handle specific providers that require additional configuration
187 // Handle specific providers that require additional configuration
186− case provider.InferenceProviderVertexAI:
188+ case catwalk.InferenceProviderVertexAI:
187 if !hasVertexCredentials(env) {
189 if !hasVertexCredentials(env) {
188 if configExists {
190 if configExists {
189 slog.Warn("Skipping Vertex AI provider due to missing credentials")
191 slog.Warn("Skipping Vertex AI provider due to missing credentials")
@@ -193,7 +195,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
193 }
195 }
194 prepared.ExtraParams["project"] = env.Get("GOOGLE_CLOUD_PROJECT")
196 prepared.ExtraParams["project"] = env.Get("GOOGLE_CLOUD_PROJECT")
195 prepared.ExtraParams["location"] = env.Get("GOOGLE_CLOUD_LOCATION")
197 prepared.ExtraParams["location"] = env.Get("GOOGLE_CLOUD_LOCATION")
196− case provider.InferenceProviderAzure:
198+ case catwalk.InferenceProviderAzure:
197 endpoint, err := resolver.ResolveValue(p.APIEndpoint)
199 endpoint, err := resolver.ResolveValue(p.APIEndpoint)
198 if err != nil || endpoint == "" {
200 if err != nil || endpoint == "" {
199 if configExists {
201 if configExists {
@@ -204,7 +206,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
204 }
206 }
205 prepared.BaseURL = endpoint
207 prepared.BaseURL = endpoint
206 prepared.ExtraParams["apiVersion"] = env.Get("AZURE_OPENAI_API_VERSION")
208 prepared.ExtraParams["apiVersion"] = env.Get("AZURE_OPENAI_API_VERSION")
207− case provider.InferenceProviderBedrock:
209+ case catwalk.InferenceProviderBedrock:
208 if !hasAWSCredentials(env) {
210 if !hasAWSCredentials(env) {
209 if configExists {
211 if configExists {
210 slog.Warn("Skipping Bedrock provider due to missing AWS credentials")
212 slog.Warn("Skipping Bedrock provider due to missing AWS credentials")
@@ -244,7 +246,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
244 }
246 }
245 // default to OpenAI if not set
247 // default to OpenAI if not set
246 if providerConfig.Type == "" {
248 if providerConfig.Type == "" {
247− providerConfig.Type = provider.TypeOpenAI
249+ providerConfig.Type = catwalk.TypeOpenAI
248 }
250 }
249
251
250 if providerConfig.Disable {
252 if providerConfig.Disable {
@@ -265,7 +267,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
265 c.Providers.Del(id)
267 c.Providers.Del(id)
266 continue
268 continue
267 }
269 }
268− if providerConfig.Type != provider.TypeOpenAI {
270+ if providerConfig.Type != catwalk.TypeOpenAI {
269 slog.Warn("Skipping custom provider because the provider type is not supported", "provider", id, "type", providerConfig.Type)
271 slog.Warn("Skipping custom provider because the provider type is not supported", "provider", id, "type", providerConfig.Type)
270 c.Providers.Del(id)
272 c.Providers.Del(id)
271 continue
273 continue
@@ -320,7 +322,7 @@ func (c *Config) setDefaults(workingDir string) {
320 c.Options.ContextPaths = slices.Compact(c.Options.ContextPaths)
322 c.Options.ContextPaths = slices.Compact(c.Options.ContextPaths)
321}
323}
322
324
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) {
324 if len(knownProviders) == 0 && c.Providers.Len() == 0 {
326 if len(knownProviders) == 0 && c.Providers.Len() == 0 {
325 err = fmt.Errorf("no providers configured, please configure at least one provider")
327 err = fmt.Errorf("no providers configured, please configure at least one provider")
326 return
328 return
@@ -389,7 +391,7 @@ func (c *Config) defaultModelSelection(knownProviders []provider.Provider) (larg
389 return
391 return
390}
392}
391
393
392−func (c *Config) configureSelectedModels(knownProviders []provider.Provider) error {
394+func (c *Config) configureSelectedModels(knownProviders []catwalk.Provider) error {
393 defaultLarge, defaultSmall, err := c.defaultModelSelection(knownProviders)
395 defaultLarge, defaultSmall, err := c.defaultModelSelection(knownProviders)
394 if err != nil {
396 if err != nil {
395 return fmt.Errorf("failed to select default models: %w", err)
397 return fmt.Errorf("failed to select default models: %w", err)
internal/config/load_test.go+80−80
@@ -8,9 +8,9 @@ import (
8 "strings"
8 "strings"
9 "testing"
9 "testing"
10
10
11+ "github.com/charmbracelet/catwalk/pkg/catwalk"
11 "github.com/charmbracelet/crush/internal/csync"
12 "github.com/charmbracelet/crush/internal/csync"
12 "github.com/charmbracelet/crush/internal/env"
13 "github.com/charmbracelet/crush/internal/env"
13− "github.com/charmbracelet/crush/internal/fur/provider"
14 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/assert"
15)
15)
16
16
@@ -56,12 +56,12 @@ func TestConfig_setDefaults(t *testing.T) {
56}
56}
57
57
58func TestConfig_configureProviders(t *testing.T) {
58func TestConfig_configureProviders(t *testing.T) {
59− knownProviders := []provider.Provider{
59+ knownProviders := []catwalk.Provider{
60 {
60 {
61 ID: "openai",
61 ID: "openai",
62 APIKey: "$OPENAI_API_KEY",
62 APIKey: "$OPENAI_API_KEY",
63 APIEndpoint: "https://api.openai.com/v1",
63 APIEndpoint: "https://api.openai.com/v1",
64− Models: []provider.Model{{
64+ Models: []catwalk.Model{{
65 ID: "test-model",
65 ID: "test-model",
66 }},
66 }},
67 },
67 },
@@ -83,12 +83,12 @@ func TestConfig_configureProviders(t *testing.T) {
83}
83}
84
84
85func TestConfig_configureProvidersWithOverride(t *testing.T) {
85func TestConfig_configureProvidersWithOverride(t *testing.T) {
86− knownProviders := []provider.Provider{
86+ knownProviders := []catwalk.Provider{
87 {
87 {
88 ID: "openai",
88 ID: "openai",
89 APIKey: "$OPENAI_API_KEY",
89 APIKey: "$OPENAI_API_KEY",
90 APIEndpoint: "https://api.openai.com/v1",
90 APIEndpoint: "https://api.openai.com/v1",
91− Models: []provider.Model{{
91+ Models: []catwalk.Model{{
92 ID: "test-model",
92 ID: "test-model",
93 }},
93 }},
94 },
94 },
@@ -100,10 +100,10 @@ func TestConfig_configureProvidersWithOverride(t *testing.T) {
100 cfg.Providers.Set("openai", ProviderConfig{
100 cfg.Providers.Set("openai", ProviderConfig{
101 APIKey: "xyz",
101 APIKey: "xyz",
102 BaseURL: "https://api.openai.com/v2",
102 BaseURL: "https://api.openai.com/v2",
103− Models: []provider.Model{
103+ Models: []catwalk.Model{
104 {
104 {
105− ID: "test-model",
105+ ID: "test-model",
106− Model: "Updated",
106+ Name: "Updated",
107 },
107 },
108 {
108 {
109 ID: "another-model",
109 ID: "another-model",
@@ -125,16 +125,16 @@ func TestConfig_configureProvidersWithOverride(t *testing.T) {
125 assert.Equal(t, "xyz", pc.APIKey)
125 assert.Equal(t, "xyz", pc.APIKey)
126 assert.Equal(t, "https://api.openai.com/v2", pc.BaseURL)
126 assert.Equal(t, "https://api.openai.com/v2", pc.BaseURL)
127 assert.Len(t, pc.Models, 2)
127 assert.Len(t, pc.Models, 2)
128− assert.Equal(t, "Updated", pc.Models[0].Model)
128+ assert.Equal(t, "Updated", pc.Models[0].Name)
129}
129}
130
130
131func TestConfig_configureProvidersWithNewProvider(t *testing.T) {
131func TestConfig_configureProvidersWithNewProvider(t *testing.T) {
132− knownProviders := []provider.Provider{
132+ knownProviders := []catwalk.Provider{
133 {
133 {
134 ID: "openai",
134 ID: "openai",
135 APIKey: "$OPENAI_API_KEY",
135 APIKey: "$OPENAI_API_KEY",
136 APIEndpoint: "https://api.openai.com/v1",
136 APIEndpoint: "https://api.openai.com/v1",
137− Models: []provider.Model{{
137+ Models: []catwalk.Model{{
138 ID: "test-model",
138 ID: "test-model",
139 }},
139 }},
140 },
140 },
@@ -145,7 +145,7 @@ func TestConfig_configureProvidersWithNewProvider(t *testing.T) {
145 "custom": {
145 "custom": {
146 APIKey: "xyz",
146 APIKey: "xyz",
147 BaseURL: "https://api.someendpoint.com/v2",
147 BaseURL: "https://api.someendpoint.com/v2",
148− Models: []provider.Model{
148+ Models: []catwalk.Model{
149 {
149 {
150 ID: "test-model",
150 ID: "test-model",
151 },
151 },
@@ -176,12 +176,12 @@ func TestConfig_configureProvidersWithNewProvider(t *testing.T) {
176}
176}
177
177
178func TestConfig_configureProvidersBedrockWithCredentials(t *testing.T) {
178func TestConfig_configureProvidersBedrockWithCredentials(t *testing.T) {
179− knownProviders := []provider.Provider{
179+ knownProviders := []catwalk.Provider{
180 {
180 {
181− ID: provider.InferenceProviderBedrock,
181+ ID: catwalk.InferenceProviderBedrock,
182 APIKey: "",
182 APIKey: "",
183 APIEndpoint: "",
183 APIEndpoint: "",
184− Models: []provider.Model{{
184+ Models: []catwalk.Model{{
185 ID: "anthropic.claude-sonnet-4-20250514-v1:0",
185 ID: "anthropic.claude-sonnet-4-20250514-v1:0",
186 }},
186 }},
187 },
187 },
@@ -205,12 +205,12 @@ func TestConfig_configureProvidersBedrockWithCredentials(t *testing.T) {
205}
205}
206
206
207func TestConfig_configureProvidersBedrockWithoutCredentials(t *testing.T) {
207func TestConfig_configureProvidersBedrockWithoutCredentials(t *testing.T) {
208− knownProviders := []provider.Provider{
208+ knownProviders := []catwalk.Provider{
209 {
209 {
210− ID: provider.InferenceProviderBedrock,
210+ ID: catwalk.InferenceProviderBedrock,
211 APIKey: "",
211 APIKey: "",
212 APIEndpoint: "",
212 APIEndpoint: "",
213− Models: []provider.Model{{
213+ Models: []catwalk.Model{{
214 ID: "anthropic.claude-sonnet-4-20250514-v1:0",
214 ID: "anthropic.claude-sonnet-4-20250514-v1:0",
215 }},
215 }},
216 },
216 },
@@ -227,12 +227,12 @@ func TestConfig_configureProvidersBedrockWithoutCredentials(t *testing.T) {
227}
227}
228
228
229func TestConfig_configureProvidersBedrockWithoutUnsupportedModel(t *testing.T) {
229func TestConfig_configureProvidersBedrockWithoutUnsupportedModel(t *testing.T) {
230− knownProviders := []provider.Provider{
230+ knownProviders := []catwalk.Provider{
231 {
231 {
232− ID: provider.InferenceProviderBedrock,
232+ ID: catwalk.InferenceProviderBedrock,
233 APIKey: "",
233 APIKey: "",
234 APIEndpoint: "",
234 APIEndpoint: "",
235− Models: []provider.Model{{
235+ Models: []catwalk.Model{{
236 ID: "some-random-model",
236 ID: "some-random-model",
237 }},
237 }},
238 },
238 },
@@ -250,12 +250,12 @@ func TestConfig_configureProvidersBedrockWithoutUnsupportedModel(t *testing.T) {
250}
250}
251
251
252func TestConfig_configureProvidersVertexAIWithCredentials(t *testing.T) {
252func TestConfig_configureProvidersVertexAIWithCredentials(t *testing.T) {
253− knownProviders := []provider.Provider{
253+ knownProviders := []catwalk.Provider{
254 {
254 {
255− ID: provider.InferenceProviderVertexAI,
255+ ID: catwalk.InferenceProviderVertexAI,
256 APIKey: "",
256 APIKey: "",
257 APIEndpoint: "",
257 APIEndpoint: "",
258− Models: []provider.Model{{
258+ Models: []catwalk.Model{{
259 ID: "gemini-pro",
259 ID: "gemini-pro",
260 }},
260 }},
261 },
261 },
@@ -282,12 +282,12 @@ func TestConfig_configureProvidersVertexAIWithCredentials(t *testing.T) {
282}
282}
283
283
284func TestConfig_configureProvidersVertexAIWithoutCredentials(t *testing.T) {
284func TestConfig_configureProvidersVertexAIWithoutCredentials(t *testing.T) {
285− knownProviders := []provider.Provider{
285+ knownProviders := []catwalk.Provider{
286 {
286 {
287− ID: provider.InferenceProviderVertexAI,
287+ ID: catwalk.InferenceProviderVertexAI,
288 APIKey: "",
288 APIKey: "",
289 APIEndpoint: "",
289 APIEndpoint: "",
290− Models: []provider.Model{{
290+ Models: []catwalk.Model{{
291 ID: "gemini-pro",
291 ID: "gemini-pro",
292 }},
292 }},
293 },
293 },
@@ -308,12 +308,12 @@ func TestConfig_configureProvidersVertexAIWithoutCredentials(t *testing.T) {
308}
308}
309
309
310func TestConfig_configureProvidersVertexAIMissingProject(t *testing.T) {
310func TestConfig_configureProvidersVertexAIMissingProject(t *testing.T) {
311− knownProviders := []provider.Provider{
311+ knownProviders := []catwalk.Provider{
312 {
312 {
313− ID: provider.InferenceProviderVertexAI,
313+ ID: catwalk.InferenceProviderVertexAI,
314 APIKey: "",
314 APIKey: "",
315 APIEndpoint: "",
315 APIEndpoint: "",
316− Models: []provider.Model{{
316+ Models: []catwalk.Model{{
317 ID: "gemini-pro",
317 ID: "gemini-pro",
318 }},
318 }},
319 },
319 },
@@ -333,12 +333,12 @@ func TestConfig_configureProvidersVertexAIMissingProject(t *testing.T) {
333}
333}
334
334
335func TestConfig_configureProvidersSetProviderID(t *testing.T) {
335func TestConfig_configureProvidersSetProviderID(t *testing.T) {
336− knownProviders := []provider.Provider{
336+ knownProviders := []catwalk.Provider{
337 {
337 {
338 ID: "openai",
338 ID: "openai",
339 APIKey: "$OPENAI_API_KEY",
339 APIKey: "$OPENAI_API_KEY",
340 APIEndpoint: "https://api.openai.com/v1",
340 APIEndpoint: "https://api.openai.com/v1",
341− Models: []provider.Model{{
341+ Models: []catwalk.Model{{
342 ID: "test-model",
342 ID: "test-model",
343 }},
343 }},
344 },
344 },
@@ -455,12 +455,12 @@ func TestConfig_IsConfigured(t *testing.T) {
455}
455}
456
456
457func TestConfig_configureProvidersWithDisabledProvider(t *testing.T) {
457func TestConfig_configureProvidersWithDisabledProvider(t *testing.T) {
458− knownProviders := []provider.Provider{
458+ knownProviders := []catwalk.Provider{
459 {
459 {
460 ID: "openai",
460 ID: "openai",
461 APIKey: "$OPENAI_API_KEY",
461 APIKey: "$OPENAI_API_KEY",
462 APIEndpoint: "https://api.openai.com/v1",
462 APIEndpoint: "https://api.openai.com/v1",
463− Models: []provider.Model{{
463+ Models: []catwalk.Model{{
464 ID: "test-model",
464 ID: "test-model",
465 }},
465 }},
466 },
466 },
@@ -494,7 +494,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
494 Providers: csync.NewMapFrom(map[string]ProviderConfig{
494 Providers: csync.NewMapFrom(map[string]ProviderConfig{
495 "custom": {
495 "custom": {
496 BaseURL: "https://api.custom.com/v1",
496 BaseURL: "https://api.custom.com/v1",
497− Models: []provider.Model{{
497+ Models: []catwalk.Model{{
498 ID: "test-model",
498 ID: "test-model",
499 }},
499 }},
500 },
500 },
@@ -507,7 +507,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
507
507
508 env := env.NewFromMap(map[string]string{})
508 env := env.NewFromMap(map[string]string{})
509 resolver := NewEnvironmentVariableResolver(env)
509 resolver := NewEnvironmentVariableResolver(env)
510− err := cfg.configureProviders(env, resolver, []provider.Provider{})
510+ err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
511 assert.NoError(t, err)
511 assert.NoError(t, err)
512
512
513 assert.Equal(t, cfg.Providers.Len(), 1)
513 assert.Equal(t, cfg.Providers.Len(), 1)
@@ -520,7 +520,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
520 Providers: csync.NewMapFrom(map[string]ProviderConfig{
520 Providers: csync.NewMapFrom(map[string]ProviderConfig{
521 "custom": {
521 "custom": {
522 APIKey: "test-key",
522 APIKey: "test-key",
523− Models: []provider.Model{{
523+ Models: []catwalk.Model{{
524 ID: "test-model",
524 ID: "test-model",
525 }},
525 }},
526 },
526 },
@@ -530,7 +530,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
530
530
531 env := env.NewFromMap(map[string]string{})
531 env := env.NewFromMap(map[string]string{})
532 resolver := NewEnvironmentVariableResolver(env)
532 resolver := NewEnvironmentVariableResolver(env)
533− err := cfg.configureProviders(env, resolver, []provider.Provider{})
533+ err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
534 assert.NoError(t, err)
534 assert.NoError(t, err)
535
535
536 assert.Equal(t, cfg.Providers.Len(), 0)
536 assert.Equal(t, cfg.Providers.Len(), 0)
@@ -544,7 +544,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
544 "custom": {
544 "custom": {
545 APIKey: "test-key",
545 APIKey: "test-key",
546 BaseURL: "https://api.custom.com/v1",
546 BaseURL: "https://api.custom.com/v1",
547− Models: []provider.Model{},
547+ Models: []catwalk.Model{},
548 },
548 },
549 }),
549 }),
550 }
550 }
@@ -552,7 +552,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
552
552
553 env := env.NewFromMap(map[string]string{})
553 env := env.NewFromMap(map[string]string{})
554 resolver := NewEnvironmentVariableResolver(env)
554 resolver := NewEnvironmentVariableResolver(env)
555− err := cfg.configureProviders(env, resolver, []provider.Provider{})
555+ err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
556 assert.NoError(t, err)
556 assert.NoError(t, err)
557
557
558 assert.Equal(t, cfg.Providers.Len(), 0)
558 assert.Equal(t, cfg.Providers.Len(), 0)
@@ -567,7 +567,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
567 APIKey: "test-key",
567 APIKey: "test-key",
568 BaseURL: "https://api.custom.com/v1",
568 BaseURL: "https://api.custom.com/v1",
569 Type: "unsupported",
569 Type: "unsupported",
570− Models: []provider.Model{{
570+ Models: []catwalk.Model{{
571 ID: "test-model",
571 ID: "test-model",
572 }},
572 }},
573 },
573 },
@@ -577,7 +577,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
577
577
578 env := env.NewFromMap(map[string]string{})
578 env := env.NewFromMap(map[string]string{})
579 resolver := NewEnvironmentVariableResolver(env)
579 resolver := NewEnvironmentVariableResolver(env)
580− err := cfg.configureProviders(env, resolver, []provider.Provider{})
580+ err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
581 assert.NoError(t, err)
581 assert.NoError(t, err)
582
582
583 assert.Equal(t, cfg.Providers.Len(), 0)
583 assert.Equal(t, cfg.Providers.Len(), 0)
@@ -591,8 +591,8 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
591 "custom": {
591 "custom": {
592 APIKey: "test-key",
592 APIKey: "test-key",
593 BaseURL: "https://api.custom.com/v1",
593 BaseURL: "https://api.custom.com/v1",
594− Type: provider.TypeOpenAI,
594+ Type: catwalk.TypeOpenAI,
595− Models: []provider.Model{{
595+ Models: []catwalk.Model{{
596 ID: "test-model",
596 ID: "test-model",
597 }},
597 }},
598 },
598 },
@@ -602,7 +602,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
602
602
603 env := env.NewFromMap(map[string]string{})
603 env := env.NewFromMap(map[string]string{})
604 resolver := NewEnvironmentVariableResolver(env)
604 resolver := NewEnvironmentVariableResolver(env)
605− err := cfg.configureProviders(env, resolver, []provider.Provider{})
605+ err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
606 assert.NoError(t, err)
606 assert.NoError(t, err)
607
607
608 assert.Equal(t, cfg.Providers.Len(), 1)
608 assert.Equal(t, cfg.Providers.Len(), 1)
@@ -619,9 +619,9 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
619 "custom": {
619 "custom": {
620 APIKey: "test-key",
620 APIKey: "test-key",
621 BaseURL: "https://api.custom.com/v1",
621 BaseURL: "https://api.custom.com/v1",
622− Type: provider.TypeOpenAI,
622+ Type: catwalk.TypeOpenAI,
623 Disable: true,
623 Disable: true,
624− Models: []provider.Model{{
624+ Models: []catwalk.Model{{
625 ID: "test-model",
625 ID: "test-model",
626 }},
626 }},
627 },
627 },
@@ -631,7 +631,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
631
631
632 env := env.NewFromMap(map[string]string{})
632 env := env.NewFromMap(map[string]string{})
633 resolver := NewEnvironmentVariableResolver(env)
633 resolver := NewEnvironmentVariableResolver(env)
634− err := cfg.configureProviders(env, resolver, []provider.Provider{})
634+ err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
635 assert.NoError(t, err)
635 assert.NoError(t, err)
636
636
637 assert.Equal(t, cfg.Providers.Len(), 0)
637 assert.Equal(t, cfg.Providers.Len(), 0)
@@ -642,12 +642,12 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
642
642
643func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
643func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
644 t.Run("VertexAI provider removed when credentials missing with existing config", func(t *testing.T) {
644 t.Run("VertexAI provider removed when credentials missing with existing config", func(t *testing.T) {
645− knownProviders := []provider.Provider{
645+ knownProviders := []catwalk.Provider{
646 {
646 {
647− ID: provider.InferenceProviderVertexAI,
647+ ID: catwalk.InferenceProviderVertexAI,
648 APIKey: "",
648 APIKey: "",
649 APIEndpoint: "",
649 APIEndpoint: "",
650− Models: []provider.Model{{
650+ Models: []catwalk.Model{{
651 ID: "gemini-pro",
651 ID: "gemini-pro",
652 }},
652 }},
653 },
653 },
@@ -675,12 +675,12 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
675 })
675 })
676
676
677 t.Run("Bedrock provider removed when AWS credentials missing with existing config", func(t *testing.T) {
677 t.Run("Bedrock provider removed when AWS credentials missing with existing config", func(t *testing.T) {
678− knownProviders := []provider.Provider{
678+ knownProviders := []catwalk.Provider{
679 {
679 {
680− ID: provider.InferenceProviderBedrock,
680+ ID: catwalk.InferenceProviderBedrock,
681 APIKey: "",
681 APIKey: "",
682 APIEndpoint: "",
682 APIEndpoint: "",
683− Models: []provider.Model{{
683+ Models: []catwalk.Model{{
684 ID: "anthropic.claude-sonnet-4-20250514-v1:0",
684 ID: "anthropic.claude-sonnet-4-20250514-v1:0",
685 }},
685 }},
686 },
686 },
@@ -706,12 +706,12 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
706 })
706 })
707
707
708 t.Run("provider removed when API key missing with existing config", func(t *testing.T) {
708 t.Run("provider removed when API key missing with existing config", func(t *testing.T) {
709− knownProviders := []provider.Provider{
709+ knownProviders := []catwalk.Provider{
710 {
710 {
711 ID: "openai",
711 ID: "openai",
712 APIKey: "$MISSING_API_KEY",
712 APIKey: "$MISSING_API_KEY",
713 APIEndpoint: "https://api.openai.com/v1",
713 APIEndpoint: "https://api.openai.com/v1",
714− Models: []provider.Model{{
714+ Models: []catwalk.Model{{
715 ID: "test-model",
715 ID: "test-model",
716 }},
716 }},
717 },
717 },
@@ -737,12 +737,12 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
737 })
737 })
738
738
739 t.Run("known provider should still be added if the endpoint is missing the client will use default endpoints", func(t *testing.T) {
739 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{
741 {
741 {
742 ID: "openai",
742 ID: "openai",
743 APIKey: "$OPENAI_API_KEY",
743 APIKey: "$OPENAI_API_KEY",
744 APIEndpoint: "$MISSING_ENDPOINT",
744 APIEndpoint: "$MISSING_ENDPOINT",
745− Models: []provider.Model{{
745+ Models: []catwalk.Model{{
746 ID: "test-model",
746 ID: "test-model",
747 }},
747 }},
748 },
748 },
@@ -772,13 +772,13 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
772
772
773func TestConfig_defaultModelSelection(t *testing.T) {
773func TestConfig_defaultModelSelection(t *testing.T) {
774 t.Run("default behavior uses the default models for given provider", func(t *testing.T) {
774 t.Run("default behavior uses the default models for given provider", func(t *testing.T) {
775− knownProviders := []provider.Provider{
775+ knownProviders := []catwalk.Provider{
776 {
776 {
777 ID: "openai",
777 ID: "openai",
778 APIKey: "abc",
778 APIKey: "abc",
779 DefaultLargeModelID: "large-model",
779 DefaultLargeModelID: "large-model",
780 DefaultSmallModelID: "small-model",
780 DefaultSmallModelID: "small-model",
781− Models: []provider.Model{
781+ Models: []catwalk.Model{
782 {
782 {
783 ID: "large-model",
783 ID: "large-model",
784 DefaultMaxTokens: 1000,
784 DefaultMaxTokens: 1000,
@@ -808,13 +808,13 @@ func TestConfig_defaultModelSelection(t *testing.T) {
808 assert.Equal(t, int64(500), small.MaxTokens)
808 assert.Equal(t, int64(500), small.MaxTokens)
809 })
809 })
810 t.Run("should error if no providers configured", func(t *testing.T) {
810 t.Run("should error if no providers configured", func(t *testing.T) {
811− knownProviders := []provider.Provider{
811+ knownProviders := []catwalk.Provider{
812 {
812 {
813 ID: "openai",
813 ID: "openai",
814 APIKey: "$MISSING_KEY",
814 APIKey: "$MISSING_KEY",
815 DefaultLargeModelID: "large-model",
815 DefaultLargeModelID: "large-model",
816 DefaultSmallModelID: "small-model",
816 DefaultSmallModelID: "small-model",
817− Models: []provider.Model{
817+ Models: []catwalk.Model{
818 {
818 {
819 ID: "large-model",
819 ID: "large-model",
820 DefaultMaxTokens: 1000,
820 DefaultMaxTokens: 1000,
@@ -838,13 +838,13 @@ func TestConfig_defaultModelSelection(t *testing.T) {
838 assert.Error(t, err)
838 assert.Error(t, err)
839 })
839 })
840 t.Run("should error if model is missing", func(t *testing.T) {
840 t.Run("should error if model is missing", func(t *testing.T) {
841− knownProviders := []provider.Provider{
841+ knownProviders := []catwalk.Provider{
842 {
842 {
843 ID: "openai",
843 ID: "openai",
844 APIKey: "abc",
844 APIKey: "abc",
845 DefaultLargeModelID: "large-model",
845 DefaultLargeModelID: "large-model",
846 DefaultSmallModelID: "small-model",
846 DefaultSmallModelID: "small-model",
847− Models: []provider.Model{
847+ Models: []catwalk.Model{
848 {
848 {
849 ID: "not-large-model",
849 ID: "not-large-model",
850 DefaultMaxTokens: 1000,
850 DefaultMaxTokens: 1000,
@@ -868,13 +868,13 @@ func TestConfig_defaultModelSelection(t *testing.T) {
868 })
868 })
869
869
870 t.Run("should configure the default models with a custom provider", func(t *testing.T) {
870 t.Run("should configure the default models with a custom provider", func(t *testing.T) {
871− knownProviders := []provider.Provider{
871+ knownProviders := []catwalk.Provider{
872 {
872 {
873 ID: "openai",
873 ID: "openai",
874 APIKey: "$MISSING", // will not be included in the config
874 APIKey: "$MISSING", // will not be included in the config
875 DefaultLargeModelID: "large-model",
875 DefaultLargeModelID: "large-model",
876 DefaultSmallModelID: "small-model",
876 DefaultSmallModelID: "small-model",
877− Models: []provider.Model{
877+ Models: []catwalk.Model{
878 {
878 {
879 ID: "not-large-model",
879 ID: "not-large-model",
880 DefaultMaxTokens: 1000,
880 DefaultMaxTokens: 1000,
@@ -892,7 +892,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
892 "custom": {
892 "custom": {
893 APIKey: "test-key",
893 APIKey: "test-key",
894 BaseURL: "https://api.custom.com/v1",
894 BaseURL: "https://api.custom.com/v1",
895− Models: []provider.Model{
895+ Models: []catwalk.Model{
896 {
896 {
897 ID: "model",
897 ID: "model",
898 DefaultMaxTokens: 600,
898 DefaultMaxTokens: 600,
@@ -917,13 +917,13 @@ func TestConfig_defaultModelSelection(t *testing.T) {
917 })
917 })
918
918
919 t.Run("should fail if no model configured", func(t *testing.T) {
919 t.Run("should fail if no model configured", func(t *testing.T) {
920− knownProviders := []provider.Provider{
920+ knownProviders := []catwalk.Provider{
921 {
921 {
922 ID: "openai",
922 ID: "openai",
923 APIKey: "$MISSING", // will not be included in the config
923 APIKey: "$MISSING", // will not be included in the config
924 DefaultLargeModelID: "large-model",
924 DefaultLargeModelID: "large-model",
925 DefaultSmallModelID: "small-model",
925 DefaultSmallModelID: "small-model",
926− Models: []provider.Model{
926+ Models: []catwalk.Model{
927 {
927 {
928 ID: "not-large-model",
928 ID: "not-large-model",
929 DefaultMaxTokens: 1000,
929 DefaultMaxTokens: 1000,
@@ -941,7 +941,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
941 "custom": {
941 "custom": {
942 APIKey: "test-key",
942 APIKey: "test-key",
943 BaseURL: "https://api.custom.com/v1",
943 BaseURL: "https://api.custom.com/v1",
944− Models: []provider.Model{},
944+ Models: []catwalk.Model{},
945 },
945 },
946 }),
946 }),
947 }
947 }
@@ -954,13 +954,13 @@ func TestConfig_defaultModelSelection(t *testing.T) {
954 assert.Error(t, err)
954 assert.Error(t, err)
955 })
955 })
956 t.Run("should use the default provider first", func(t *testing.T) {
956 t.Run("should use the default provider first", func(t *testing.T) {
957− knownProviders := []provider.Provider{
957+ knownProviders := []catwalk.Provider{
958 {
958 {
959 ID: "openai",
959 ID: "openai",
960 APIKey: "set",
960 APIKey: "set",
961 DefaultLargeModelID: "large-model",
961 DefaultLargeModelID: "large-model",
962 DefaultSmallModelID: "small-model",
962 DefaultSmallModelID: "small-model",
963− Models: []provider.Model{
963+ Models: []catwalk.Model{
964 {
964 {
965 ID: "large-model",
965 ID: "large-model",
966 DefaultMaxTokens: 1000,
966 DefaultMaxTokens: 1000,
@@ -978,7 +978,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
978 "custom": {
978 "custom": {
979 APIKey: "test-key",
979 APIKey: "test-key",
980 BaseURL: "https://api.custom.com/v1",
980 BaseURL: "https://api.custom.com/v1",
981− Models: []provider.Model{
981+ Models: []catwalk.Model{
982 {
982 {
983 ID: "large-model",
983 ID: "large-model",
984 DefaultMaxTokens: 1000,
984 DefaultMaxTokens: 1000,
@@ -1005,13 +1005,13 @@ func TestConfig_defaultModelSelection(t *testing.T) {
1005
1005
1006func TestConfig_configureSelectedModels(t *testing.T) {
1006func TestConfig_configureSelectedModels(t *testing.T) {
1007 t.Run("should override defaults", func(t *testing.T) {
1007 t.Run("should override defaults", func(t *testing.T) {
1008− knownProviders := []provider.Provider{
1008+ knownProviders := []catwalk.Provider{
1009 {
1009 {
1010 ID: "openai",
1010 ID: "openai",
1011 APIKey: "abc",
1011 APIKey: "abc",
1012 DefaultLargeModelID: "large-model",
1012 DefaultLargeModelID: "large-model",
1013 DefaultSmallModelID: "small-model",
1013 DefaultSmallModelID: "small-model",
1014− Models: []provider.Model{
1014+ Models: []catwalk.Model{
1015 {
1015 {
1016 ID: "larger-model",
1016 ID: "larger-model",
1017 DefaultMaxTokens: 2000,
1017 DefaultMaxTokens: 2000,
@@ -1053,13 +1053,13 @@ func TestConfig_configureSelectedModels(t *testing.T) {
1053 assert.Equal(t, int64(500), small.MaxTokens)
1053 assert.Equal(t, int64(500), small.MaxTokens)
1054 })
1054 })
1055 t.Run("should be possible to use multiple providers", func(t *testing.T) {
1055 t.Run("should be possible to use multiple providers", func(t *testing.T) {
1056− knownProviders := []provider.Provider{
1056+ knownProviders := []catwalk.Provider{
1057 {
1057 {
1058 ID: "openai",
1058 ID: "openai",
1059 APIKey: "abc",
1059 APIKey: "abc",
1060 DefaultLargeModelID: "large-model",
1060 DefaultLargeModelID: "large-model",
1061 DefaultSmallModelID: "small-model",
1061 DefaultSmallModelID: "small-model",
1062− Models: []provider.Model{
1062+ Models: []catwalk.Model{
1063 {
1063 {
1064 ID: "large-model",
1064 ID: "large-model",
1065 DefaultMaxTokens: 1000,
1065 DefaultMaxTokens: 1000,
@@ -1075,7 +1075,7 @@ func TestConfig_configureSelectedModels(t *testing.T) {
1075 APIKey: "abc",
1075 APIKey: "abc",
1076 DefaultLargeModelID: "a-large-model",
1076 DefaultLargeModelID: "a-large-model",
1077 DefaultSmallModelID: "a-small-model",
1077 DefaultSmallModelID: "a-small-model",
1078− Models: []provider.Model{
1078+ Models: []catwalk.Model{
1079 {
1079 {
1080 ID: "a-large-model",
1080 ID: "a-large-model",
1081 DefaultMaxTokens: 1000,
1081 DefaultMaxTokens: 1000,
@@ -1116,13 +1116,13 @@ func TestConfig_configureSelectedModels(t *testing.T) {
1116 })
1116 })
1117
1117
1118 t.Run("should override the max tokens only", func(t *testing.T) {
1118 t.Run("should override the max tokens only", func(t *testing.T) {
1119− knownProviders := []provider.Provider{
1119+ knownProviders := []catwalk.Provider{
1120 {
1120 {
1121 ID: "openai",
1121 ID: "openai",
1122 APIKey: "abc",
1122 APIKey: "abc",
1123 DefaultLargeModelID: "large-model",
1123 DefaultLargeModelID: "large-model",
1124 DefaultSmallModelID: "small-model",
1124 DefaultSmallModelID: "small-model",
1125− Models: []provider.Model{
1125+ Models: []catwalk.Model{
1126 {
1126 {
1127 ID: "large-model",
1127 ID: "large-model",
1128 DefaultMaxTokens: 1000,
1128 DefaultMaxTokens: 1000,
internal/config/provider.go+10−11
@@ -10,17 +10,16 @@ import (
10 "sync"
10 "sync"
11 "time"
11 "time"
12
12
13− "github.com/charmbracelet/crush/internal/fur/client"
13+ "github.com/charmbracelet/catwalk/pkg/catwalk"
14− "github.com/charmbracelet/crush/internal/fur/provider"
15)
14)
16
15
17type ProviderClient interface {
16type ProviderClient interface {
18− GetProviders() ([]provider.Provider, error)
17+ GetProviders() ([]catwalk.Provider, error)
19}
18}
20
19
21var (
20var (
22 providerOnce sync.Once
21 providerOnce sync.Once
23− providerList []provider.Provider
22+ providerList []catwalk.Provider
24)
23)
25
24
26// file to cache provider data
25// file to cache provider data
@@ -44,7 +43,7 @@ func providerCacheFileData() string {
44 return filepath.Join(os.Getenv("HOME"), ".local", "share", appName, "providers.json")
43 return filepath.Join(os.Getenv("HOME"), ".local", "share", appName, "providers.json")
45}
44}
46
45
47−func saveProvidersInCache(path string, providers []provider.Provider) error {
46+func saveProvidersInCache(path string, providers []catwalk.Provider) error {
48 slog.Info("Caching provider data")
47 slog.Info("Caching provider data")
49 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
48 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
50 return fmt.Errorf("failed to create directory for provider cache: %w", err)
49 return fmt.Errorf("failed to create directory for provider cache: %w", err)
@@ -61,26 +60,26 @@ func saveProvidersInCache(path string, providers []provider.Provider) error {
61 return nil
60 return nil
62}
61}
63
62
64−func loadProvidersFromCache(path string) ([]provider.Provider, error) {
63+func loadProvidersFromCache(path string) ([]catwalk.Provider, error) {
65 data, err := os.ReadFile(path)
64 data, err := os.ReadFile(path)
66 if err != nil {
65 if err != nil {
67 return nil, fmt.Errorf("failed to read provider cache file: %w", err)
66 return nil, fmt.Errorf("failed to read provider cache file: %w", err)
68 }
67 }
69
68
70− var providers []provider.Provider
69+ var providers []catwalk.Provider
71 if err := json.Unmarshal(data, &providers); err != nil {
70 if err := json.Unmarshal(data, &providers); err != nil {
72 return nil, fmt.Errorf("failed to unmarshal provider data from cache: %w", err)
71 return nil, fmt.Errorf("failed to unmarshal provider data from cache: %w", err)
73 }
72 }
74 return providers, nil
73 return providers, nil
75}
74}
76
75
77−func Providers() ([]provider.Provider, error) {
76+func Providers() ([]catwalk.Provider, error) {
78− client := client.New()
77+ client := catwalk.NewWithURL(catwalkURL)
79 path := providerCacheFileData()
78 path := providerCacheFileData()
80 return loadProvidersOnce(client, path)
79 return loadProvidersOnce(client, path)
81}
80}
82
81
83−func loadProvidersOnce(client ProviderClient, path string) ([]provider.Provider, error) {
82+func loadProvidersOnce(client ProviderClient, path string) ([]catwalk.Provider, error) {
84 var err error
83 var err error
85 providerOnce.Do(func() {
84 providerOnce.Do(func() {
86 providerList, err = loadProviders(client, path)
85 providerList, err = loadProviders(client, path)
@@ -91,7 +90,7 @@ func loadProvidersOnce(client ProviderClient, path string) ([]provider.Provider,
91 return providerList, nil
90 return providerList, nil
92}
91}
93
92
94−func loadProviders(client ProviderClient, path string) (providerList []provider.Provider, err error) {
93+func loadProviders(client ProviderClient, path string) (providerList []catwalk.Provider, err error) {
95 // if cache is not stale, load from it
94 // if cache is not stale, load from it
96 stale, exists := isCacheStale(path)
95 stale, exists := isCacheStale(path)
97 if !stale {
96 if !stale {
internal/config/provider_empty_test.go+4−4
@@ -5,14 +5,14 @@ import (
5 "os"
5 "os"
6 "testing"
6 "testing"
7
7
8− "github.com/charmbracelet/crush/internal/fur/provider"
8+ "github.com/charmbracelet/catwalk/pkg/catwalk"
9 "github.com/stretchr/testify/require"
9 "github.com/stretchr/testify/require"
10)
10)
11
11
12type emptyProviderClient struct{}
12type emptyProviderClient struct{}
13
13
14−func (m *emptyProviderClient) GetProviders() ([]provider.Provider, error) {
14+func (m *emptyProviderClient) GetProviders() ([]catwalk.Provider, error) {
15− return []provider.Provider{}, nil
15+ return []catwalk.Provider{}, nil
16}
16}
17
17
18func TestProvider_loadProvidersEmptyResult(t *testing.T) {
18func TestProvider_loadProvidersEmptyResult(t *testing.T) {
@@ -33,7 +33,7 @@ func TestProvider_loadProvidersEmptyCache(t *testing.T) {
33 tmpPath := t.TempDir() + "/providers.json"
33 tmpPath := t.TempDir() + "/providers.json"
34
34
35 // Create an empty cache file
35 // Create an empty cache file
36− emptyProviders := []provider.Provider{}
36+ emptyProviders := []catwalk.Provider{}
37 data, err := json.Marshal(emptyProviders)
37 data, err := json.Marshal(emptyProviders)
38 require.NoError(t, err)
38 require.NoError(t, err)
39 require.NoError(t, os.WriteFile(tmpPath, data, 0o644))
39 require.NoError(t, os.WriteFile(tmpPath, data, 0o644))
internal/config/provider_test.go+4−4
@@ -6,7 +6,7 @@ import (
6 "os"
6 "os"
7 "testing"
7 "testing"
8
8
9− "github.com/charmbracelet/crush/internal/fur/provider"
9+ "github.com/charmbracelet/catwalk/pkg/catwalk"
10 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/assert"
11)
11)
12
12
@@ -14,11 +14,11 @@ type mockProviderClient struct {
14 shouldFail bool
14 shouldFail bool
15}
15}
16
16
17−func (m *mockProviderClient) GetProviders() ([]provider.Provider, error) {
17+func (m *mockProviderClient) GetProviders() ([]catwalk.Provider, error) {
18 if m.shouldFail {
18 if m.shouldFail {
19 return nil, errors.New("failed to load providers")
19 return nil, errors.New("failed to load providers")
20 }
20 }
21− return []provider.Provider{
21+ return []catwalk.Provider{
22 {
22 {
23 Name: "Mock",
23 Name: "Mock",
24 },
24 },
@@ -43,7 +43,7 @@ func TestProvider_loadProvidersWithIssues(t *testing.T) {
43 client := &mockProviderClient{shouldFail: true}
43 client := &mockProviderClient{shouldFail: true}
44 tmpPath := t.TempDir() + "/providers.json"
44 tmpPath := t.TempDir() + "/providers.json"
45 // store providers to a temporary file
45 // store providers to a temporary file
46− oldProviders := []provider.Provider{
46+ oldProviders := []catwalk.Provider{
47 {
47 {
48 Name: "OldProvider",
48 Name: "OldProvider",
49 },
49 },
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 (
10 "sync"
10 "sync"
11 "time"
11 "time"
12
12
13+ "github.com/charmbracelet/catwalk/pkg/catwalk"
13 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/csync"
15 "github.com/charmbracelet/crush/internal/csync"
15− fur "github.com/charmbracelet/crush/internal/fur/provider"
16 "github.com/charmbracelet/crush/internal/history"
16 "github.com/charmbracelet/crush/internal/history"
17 "github.com/charmbracelet/crush/internal/llm/prompt"
17 "github.com/charmbracelet/crush/internal/llm/prompt"
18 "github.com/charmbracelet/crush/internal/llm/provider"
18 "github.com/charmbracelet/crush/internal/llm/provider"
@@ -23,6 +23,7 @@ import (
23 "github.com/charmbracelet/crush/internal/permission"
23 "github.com/charmbracelet/crush/internal/permission"
24 "github.com/charmbracelet/crush/internal/pubsub"
24 "github.com/charmbracelet/crush/internal/pubsub"
25 "github.com/charmbracelet/crush/internal/session"
25 "github.com/charmbracelet/crush/internal/session"
26+ "github.com/charmbracelet/crush/internal/shell"
26)
27)
27
28
28// Common errors
29// Common errors
@@ -52,7 +53,7 @@ type AgentEvent struct {
52
53
53type Service interface {
54type Service interface {
54 pubsub.Suscriber[AgentEvent]
55 pubsub.Suscriber[AgentEvent]
55− Model() fur.Model
56+ Model() catwalk.Model
56 Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error)
57 Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error)
57 Cancel(sessionID string)
58 Cancel(sessionID string)
58 CancelAll()
59 CancelAll()
@@ -226,7 +227,7 @@ func NewAgent(
226 }, nil
227 }, nil
227}
228}
228
229
229−func (a *agent) Model() fur.Model {
230+func (a *agent) Model() catwalk.Model {
230 return *config.Get().GetModelByType(a.agentCfg.Model)
231 return *config.Get().GetModelByType(a.agentCfg.Model)
231}
232}
232
233
@@ -234,7 +235,7 @@ func (a *agent) Cancel(sessionID string) {
234 // Cancel regular requests
235 // Cancel regular requests
235 if cancelFunc, exists := a.activeRequests.LoadAndDelete(sessionID); exists {
236 if cancelFunc, exists := a.activeRequests.LoadAndDelete(sessionID); exists {
236 if cancel, ok := cancelFunc.(context.CancelFunc); ok {
237 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)
238 cancel()
239 cancel()
239 }
240 }
240 }
241 }
@@ -242,7 +243,7 @@ func (a *agent) Cancel(sessionID string) {
242 // Also check for summarize requests
243 // Also check for summarize requests
243 if cancelFunc, exists := a.activeRequests.LoadAndDelete(sessionID + "-summarize"); exists {
244 if cancelFunc, exists := a.activeRequests.LoadAndDelete(sessionID + "-summarize"); exists {
244 if cancel, ok := cancelFunc.(context.CancelFunc); ok {
245 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)
246 cancel()
247 cancel()
247 }
248 }
248 }
249 }
@@ -372,7 +373,7 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string
372 })
373 })
373 titleErr := a.generateTitle(context.Background(), sessionID, content)
374 titleErr := a.generateTitle(context.Background(), sessionID, content)
374 if titleErr != nil && !errors.Is(titleErr, context.Canceled) && !errors.Is(titleErr, context.DeadlineExceeded) {
375 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)
376 }
377 }
377 }()
378 }()
378 }
379 }
@@ -645,7 +646,7 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
645 return nil
646 return nil
646}
647}
647
648
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 {
649 sess, err := a.sessions.Get(ctx, sessionID)
650 sess, err := a.sessions.Get(ctx, sessionID)
650 if err != nil {
651 if err != nil {
651 return fmt.Errorf("failed to get session: %w", err)
652 return fmt.Errorf("failed to get session: %w", err)
@@ -770,6 +771,8 @@ func (a *agent) Summarize(ctx context.Context, sessionID string) error {
770 a.Publish(pubsub.CreatedEvent, event)
771 a.Publish(pubsub.CreatedEvent, event)
771 return
772 return
772 }
773 }
774+ shell := shell.GetPersistentShell(config.Get().WorkingDir())
775+ summary += "\n\n**Current working directory of the persistent shell**\n\n" + shell.GetWorkingDir()
773 event = AgentEvent{
776 event = AgentEvent{
774 Type: AgentEventTypeSummarize,
777 Type: AgentEventTypeSummarize,
775 Progress: "Creating new session...",
778 Progress: "Creating new session...",
internal/llm/prompt/coder.go+3−3
@@ -9,17 +9,17 @@ import (
9 "runtime"
9 "runtime"
10 "time"
10 "time"
11
11
12+ "github.com/charmbracelet/catwalk/pkg/catwalk"
12 "github.com/charmbracelet/crush/internal/config"
13 "github.com/charmbracelet/crush/internal/config"
13− "github.com/charmbracelet/crush/internal/fur/provider"
14 "github.com/charmbracelet/crush/internal/llm/tools"
14 "github.com/charmbracelet/crush/internal/llm/tools"
15)
15)
16
16
17func CoderPrompt(p string, contextFiles ...string) string {
17func CoderPrompt(p string, contextFiles ...string) string {
18 var basePrompt string
18 var basePrompt string
19 switch p {
19 switch p {
20− case string(provider.InferenceProviderOpenAI):
20+ case string(catwalk.InferenceProviderOpenAI):
21 basePrompt = baseOpenAICoderPrompt
21 basePrompt = baseOpenAICoderPrompt
22− case string(provider.InferenceProviderGemini), string(provider.InferenceProviderVertexAI):
22+ case string(catwalk.InferenceProviderGemini), string(catwalk.InferenceProviderVertexAI):
23 basePrompt = baseGeminiCoderPrompt
23 basePrompt = baseGeminiCoderPrompt
24 default:
24 default:
25 basePrompt = baseAnthropicCoderPrompt
25 basePrompt = baseAnthropicCoderPrompt
internal/llm/provider/anthropic.go+5−5
@@ -15,8 +15,8 @@ import (
15 "github.com/anthropics/anthropic-sdk-go"
15 "github.com/anthropics/anthropic-sdk-go"
16 "github.com/anthropics/anthropic-sdk-go/bedrock"
16 "github.com/anthropics/anthropic-sdk-go/bedrock"
17 "github.com/anthropics/anthropic-sdk-go/option"
17 "github.com/anthropics/anthropic-sdk-go/option"
18+ "github.com/charmbracelet/catwalk/pkg/catwalk"
18 "github.com/charmbracelet/crush/internal/config"
19 "github.com/charmbracelet/crush/internal/config"
19− "github.com/charmbracelet/crush/internal/fur/provider"
20 "github.com/charmbracelet/crush/internal/llm/tools"
20 "github.com/charmbracelet/crush/internal/llm/tools"
21 "github.com/charmbracelet/crush/internal/message"
21 "github.com/charmbracelet/crush/internal/message"
22)
22)
@@ -71,7 +71,7 @@ func (a *anthropicClient) convertMessages(messages []message.Message) (anthropic
71 var contentBlocks []anthropic.ContentBlockParamUnion
71 var contentBlocks []anthropic.ContentBlockParamUnion
72 contentBlocks = append(contentBlocks, content)
72 contentBlocks = append(contentBlocks, content)
73 for _, binaryContent := range msg.BinaryContent() {
73 for _, binaryContent := range msg.BinaryContent() {
74− base64Image := binaryContent.String(provider.InferenceProviderAnthropic)
74+ base64Image := binaryContent.String(catwalk.InferenceProviderAnthropic)
75 imageBlock := anthropic.NewImageBlockBase64(binaryContent.MIMEType, base64Image)
75 imageBlock := anthropic.NewImageBlockBase64(binaryContent.MIMEType, base64Image)
76 contentBlocks = append(contentBlocks, imageBlock)
76 contentBlocks = append(contentBlocks, imageBlock)
77 }
77 }
@@ -248,7 +248,7 @@ func (a *anthropicClient) send(ctx context.Context, messages []message.Message,
248 return nil, retryErr
248 return nil, retryErr
249 }
249 }
250 if retry {
250 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)
252 select {
252 select {
253 case <-ctx.Done():
253 case <-ctx.Done():
254 return nil, ctx.Err()
254 return nil, ctx.Err()
@@ -401,7 +401,7 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
401 return
401 return
402 }
402 }
403 if retry {
403 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)
405 select {
405 select {
406 case <-ctx.Done():
406 case <-ctx.Done():
407 // context cancelled
407 // context cancelled
@@ -529,6 +529,6 @@ func (a *anthropicClient) usage(msg anthropic.Message) TokenUsage {
529 }
529 }
530}
530}
531
531
532−func (a *anthropicClient) Model() provider.Model {
532+func (a *anthropicClient) Model() catwalk.Model {
533 return a.providerOptions.model(a.providerOptions.modelType)
533 return a.providerOptions.model(a.providerOptions.modelType)
534}
534}
internal/llm/provider/bedrock.go+3−3
@@ -6,8 +6,8 @@ import (
6 "fmt"
6 "fmt"
7 "strings"
7 "strings"
8
8
9+ "github.com/charmbracelet/catwalk/pkg/catwalk"
9 "github.com/charmbracelet/crush/internal/config"
10 "github.com/charmbracelet/crush/internal/config"
10− "github.com/charmbracelet/crush/internal/fur/provider"
11 "github.com/charmbracelet/crush/internal/llm/tools"
11 "github.com/charmbracelet/crush/internal/llm/tools"
12 "github.com/charmbracelet/crush/internal/message"
12 "github.com/charmbracelet/crush/internal/message"
13)
13)
@@ -32,7 +32,7 @@ func newBedrockClient(opts providerClientOptions) BedrockClient {
32 }
32 }
33 }
33 }
34
34
35− opts.model = func(modelType config.SelectedModelType) provider.Model {
35+ opts.model = func(modelType config.SelectedModelType) catwalk.Model {
36 model := config.Get().GetModelByType(modelType)
36 model := config.Get().GetModelByType(modelType)
37
37
38 // Prefix the model name with region
38 // Prefix the model name with region
@@ -88,6 +88,6 @@ func (b *bedrockClient) stream(ctx context.Context, messages []message.Message,
88 return b.childProvider.stream(ctx, messages, tools)
88 return b.childProvider.stream(ctx, messages, tools)
89}
89}
90
90
91−func (b *bedrockClient) Model() provider.Model {
91+func (b *bedrockClient) Model() catwalk.Model {
92 return b.providerOptions.model(b.providerOptions.modelType)
92 return b.providerOptions.model(b.providerOptions.modelType)
93}
93}
internal/llm/provider/gemini.go+4−4
@@ -10,8 +10,8 @@ import (
10 "strings"
10 "strings"
11 "time"
11 "time"
12
12
13+ "github.com/charmbracelet/catwalk/pkg/catwalk"
13 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/config"
14− "github.com/charmbracelet/crush/internal/fur/provider"
15 "github.com/charmbracelet/crush/internal/llm/tools"
15 "github.com/charmbracelet/crush/internal/llm/tools"
16 "github.com/charmbracelet/crush/internal/message"
16 "github.com/charmbracelet/crush/internal/message"
17 "github.com/google/uuid"
17 "github.com/google/uuid"
@@ -210,7 +210,7 @@ func (g *geminiClient) send(ctx context.Context, messages []message.Message, too
210 return nil, retryErr
210 return nil, retryErr
211 }
211 }
212 if retry {
212 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)
214 select {
214 select {
215 case <-ctx.Done():
215 case <-ctx.Done():
216 return nil, ctx.Err()
216 return nil, ctx.Err()
@@ -323,7 +323,7 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
323 return
323 return
324 }
324 }
325 if retry {
325 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)
327 select {
327 select {
328 case <-ctx.Done():
328 case <-ctx.Done():
329 if ctx.Err() != nil {
329 if ctx.Err() != nil {
@@ -463,7 +463,7 @@ func (g *geminiClient) usage(resp *genai.GenerateContentResponse) TokenUsage {
463 }
463 }
464}
464}
465
465
466−func (g *geminiClient) Model() provider.Model {
466+func (g *geminiClient) Model() catwalk.Model {
467 return g.providerOptions.model(g.providerOptions.modelType)
467 return g.providerOptions.model(g.providerOptions.modelType)
468}
468}
469
469
internal/llm/provider/openai.go+5−5
@@ -9,8 +9,8 @@ import (
9 "log/slog"
9 "log/slog"
10 "time"
10 "time"
11
11
12+ "github.com/charmbracelet/catwalk/pkg/catwalk"
12 "github.com/charmbracelet/crush/internal/config"
13 "github.com/charmbracelet/crush/internal/config"
13− "github.com/charmbracelet/crush/internal/fur/provider"
14 "github.com/charmbracelet/crush/internal/llm/tools"
14 "github.com/charmbracelet/crush/internal/llm/tools"
15 "github.com/charmbracelet/crush/internal/message"
15 "github.com/charmbracelet/crush/internal/message"
16 "github.com/openai/openai-go"
16 "github.com/openai/openai-go"
@@ -66,7 +66,7 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
66 textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()}
66 textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()}
67 content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock})
67 content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock})
68 for _, binaryContent := range msg.BinaryContent() {
68 for _, binaryContent := range msg.BinaryContent() {
69− imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(provider.InferenceProviderOpenAI)}
69+ imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(catwalk.InferenceProviderOpenAI)}
70 imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL}
70 imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL}
71
71
72 content = append(content, openai.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock})
72 content = append(content, openai.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock})
@@ -222,7 +222,7 @@ func (o *openaiClient) send(ctx context.Context, messages []message.Message, too
222 return nil, retryErr
222 return nil, retryErr
223 }
223 }
224 if retry {
224 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)
226 select {
226 select {
227 case <-ctx.Done():
227 case <-ctx.Done():
228 return nil, ctx.Err()
228 return nil, ctx.Err()
@@ -395,7 +395,7 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
395 return
395 return
396 }
396 }
397 if retry {
397 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)
399 select {
399 select {
400 case <-ctx.Done():
400 case <-ctx.Done():
401 // context cancelled
401 // context cancelled
@@ -486,6 +486,6 @@ func (o *openaiClient) usage(completion openai.ChatCompletion) TokenUsage {
486 }
486 }
487}
487}
488
488
489−func (o *openaiClient) Model() provider.Model {
489+func (o *openaiClient) Model() catwalk.Model {
490 return o.providerOptions.model(o.providerOptions.modelType)
490 return o.providerOptions.model(o.providerOptions.modelType)
491}
491}
internal/llm/provider/openai_test.go+5−5
@@ -9,8 +9,8 @@ import (
9 "testing"
9 "testing"
10 "time"
10 "time"
11
11
12+ "github.com/charmbracelet/catwalk/pkg/catwalk"
12 "github.com/charmbracelet/crush/internal/config"
13 "github.com/charmbracelet/crush/internal/config"
13− "github.com/charmbracelet/crush/internal/fur/provider"
14 "github.com/charmbracelet/crush/internal/llm/tools"
14 "github.com/charmbracelet/crush/internal/llm/tools"
15 "github.com/charmbracelet/crush/internal/message"
15 "github.com/charmbracelet/crush/internal/message"
16 "github.com/openai/openai-go"
16 "github.com/openai/openai-go"
@@ -55,10 +55,10 @@ func TestOpenAIClientStreamChoices(t *testing.T) {
55 modelType: config.SelectedModelTypeLarge,
55 modelType: config.SelectedModelTypeLarge,
56 apiKey: "test-key",
56 apiKey: "test-key",
57 systemMessage: "test",
57 systemMessage: "test",
58− model: func(config.SelectedModelType) provider.Model {
58+ model: func(config.SelectedModelType) catwalk.Model {
59− return provider.Model{
59+ return catwalk.Model{
60− ID: "test-model",
60+ ID: "test-model",
61− Model: "test-model",
61+ Name: "test-model",
62 }
62 }
63 },
63 },
64 },
64 },
internal/llm/provider/provider.go+13−13
@@ -4,8 +4,8 @@ import (
4 "context"
4 "context"
5 "fmt"
5 "fmt"
6
6
7+ "github.com/charmbracelet/catwalk/pkg/catwalk"
7 "github.com/charmbracelet/crush/internal/config"
8 "github.com/charmbracelet/crush/internal/config"
8− "github.com/charmbracelet/crush/internal/fur/provider"
9 "github.com/charmbracelet/crush/internal/llm/tools"
9 "github.com/charmbracelet/crush/internal/llm/tools"
10 "github.com/charmbracelet/crush/internal/message"
10 "github.com/charmbracelet/crush/internal/message"
11)
11)
@@ -57,7 +57,7 @@ type Provider interface {
57
57
58 StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
58 StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
59
59
60− Model() provider.Model
60+ Model() catwalk.Model
61}
61}
62
62
63type providerClientOptions struct {
63type providerClientOptions struct {
@@ -65,7 +65,7 @@ type providerClientOptions struct {
65 config config.ProviderConfig
65 config config.ProviderConfig
66 apiKey string
66 apiKey string
67 modelType config.SelectedModelType
67 modelType config.SelectedModelType
68− model func(config.SelectedModelType) provider.Model
68+ model func(config.SelectedModelType) catwalk.Model
69 disableCache bool
69 disableCache bool
70 systemMessage string
70 systemMessage string
71 maxTokens int64
71 maxTokens int64
@@ -80,7 +80,7 @@ type ProviderClient interface {
80 send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error)
80 send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error)
81 stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
81 stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
82
82
83− Model() provider.Model
83+ Model() catwalk.Model
84}
84}
85
85
86type baseProvider[C ProviderClient] struct {
86type baseProvider[C ProviderClient] struct {
@@ -109,7 +109,7 @@ func (p *baseProvider[C]) StreamResponse(ctx context.Context, messages []message
109 return p.client.stream(ctx, messages, tools)
109 return p.client.stream(ctx, messages, tools)
110}
110}
111
111
112−func (p *baseProvider[C]) Model() provider.Model {
112+func (p *baseProvider[C]) Model() catwalk.Model {
113 return p.client.Model()
113 return p.client.Model()
114}
114}
115
115
@@ -149,7 +149,7 @@ func NewProvider(cfg config.ProviderConfig, opts ...ProviderClientOption) (Provi
149 apiKey: resolvedAPIKey,
149 apiKey: resolvedAPIKey,
150 extraHeaders: cfg.ExtraHeaders,
150 extraHeaders: cfg.ExtraHeaders,
151 extraBody: cfg.ExtraBody,
151 extraBody: cfg.ExtraBody,
152− model: func(tp config.SelectedModelType) provider.Model {
152+ model: func(tp config.SelectedModelType) catwalk.Model {
153 return *config.Get().GetModelByType(tp)
153 return *config.Get().GetModelByType(tp)
154 },
154 },
155 }
155 }
@@ -157,37 +157,37 @@ func NewProvider(cfg config.ProviderConfig, opts ...ProviderClientOption) (Provi
157 o(&clientOptions)
157 o(&clientOptions)
158 }
158 }
159 switch cfg.Type {
159 switch cfg.Type {
160− case provider.TypeAnthropic:
160+ case catwalk.TypeAnthropic:
161 return &baseProvider[AnthropicClient]{
161 return &baseProvider[AnthropicClient]{
162 options: clientOptions,
162 options: clientOptions,
163 client: newAnthropicClient(clientOptions, false),
163 client: newAnthropicClient(clientOptions, false),
164 }, nil
164 }, nil
165− case provider.TypeOpenAI:
165+ case catwalk.TypeOpenAI:
166 return &baseProvider[OpenAIClient]{
166 return &baseProvider[OpenAIClient]{
167 options: clientOptions,
167 options: clientOptions,
168 client: newOpenAIClient(clientOptions),
168 client: newOpenAIClient(clientOptions),
169 }, nil
169 }, nil
170− case provider.TypeGemini:
170+ case catwalk.TypeGemini:
171 return &baseProvider[GeminiClient]{
171 return &baseProvider[GeminiClient]{
172 options: clientOptions,
172 options: clientOptions,
173 client: newGeminiClient(clientOptions),
173 client: newGeminiClient(clientOptions),
174 }, nil
174 }, nil
175− case provider.TypeBedrock:
175+ case catwalk.TypeBedrock:
176 return &baseProvider[BedrockClient]{
176 return &baseProvider[BedrockClient]{
177 options: clientOptions,
177 options: clientOptions,
178 client: newBedrockClient(clientOptions),
178 client: newBedrockClient(clientOptions),
179 }, nil
179 }, nil
180− case provider.TypeAzure:
180+ case catwalk.TypeAzure:
181 return &baseProvider[AzureClient]{
181 return &baseProvider[AzureClient]{
182 options: clientOptions,
182 options: clientOptions,
183 client: newAzureClient(clientOptions),
183 client: newAzureClient(clientOptions),
184 }, nil
184 }, nil
185− case provider.TypeVertexAI:
185+ case catwalk.TypeVertexAI:
186 return &baseProvider[VertexAIClient]{
186 return &baseProvider[VertexAIClient]{
187 options: clientOptions,
187 options: clientOptions,
188 client: newVertexAIClient(clientOptions),
188 client: newVertexAIClient(clientOptions),
189 }, nil
189 }, nil
190− case provider.TypeXAI:
190+ case catwalk.TypeXAI:
191 clientOptions.baseURL = "https://api.x.ai/v1"
191 clientOptions.baseURL = "https://api.x.ai/v1"
192 return &baseProvider[OpenAIClient]{
192 return &baseProvider[OpenAIClient]{
193 options: clientOptions,
193 options: clientOptions,
internal/llm/tools/bash.go+16−17
@@ -4,7 +4,6 @@ import (
4 "context"
4 "context"
5 "encoding/json"
5 "encoding/json"
6 "fmt"
6 "fmt"
7− "log/slog"
8 "strings"
7 "strings"
9 "time"
8 "time"
10
9
@@ -23,8 +22,10 @@ type BashPermissionsParams struct {
23}
22}
24
23
25type BashResponseMetadata struct {
24type BashResponseMetadata struct {
26− StartTime int64 `json:"start_time"`
25+ StartTime int64 `json:"start_time"`
27− EndTime int64 `json:"end_time"`
26+ EndTime int64 `json:"end_time"`
27+ Output string `json:"output"`
28+ WorkingDirectory string `json:"working_directory"`
28}
29}
29type bashTool struct {
30type bashTool struct {
30 permissions permission.Service
31 permissions permission.Service
@@ -146,6 +147,7 @@ Before executing the command, please follow these steps:
1465. Return Result:
1475. Return Result:
147 - Provide the processed output of the command.
148 - Provide the processed output of the command.
148 - If any errors occurred during execution, include those in the output.
149 - 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.
149
151
150Usage notes:
152Usage notes:
151- The command argument is required.
153- The command argument is required.
@@ -389,9 +391,12 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
389 ctx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Millisecond)
391 ctx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Millisecond)
390 defer cancel()
392 defer cancel()
391 }
393 }
392− stdout, stderr, err := shell.
394+
393− GetPersistentShell(b.workingDir).
395+ persistentShell := shell.GetPersistentShell(b.workingDir)
394− Exec(ctx, params.Command)
396+ stdout, stderr, err := persistentShell.Exec(ctx, params.Command)
397+
398+ // Get the current working directory after command execution
399+ currentWorkingDir := persistentShell.GetWorkingDir()
395 interrupted := shell.IsInterrupt(err)
400 interrupted := shell.IsInterrupt(err)
396 exitCode := shell.ExitCode(err)
401 exitCode := shell.ExitCode(err)
397 if exitCode == 0 && !interrupted && err != nil {
402 if exitCode == 0 && !interrupted && err != nil {
@@ -401,15 +406,6 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
401 stdout = truncateOutput(stdout)
406 stdout = truncateOutput(stdout)
402 stderr = truncateOutput(stderr)
407 stderr = truncateOutput(stderr)
403
408
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−
413 errorMessage := stderr
409 errorMessage := stderr
414 if errorMessage == "" && err != nil {
410 if errorMessage == "" && err != nil {
415 errorMessage = err.Error()
411 errorMessage = err.Error()
@@ -438,9 +434,12 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
438 }
434 }
439
435
440 metadata := BashResponseMetadata{
436 metadata := BashResponseMetadata{
441− StartTime: startTime.UnixMilli(),
437+ StartTime: startTime.UnixMilli(),
442− EndTime: time.Now().UnixMilli(),
438+ EndTime: time.Now().UnixMilli(),
439+ Output: stdout,
440+ WorkingDirectory: currentWorkingDir,
443 }
441 }
442+ stdout += fmt.Sprintf("\n\n<cwd>%s</cwd>", currentWorkingDir)
444 if stdout == "" {
443 if stdout == "" {
445 return WithResponseMetadata(NewTextResponse(BashNoOutput), metadata), nil
444 return WithResponseMetadata(NewTextResponse(BashNoOutput), metadata), nil
446 }
445 }
internal/llm/tools/glob.go+1−1
@@ -146,7 +146,7 @@ func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
146 if err == nil {
146 if err == nil {
147 return matches, len(matches) >= limit && limit > 0, nil
147 return matches, len(matches) >= limit && limit > 0, nil
148 }
148 }
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)
150 }
150 }
151
151
152 return fsext.GlobWithDoubleStar(pattern, searchPath, limit)
152 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
90 slog.Debug("BaseURI", "baseURI", u)
90 slog.Debug("BaseURI", "baseURI", u)
91 }
91 }
92 default:
92 default:
93− slog.Debug("GlobPattern", "unknown type", fmt.Sprintf("%T", v))
93+ slog.Debug("GlobPattern unknown type", "type", fmt.Sprintf("%T", v))
94 }
94 }
95
95
96 // Log WatchKind
96 // Log WatchKind
internal/message/content.go+3−3
@@ -5,7 +5,7 @@ import (
5 "slices"
5 "slices"
6 "time"
6 "time"
7
7
8− "github.com/charmbracelet/crush/internal/fur/provider"
8+ "github.com/charmbracelet/catwalk/pkg/catwalk"
9)
9)
10
10
11type MessageRole string
11type MessageRole string
@@ -74,9 +74,9 @@ type BinaryContent struct {
74 Data []byte
74 Data []byte
75}
75}
76
76
77−func (bc BinaryContent) String(p provider.InferenceProvider) string {
77+func (bc BinaryContent) String(p catwalk.InferenceProvider) string {
78 base64Encoded := base64.StdEncoding.EncodeToString(bc.Data)
78 base64Encoded := base64.StdEncoding.EncodeToString(bc.Data)
79− if p == provider.InferenceProviderOpenAI {
79+ if p == catwalk.InferenceProviderOpenAI {
80 return "data:" + bc.MIMEType + ";base64," + base64Encoded
80 return "data:" + bc.MIMEType + ";base64," + base64Encoded
81 }
81 }
82 return base64Encoded
82 return base64Encoded
internal/permission/permission.go+9−1
@@ -50,6 +50,7 @@ type permissionService struct {
50 autoApproveSessions []string
50 autoApproveSessions []string
51 autoApproveSessionsMu sync.RWMutex
51 autoApproveSessionsMu sync.RWMutex
52 skip bool
52 skip bool
53+ allowedTools []string
53}
54}
54
55
55func (s *permissionService) GrantPersistent(permission PermissionRequest) {
56func (s *permissionService) GrantPersistent(permission PermissionRequest) {
@@ -82,6 +83,12 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
82 return true
83 return true
83 }
84 }
84
85
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+
85 s.autoApproveSessionsMu.RLock()
92 s.autoApproveSessionsMu.RLock()
86 autoApprove := slices.Contains(s.autoApproveSessions, opts.SessionID)
93 autoApprove := slices.Contains(s.autoApproveSessions, opts.SessionID)
87 s.autoApproveSessionsMu.RUnlock()
94 s.autoApproveSessionsMu.RUnlock()
@@ -130,11 +137,12 @@ func (s *permissionService) AutoApproveSession(sessionID string) {
130 s.autoApproveSessionsMu.Unlock()
137 s.autoApproveSessionsMu.Unlock()
131}
138}
132
139
133−func NewPermissionService(workingDir string, skip bool) Service {
140+func NewPermissionService(workingDir string, skip bool, allowedTools []string) Service {
134 return &permissionService{
141 return &permissionService{
135 Broker: pubsub.NewBroker[PermissionRequest](),
142 Broker: pubsub.NewBroker[PermissionRequest](),
136 workingDir: workingDir,
143 workingDir: workingDir,
137 sessionPermissions: make([]PermissionRequest, 0),
144 sessionPermissions: make([]PermissionRequest, 0),
138 skip: skip,
145 skip: skip,
146+ allowedTools: allowedTools,
139 }
147 }
140}
148}
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,
221 interp.Interactive(false),
221 interp.Interactive(false),
222 interp.Env(expand.ListEnviron(s.env...)),
222 interp.Env(expand.ListEnviron(s.env...)),
223 interp.Dir(s.cwd),
223 interp.Dir(s.cwd),
224− interp.ExecHandlers(s.blockHandler()),
224+ interp.ExecHandlers(s.blockHandler(), s.coreUtilsHandler()),
225 )
225 )
226 if err != nil {
226 if err != nil {
227 return "", "", fmt.Errorf("could not run command: %w", err)
227 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 {
289 var b strings.Builder
289 var b strings.Builder
290 for i := range a.width {
290 for i := range a.width {
291 switch {
291 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]:
293 // Birth offset not reached: render initial character.
293 // Birth offset not reached: render initial character.
294 b.WriteString(a.initialFrames[a.step][i])
294 b.WriteString(a.initialFrames[a.step][i])
295 case i < a.cyclingCharWidth:
295 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) {
187 value = value[:m.completionsStartIndex]
187 value = value[:m.completionsStartIndex]
188 value += item.Path
188 value += item.Path
189 m.textarea.SetValue(value)
189 m.textarea.SetValue(value)
190+ if !msg.Insert {
190− m.isCompletionsOpen = false
191+ m.isCompletionsOpen = false
191− m.currentQuery = ""
192+ m.currentQuery = ""
192− m.completionsStartIndex = 0
193+ m.completionsStartIndex = 0
194+ }
193 return m, nil
195 return m, nil
194 }
196 }
195 case openEditorMsg:
197 case openEditorMsg:
internal/tui/components/chat/messages/messages.go+4−4
@@ -8,11 +8,11 @@ import (
8
8
9 "github.com/charmbracelet/bubbles/v2/viewport"
9 "github.com/charmbracelet/bubbles/v2/viewport"
10 tea "github.com/charmbracelet/bubbletea/v2"
10 tea "github.com/charmbracelet/bubbletea/v2"
11+ "github.com/charmbracelet/catwalk/pkg/catwalk"
11 "github.com/charmbracelet/lipgloss/v2"
12 "github.com/charmbracelet/lipgloss/v2"
12 "github.com/charmbracelet/x/ansi"
13 "github.com/charmbracelet/x/ansi"
13
14
14 "github.com/charmbracelet/crush/internal/config"
15 "github.com/charmbracelet/crush/internal/config"
15− "github.com/charmbracelet/crush/internal/fur/provider"
16 "github.com/charmbracelet/crush/internal/message"
16 "github.com/charmbracelet/crush/internal/message"
17 "github.com/charmbracelet/crush/internal/tui/components/anim"
17 "github.com/charmbracelet/crush/internal/tui/components/anim"
18 "github.com/charmbracelet/crush/internal/tui/components/core"
18 "github.com/charmbracelet/crush/internal/tui/components/core"
@@ -369,11 +369,11 @@ func (m *assistantSectionModel) View() string {
369 model := config.Get().GetModel(m.message.Provider, m.message.Model)
369 model := config.Get().GetModel(m.message.Provider, m.message.Model)
370 if model == nil {
370 if model == nil {
371 // This means the model is not configured anymore
371 // This means the model is not configured anymore
372− model = &provider.Model{
372+ model = &catwalk.Model{
373− Model: "Unknown Model",
373+ Name: "Unknown Model",
374 }
374 }
375 }
375 }
376− modelFormatted := t.S().Muted.Render(model.Model)
376+ modelFormatted := t.S().Muted.Render(model.Name)
377 assistant := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg)
377 assistant := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg)
378 return t.S().Base.PaddingLeft(2).Render(
378 return t.S().Base.PaddingLeft(2).Render(
379 core.Section(assistant, m.width-2),
379 core.Section(assistant, m.width-2),
internal/tui/components/chat/messages/renderer.go+14−21
@@ -6,6 +6,7 @@ import (
6 "strings"
6 "strings"
7 "time"
7 "time"
8
8
9+ "github.com/charmbracelet/crush/internal/ansiext"
9 "github.com/charmbracelet/crush/internal/fsext"
10 "github.com/charmbracelet/crush/internal/fsext"
10 "github.com/charmbracelet/crush/internal/llm/agent"
11 "github.com/charmbracelet/crush/internal/llm/agent"
11 "github.com/charmbracelet/crush/internal/llm/tools"
12 "github.com/charmbracelet/crush/internal/llm/tools"
@@ -212,10 +213,19 @@ func (br bashRenderer) Render(v *toolCallCmp) string {
212 args := newParamBuilder().addMain(cmd).build()
213 args := newParamBuilder().addMain(cmd).build()
213
214
214 return br.renderWithParams(v, "Bash", args, func() string {
215 return br.renderWithParams(v, "Bash", args, func() string {
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.
215− if v.result.Content == tools.BashNoOutput {
221+ if meta.Output == "" && v.result.Content != tools.BashNoOutput {
222+ meta.Output = v.result.Content
223+ }
224+
225+ if meta.Output == "" {
216 return ""
226 return ""
217 }
227 }
218− return renderPlainContent(v, v.result.Content)
228+ return renderPlainContent(v, meta.Output)
219 })
229 })
220}
230}
221
231
@@ -693,7 +703,7 @@ func renderPlainContent(v *toolCallCmp, content string) string {
693 if i >= responseContextHeight {
703 if i >= responseContextHeight {
694 break
704 break
695 }
705 }
696− ln = escapeContent(ln)
706+ ln = ansiext.Escape(ln)
697 ln = " " + ln // left padding
707 ln = " " + ln // left padding
698 if len(ln) > width {
708 if len(ln) > width {
699 ln = v.fit(ln, width)
709 ln = v.fit(ln, width)
@@ -731,7 +741,7 @@ func renderCodeContent(v *toolCallCmp, path, content string, offset int) string
731
741
732 lines := strings.Split(truncated, "\n")
742 lines := strings.Split(truncated, "\n")
733 for i, ln := range lines {
743 for i, ln := range lines {
734− lines[i] = escapeContent(ln)
744+ lines[i] = ansiext.Escape(ln)
735 }
745 }
736
746
737 highlighted, _ := highlight.SyntaxHighlight(strings.Join(lines, "\n"), path, t.BgBase)
747 highlighted, _ := highlight.SyntaxHighlight(strings.Join(lines, "\n"), path, t.BgBase)
@@ -807,20 +817,3 @@ func prettifyToolName(name string) string {
807 return name
817 return name
808 }
818 }
809}
819}
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 (
9 "sync"
9 "sync"
10
10
11 tea "github.com/charmbracelet/bubbletea/v2"
11 tea "github.com/charmbracelet/bubbletea/v2"
12+ "github.com/charmbracelet/catwalk/pkg/catwalk"
12 "github.com/charmbracelet/crush/internal/config"
13 "github.com/charmbracelet/crush/internal/config"
13 "github.com/charmbracelet/crush/internal/diff"
14 "github.com/charmbracelet/crush/internal/diff"
14 "github.com/charmbracelet/crush/internal/fsext"
15 "github.com/charmbracelet/crush/internal/fsext"
15− "github.com/charmbracelet/crush/internal/fur/provider"
16 "github.com/charmbracelet/crush/internal/history"
16 "github.com/charmbracelet/crush/internal/history"
17 "github.com/charmbracelet/crush/internal/lsp"
17 "github.com/charmbracelet/crush/internal/lsp"
18 "github.com/charmbracelet/crush/internal/lsp/protocol"
18 "github.com/charmbracelet/crush/internal/lsp/protocol"
@@ -897,7 +897,7 @@ func (s *sidebarCmp) currentModelBlock() string {
897 t := styles.CurrentTheme()
897 t := styles.CurrentTheme()
898
898
899 modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
899 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)
901 modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
901 modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
902 parts := []string{
902 parts := []string{
903 modelInfo,
903 modelInfo,
@@ -905,14 +905,14 @@ func (s *sidebarCmp) currentModelBlock() string {
905 if model.CanReason {
905 if model.CanReason {
906 reasoningInfoStyle := t.S().Subtle.PaddingLeft(2)
906 reasoningInfoStyle := t.S().Subtle.PaddingLeft(2)
907 switch modelProvider.Type {
907 switch modelProvider.Type {
908− case provider.TypeOpenAI:
908+ case catwalk.TypeOpenAI:
909 reasoningEffort := model.DefaultReasoningEffort
909 reasoningEffort := model.DefaultReasoningEffort
910 if selectedModel.ReasoningEffort != "" {
910 if selectedModel.ReasoningEffort != "" {
911 reasoningEffort = selectedModel.ReasoningEffort
911 reasoningEffort = selectedModel.ReasoningEffort
912 }
912 }
913 formatter := cases.Title(language.English, cases.NoLower)
913 formatter := cases.Title(language.English, cases.NoLower)
914 parts = append(parts, reasoningInfoStyle.Render(formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort))))
914 parts = append(parts, reasoningInfoStyle.Render(formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort))))
915− case provider.TypeAnthropic:
915+ case catwalk.TypeAnthropic:
916 formatter := cases.Title(language.English, cases.NoLower)
916 formatter := cases.Title(language.English, cases.NoLower)
917 if selectedModel.Think {
917 if selectedModel.Think {
918 parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking on")))
918 parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking on")))
internal/tui/components/chat/splash/splash.go+3−3
@@ -10,8 +10,8 @@ import (
10 "github.com/charmbracelet/bubbles/v2/key"
10 "github.com/charmbracelet/bubbles/v2/key"
11 "github.com/charmbracelet/bubbles/v2/spinner"
11 "github.com/charmbracelet/bubbles/v2/spinner"
12 tea "github.com/charmbracelet/bubbletea/v2"
12 tea "github.com/charmbracelet/bubbletea/v2"
13+ "github.com/charmbracelet/catwalk/pkg/catwalk"
13 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/config"
14− "github.com/charmbracelet/crush/internal/fur/provider"
15 "github.com/charmbracelet/crush/internal/llm/prompt"
15 "github.com/charmbracelet/crush/internal/llm/prompt"
16 "github.com/charmbracelet/crush/internal/tui/components/chat"
16 "github.com/charmbracelet/crush/internal/tui/components/chat"
17 "github.com/charmbracelet/crush/internal/tui/components/completions"
17 "github.com/charmbracelet/crush/internal/tui/components/completions"
@@ -109,7 +109,7 @@ func (s *splashCmp) SetOnboarding(onboarding bool) {
109 if err != nil {
109 if err != nil {
110 return
110 return
111 }
111 }
112− filteredProviders := []provider.Provider{}
112+ filteredProviders := []catwalk.Provider{}
113 simpleProviders := []string{
113 simpleProviders := []string{
114 "anthropic",
114 "anthropic",
115 "openai",
115 "openai",
@@ -407,7 +407,7 @@ func (s *splashCmp) setPreferredModel(selectedItem models.ModelOption) tea.Cmd {
407 return nil
407 return nil
408}
408}
409
409
410−func (s *splashCmp) getProvider(providerID provider.InferenceProvider) (*provider.Provider, error) {
410+func (s *splashCmp) getProvider(providerID catwalk.InferenceProvider) (*catwalk.Provider, error) {
411 providers, err := config.Providers()
411 providers, err := config.Providers()
412 if err != nil {
412 if err != nil {
413 return nil, err
413 return nil, err
internal/tui/components/completions/completions.go+26−1
@@ -36,7 +36,8 @@ type CompletionsOpenedMsg struct{}
36type CloseCompletionsMsg struct{}
36type CloseCompletionsMsg struct{}
37
37
38type SelectCompletionMsg struct {
38type 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
40}
41}
41
42
42type Completions interface {
43type Completions interface {
@@ -115,6 +116,30 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
115 d, cmd := c.list.Update(msg)
116 d, cmd := c.list.Update(msg)
116 c.list = d.(list.ListModel)
117 c.list = d.(list.ListModel)
117 return c, cmd
118 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+ })
118 case key.Matches(msg, c.keyMap.Select):
143 case key.Matches(msg, c.keyMap.Select):
119 selectedItemInx := c.list.SelectedIndex()
144 selectedItemInx := c.list.SelectedIndex()
120 if selectedItemInx == list.NoSelection {
145 if selectedItemInx == list.NoSelection {
internal/tui/components/completions/keys.go+10
@@ -9,6 +9,8 @@ type KeyMap struct {
9 Up,
9 Up,
10 Select,
10 Select,
11 Cancel key.Binding
11 Cancel key.Binding
12+ DownInsert,
13+ UpInsert key.Binding
12}
14}
13
15
14func DefaultKeyMap() KeyMap {
16func DefaultKeyMap() KeyMap {
@@ -29,6 +31,14 @@ func DefaultKeyMap() KeyMap {
29 key.WithKeys("esc"),
31 key.WithKeys("esc"),
30 key.WithHelp("esc", "cancel"),
32 key.WithHelp("esc", "cancel"),
31 ),
33 ),
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+ ),
32 }
42 }
33}
43}
34
44
internal/tui/components/core/status/status.go+4−9
@@ -1,14 +1,12 @@
1package status
1package status
2
2
3import (
3import (
4− "strings"
5 "time"
4 "time"
6
5
7 "github.com/charmbracelet/bubbles/v2/help"
6 "github.com/charmbracelet/bubbles/v2/help"
8 tea "github.com/charmbracelet/bubbletea/v2"
7 tea "github.com/charmbracelet/bubbletea/v2"
9 "github.com/charmbracelet/crush/internal/tui/styles"
8 "github.com/charmbracelet/crush/internal/tui/styles"
10 "github.com/charmbracelet/crush/internal/tui/util"
9 "github.com/charmbracelet/crush/internal/tui/util"
11− "github.com/charmbracelet/lipgloss/v2"
12 "github.com/charmbracelet/x/ansi"
10 "github.com/charmbracelet/x/ansi"
13)
11)
14
12
@@ -74,18 +72,15 @@ func (m *statusCmp) infoMsg() string {
74 switch m.info.Type {
72 switch m.info.Type {
75 case util.InfoTypeError:
73 case util.InfoTypeError:
76 infoType = t.S().Base.Background(t.Red).Padding(0, 1).Render("ERROR")
74 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)
79 case util.InfoTypeWarn:
76 case util.InfoTypeWarn:
80 infoType = t.S().Base.Foreground(t.BgOverlay).Background(t.Yellow).Padding(0, 1).Render("WARNING")
77 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)
83 default:
79 default:
84 infoType = t.S().Base.Foreground(t.BgOverlay).Background(t.Green).Padding(0, 1).Render("OKAY!")
80 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)
87 }
82 }
88− return strings.Join([]string{infoType, message}, "")
83+ return ansi.Truncate(infoType+message, m.width, "…")
89}
84}
90
85
91func (m *statusCmp) ToggleFullHelp() {
86func (m *statusCmp) ToggleFullHelp() {
internal/tui/components/dialogs/commands/commands.go+2−2
@@ -4,10 +4,10 @@ import (
4 "github.com/charmbracelet/bubbles/v2/help"
4 "github.com/charmbracelet/bubbles/v2/help"
5 "github.com/charmbracelet/bubbles/v2/key"
5 "github.com/charmbracelet/bubbles/v2/key"
6 tea "github.com/charmbracelet/bubbletea/v2"
6 tea "github.com/charmbracelet/bubbletea/v2"
7+ "github.com/charmbracelet/catwalk/pkg/catwalk"
7 "github.com/charmbracelet/lipgloss/v2"
8 "github.com/charmbracelet/lipgloss/v2"
8
9
9 "github.com/charmbracelet/crush/internal/config"
10 "github.com/charmbracelet/crush/internal/config"
10− "github.com/charmbracelet/crush/internal/fur/provider"
11 "github.com/charmbracelet/crush/internal/llm/prompt"
11 "github.com/charmbracelet/crush/internal/llm/prompt"
12 "github.com/charmbracelet/crush/internal/tui/components/chat"
12 "github.com/charmbracelet/crush/internal/tui/components/chat"
13 "github.com/charmbracelet/crush/internal/tui/components/completions"
13 "github.com/charmbracelet/crush/internal/tui/components/completions"
@@ -270,7 +270,7 @@ func (c *commandDialogCmp) defaultCommands() []Command {
270 providerCfg := cfg.GetProviderForModel(agentCfg.Model)
270 providerCfg := cfg.GetProviderForModel(agentCfg.Model)
271 model := cfg.GetModelByType(agentCfg.Model)
271 model := cfg.GetModelByType(agentCfg.Model)
272 if providerCfg != nil && model != nil &&
272 if providerCfg != nil && model != nil &&
273− providerCfg.Type == provider.TypeAnthropic && model.CanReason {
273+ providerCfg.Type == catwalk.TypeAnthropic && model.CanReason {
274 selectedModel := cfg.Models[agentCfg.Model]
274 selectedModel := cfg.Models[agentCfg.Model]
275 status := "Enable"
275 status := "Enable"
276 if selectedModel.Think {
276 if selectedModel.Think {
internal/tui/components/dialogs/models/list.go+11−11
@@ -5,8 +5,8 @@ import (
5 "slices"
5 "slices"
6
6
7 tea "github.com/charmbracelet/bubbletea/v2"
7 tea "github.com/charmbracelet/bubbletea/v2"
8+ "github.com/charmbracelet/catwalk/pkg/catwalk"
8 "github.com/charmbracelet/crush/internal/config"
9 "github.com/charmbracelet/crush/internal/config"
9− "github.com/charmbracelet/crush/internal/fur/provider"
10 "github.com/charmbracelet/crush/internal/tui/components/completions"
10 "github.com/charmbracelet/crush/internal/tui/components/completions"
11 "github.com/charmbracelet/crush/internal/tui/components/core/list"
11 "github.com/charmbracelet/crush/internal/tui/components/core/list"
12 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
12 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
@@ -18,7 +18,7 @@ import (
18type ModelListComponent struct {
18type ModelListComponent struct {
19 list list.ListModel
19 list list.ListModel
20 modelType int
20 modelType int
21− providers []provider.Provider
21+ providers []catwalk.Provider
22}
22}
23
23
24func NewModelListComponent(keyMap list.KeyMap, inputStyle lipgloss.Style, inputPlaceholder string) *ModelListComponent {
24func NewModelListComponent(keyMap list.KeyMap, inputStyle lipgloss.Style, inputPlaceholder string) *ModelListComponent {
@@ -109,19 +109,19 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
109 }
109 }
110
110
111 // Check if this provider is not in the known providers list
111 // 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) }) {
113 // Convert config provider to provider.Provider format
113 // Convert config provider to provider.Provider format
114− configProvider := provider.Provider{
114+ configProvider := catwalk.Provider{
115 Name: providerConfig.Name,
115 Name: providerConfig.Name,
116− ID: provider.InferenceProvider(providerID),
116+ ID: catwalk.InferenceProvider(providerID),
117− Models: make([]provider.Model, len(providerConfig.Models)),
117+ Models: make([]catwalk.Model, len(providerConfig.Models)),
118 }
118 }
119
119
120 // Convert models
120 // Convert models
121 for i, model := range providerConfig.Models {
121 for i, model := range providerConfig.Models {
122− configProvider.Models[i] = provider.Model{
122+ configProvider.Models[i] = catwalk.Model{
123 ID: model.ID,
123 ID: model.ID,
124− Model: model.Model,
124+ Name: model.Name,
125 CostPer1MIn: model.CostPer1MIn,
125 CostPer1MIn: model.CostPer1MIn,
126 CostPer1MOut: model.CostPer1MOut,
126 CostPer1MOut: model.CostPer1MOut,
127 CostPer1MInCached: model.CostPer1MInCached,
127 CostPer1MInCached: model.CostPer1MInCached,
@@ -144,7 +144,7 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
144 section.SetInfo(configured)
144 section.SetInfo(configured)
145 modelItems = append(modelItems, section)
145 modelItems = append(modelItems, section)
146 for _, model := range configProvider.Models {
146 for _, model := range configProvider.Models {
147− modelItems = append(modelItems, completions.NewCompletionItem(model.Model, ModelOption{
147+ modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{
148 Provider: configProvider,
148 Provider: configProvider,
149 Model: model,
149 Model: model,
150 }))
150 }))
@@ -179,7 +179,7 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
179 }
179 }
180 modelItems = append(modelItems, section)
180 modelItems = append(modelItems, section)
181 for _, model := range provider.Models {
181 for _, model := range provider.Models {
182− modelItems = append(modelItems, completions.NewCompletionItem(model.Model, ModelOption{
182+ modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{
183 Provider: provider,
183 Provider: provider,
184 Model: model,
184 Model: model,
185 }))
185 }))
@@ -201,6 +201,6 @@ func (m *ModelListComponent) SetInputPlaceholder(placeholder string) {
201 m.list.SetFilterPlaceholder(placeholder)
201 m.list.SetFilterPlaceholder(placeholder)
202}
202}
203
203
204−func (m *ModelListComponent) SetProviders(providers []provider.Provider) {
204+func (m *ModelListComponent) SetProviders(providers []catwalk.Provider) {
205 m.providers = providers
205 m.providers = providers
206}
206}
internal/tui/components/dialogs/models/models.go+4−4
@@ -8,8 +8,8 @@ import (
8 "github.com/charmbracelet/bubbles/v2/key"
8 "github.com/charmbracelet/bubbles/v2/key"
9 "github.com/charmbracelet/bubbles/v2/spinner"
9 "github.com/charmbracelet/bubbles/v2/spinner"
10 tea "github.com/charmbracelet/bubbletea/v2"
10 tea "github.com/charmbracelet/bubbletea/v2"
11+ "github.com/charmbracelet/catwalk/pkg/catwalk"
11 "github.com/charmbracelet/crush/internal/config"
12 "github.com/charmbracelet/crush/internal/config"
12− "github.com/charmbracelet/crush/internal/fur/provider"
13 "github.com/charmbracelet/crush/internal/tui/components/completions"
13 "github.com/charmbracelet/crush/internal/tui/components/completions"
14 "github.com/charmbracelet/crush/internal/tui/components/core"
14 "github.com/charmbracelet/crush/internal/tui/components/core"
15 "github.com/charmbracelet/crush/internal/tui/components/core/list"
15 "github.com/charmbracelet/crush/internal/tui/components/core/list"
@@ -48,8 +48,8 @@ type ModelDialog interface {
48}
48}
49
49
50type ModelOption struct {
50type ModelOption struct {
51− Provider provider.Provider
51+ Provider catwalk.Provider
52− Model provider.Model
52+ Model catwalk.Model
53}
53}
54
54
55type modelDialogCmp struct {
55type modelDialogCmp struct {
@@ -363,7 +363,7 @@ func (m *modelDialogCmp) isProviderConfigured(providerID string) bool {
363 return false
363 return false
364}
364}
365
365
366−func (m *modelDialogCmp) getProvider(providerID provider.InferenceProvider) (*provider.Provider, error) {
366+func (m *modelDialogCmp) getProvider(providerID catwalk.InferenceProvider) (*catwalk.Provider, error) {
367 providers, err := config.Providers()
367 providers, err := config.Providers()
368 if err != nil {
368 if err != nil {
369 return nil, err
369 return nil, err
internal/tui/exp/diffview/chroma.go+7−2
@@ -4,8 +4,10 @@ import (
4 "fmt"
4 "fmt"
5 "image/color"
5 "image/color"
6 "io"
6 "io"
7+ "strings"
7
8
8 "github.com/alecthomas/chroma/v2"
9 "github.com/alecthomas/chroma/v2"
10+ "github.com/charmbracelet/crush/internal/ansiext"
9 "github.com/charmbracelet/lipgloss/v2"
11 "github.com/charmbracelet/lipgloss/v2"
10)
12)
11
13
@@ -20,9 +22,12 @@ type chromaFormatter struct {
20// Format implements the chroma.Formatter interface.
22// Format implements the chroma.Formatter interface.
21func (c chromaFormatter) Format(w io.Writer, style *chroma.Style, it chroma.Iterator) error {
23func (c chromaFormatter) Format(w io.Writer, style *chroma.Style, it chroma.Iterator) error {
22 for token := it(); token != chroma.EOF; token = it() {
24 for token := it(); token != chroma.EOF; token = it() {
25+ value := strings.TrimRight(token.Value, "\n")
26+ value = ansiext.Escape(value)
27+
23 entry := style.Get(token.Type)
28 entry := style.Get(token.Type)
24 if entry.IsZero() {
29 if entry.IsZero() {
25− if _, err := fmt.Fprint(w, token.Value); err != nil {
30+ if _, err := fmt.Fprint(w, value); err != nil {
26 return err
31 return err
27 }
32 }
28 continue
33 continue
@@ -44,7 +49,7 @@ func (c chromaFormatter) Format(w io.Writer, style *chroma.Style, it chroma.Iter
44 s = s.Foreground(lipgloss.Color(entry.Colour.String()))
49 s = s.Foreground(lipgloss.Color(entry.Colour.String()))
45 }
50 }
46
51
47− if _, err := fmt.Fprint(w, s.Render(token.Value)); err != nil {
52+ if _, err := fmt.Fprint(w, s.Render(value)); err != nil {
48 return err
53 return err
49 }
54 }
50 }
55 }
internal/tui/exp/diffview/diffview.go+9−4
@@ -193,6 +193,7 @@ func (dv *DiffView) clearSyntaxCache() {
193
193
194// String returns the string representation of the DiffView.
194// String returns the string representation of the DiffView.
195func (dv *DiffView) String() string {
195func (dv *DiffView) String() string {
196+ dv.normalizeLineEndings()
196 dv.replaceTabs()
197 dv.replaceTabs()
197 if err := dv.computeDiff(); err != nil {
198 if err := dv.computeDiff(); err != nil {
198 return err.Error()
199 return err.Error()
@@ -227,6 +228,12 @@ func (dv *DiffView) String() string {
227 }
228 }
228}
229}
229
230
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+
230// replaceTabs replaces tabs in the before and after file contents with spaces
237// replaceTabs replaces tabs in the before and after file contents with spaces
231// according to the specified tab width.
238// according to the specified tab width.
232func (dv *DiffView) replaceTabs() {
239func (dv *DiffView) replaceTabs() {
@@ -396,8 +403,7 @@ func (dv *DiffView) renderUnified() string {
396 shouldWrite := func() bool { return printedLines >= 0 }
403 shouldWrite := func() bool { return printedLines >= 0 }
397
404
398 getContent := func(in string, ls LineStyle) (content string, leadingEllipsis bool) {
405 getContent := func(in string, ls LineStyle) (content string, leadingEllipsis bool) {
399− content = strings.ReplaceAll(in, "\r\n", "\n")
406+ content = strings.TrimSuffix(in, "\n")
400− content = strings.TrimSuffix(content, "\n")
401 content = dv.hightlightCode(content, ls.Code.GetBackground())
407 content = dv.hightlightCode(content, ls.Code.GetBackground())
402 content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
408 content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
403 content = ansi.Truncate(content, dv.codeWidth, "…")
409 content = ansi.Truncate(content, dv.codeWidth, "…")
@@ -520,8 +526,7 @@ func (dv *DiffView) renderSplit() string {
520 shouldWrite := func() bool { return printedLines >= 0 }
526 shouldWrite := func() bool { return printedLines >= 0 }
521
527
522 getContent := func(in string, ls LineStyle) (content string, leadingEllipsis bool) {
528 getContent := func(in string, ls LineStyle) (content string, leadingEllipsis bool) {
523− content = strings.ReplaceAll(in, "\r\n", "\n")
529+ content = strings.TrimSuffix(in, "\n")
524− content = strings.TrimSuffix(content, "\n")
525 content = dv.hightlightCode(content, ls.Code.GetBackground())
530 content = dv.hightlightCode(content, ls.Code.GetBackground())
526 content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
531 content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
527 content = ansi.Truncate(content, dv.codeWidth, "…")
532 content = ansi.Truncate(content, dv.codeWidth, "…")
internal/tui/exp/diffview/diffview_test.go+26
@@ -36,6 +36,12 @@ var TestTabsBefore string
36//go:embed testdata/TestTabs.after
36//go:embed testdata/TestTabs.after
37var TestTabsAfter string
37var TestTabsAfter string
38
38
39+//go:embed testdata/TestLineBreakIssue.before
40+var TestLineBreakIssueBefore string
41+
42+//go:embed testdata/TestLineBreakIssue.after
43+var TestLineBreakIssueAfter string
44+
39type (
45type (
40 TestFunc func(dv *diffview.DiffView) *diffview.DiffView
46 TestFunc func(dv *diffview.DiffView) *diffview.DiffView
41 TestFuncs map[string]TestFunc
47 TestFuncs map[string]TestFunc
@@ -177,6 +183,26 @@ func TestDiffViewTabs(t *testing.T) {
177 }
183 }
178}
184}
179
185
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+
180func TestDiffViewWidth(t *testing.T) {
206func TestDiffViewWidth(t *testing.T) {
181 for layoutName, layoutFunc := range LayoutFuncs {
207 for layoutName, layoutFunc := range LayoutFuncs {
182 t.Run(layoutName, func(t *testing.T) {
208 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) {
279 if model.SupportsImages {
279 if model.SupportsImages {
280 return p, util.CmdHandler(OpenFilePickerMsg{})
280 return p, util.CmdHandler(OpenFilePickerMsg{})
281 } else {
281 } 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)
283 }
283 }
284 case key.Matches(msg, p.keyMap.Tab):
284 case key.Matches(msg, p.keyMap.Tab):
285 if p.session.ID == "" {
285 if p.session.ID == "" {
internal/tui/tui.go+41−23
@@ -3,6 +3,7 @@ package tui
3import (
3import (
4 "context"
4 "context"
5 "fmt"
5 "fmt"
6+ "strings"
6
7
7 "github.com/charmbracelet/bubbles/v2/key"
8 "github.com/charmbracelet/bubbles/v2/key"
8 tea "github.com/charmbracelet/bubbletea/v2"
9 tea "github.com/charmbracelet/bubbletea/v2"
@@ -112,6 +113,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
112 }
113 }
113 return a, tea.Batch(cmds...)
114 return a, tea.Batch(cmds...)
114 case tea.WindowSizeMsg:
115 case tea.WindowSizeMsg:
116+ a.wWidth, a.wHeight = msg.Width, msg.Height
115 a.completions.Update(msg)
117 a.completions.Update(msg)
116 return a, a.handleWindowResize(msg.Width, msg.Height)
118 return a, a.handleWindowResize(msg.Width, msg.Height)
117
119
@@ -290,7 +292,6 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
290// handleWindowResize processes window resize events and updates all components.
292// handleWindowResize processes window resize events and updates all components.
291func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
293func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
292 var cmds []tea.Cmd
294 var cmds []tea.Cmd
293− a.wWidth, a.wHeight = width, height
294 if a.showingFullHelp {
295 if a.showingFullHelp {
295 height -= 5
296 height -= 5
296 } else {
297 } else {
@@ -319,26 +320,20 @@ func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
319
320
320// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
321// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
321func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
322func (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+ }
322 switch {
335 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
342 case key.Matches(msg, a.keyMap.Help):
337 case key.Matches(msg, a.keyMap.Help):
343 a.status.ToggleFullHelp()
338 a.status.ToggleFullHelp()
344 a.showingFullHelp = !a.showingFullHelp
339 a.showingFullHelp = !a.showingFullHelp
@@ -429,6 +424,27 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
429
424
430// View renders the complete application interface including pages, dialogs, and overlays.
425// View renders the complete application interface including pages, dialogs, and overlays.
431func (a *appModel) View() tea.View {
426func (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+
432 page := a.pages[a.currentPage]
448 page := a.pages[a.currentPage]
433 if withHelp, ok := page.(core.KeyMapHelp); ok {
449 if withHelp, ok := page.(core.KeyMapHelp); ok {
434 a.status.SetKeyMap(withHelp.Help())
450 a.status.SetKeyMap(withHelp.Help())
@@ -453,6 +469,11 @@ func (a *appModel) View() tea.View {
453 var cursor *tea.Cursor
469 var cursor *tea.Cursor
454 if v, ok := page.(util.Cursor); ok {
470 if v, ok := page.(util.Cursor); ok {
455 cursor = v.Cursor()
471 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+ }
456 }
477 }
457 activeView := a.dialog.ActiveModel()
478 activeView := a.dialog.ActiveModel()
458 if activeView != nil {
479 if activeView != nil {
@@ -475,10 +496,7 @@ func (a *appModel) View() tea.View {
475 layers...,
496 layers...,
476 )
497 )
477
498
478− var view tea.View
479− t := styles.CurrentTheme()
480 view.Layer = canvas
499 view.Layer = canvas
481− view.BackgroundColor = t.BgBase
482 view.Cursor = cursor
500 view.Cursor = cursor
483 return view
501 return view
484}
502}
main.go+1−21
@@ -1,12 +1,9 @@
1package main
1package main
2
2
3import (
3import (
4− "fmt"
5 "log/slog"
4 "log/slog"
6 "net/http"
5 "net/http"
7 "os"
6 "os"
8− "runtime"
9− "strings"
10
7
11 _ "net/http/pprof" // profiling
8 _ "net/http/pprof" // profiling
12
9
@@ -14,14 +11,9 @@ import (
14
11
15 "github.com/charmbracelet/crush/internal/cmd"
12 "github.com/charmbracelet/crush/internal/cmd"
16 "github.com/charmbracelet/crush/internal/log"
13 "github.com/charmbracelet/crush/internal/log"
17− "github.com/charmbracelet/lipgloss/v2"
18)
14)
19
15
20func main() {
16func main() {
21− if runtime.GOOS == "windows" {
22− showWindowsWarning()
23− }
24−
25 defer log.RecoverPanic("main", func() {
17 defer log.RecoverPanic("main", func() {
26 slog.Error("Application terminated due to unhandled panic")
18 slog.Error("Application terminated due to unhandled panic")
27 })
19 })
@@ -30,22 +22,10 @@ func main() {
30 go func() {
22 go func() {
31 slog.Info("Serving pprof at localhost:6060")
23 slog.Info("Serving pprof at localhost:6060")
32 if httpErr := http.ListenAndServe("localhost:6060", nil); httpErr != nil {
24 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)
34 }
26 }
35 }()
27 }()
36 }
28 }
37
29
38 cmd.Execute()
30 cmd.Execute()
39}
31}
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−}