on main help sign in
Merge remote-tracking branch 'origin/main' into schema
bb2d9ceCarlos Alexandro Becker
.goreleaser.yml+9
@@ -46,9 +46,18 @@ builds:
46 - linux
46 - linux
47 - darwin
47 - darwin
48 - windows
48 - windows
49+ - freebsd
50+ # TODO: those fail to build
51+ # - openbsd
52+ # - netbsd
49 goarch:
53 goarch:
50 - amd64
54 - amd64
51 - arm64
55 - arm64
56+ - "386"
57+ - arm
58+ goarm:
59+ - "7"
60+
52 ldflags:
61 ldflags:
53 - -s -w -X github.com/charmbracelet/crush/internal/version.Version={{.Version}}
62 - -s -w -X github.com/charmbracelet/crush/internal/version.Version={{.Version}}
54
63
README.md+149−88
@@ -1,60 +1,100 @@
1−> [!WARNING]
2−> 🚧 This is a pre-release under heavy, active development. Things are still in flux but we’re excited to share early progress.
3−
4# Crush
1# Crush
5
2
6−<p>
3+<p align="center">
7− <img width="450" alt="Charm Crush Art" src="https://github.com/user-attachments/assets/9ab4c30c-9327-46b6-a722-3ad0e20f6976" /><br>
4+ <a href="https://stuff.charm.sh/crush/charm-crush.png"><img width="450" alt="Charm Crush Logo" src="https://github.com/user-attachments/assets/adc1a6f4-b284-4603-836c-59038caa2e8b" /></a><br />
8 <a href="https://github.com/charmbracelet/crush/releases"><img src="https://img.shields.io/github/release/charmbracelet/crush" alt="Latest Release"></a>
5 <a href="https://github.com/charmbracelet/crush/releases"><img src="https://img.shields.io/github/release/charmbracelet/crush" alt="Latest Release"></a>
9 <a href="https://github.com/charmbracelet/crush/actions"><img src="https://github.com/charmbracelet/crush/workflows/build/badge.svg" alt="Build Status"></a>
6 <a href="https://github.com/charmbracelet/crush/actions"><img src="https://github.com/charmbracelet/crush/workflows/build/badge.svg" alt="Build Status"></a>
10</p>
7</p>
11
8
12−Crush is a tool for building software with AI.
9+<p align="center">Your new coding bestie, now available in your favourite terminal.<br />Your tools, your code, and your workflows, wired into your LLM of choice.</p>
10+
11+<p align="center"><img width="800" alt="Crush Demo" src="https://github.com/user-attachments/assets/58280caf-851b-470a-b6f7-d5c4ea8a1968" /></p>
12+
13+## Features
14+
15+- **Multi-Model:** choose from a wide range of LLMs or add your own via OpenAI- or Anthropic-compatible APIs
16+- **Flexible:** switch LLMs mid-session while preserving context
17+- **Session-Based:** maintain multiple work sessions and contexts per project
18+- **LSP-Enhanced:** Crush uses LSPs for additional context, just like you do
19+- **Extensible:** add capabilities via MCPs (`http`, `stdio`, and `sse`)
20+- **Works Everywhere:** first-class support in every terminal on macOS, Linux, Windows (PowerShell and WSL), and FreeBSD
13
21
14## Installation
22## Installation
15
23
16−Crush has first class support for macOS, Linux, and Windows.
24+Use a package manager:
17
25
18−Nightly builds are available while Crush is in development.
26+```bash
27+# macOS or Linux
28+brew install charmbracelet/tap/crush
19
29
20−- [Packages](https://github.com/charmbracelet/crush/releases/tag/nightly) are available in Debian, RPM, APK, and PKG formats
30+# NPM
21−- [Binaries](https://github.com/charmbracelet/crush/releases/tag/nightly) are available for Linux, macOS and Windows
31+npm install -g @charmland/crush
22
32
23−You can also just install it with go:
33+# Arch Linux (btw)
34+yay -S crush-bin
24
35
25−```
36+# Windows (with Winget)
26−git clone git@github.com:charmbracelet/crush.git
37+winget install charmbracelet.crush
27−cd crush
38+
28−go install
39+# Nix
40+nix-shell -p nur.repos.charmbracelet.crush
29```
41```
30
42
31<details>
43<details>
32−<summary>Not a developer? Here’s a quick how-to.</summary>
44+<summary><strong>Debian/Ubuntu</strong></summary>
33
45
34−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.
46+```bash
47+sudo mkdir -p /etc/apt/keyrings
48+curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/charm.gpg
49+echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list
50+sudo apt update && sudo apt install crush
51+```
35
52
36−Next, open a terminal and run the following commands:
53+</details>
54+
55+<details>
56+<summary><strong>Fedora/RHEL</strong></summary>
37
57
38```bash
58```bash
39−cd ~/Downloads
59+echo '[charm]
40−tar -xvzf crush_0.1.0-nightly_Darwin_arm64.tar.gz -C crush
60+name=Charm
41−sudo mv ./crush/crush /usr/local/bin/crush
61+baseurl=https://repo.charm.sh/yum/
42−rm -rf ./crush
62+enabled=1
63+gpgcheck=1
64+gpgkey=https://repo.charm.sh/yum/gpg.key' | sudo tee /etc/yum.repos.d/charm.repo
65+sudo yum install crush
43```
66```
44
67
45−Then, run Crush by typing `crush`.
68+</details>
46
69
47−---
70+Or, download it:
48
71
49−</details>
72+- [Packages][releases] are available in Debian and RPM formats
73+- [Binaries][releases] are available for Linux, macOS, Windows, FreeBSD, OpenBSD, and NetBSD
74+
75+[releases]: https://github.com/charmbracelet/crush/releases
76+
77+Or just install it with go:
78+
79+```
80+go install github.com/charmbracelet/crush@latest
81+```
82+
83+> [!WARNING]
84+> Productivity may increase when using Crush and you may find yourself nerd
85+> sniped when first using the application. If the symptoms persist, join the
86+> [Discord][discord] and nerd snipe the rest of us.
50
87
51## Getting Started
88## Getting Started
52
89
53−The quickest way to get started to grab an API key for your preferred
90+The quickest way to get started is to grab an API key for your preferred
54−provider such as Anthropic, OpenAI, or Groq, and just start Crush. You'll be
91+provider such as Anthropic, OpenAI, Groq, or OpenRouter and just start
55−prompted to enter your API key.
92+Crush. You'll be prompted to enter your API key.
93+
94+That said, you can also set environment variables for preferred providers.
56
95
57−That said, you can also set environment variables for preferred providers:
96+<details>
97+<summary><strong>Supported Environment Variables</strong></summary>
58
98
59| Environment Variable | Provider |
99| Environment Variable | Provider |
60| -------------------------- | -------------------------------------------------- |
100| -------------------------- | -------------------------------------------------- |
@@ -71,17 +111,31 @@ That said, you can also set environment variables for preferred providers:
71| `AZURE_OPENAI_API_KEY` | Azure OpenAI models (optional when using Entra ID) |
111| `AZURE_OPENAI_API_KEY` | Azure OpenAI models (optional when using Entra ID) |
72| `AZURE_OPENAI_API_VERSION` | Azure OpenAI models |
112| `AZURE_OPENAI_API_VERSION` | Azure OpenAI models |
73
113
114+</details>
115+
74## Configuration
116## Configuration
75
117
76−For many use cases, Crush can be run with no config. That said, if you do need config, it can be added either local to the project itself, or globally. Configuration has the following priority:
118+Crush runs great with no configuration. That said, if you do need or want to
119+customize Crush, configuration can be added either local to the project itself,
120+or globally, with the following priority:
77
121
78−1. `.crush.json`
122+1. `./.crush.json`
79−2. `crush.json`
123+2. `./crush.json`
803. `$HOME/.config/crush/crush.json`
1243. `$HOME/.config/crush/crush.json`
81
125
126+Configuration itself is stored as a JSON object:
127+
128+```json
129+{
130+ "this-setting": { }
131+ "that-setting": { }
132+}
133+```
134+
82### LSPs
135### LSPs
83
136
84−Crush can use LSPs for additional context to help inform its decisions, just like you would. LSPs can be added manually like so:
137+Crush can use LSPs for additional context to help inform its decisions, just
138+like you would. LSPs can be added manually like so:
85
139
86```json
140```json
87{
141{
@@ -95,7 +149,7 @@ Crush can use LSPs for additional context to help inform its decisions, just lik
95 "args": ["--stdio"]
149 "args": ["--stdio"]
96 },
150 },
97 "nix": {
151 "nix": {
98− "command": "alejandra"
152+ "command": "nil"
99 }
153 }
100 }
154 }
101}
155}
@@ -103,7 +157,10 @@ Crush can use LSPs for additional context to help inform its decisions, just lik
103
157
104### MCPs
158### MCPs
105
159
160+Crush also supports Model Context Protocol (MCP) servers through three
161+transport types: `stdio` for command-line servers, `http` for HTTP endpoints,
106−Crush supports Model Context Protocol (MCP) servers through three transport types: `stdio` for command-line servers, `http` for HTTP endpoints, and `sse` for Server-Sent Events. Environment variable expansion is supported using `$(echo $VAR)` syntax.
162+and `sse` for Server-Sent Events. Environment variable expansion is supported
163+using `$(echo $VAR)` syntax.
107
164
108```json
165```json
109{
166{
@@ -135,39 +192,11 @@ Crush supports Model Context Protocol (MCP) servers through three transport type
135}
192}
136```
193```
137
194
138−### Logging
195+### Whitelisting Tools
139−
140−Enable debug logging with the `-d` flag or in config. View logs with `crush logs`. Logs are stored in `.crush/logs/crush.log`.
141−
142−```bash
143−# Run with debug logging
144−crush -d
145−
146−# View last 1000 lines
147−crush logs
148
196
149−# Follow logs in real-time
197+By default, Crush will ask you for permission before running tool calls. If
150−crush logs -f
198+you'd like, you can whitelist tools to be executed without prompting you for
151−
199+permissions. Use this with care.
152−# Show last 500 lines
153−crush logs -t 500
154−```
155−
156−Add to your `crush.json` config file:
157−
158−```json
159−{
160− "$schema": "https://charm.land/crush.json",
161− "options": {
162− "debug": true,
163− "debug_lsp": true
164− }
165−}
166−```
167−
168−### Configurable Default Permissions
169−
170−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:
171
200
172```json
201```json
173{
202{
@@ -177,27 +206,25 @@ Crush includes a permission system to control which tools can be executed withou
177 "view",
206 "view",
178 "ls",
207 "ls",
179 "grep",
208 "grep",
180− "edit:write",
209+ "edit",
181 "mcp_context7_get-library-doc"
210 "mcp_context7_get-library-doc"
182 ]
211 ]
183 }
212 }
184}
213}
185```
214```
186
215
187−The `allowed_tools` array accepts:
188−
189−- Tool names (e.g., `"view"`) - allows all actions for that tool
190−- Tool:action combinations (e.g., `"edit:write"`) - allows only specific actions
191−
192−You can also skip all permission prompts entirely by running Crush with the `--yolo` flag.
216+You can also skip all permission prompts entirely by running Crush with the
217+`--yolo` flag. Be very, very careful with this feature.
193
218
194### Custom Providers
219### Custom Providers
195
220
196−Crush supports custom provider configurations for both OpenAI-compatible and Anthropic-compatible APIs.
221+Crush supports custom provider configurations for both OpenAI-compatible and
222+Anthropic-compatible APIs.
197
223
198#### OpenAI-Compatible APIs
224#### OpenAI-Compatible APIs
199
225
200−Here's an example configuration for Deepseek, which uses an OpenAI-compatible API. Don't forget to set `DEEPSEEK_API_KEY` in your environment.
226+Here’s an example configuration for Deepseek, which uses an OpenAI-compatible
227+API. Don't forget to set `DEEPSEEK_API_KEY` in your environment.
201
228
202```json
229```json
203{
230{
@@ -226,7 +253,7 @@ Here's an example configuration for Deepseek, which uses an OpenAI-compatible AP
226
253
227#### Anthropic-Compatible APIs
254#### Anthropic-Compatible APIs
228
255
229−You can also configure custom Anthropic-compatible providers:
256+Custom Anthropic-compatible providers follow this format:
230
257
231```json
258```json
232{
259{
@@ -241,14 +268,15 @@ You can also configure custom Anthropic-compatible providers:
241 },
268 },
242 "models": [
269 "models": [
243 {
270 {
244− "id": "claude-3-sonnet",
271+ "id": "claude-sonnet-4-20250514",
245− "model": "Claude 3 Sonnet",
272+ "name": "Claude Sonnet 4",
246− "cost_per_1m_in": 3000,
273+ "cost_per_1m_in": 3,
247− "cost_per_1m_out": 15000,
274+ "cost_per_1m_out": 15,
248− "cost_per_1m_in_cached": 300,
275+ "cost_per_1m_in_cached": 3.75,
249− "cost_per_1m_out_cached": 15000,
276+ "cost_per_1m_out_cached": 0.3,
250 "context_window": 200000,
277 "context_window": 200000,
251− "default_max_tokens": 4096,
278+ "default_max_tokens": 50000,
279+ "can_reason": true,
252 "supports_attachments": true
280 "supports_attachments": true
253 }
281 }
254 ]
282 ]
@@ -257,23 +285,56 @@ You can also configure custom Anthropic-compatible providers:
257}
285}
258```
286```
259
287
288+## Logging
289+
290+Sometimes you need to look at logs. Luckily, Crush logs all sorts of
291+stuff. Logs are stored in `./.crush/logs/crush.log` relative to the project.
292+
293+The CLI also contains some helper commands to make perusing recent logs easier:
294+
295+```bash
296+# Print the last 1000 lines
297+crush logs
298+
299+# Print the last 500 lines
300+crush logs --tail 500
301+
302+# Follow logs in real time
303+crush logs --follow
304+```
305+
306+Want more logging? Run `crush` with the `--debug` flag, or enable it in the
307+config:
308+
309+```json
310+{
311+ "options": {
312+ "debug": true,
313+ "debug_lsp": true
314+ }
315+}
316+```
317+
260## Whatcha think?
318## Whatcha think?
261
319
262−We’d love to hear your thoughts on this project. Feel free to drop us a note!
320+We’d love to hear your thoughts on this project. Need help? We gotchu. You can find us on:
263
321
264- [Twitter](https://twitter.com/charmcli)
322- [Twitter](https://twitter.com/charmcli)
323+- [Discord][discord]
324+- [Slack](https://charm.land/slack)
265- [The Fediverse](https://mastodon.social/@charmcli)
325- [The Fediverse](https://mastodon.social/@charmcli)
266−- [Discord](https://charm.sh/chat)
326+
327+[discord]: https://charm.land/discord
267
328
268## License
329## License
269
330
270−[MIT](https://github.com/charmbracelet/crush/raw/main/LICENSE)
331+[FSL-1.1-MIT](https://github.com/charmbracelet/crush/raw/main/LICENSE)
271
332
272---
333---
273
334
274Part of [Charm](https://charm.land).
335Part of [Charm](https://charm.land).
275
336
276−<a href="https://charm.sh/"><img alt="The Charm logo" width="400" src="https://stuff.charm.sh/charm-banner-next.jpg" /></a>
337+<a href="https://charm.land/"><img alt="The Charm logo" width="400" src="https://stuff.charm.sh/charm-banner-next.jpg" /></a>
277
338
278<!--prettier-ignore-->
339<!--prettier-ignore-->
279Charm热爱开源 • Charm loves open source
340Charm热爱开源 • Charm loves open source
go.mod+5−5
@@ -8,12 +8,13 @@ require (
8 github.com/PuerkitoBio/goquery v1.9.2
8 github.com/PuerkitoBio/goquery v1.9.2
9 github.com/alecthomas/chroma/v2 v2.15.0
9 github.com/alecthomas/chroma/v2 v2.15.0
10 github.com/anthropics/anthropic-sdk-go v1.6.2
10 github.com/anthropics/anthropic-sdk-go v1.6.2
11+ github.com/atotto/clipboard v0.1.4
11 github.com/aymanbagabas/go-udiff v0.3.1
12 github.com/aymanbagabas/go-udiff v0.3.1
12 github.com/bmatcuk/doublestar/v4 v4.9.0
13 github.com/bmatcuk/doublestar/v4 v4.9.0
13 github.com/charlievieth/fastwalk v1.0.11
14 github.com/charlievieth/fastwalk v1.0.11
14 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5
15 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5
15 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69
16 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69
16− github.com/charmbracelet/catwalk v0.3.1
17+ github.com/charmbracelet/catwalk v0.3.5
17 github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674
18 github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674
18 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
19 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
19 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250721205738-ea66aa652ee0
20 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250721205738-ea66aa652ee0
@@ -62,7 +63,6 @@ require (
62 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
63 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
63 github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
64 github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
64 github.com/andybalholm/cascadia v1.3.2 // indirect
65 github.com/andybalholm/cascadia v1.3.2 // indirect
65− github.com/atotto/clipboard v0.1.4 // indirect
66 github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect
66 github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect
67 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect
67 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect
68 github.com/aws/aws-sdk-go-v2/config v1.27.27 // indirect
68 github.com/aws/aws-sdk-go-v2/config v1.27.27 // indirect
@@ -133,13 +133,13 @@ require (
133 go.opentelemetry.io/otel/metric v1.35.0 // indirect
133 go.opentelemetry.io/otel/metric v1.35.0 // indirect
134 go.opentelemetry.io/otel/trace v1.35.0 // indirect
134 go.opentelemetry.io/otel/trace v1.35.0 // indirect
135 go.uber.org/multierr v1.11.0 // indirect
135 go.uber.org/multierr v1.11.0 // indirect
136− golang.org/x/crypto v0.37.0 // indirect
136+ golang.org/x/crypto v0.38.0 // indirect
137 golang.org/x/image v0.26.0 // indirect
137 golang.org/x/image v0.26.0 // indirect
138− golang.org/x/net v0.39.0 // indirect
138+ golang.org/x/net v0.40.0 // indirect
139 golang.org/x/sync v0.16.0 // indirect
139 golang.org/x/sync v0.16.0 // indirect
140 golang.org/x/sys v0.34.0
140 golang.org/x/sys v0.34.0
141 golang.org/x/term v0.32.0 // indirect
141 golang.org/x/term v0.32.0 // indirect
142− golang.org/x/text v0.24.0
142+ golang.org/x/text v0.25.0
143 google.golang.org/genai v1.3.0
143 google.golang.org/genai v1.3.0
144 google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
144 google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
145 google.golang.org/grpc v1.71.0 // indirect
145 google.golang.org/grpc v1.71.0 // indirect
go.sum+8−8
@@ -76,8 +76,8 @@ github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5
76github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
76github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
77github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69 h1:nXLMl4ows2qogDXhuEtDNgFNXQiU+PJer+UEBsQZuns=
77github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69 h1:nXLMl4ows2qogDXhuEtDNgFNXQiU+PJer+UEBsQZuns=
78github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69/go.mod h1:XIQ1qQfRph6Z5o2EikCydjumo0oDInQySRHuPATzbZc=
78github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69/go.mod h1:XIQ1qQfRph6Z5o2EikCydjumo0oDInQySRHuPATzbZc=
79−github.com/charmbracelet/catwalk v0.3.1 h1:MkGWspcMyE659zDkqS+9wsaCMTKRFEDBFY2A2sap6+U=
79+github.com/charmbracelet/catwalk v0.3.5 h1:ChMvA5ooTNZhDKFagmGNQgIZvZp8XjpdaJ+cDmhgCgA=
80−github.com/charmbracelet/catwalk v0.3.1/go.mod h1:gUUCqqZ8bk4D7ZzGTu3I77k7cC2x4exRuJBN1H2u2pc=
80+github.com/charmbracelet/catwalk v0.3.5/go.mod h1:gUUCqqZ8bk4D7ZzGTu3I77k7cC2x4exRuJBN1H2u2pc=
81github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
81github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
82github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
82github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
83github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674 h1:+Cz+VfxD5DO+JT1LlswXWhre0HYLj6l2HW8HVGfMuC0=
83github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674 h1:+Cz+VfxD5DO+JT1LlswXWhre0HYLj6l2HW8HVGfMuC0=
@@ -309,8 +309,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
309golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
309golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
310golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
310golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
311golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
311golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
312−golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
312+golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
313−golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
313+golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
314golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
314golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
315golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
315golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
316golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
316golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
@@ -326,8 +326,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
326golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
326golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
327golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
327golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
328golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
328golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
329−golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
329+golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
330−golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
330+golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
331golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
331golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
332golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
332golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
333golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
333golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -365,8 +365,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
365golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
365golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
366golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
366golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
367golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
367golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
368−golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
368+golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
369−golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
369+golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
370golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
370golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
371golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
371golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
372golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
372golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
internal/app/app.go+1
@@ -205,6 +205,7 @@ func (app *App) setupEvents() {
205 setupSubscriber(ctx, app.serviceEventsWG, "sessions", app.Sessions.Subscribe, app.events)
205 setupSubscriber(ctx, app.serviceEventsWG, "sessions", app.Sessions.Subscribe, app.events)
206 setupSubscriber(ctx, app.serviceEventsWG, "messages", app.Messages.Subscribe, app.events)
206 setupSubscriber(ctx, app.serviceEventsWG, "messages", app.Messages.Subscribe, app.events)
207 setupSubscriber(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events)
207 setupSubscriber(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events)
208+ setupSubscriber(ctx, app.serviceEventsWG, "permissions-notifications", app.Permissions.SubscribeNotifications, app.events)
208 setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events)
209 setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events)
209 cleanupFunc := func() {
210 cleanupFunc := func() {
210 cancel()
211 cancel()
internal/config/config.go+7
@@ -5,6 +5,7 @@ import (
5 "fmt"
5 "fmt"
6 "log/slog"
6 "log/slog"
7 "net/http"
7 "net/http"
8+ "net/url"
8 "os"
9 "os"
9 "slices"
10 "slices"
10 "strings"
11 "strings"
@@ -471,6 +472,12 @@ func (c *ProviderConfig) TestConnection(resolver VariableResolver) error {
471 testURL = baseURL + "/models"
472 testURL = baseURL + "/models"
472 headers["x-api-key"] = apiKey
473 headers["x-api-key"] = apiKey
473 headers["anthropic-version"] = "2023-06-01"
474 headers["anthropic-version"] = "2023-06-01"
475+ case catwalk.TypeGemini:
476+ baseURL, _ := resolver.ResolveValue(c.BaseURL)
477+ if baseURL == "" {
478+ baseURL = "https://generativelanguage.googleapis.com"
479+ }
480+ testURL = baseURL + "/v1beta/models?key=" + url.QueryEscape(apiKey)
474 }
481 }
475 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
482 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
476 defer cancel()
483 defer cancel()
internal/config/load.go+10−1
@@ -5,6 +5,7 @@ import (
5 "fmt"
5 "fmt"
6 "io"
6 "io"
7 "log/slog"
7 "log/slog"
8+ "maps"
8 "os"
9 "os"
9 "path/filepath"
10 "path/filepath"
10 "runtime"
11 "runtime"
@@ -135,6 +136,14 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
135 p.Models = models
136 p.Models = models
136 }
137 }
137 }
138 }
139+
140+ headers := map[string]string{}
141+ if len(p.DefaultHeaders) > 0 {
142+ maps.Copy(headers, p.DefaultHeaders)
143+ }
144+ if len(config.ExtraHeaders) > 0 {
145+ maps.Copy(headers, config.ExtraHeaders)
146+ }
138 prepared := ProviderConfig{
147 prepared := ProviderConfig{
139 ID: string(p.ID),
148 ID: string(p.ID),
140 Name: p.Name,
149 Name: p.Name,
@@ -142,7 +151,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
142 APIKey: p.APIKey,
151 APIKey: p.APIKey,
143 Type: p.Type,
152 Type: p.Type,
144 Disable: config.Disable,
153 Disable: config.Disable,
145− ExtraHeaders: config.ExtraHeaders,
154+ ExtraHeaders: headers,
146 ExtraBody: config.ExtraBody,
155 ExtraBody: config.ExtraBody,
147 ExtraParams: make(map[string]string),
156 ExtraParams: make(map[string]string),
148 Models: p.Models,
157 Models: p.Models,
internal/llm/agent/mcp-tools.go+1
@@ -102,6 +102,7 @@ func (b *mcpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolRes
102 p := b.permissions.Request(
102 p := b.permissions.Request(
103 permission.CreatePermissionRequest{
103 permission.CreatePermissionRequest{
104 SessionID: sessionID,
104 SessionID: sessionID,
105+ ToolCallID: params.ID,
105 Path: b.workingDir,
106 Path: b.workingDir,
106 ToolName: b.Info().Name,
107 ToolName: b.Info().Name,
107 Action: "execute",
108 Action: "execute",
internal/llm/prompt/coder.go+5−354
@@ -2,6 +2,7 @@ package prompt
2
2
3import (
3import (
4 "context"
4 "context"
5+ _ "embed"
5 "fmt"
6 "fmt"
6 "log/slog"
7 "log/slog"
7 "os"
8 "os"
@@ -9,21 +10,14 @@ import (
9 "runtime"
10 "runtime"
10 "time"
11 "time"
11
12
12− "github.com/charmbracelet/catwalk/pkg/catwalk"
13 "github.com/charmbracelet/crush/internal/config"
13 "github.com/charmbracelet/crush/internal/config"
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+
20− case string(catwalk.InferenceProviderOpenAI):
20+ basePrompt = string(baseCoderPrompt)
21− basePrompt = baseOpenAICoderPrompt
22− case string(catwalk.InferenceProviderGemini), string(catwalk.InferenceProviderVertexAI):
23− basePrompt = baseGeminiCoderPrompt
24− default:
25− basePrompt = baseAnthropicCoderPrompt
26− }
27 envInfo := getEnvironmentInfo()
21 envInfo := getEnvironmentInfo()
28
22
29 basePrompt = fmt.Sprintf("%s\n\n%s\n%s", basePrompt, envInfo, lspInformation())
23 basePrompt = fmt.Sprintf("%s\n\n%s\n%s", basePrompt, envInfo, lspInformation())
@@ -36,351 +30,8 @@ func CoderPrompt(p string, contextFiles ...string) string {
36 return basePrompt
30 return basePrompt
37}
31}
38
32
39−const baseOpenAICoderPrompt = `
33+//go:embed coder.md
40−Please resolve the user's task by editing and testing the code files in your current code execution session.
34+var baseCoderPrompt []byte
41−You are a deployed coding agent.
42−Your session allows you to easily modify and run code in the user's local environment.
43−The repo(s) are already available in your working directory, and you must fully solve the problem for your answer to be considered correct.
44−
45−IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
46−
47−# Memory
48−If the current working directory contains a file called CRUSH.md, it will be automatically added to your context. This file serves multiple purposes:
49−1. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
50−2. Recording the user's code style preferences (naming conventions, preferred libraries, etc.)
51−3. Maintaining useful information about the codebase structure and organization
52−
53−When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to CRUSH.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to CRUSH.md so you can remember it for next time.
54−
55−You MUST adhere to the following criteria when executing the task:
56−
57−- Working on the repo(s) in the current environment is allowed, even if they are proprietary.
58−- Analyzing code for vulnerabilities is allowed.
59−- Showing user code and tool call details is allowed.
60−- User instructions may overwrite the _CODING GUIDELINES_ section in this developer message.
61−- Do not use ` + "`ls -R`" + `, ` + "`find`" + `, or ` + "`grep`" + ` - these are slow in large repos. Use the Agent tool for searching instead.
62−- Use the ` + "`edit`" + ` tool to modify files: provide file_path, old_string (with sufficient context), and new_string. The edit tool requires:
63− - Absolute file paths (starting with /)
64− - Unique old_string matches with 3-5 lines of context before and after
65− - Exact whitespace and indentation matching
66− - For new files: provide file_path and new_string, leave old_string empty
67− - For deleting content: provide file_path and old_string, leave new_string empty
68−
69−# Following conventions
70−When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns.
71−- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language).
72−- When you create a new component, first look at existing components to see how they're written; then consider framework choice, naming conventions, typing, and other conventions.
73−- When you edit a piece of code, first look at the code's surrounding context (especially its imports) to understand the code's choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic.
74−- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
75−
76−# Code style
77−- IMPORTANT: DO NOT ADD ***ANY*** COMMENTS unless asked
78−
79−- If completing the user's task requires writing or modifying files:
80− - Your code and final answer should follow these _CODING GUIDELINES_:
81− - Fix the problem at the root cause rather than applying surface-level patches, when possible.
82− - Avoid unneeded complexity in your solution.
83− - Ignore unrelated bugs or broken tests; it is not your responsibility to fix them.
84− - Update documentation as necessary.
85− - Keep changes consistent with the style of the existing codebase. Changes should be minimal and focused on the task.
86− - Use ` + "`git log`" + ` and ` + "`git blame`" + ` to search the history of the codebase if additional context is required.
87− - NEVER add copyright or license headers unless specifically requested.
88− - You do not need to ` + "`git commit`" + ` your changes; this will be done automatically for you.
89− - If there is a .pre-commit-config.yaml, use ` + "`pre-commit run --files ...`" + ` to check that your changes pass the pre-commit checks. However, do not fix pre-existing errors on lines you didn't touch.
90− - If pre-commit doesn't work after a few retries, politely inform the user that the pre-commit setup is broken.
91− - Once you finish coding, you must
92− - Check ` + "`git status`" + ` to sanity check your changes; revert any scratch files or changes.
93− - Remove all inline comments you added as much as possible, even if they look normal. Check using ` + "`git diff`" + `. Inline comments must be generally avoided, unless active maintainers of the repo, after long careful study of the code and the issue, will still misinterpret the code without the comments.
94− - Check if you accidentally add copyright or license headers. If so, remove them.
95− - Try to run pre-commit if it is available.
96− - For smaller tasks, describe in brief bullet points
97− - For more complex tasks, include brief high-level description, use bullet points, and include details that would be relevant to a code reviewer.
98−
99−# Doing tasks
100−The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
101−1. Use the available search tools to understand the codebase and the user's query.
102−2. Implement the solution using all tools available to you
103−3. Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
104−4. VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to CRUSH.md so that you will know to run it next time.
105−
106−NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
107−
108−# Tool usage policy
109−- When doing file search, prefer to use the Agent tool in order to reduce context usage.
110−- If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in parallel.
111−- IMPORTANT: The user does not see the full output of the tool responses, so if you need the output of the tool for the response make sure to summarize it for the user.
112−
113−# Proactiveness
114−You are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between:
115−1. Doing the right thing when asked, including taking actions and follow-up actions
116−2. Not surprising the user with actions you take without asking
117−For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions.
118−3. Do not add additional code explanation summary unless requested by the user. After working on a file, just stop, rather than providing an explanation of what you did.
119−
120−- If completing the user's task DOES NOT require writing or modifying files (e.g., the user asks a question about the code base):
121− - Respond in a friendly tone as a remote teammate, who is knowledgeable, capable and eager to help with coding.
122−- When your task involves writing or modifying files:
123− - Do NOT tell the user to "save the file" or "copy the code into a file" if you already created or modified the file using ` + "`edit`" + `. Instead, reference the file as already saved.
124− - Do NOT show the full contents of large files you have already written, unless the user explicitly asks for them.
125−- NEVER use emojis in your responses
126−`
127−
128−const baseAnthropicCoderPrompt = `You are Crush, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
129−
130−IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
131−
132−
133−# Memory
134−If the current working directory contains a file called CRUSH.md, it will be automatically added to your context. This file serves multiple purposes:
135−1. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
136−2. Recording the user's code style preferences (naming conventions, preferred libraries, etc.)
137−3. Maintaining useful information about the codebase structure and organization
138−
139−When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to CRUSH.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to CRUSH.md so you can remember it for next time.
140−
141−# Tone and style
142−You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
143−Remember that your output will be displayed on a command line interface. Your responses can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
144−Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
145−If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
146−IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do.
147−IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
148−IMPORTANT: Keep your responses short, since they will be displayed on a command line interface. You MUST answer concisely with fewer than 4 lines (not including tool use or code generation), unless user asks for detail. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...". Here are some examples to demonstrate appropriate verbosity:
149−<example>
150−user: 2 + 2
151−assistant: 4
152−</example>
153−
154−<example>
155−user: what is 2+2?
156−assistant: 4
157−</example>
158−
159−<example>
160−user: is 11 a prime number?
161−assistant: true
162−</example>
163−
164−<example>
165−user: what command should I run to list files in the current directory?
166−assistant: ls
167−</example>
168−
169−<example>
170−user: what command should I run to watch files in the current directory?
171−assistant: [use the ls tool to list the files in the current directory, then read docs/commands in the relevant file to find out how to watch files]
172−npm run dev
173−</example>
174−
175−<example>
176−user: How many golf balls fit inside a jetta?
177−assistant: 150000
178−</example>
179−
180−<example>
181−user: what files are in the directory src/?
182−assistant: [runs ls and sees foo.c, bar.c, baz.c]
183−user: which file contains the implementation of foo?
184−assistant: src/foo.c
185−</example>
186−
187−<example>
188−user: write tests for new feature
189−assistant: [uses grep and glob search tools to find where similar tests are defined, uses concurrent read file tool use blocks in one tool call to read relevant files at the same time, uses edit file tool to write new tests]
190−</example>
191−
192−# Proactiveness
193−You are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between:
194−1. Doing the right thing when asked, including taking actions and follow-up actions
195−2. Not surprising the user with actions you take without asking
196−For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions.
197−3. Do not add additional code explanation summary unless requested by the user. After working on a file, just stop, rather than providing an explanation of what you did.
198−
199−# Following conventions
200−When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns.
201−- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language).
202−- When you create a new component, first look at existing components to see how they're written; then consider framework choice, naming conventions, typing, and other conventions.
203−- When you edit a piece of code, first look at the code's surrounding context (especially its imports) to understand the code's choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic.
204−- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
205−
206−# Code style
207−- IMPORTANT: DO NOT ADD ***ANY*** COMMENTS unless asked
208−
209−# Doing tasks
210−The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
211−1. Use the available search tools to understand the codebase and the user's query.
212−2. Implement the solution using all tools available to you
213−3. Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
214−4. VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to CRUSH.md so that you will know to run it next time.
215−
216−NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
217−
218−# Tool usage policy
219−- When doing file search, prefer to use the Agent tool in order to reduce context usage.
220−- If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in parallel.
221−- IMPORTANT: The user does not see the full output of the tool responses, so if you need the output of the tool for the response make sure to summarize it for the user.
222−
223−VERY IMPORTANT NEVER use emojis in your responses.
224−
225−You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.`
226−
227−const baseGeminiCoderPrompt = `
228−You are an interactive CLI agent specializing in software engineering tasks. Your primary goal is to help users safely and efficiently, adhering strictly to the following instructions and utilizing your available tools.
229−
230−IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
231−
232−# Memory
233−If the current working directory contains a file called CRUSH.md, it will be automatically added to your context. This file serves multiple purposes:
234−1. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
235−2. Recording the user's code style preferences (naming conventions, preferred libraries, etc.)
236−3. Maintaining useful information about the codebase structure and organization
237−
238−When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to CRUSH.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to CRUSH.md so you can remember it for next time.
239−
240−# Core Mandates
241−
242−- **Conventions:** Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first.
243−- **Libraries/Frameworks:** NEVER assume a library/framework is available or appropriate. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', 'build.gradle', etc., or observe neighboring files) before employing it.
244−- **Style & Structure:** Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project.
245−- **Idiomatic Changes:** When editing, understand the local context (imports, functions/classes) to ensure your changes integrate naturally and idiomatically.
246−- **Comments:** Add code comments sparingly. Focus on *why* something is done, especially for complex logic, rather than *what* is done. Only add high-value comments if necessary for clarity or if requested by the user. Do not edit comments that are separate from the code you are changing. *NEVER* talk to the user or describe your changes through comments.
247−- **Proactiveness:** Fulfill the user's request thoroughly, including reasonable, directly implied follow-up actions.
248−- **Confirm Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request without confirming with the user. If asked *how* to do something, explain first, don't just do it.
249−- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked.
250−- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.
251−
252−# Code style
253−- IMPORTANT: DO NOT ADD ***ANY*** COMMENTS unless asked
254−
255−# Primary Workflows
256−
257−## Software Engineering Tasks
258−When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this sequence:
259−1. **Understand:** Think about the user's request and the relevant codebase context. Use ` + "`grep`" + ` and ` + "`glob`" + ` search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions. Use ` + "`view`" + ` to understand context and validate any assumptions you may have.
260−2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process. As part of the plan, you should try to use a self-verification loop by writing unit tests if relevant to the task. Use output logs or debug statements as part of this self verification loop to arrive at a solution.
261−3. **Implement:** Use the available tools (e.g., ` + "`edit`" + `, ` + "`write`" + ` ` + "`bash`" + ` ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
262−4. **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
263−5. **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
264−
265−NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
266−
267−# Operational Guidelines
268−
269−## Tone and Style (CLI Interaction)
270−- **Concise & Direct:** Adopt a professional, direct, and concise tone suitable for a CLI environment.
271−- **Minimal Output:** Aim for fewer than 3 lines of text output (excluding tool use/code generation) per response whenever practical. Focus strictly on the user's query.
272−- **Clarity over Brevity (When Needed):** While conciseness is key, prioritize clarity for essential explanations or when seeking necessary clarification if a request is ambiguous.
273−- **No Chitchat:** Avoid conversational filler, preambles ("Okay, I will now..."), or postambles ("I have finished the changes..."). Get straight to the action or answer.
274−- **Formatting:** Use GitHub-flavored Markdown. Responses will be rendered in monospace.
275−- **Tools vs. Text:** Use tools for actions, text output *only* for communication. Do not add explanatory comments within tool calls or code blocks unless specifically part of the required code/command itself.
276−- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly (1-2 sentences) without excessive justification. Offer alternatives if appropriate.
277−
278−## Security and Safety Rules
279−- **Explain Critical Commands:** Before executing commands with ` + "`bash`" + ` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety.
280−- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
281−
282−## Tool Usage
283−- **File Paths:** Always use absolute paths when referring to files with tools like ` + "`view`" + ` or ` + "`write`" + `. Relative paths are not supported. You must provide an absolute path.
284−- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
285−- **Command Execution:** Use the ` + "`bash`" + ` tool for running shell commands, remembering the safety rule to explain modifying commands first.
286−- **Background Processes:** Use background processes (via ` + "`&`" + `) for commands that are unlikely to stop on their own, e.g. ` + "`node server.js &`" + `. If unsure, ask the user.
287−- **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. ` + "`git rebase -i`" + `). Use non-interactive versions of commands (e.g. ` + "`npm init -y`" + ` instead of ` + "`npm init`" + `) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user.
288−
289−# Examples (Illustrating Tone and Workflow)
290−
291−<example>
292−user: 1 + 2
293−model: 3
294−</example>
295−
296−<example>
297−user: is 13 a prime number?
298−model: true
299−</example>
300−
301−<example>
302−user: list files here.
303−model: [tool_call: ls for path '.']
304−</example>
305−
306−<example>
307−user: start the server implemented in server.js
308−model: [tool_call: bash for 'node server.js &' because it must run in the background]
309−</example>
310−
311−<example>
312−user: Refactor the auth logic in src/auth.py to use the requests library instead of urllib.
313−model: Okay, I can refactor 'src/auth.py'.
314−First, I'll analyze the code and check for a test safety net before planning any changes.
315−[tool_call: glob for pattern 'tests/test_auth.py']
316−[tool_call: view for absolute_path '/path/to/tests/test_auth.py']
317−(After analysis)
318−Great, 'tests/test_auth.py' exists and covers the core authentication logic. With this safety net in place, I can safely plan the refactoring.
319−I'll also confirm 'requests' is a dependency.
320−[tool_call: view for absolute_path '/path/to/requirements.txt']
321−(After analysis)
322−Looks good, 'requests' is available.
323−
324−Here's the plan:
325−1. Replace the 'urllib' calls with 'requests'.
326−2. Add proper 'try...except' error handling for the new network calls.
327−3. Remove the old 'urllib' import.
328−4. Run the project's linter and tests to verify the changes.
329−
330−Should I proceed?
331−user: Yes
332−model:
333−[tool_call: edit to apply the refactoring to 'src/auth.py']
334−Refactoring complete. Running verification...
335−[tool_call: bash for 'ruff check src/auth.py && pytest']
336−(After verification passes)
337−All checks passed. This is a stable checkpoint.
338−</example>
339−
340−<example>
341−user: Delete the temp directory.
342−model: I can run ` + "`rm -rf ./temp`" + `. This will permanently delete the directory and all its contents.
343−</example>
344−
345−<example>
346−user: Write tests for someFile.ts
347−model:
348−Okay, I can write those tests. First, I'll read ` + "`someFile.ts`" + ` to understand its functionality.
349−[tool_call: view for absolute_path '/path/to/someFile.ts' or use glob to find ` + "`someFile.ts`" + ` if its location is unknown]
350−Now I'll look for existing or related test files to understand current testing conventions and dependencies.
351−[tool_call: glob for pattern '**/*.test.ts' and '**/*.spec.ts']
352−(After reviewing existing tests and the file content)
353−[tool_call: write to create /path/to/someFile.test.ts with the test code]
354−I've written the tests. Now I'll run the project's test command to verify them.
355−[tool_call: bash for 'npm run test']
356−</example>
357−
358−<example>
359−user: How do I update the user's profile information in this system?
360−model:
361−I'm not immediately sure how user profile information is updated. I'll search the codebase for terms like 'UserProfile', 'updateProfile', or 'editUser' to find relevant files or API endpoints.
362−[tool_call: grep for pattern 'UserProfile|updateProfile|editUser']
363−(After reviewing search results, assuming a relevant file like '/path/to/UserProfileService.java' was found)
364−Okay, ` + "`/path/to/UserProfileService.java`" + ` seems like the most relevant file. I'll read its content to understand how updates are handled.
365−[tool_call: view for absolute_path '/path/to/UserProfileService.java']
366−(After reading the file)
367−It appears the ` + "`updateUserProfile`" + ` method in ` + "`UserProfileService.java`" + ` is responsible for this. It expects a user ID and a ` + "`UserProfileDTO`" + ` object...
368−</example>
369−
370−<example>
371−user: Where are all the 'app.config' files in this project? I need to check their settings.
372−model:
373−[tool_call: glob for pattern '**/app.config']
374−(Assuming glob returns a list of paths like ['/path/to/moduleA/app.config', '/path/to/moduleB/app.config'])
375−I found the following 'app.config' files:
376−- /path/to/moduleA/app.config
377−- /path/to/moduleB/app.config
378−To help you check their settings, I can read their contents. Which one would you like to start with, or should I read all of them?
379−</example>
380−
381−# Final Reminder
382−Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use ` + "`view`" + ` to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved.
383−`
384
35
385func getEnvironmentInfo() string {
36func getEnvironmentInfo() string {
386 cwd := config.Get().WorkingDir()
37 cwd := config.Get().WorkingDir()
internal/llm/prompt/coder.mdnew file+267
@@ -0,0 +1,267 @@
1+You are Crush, an autonomous software engineering agent that helps users with coding tasks. Use the instructions below and the tools available to you to assist the user.
2+
3+# Core Principles
4+
5+You are an agent - please keep going until the user's query is completely resolved, before ending your turn and yielding back to the user.
6+
7+Your thinking should be thorough and so it's fine if it's very long. However, avoid unnecessary repetition and verbosity. You should be concise, but thorough.
8+
9+You MUST iterate and keep going until the problem is solved.
10+
11+You have everything you need to resolve this problem. I want you to fully solve this autonomously before coming back to me.
12+
13+Only terminate your turn when you are sure that the problem is solved and all items have been checked off. Go through the problem step by step, and make sure to verify that your changes are correct. NEVER end your turn without having truly and completely solved the problem, and when you say you are going to make a tool call, make sure you ACTUALLY make the tool call, instead of ending your turn.
14+
15+**IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames, directory structure, and existing codebase patterns.**
16+
17+When the user provides URLs or when you need to research external information, use the fetch tool to gather that information. If you find relevant links in the fetched content, follow them to gather comprehensive information.
18+
19+When working with third-party packages, libraries, or frameworks that you're unfamiliar with or need to verify usage patterns for, you can use the Sourcegraph tool to search for code examples across public repositories. This can help you understand best practices and common implementation patterns.
20+
21+Always tell the user what you are going to do before making a tool call with a single concise sentence. This will help them understand what you are doing and why.
22+
23+If the user request is "resume" or "continue" or "try again", check the previous conversation history to see what the next incomplete step in the todo list is. Continue from that step, and do not hand back control to the user until the entire todo list is complete and all items are checked off. Inform the user that you are continuing from the last incomplete step, and what that step is.
24+
25+Take your time and think through every step - remember to check your solution rigorously and watch out for boundary cases, especially with the changes you made. Use the sequential thinking approach if needed. Your solution must be perfect. If not, continue working on it. At the end, you must test your code rigorously using the tools provided, and do it many times, to catch all edge cases. If it is not robust, iterate more and make it perfect. Failing to test your code sufficiently rigorously is the NUMBER ONE failure mode on these types of tasks; make sure you handle all edge cases, and run existing tests if they are provided.
26+
27+You MUST plan extensively before each function call, and reflect extensively on the outcomes of the previous function calls. DO NOT do this entire process by making function calls only, as this can impair your ability to solve the problem and think insightfully.
28+
29+You MUST keep working until the problem is completely solved, and all items in the todo list are checked off. Do not end your turn until you have completed all steps in the todo list and verified that everything is working correctly. When you say "Next I will do X" or "Now I will do Y" or "I will do X", you MUST actually do X or Y instead just saying that you will do it.
30+
31+You are a highly capable and autonomous agent, and you can definitely solve this problem without needing to ask the user for further input.
32+
33+# Proactiveness and Balance
34+
35+You should strive to strike a balance between:
36+
37+1. Doing the right thing when asked, including taking actions and follow-up actions
38+2. Not surprising the user with actions you take without asking
39+3. Being thorough and autonomous while staying focused on the user's actual request
40+
41+For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions. However, when they ask you to solve a problem or implement something, be proactive in completing the entire task.
42+
43+# Workflow
44+
45+1. **Understand the Context**: Think about what the code you're editing is supposed to do based on filenames, directory structure, and existing patterns.
46+2. **Fetch URLs**: Fetch any URLs provided by the user using the `fetch` tool.
47+3. **Deep Problem Understanding**: Carefully read the issue and think critically about what is required.
48+4. **Codebase Investigation**: Explore relevant files, search for key functions, and gather context.
49+5. **Research**: If needed, research the problem using available tools.
50+6. **Plan Development**: Develop a clear, step-by-step plan with a todo list.
51+7. **Incremental Implementation**: Make small, testable code changes.
52+8. **Debug and Test**: Debug as needed and test frequently.
53+9. **Iterate**: Continue until the root cause is fixed and all tests pass.
54+10. **Comprehensive Validation**: Reflect and validate thoroughly after tests pass.
55+
56+Refer to the detailed sections below for more information on each step.
57+
58+## 1. Understanding Context and Fetching URLs
59+
60+- **Context First**: Before diving into code, understand what the existing code is supposed to do based on file names, directory structure, imports, and existing patterns.
61+- **URL Fetching**: If the user provides a URL, use the `fetch` tool to retrieve the content.
62+- **Recursive Information Gathering**: If you find additional relevant URLs or links, fetch those as well until you have all necessary information.
63+
64+## 2. Deep Problem Understanding
65+
66+Carefully read the issue and think hard about a plan to solve it before coding. Consider:
67+
68+- What is the expected behavior?
69+- What are the edge cases?
70+- What are the potential pitfalls?
71+- How does this fit into the larger context of the codebase?
72+- What are the dependencies and interactions with other parts of the code?
73+
74+## 3. Codebase Investigation
75+
76+- Explore relevant files and directories using `ls`, `view`, `glob`, and `grep` tools.
77+- Search for key functions, classes, or variables related to the issue.
78+- Read and understand relevant code snippets.
79+- Identify the root cause of the problem.
80+- Validate and update your understanding continuously as you gather more context.
81+
82+## 4. Research When Needed
83+
84+- Use the `sourcegraph` tool when you need to find code examples or verify usage patterns for libraries/frameworks.
85+- Use the `fetch` tool to retrieve documentation or other web resources.
86+- Look for patterns, best practices, and implementation examples.
87+- Focus your research on what's necessary to solve the specific problem at hand.
88+
89+## 5. Develop a Detailed Plan
90+
91+- Outline a specific, simple, and verifiable sequence of steps to fix the problem.
92+- Create a todo list in markdown format to track your progress.
93+- Each time you complete a step, check it off using `[x]` syntax.
94+- Each time you check off a step, display the updated todo list to the user.
95+- Make sure that you ACTUALLY continue on to the next step after checking off a step instead of ending your turn.
96+
97+## 6. Making Code Changes
98+
99+- Before editing, always read the relevant file contents or section to ensure complete context using the `view` tool.
100+- Always read at least 2000 lines of code at a time to ensure you have enough context.
101+- If a patch is not applied correctly, attempt to reapply it.
102+- Make small, testable, incremental changes that logically follow from your investigation and plan.
103+- Whenever you detect that a project requires an environment variable (such as an API key or secret), always check if a .env file exists in the project root. If it does not exist, automatically create a .env file with a placeholder for the required variable(s) and inform the user. Do this proactively, without waiting for the user to request it.
104+- Prefer using the `multiedit` tool when making multiple edits to the same file.
105+
106+## 7. Debugging and Testing
107+
108+- Use the `bash` tool to run commands and check for errors.
109+- Make code changes only if you have high confidence they can solve the problem.
110+- When debugging, try to determine the root cause rather than addressing symptoms.
111+- Debug for as long as needed to identify the root cause and identify a fix.
112+- Use print statements, logs, or temporary code to inspect program state, including descriptive statements or error messages to understand what's happening.
113+- To test hypotheses, you can also add test statements or functions.
114+- Revisit your assumptions if unexpected behavior occurs.
115+- **Test rigorously and frequently** - this is critical for success.
116+
117+# Memory
118+
119+If the current working directory contains a file called CRUSH.md, it will be automatically added to your context. This file serves multiple purposes:
120+
121+1. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
122+2. Recording the user's code style preferences (naming conventions, preferred libraries, etc.)
123+3. Maintaining useful information about the codebase structure and organization
124+
125+When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to CRUSH.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to CRUSH.md so you can remember it for next time.
126+
127+# How to Create a Todo List
128+
129+Use the following format to create a todo list:
130+
131+```markdown
132+- [ ] Step 1: Description of the first step
133+- [ ] Step 2: Description of the second step
134+- [ ] Step 3: Description of the third step
135+```
136+
137+Do not ever use HTML tags or any other formatting for the todo list, as it will not be rendered correctly. Always use the markdown format shown above. Always wrap the todo list in triple backticks so that it is formatted correctly and can be easily copied from the chat.
138+
139+Always show the completed todo list to the user as the last item in your message, so that they can see that you have addressed all of the steps.
140+
141+# Communication Guidelines
142+
143+Always communicate clearly and concisely in a casual, friendly yet professional tone.
144+
145+<examples>
146+"Let me fetch the URL you provided to gather more information."
147+"Ok, I've got all of the information I need on the API and I know how to use it."
148+"Now, I will search the codebase for the function that handles the API requests."
149+"I need to update several files here - stand by"
150+"OK! Now let's run the tests to make sure everything is working correctly."
151+"Whelp - I see we have some problems. Let's fix those up."
152+</examples>
153+
154+- Respond with clear, direct answers. Use bullet points and code blocks for structure.
155+- Avoid unnecessary explanations, repetition, and filler.
156+- Always write code directly to the correct files.
157+- Do not display code to the user unless they specifically ask for it.
158+- Only elaborate when clarification is essential for accuracy or user understanding.
159+
160+# Tone and Style
161+
162+You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
163+
164+Remember that your output will be displayed on a command line interface. Your responses can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
165+
166+Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
167+
168+If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
169+
170+IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request.
171+
172+IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
173+
174+VERY IMPORTANT: NEVER use emojis in your responses.
175+
176+# Following Conventions
177+
178+When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns.
179+
180+- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language).
181+- When you create a new component, first look at existing components to see how they're written; then consider framework choice, naming conventions, typing, and other conventions.
182+- When you edit a piece of code, first look at the code's surrounding context (especially its imports) to understand the code's choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic.
183+- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
184+
185+# Code Style
186+
187+- IMPORTANT: DO NOT ADD **_ANY_** COMMENTS unless asked
188+
189+# Task Execution
190+
191+The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
192+
193+1. Use the available search tools to understand the codebase and the user's query.
194+2. Implement the solution using all tools available to you
195+3. Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
196+4. VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to CRUSH.md so that you will know to run it next time.
197+
198+NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
199+
200+# Tool Usage Policy
201+
202+- When doing file search, prefer to use the Agent tool in order to reduce context usage.
203+- **IMPORTANT**: If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in parallel for efficiency.
204+- **IMPORTANT**: The user does not see the full output of the tool responses, so if you need the output of the tool for your response, make sure to summarize it for the user.
205+- All tools are executed in parallel when multiple tool calls are sent in a single message. Only send multiple tool calls when they are safe to run in parallel (no dependencies between them).
206+
207+# Reading Files and Folders
208+
209+**Always check if you have already read a file, folder, or workspace structure before reading it again.**
210+
211+- If you have already read the content and it has not changed, do NOT re-read it.
212+- Only re-read files or folders if:
213+ - You suspect the content has changed since your last read.
214+ - You have made edits to the file or folder.
215+ - You encounter an error that suggests the context may be stale or incomplete.
216+- Use your internal memory and previous context to avoid redundant reads.
217+- This will save time, reduce unnecessary operations, and make your workflow more efficient.
218+
219+# Directory Context and Navigation
220+
221+**Always maintain awareness of your current working directory by tracking it mentally from the command history.**
222+
223+- **Remember directory changes**: When you use `cd` to change directories, mentally note and remember the new location for all subsequent operations.
224+- **Track your location from context**: Use the command history and previous `cd` commands to know where you currently are without constantly checking.
225+- **Check location only when commands fail**: If a command fails unexpectedly with file/path errors, then use `pwd` to verify your current directory as the failure might be due to being in the wrong location.
226+- **Use relative paths confidently**: Once you know your location, use relative paths appropriately based on your mental model of the current directory.
227+- **Maintain directory awareness across operations**: Keep track of where you are throughout a multi-step task, especially when working with files in different directories.
228+
229+**When to verify with `pwd`:**
230+
231+- After a command fails with "file not found" or similar path-related or `exit status 1` errors
232+- When resuming work or continuing from a previous step if uncertain
233+- When you realize you may have lost track of your current location
234+
235+**Mental tracking example:**
236+
237+```bash
238+# You start in /project/root
239+cd src/components # Now mentally note: I'm in /project/root/src/components
240+# Work with files here using relative paths
241+ls ./Button.tsx # This should work because I know I'm in components/
242+# If this fails, THEN run pwd to double-check location
243+```
244+
245+# Git and Version Control
246+
247+If the user tells you to stage and commit, you may do so.
248+
249+You are NEVER allowed to stage and commit files automatically. Only do this when explicitly requested.
250+
251+# Error Handling and Recovery
252+
253+- When you encounter errors, don't give up - analyze the error carefully and try alternative approaches.
254+- If a tool fails, try a different tool or approach to accomplish the same goal.
255+- When debugging, be systematic: isolate the problem, test hypotheses, and iterate until resolved.
256+- Always validate your solutions work correctly before considering the task complete.
257+
258+# Final Validation
259+
260+Before completing any task:
261+
262+1. Ensure all todo items are checked off
263+2. Run all relevant tests
264+3. Run linting and type checking if available
265+4. Verify the original problem is solved
266+5. Test edge cases and boundary conditions
267+6. Confirm no regressions were introduced
internal/llm/prompt/init.mdnew file+10
@@ -0,0 +1,10 @@
1+`Please analyze this codebase and create a **CRUSH.md** file containing:
2+
3+- Build/lint/test commands - especially for running a single test
4+- Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
5+
6+The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20-30 lines long.
7+If there's already a **CRUSH.md**, improve it.
8+
9+If there are Cursor rules (in `.cursor/rules/` or `.cursorrules`) or Copilot rules (in `.github/copilot-instructions.md`), make sure to include them.
10+Add the `.crush` directory to the `.gitignore` file if it's not already there.
internal/llm/prompt/initialize.go+5−9
@@ -1,14 +1,10 @@
1package prompt
1package prompt
2
2
3−func Initialize() string {
3+import _ "embed"
4− return `Please analyze this codebase and create a **CRUSH.md** file containing:
5−
6−- Build/lint/test commands - especially for running a single test
7−- Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
8
4
9−The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20-30 lines long.
5+//go:embed init.md
10−If there's already a **CRUSH.md**, improve it.
6+var initPrompt []byte
11
7
12−If there are Cursor rules` + " (in `.cursor/rules/` or `.cursorrules`) or Copilot rules (in `.github/copilot-instructions.md`), make sure to include them.\n" +
8+func Initialize() string {
13− "Add the `.crush` directory to the `.gitignore` file if it's not already there."
9+ return string(initPrompt)
14}
10}
internal/llm/prompt/summarize.mdnew file+11
@@ -0,0 +1,11 @@
1+You are a helpful AI assistant tasked with summarizing conversations.
2+
3+When asked to summarize, provide a detailed but concise summary of the conversation.
4+Focus on information that would be helpful for continuing the conversation, including:
5+
6+- What was done
7+- What is currently being worked on
8+- Which files are being modified
9+- What needs to be done next
10+
11+Your summary should be comprehensive enough to provide context but concise enough to be quickly understood.
internal/llm/prompt/summarizer.go+5−9
@@ -1,14 +1,10 @@
1package prompt
1package prompt
2
2
3−func SummarizerPrompt() string {
3+import _ "embed"
4− return `You are a helpful AI assistant tasked with summarizing conversations.
5
4
6−When asked to summarize, provide a detailed but concise summary of the conversation.
5+//go:embed summarize.md
7−Focus on information that would be helpful for continuing the conversation, including:
6+var summarizePrompt []byte
8−- What was done
9−- What is currently being worked on
10−- Which files are being modified
11−- What needs to be done next
12
7
13−Your summary should be comprehensive enough to provide context but concise enough to be quickly understood.`
8+func SummarizerPrompt() string {
9+ return string(summarizePrompt)
14}
10}
internal/llm/prompt/title.go+6−7
@@ -1,11 +1,10 @@
1package prompt
1package prompt
2
2
3+import _ "embed"
4+
5+//go:embed title.md
6+var titlePrompt []byte
7+
3func TitlePrompt() string {
8func TitlePrompt() string {
4− return `you will generate a short title based on the first message a user begins a conversation with
9+ return string(titlePrompt)
5−- ensure it is not more than 50 characters long
6−- the title should be a summary of the user's message
7−- it should be one line long
8−- do not use quotes or colons
9−- the entire text you return will be used as the title
10−- never return anything that is more than one sentence (one line) long`
11}
10}
internal/llm/prompt/title.mdnew file+8
@@ -0,0 +1,8 @@
1+you will generate a short title based on the first message a user begins a conversation with
2+
3+- ensure it is not more than 50 characters long
4+- the title should be a summary of the user's message
5+- it should be one line long
6+- do not use quotes or colons
7+- the entire text you return will be used as the title
8+- never return anything that is more than one sentence (one line) long
internal/llm/provider/openai.go+42−3
@@ -7,6 +7,7 @@ import (
7 "fmt"
7 "fmt"
8 "io"
8 "io"
9 "log/slog"
9 "log/slog"
10+ "strings"
10 "time"
11 "time"
11
12
12 "github.com/charmbracelet/catwalk/pkg/catwalk"
13 "github.com/charmbracelet/catwalk/pkg/catwalk"
@@ -56,14 +57,33 @@ func createOpenAIClient(opts providerClientOptions) openai.Client {
56}
57}
57
58
58func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessages []openai.ChatCompletionMessageParamUnion) {
59func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessages []openai.ChatCompletionMessageParamUnion) {
60+ isAnthropicModel := o.providerOptions.config.ID == string(catwalk.InferenceProviderOpenRouter) && strings.HasPrefix(o.Model().ID, "anthropic/")
59 // Add system message first
61 // Add system message first
60 systemMessage := o.providerOptions.systemMessage
62 systemMessage := o.providerOptions.systemMessage
61 if o.providerOptions.systemPromptPrefix != "" {
63 if o.providerOptions.systemPromptPrefix != "" {
62 systemMessage = o.providerOptions.systemPromptPrefix + "\n" + systemMessage
64 systemMessage = o.providerOptions.systemPromptPrefix + "\n" + systemMessage
63 }
65 }
64− openaiMessages = append(openaiMessages, openai.SystemMessage(systemMessage))
65
66
67+ systemTextBlock := openai.ChatCompletionContentPartTextParam{Text: systemMessage}
68+ if isAnthropicModel && !o.providerOptions.disableCache {
69+ systemTextBlock.SetExtraFields(
70+ map[string]any{
71+ "cache_control": map[string]string{
72+ "type": "ephemeral",
73+ },
74+ },
75+ )
76+ }
77+ var content []openai.ChatCompletionContentPartTextParam
78+ content = append(content, systemTextBlock)
79+ system := openai.SystemMessage(content)
80+ openaiMessages = append(openaiMessages, system)
81+
66− for _, msg := range messages {
82+ for i, msg := range messages {
83+ cache := false
84+ if i > len(messages)-3 {
85+ cache = true
86+ }
67 switch msg.Role {
87 switch msg.Role {
68 case message.User:
88 case message.User:
69 var content []openai.ChatCompletionContentPartUnionParam
89 var content []openai.ChatCompletionContentPartUnionParam
@@ -75,6 +95,13 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
75
95
76 content = append(content, openai.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock})
96 content = append(content, openai.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock})
77 }
97 }
98+ if cache && !o.providerOptions.disableCache && isAnthropicModel {
99+ textBlock.SetExtraFields(map[string]any{
100+ "cache_control": map[string]string{
101+ "type": "ephemeral",
102+ },
103+ })
104+ }
78
105
79 openaiMessages = append(openaiMessages, openai.UserMessage(content))
106 openaiMessages = append(openaiMessages, openai.UserMessage(content))
80
107
@@ -86,8 +113,20 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
86 hasContent := false
113 hasContent := false
87 if msg.Content().String() != "" {
114 if msg.Content().String() != "" {
88 hasContent = true
115 hasContent = true
116+ textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()}
117+ if cache && !o.providerOptions.disableCache && isAnthropicModel {
118+ textBlock.SetExtraFields(map[string]any{
119+ "cache_control": map[string]string{
120+ "type": "ephemeral",
121+ },
122+ })
123+ }
89 assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{
124 assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{
90− OfString: openai.String(msg.Content().String()),
125+ OfArrayOfContentParts: []openai.ChatCompletionAssistantMessageParamContentArrayOfContentPartUnion{
126+ {
127+ OfText: &textBlock,
128+ },
129+ },
91 }
130 }
92 }
131 }
93
132
internal/llm/provider/provider.go−6
@@ -199,12 +199,6 @@ func NewProvider(cfg config.ProviderConfig, opts ...ProviderClientOption) (Provi
199 options: clientOptions,
199 options: clientOptions,
200 client: newVertexAIClient(clientOptions),
200 client: newVertexAIClient(clientOptions),
201 }, nil
201 }, nil
202− case catwalk.TypeXAI:
203− clientOptions.baseURL = "https://api.x.ai/v1"
204− return &baseProvider[OpenAIClient]{
205− options: clientOptions,
206− client: newOpenAIClient(clientOptions),
207− }, nil
208 }
202 }
209 return nil, fmt.Errorf("provider not supported: %s", cfg.Type)
203 return nil, fmt.Errorf("provider not supported: %s", cfg.Type)
210}
204}
internal/llm/tools/bash.go+2−1
@@ -373,6 +373,7 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
373 permission.CreatePermissionRequest{
373 permission.CreatePermissionRequest{
374 SessionID: sessionID,
374 SessionID: sessionID,
375 Path: b.workingDir,
375 Path: b.workingDir,
376+ ToolCallID: call.ID,
376 ToolName: BashToolName,
377 ToolName: BashToolName,
377 Action: "execute",
378 Action: "execute",
378 Description: fmt.Sprintf("Execute command: %s", params.Command),
379 Description: fmt.Sprintf("Execute command: %s", params.Command),
@@ -439,10 +440,10 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
439 Output: stdout,
440 Output: stdout,
440 WorkingDirectory: currentWorkingDir,
441 WorkingDirectory: currentWorkingDir,
441 }
442 }
442− stdout += fmt.Sprintf("\n\n<cwd>%s</cwd>", currentWorkingDir)
443 if stdout == "" {
443 if stdout == "" {
444 return WithResponseMetadata(NewTextResponse(BashNoOutput), metadata), nil
444 return WithResponseMetadata(NewTextResponse(BashNoOutput), metadata), nil
445 }
445 }
446+ stdout += fmt.Sprintf("\n\n<cwd>%s</cwd>", currentWorkingDir)
446 return WithResponseMetadata(NewTextResponse(stdout), metadata), nil
447 return WithResponseMetadata(NewTextResponse(stdout), metadata), nil
447}
448}
448
449
internal/llm/tools/edit.go+68−34
@@ -18,9 +18,10 @@ import (
18)
18)
19
19
20type EditParams struct {
20type EditParams struct {
21− FilePath string `json:"file_path"`
21+ FilePath string `json:"file_path"`
22− OldString string `json:"old_string"`
22+ OldString string `json:"old_string"`
23− NewString string `json:"new_string"`
23+ NewString string `json:"new_string"`
24+ ReplaceAll bool `json:"replace_all,omitempty"`
24}
25}
25
26
26type EditPermissionsParams struct {
27type EditPermissionsParams struct {
@@ -58,31 +59,33 @@ To make a file edit, provide the following:
581. file_path: The absolute path to the file to modify (must be absolute, not relative)
591. file_path: The absolute path to the file to modify (must be absolute, not relative)
592. old_string: The text to replace (must be unique within the file, and must match the file contents exactly, including all whitespace and indentation)
602. old_string: The text to replace (must be unique within the file, and must match the file contents exactly, including all whitespace and indentation)
603. new_string: The edited text to replace the old_string
613. new_string: The edited text to replace the old_string
62+4. replace_all: Replace all occurrences of old_string (default false)
61
63
62Special cases:
64Special cases:
63- To create a new file: provide file_path and new_string, leave old_string empty
65- To create a new file: provide file_path and new_string, leave old_string empty
64- To delete content: provide file_path and old_string, leave new_string empty
66- To delete content: provide file_path and old_string, leave new_string empty
65
67
66−The tool will replace ONE occurrence of old_string with new_string in the specified file.
68+The tool will replace ONE occurrence of old_string with new_string in the specified file by default. Set replace_all to true to replace all occurrences.
67
69
68CRITICAL REQUIREMENTS FOR USING THIS TOOL:
70CRITICAL REQUIREMENTS FOR USING THIS TOOL:
69
71
70−1. UNIQUENESS: The old_string MUST uniquely identify the specific instance you want to change. This means:
72+1. UNIQUENESS: When replace_all is false (default), the old_string MUST uniquely identify the specific instance you want to change. This means:
71 - Include AT LEAST 3-5 lines of context BEFORE the change point
73 - Include AT LEAST 3-5 lines of context BEFORE the change point
72 - Include AT LEAST 3-5 lines of context AFTER the change point
74 - Include AT LEAST 3-5 lines of context AFTER the change point
73 - Include all whitespace, indentation, and surrounding code exactly as it appears in the file
75 - Include all whitespace, indentation, and surrounding code exactly as it appears in the file
74
76
75−2. SINGLE INSTANCE: This tool can only change ONE instance at a time. If you need to change multiple instances:
77+2. SINGLE INSTANCE: When replace_all is false, this tool can only change ONE instance at a time. If you need to change multiple instances:
78+ - Set replace_all to true to replace all occurrences at once
76− - Make separate calls to this tool for each instance
79+ - Or make separate calls to this tool for each instance
77 - Each call must uniquely identify its specific instance using extensive context
80 - Each call must uniquely identify its specific instance using extensive context
78
81
793. VERIFICATION: Before using this tool:
823. VERIFICATION: Before using this tool:
80 - Check how many instances of the target text exist in the file
83 - Check how many instances of the target text exist in the file
81− - If multiple instances exist, gather enough context to uniquely identify each one
84+ - If multiple instances exist and replace_all is false, gather enough context to uniquely identify each one
82− - Plan separate tool calls for each instance
85+ - Plan separate tool calls for each instance or use replace_all
83
86
84WARNING: If you do not follow these requirements:
87WARNING: If you do not follow these requirements:
85− - The tool will fail if old_string matches multiple locations
88+ - The tool will fail if old_string matches multiple locations and replace_all is false
86 - The tool will fail if old_string doesn't match exactly (including whitespace)
89 - The tool will fail if old_string doesn't match exactly (including whitespace)
87 - You may change the wrong instance if you don't include enough context
90 - You may change the wrong instance if you don't include enough context
88
91
@@ -129,6 +132,10 @@ func (e *editTool) Info() ToolInfo {
129 "type": "string",
132 "type": "string",
130 "description": "The text to replace it with",
133 "description": "The text to replace it with",
131 },
134 },
135+ "replace_all": map[string]any{
136+ "type": "boolean",
137+ "description": "Replace all occurrences of old_string (default false)",
138+ },
132 },
139 },
133 Required: []string{"file_path", "old_string", "new_string"},
140 Required: []string{"file_path", "old_string", "new_string"},
134 }
141 }
@@ -152,20 +159,20 @@ func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
152 var err error
159 var err error
153
160
154 if params.OldString == "" {
161 if params.OldString == "" {
155− response, err = e.createNewFile(ctx, params.FilePath, params.NewString)
162+ response, err = e.createNewFile(ctx, params.FilePath, params.NewString, call)
156 if err != nil {
163 if err != nil {
157 return response, err
164 return response, err
158 }
165 }
159 }
166 }
160
167
161 if params.NewString == "" {
168 if params.NewString == "" {
162− response, err = e.deleteContent(ctx, params.FilePath, params.OldString)
169+ response, err = e.deleteContent(ctx, params.FilePath, params.OldString, params.ReplaceAll, call)
163 if err != nil {
170 if err != nil {
164 return response, err
171 return response, err
165 }
172 }
166 }
173 }
167
174
168− response, err = e.replaceContent(ctx, params.FilePath, params.OldString, params.NewString)
175+ response, err = e.replaceContent(ctx, params.FilePath, params.OldString, params.NewString, params.ReplaceAll, call)
169 if err != nil {
176 if err != nil {
170 return response, err
177 return response, err
171 }
178 }
@@ -182,7 +189,7 @@ func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
182 return response, nil
189 return response, nil
183}
190}
184
191
185−func (e *editTool) createNewFile(ctx context.Context, filePath, content string) (ToolResponse, error) {
192+func (e *editTool) createNewFile(ctx context.Context, filePath, content string, call ToolCall) (ToolResponse, error) {
186 fileInfo, err := os.Stat(filePath)
193 fileInfo, err := os.Stat(filePath)
187 if err == nil {
194 if err == nil {
188 if fileInfo.IsDir() {
195 if fileInfo.IsDir() {
@@ -217,6 +224,7 @@ func (e *editTool) createNewFile(ctx context.Context, filePath, content string)
217 permission.CreatePermissionRequest{
224 permission.CreatePermissionRequest{
218 SessionID: sessionID,
225 SessionID: sessionID,
219 Path: permissionPath,
226 Path: permissionPath,
227+ ToolCallID: call.ID,
220 ToolName: EditToolName,
228 ToolName: EditToolName,
221 Action: "write",
229 Action: "write",
222 Description: fmt.Sprintf("Create file %s", filePath),
230 Description: fmt.Sprintf("Create file %s", filePath),
@@ -264,7 +272,7 @@ func (e *editTool) createNewFile(ctx context.Context, filePath, content string)
264 ), nil
272 ), nil
265}
273}
266
274
267−func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string) (ToolResponse, error) {
275+func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string, replaceAll bool, call ToolCall) (ToolResponse, error) {
268 fileInfo, err := os.Stat(filePath)
276 fileInfo, err := os.Stat(filePath)
269 if err != nil {
277 if err != nil {
270 if os.IsNotExist(err) {
278 if os.IsNotExist(err) {
@@ -297,17 +305,29 @@ func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string
297
305
298 oldContent := string(content)
306 oldContent := string(content)
299
307
300− index := strings.Index(oldContent, oldString)
308+ var newContent string
301− if index == -1 {
309+ var deletionCount int
302− return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
303− }
304
310
311+ if replaceAll {
312+ newContent = strings.ReplaceAll(oldContent, oldString, "")
313+ deletionCount = strings.Count(oldContent, oldString)
314+ if deletionCount == 0 {
315+ return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
316+ }
317+ } else {
318+ index := strings.Index(oldContent, oldString)
319+ if index == -1 {
320+ return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
321+ }
322+
305− lastIndex := strings.LastIndex(oldContent, oldString)
323+ lastIndex := strings.LastIndex(oldContent, oldString)
306− if index != lastIndex {
324+ if index != lastIndex {
307− return NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match"), nil
325+ return NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true"), nil
308− }
326+ }
309
327
310− newContent := oldContent[:index] + oldContent[index+len(oldString):]
328+ newContent = oldContent[:index] + oldContent[index+len(oldString):]
329+ deletionCount = 1
330+ }
311
331
312 sessionID, messageID := GetContextValues(ctx)
332 sessionID, messageID := GetContextValues(ctx)
313
333
@@ -330,6 +350,7 @@ func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string
330 permission.CreatePermissionRequest{
350 permission.CreatePermissionRequest{
331 SessionID: sessionID,
351 SessionID: sessionID,
332 Path: permissionPath,
352 Path: permissionPath,
353+ ToolCallID: call.ID,
333 ToolName: EditToolName,
354 ToolName: EditToolName,
334 Action: "write",
355 Action: "write",
335 Description: fmt.Sprintf("Delete content from file %s", filePath),
356 Description: fmt.Sprintf("Delete content from file %s", filePath),
@@ -385,7 +406,7 @@ func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string
385 ), nil
406 ), nil
386}
407}
387
408
388−func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newString string) (ToolResponse, error) {
409+func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newString string, replaceAll bool, call ToolCall) (ToolResponse, error) {
389 fileInfo, err := os.Stat(filePath)
410 fileInfo, err := os.Stat(filePath)
390 if err != nil {
411 if err != nil {
391 if os.IsNotExist(err) {
412 if os.IsNotExist(err) {
@@ -418,17 +439,29 @@ func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newS
418
439
419 oldContent := string(content)
440 oldContent := string(content)
420
441
421− index := strings.Index(oldContent, oldString)
442+ var newContent string
422− if index == -1 {
443+ var replacementCount int
423− return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
424− }
425
444
445+ if replaceAll {
446+ newContent = strings.ReplaceAll(oldContent, oldString, newString)
447+ replacementCount = strings.Count(oldContent, oldString)
448+ if replacementCount == 0 {
449+ return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
450+ }
451+ } else {
452+ index := strings.Index(oldContent, oldString)
453+ if index == -1 {
454+ return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
455+ }
456+
426− lastIndex := strings.LastIndex(oldContent, oldString)
457+ lastIndex := strings.LastIndex(oldContent, oldString)
427− if index != lastIndex {
458+ if index != lastIndex {
428− return NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match"), nil
459+ return NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true"), nil
429− }
460+ }
430
461
431− newContent := oldContent[:index] + newString + oldContent[index+len(oldString):]
462+ newContent = oldContent[:index] + newString + oldContent[index+len(oldString):]
463+ replacementCount = 1
464+ }
432
465
433 if oldContent == newContent {
466 if oldContent == newContent {
434 return NewTextErrorResponse("new content is the same as old content. No changes made."), nil
467 return NewTextErrorResponse("new content is the same as old content. No changes made."), nil
@@ -452,6 +485,7 @@ func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newS
452 permission.CreatePermissionRequest{
485 permission.CreatePermissionRequest{
453 SessionID: sessionID,
486 SessionID: sessionID,
454 Path: permissionPath,
487 Path: permissionPath,
488+ ToolCallID: call.ID,
455 ToolName: EditToolName,
489 ToolName: EditToolName,
456 Action: "write",
490 Action: "write",
457 Description: fmt.Sprintf("Replace content in file %s", filePath),
491 Description: fmt.Sprintf("Replace content in file %s", filePath),
internal/llm/tools/fetch.go+1
@@ -136,6 +136,7 @@ func (t *fetchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
136 permission.CreatePermissionRequest{
136 permission.CreatePermissionRequest{
137 SessionID: sessionID,
137 SessionID: sessionID,
138 Path: t.workingDir,
138 Path: t.workingDir,
139+ ToolCallID: call.ID,
139 ToolName: FetchToolName,
140 ToolName: FetchToolName,
140 Action: "fetch",
141 Action: "fetch",
141 Description: fmt.Sprintf("Fetch content from URL: %s", params.URL),
142 Description: fmt.Sprintf("Fetch content from URL: %s", params.URL),
internal/llm/tools/multiedit.gonew file+467
@@ -0,0 +1,467 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "log/slog"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+ "time"
12+
13+ "github.com/charmbracelet/crush/internal/diff"
14+ "github.com/charmbracelet/crush/internal/history"
15+ "github.com/charmbracelet/crush/internal/lsp"
16+ "github.com/charmbracelet/crush/internal/permission"
17+)
18+
19+type MultiEditOperation struct {
20+ OldString string `json:"old_string"`
21+ NewString string `json:"new_string"`
22+ ReplaceAll bool `json:"replace_all,omitempty"`
23+}
24+
25+type MultiEditParams struct {
26+ FilePath string `json:"file_path"`
27+ Edits []MultiEditOperation `json:"edits"`
28+}
29+
30+type MultiEditPermissionsParams struct {
31+ FilePath string `json:"file_path"`
32+ OldContent string `json:"old_content,omitempty"`
33+ NewContent string `json:"new_content,omitempty"`
34+}
35+
36+type MultiEditResponseMetadata struct {
37+ Additions int `json:"additions"`
38+ Removals int `json:"removals"`
39+ OldContent string `json:"old_content,omitempty"`
40+ NewContent string `json:"new_content,omitempty"`
41+ EditsApplied int `json:"edits_applied"`
42+}
43+
44+type multiEditTool struct {
45+ lspClients map[string]*lsp.Client
46+ permissions permission.Service
47+ files history.Service
48+ workingDir string
49+}
50+
51+const (
52+ MultiEditToolName = "multiedit"
53+ multiEditDescription = `This is a tool for making multiple edits to a single file in one operation. It is built on top of the Edit tool and allows you to perform multiple find-and-replace operations efficiently. Prefer this tool over the Edit tool when you need to make multiple edits to the same file.
54+
55+Before using this tool:
56+
57+1. Use the Read tool to understand the file's contents and context
58+
59+2. Verify the directory path is correct
60+
61+To make multiple file edits, provide the following:
62+1. file_path: The absolute path to the file to modify (must be absolute, not relative)
63+2. edits: An array of edit operations to perform, where each edit contains:
64+ - old_string: The text to replace (must match the file contents exactly, including all whitespace and indentation)
65+ - new_string: The edited text to replace the old_string
66+ - replace_all: Replace all occurrences of old_string. This parameter is optional and defaults to false.
67+
68+IMPORTANT:
69+- All edits are applied in sequence, in the order they are provided
70+- Each edit operates on the result of the previous edit
71+- All edits must be valid for the operation to succeed - if any edit fails, none will be applied
72+- This tool is ideal when you need to make several changes to different parts of the same file
73+
74+CRITICAL REQUIREMENTS:
75+1. All edits follow the same requirements as the single Edit tool
76+2. The edits are atomic - either all succeed or none are applied
77+3. Plan your edits carefully to avoid conflicts between sequential operations
78+
79+WARNING:
80+- The tool will fail if edits.old_string doesn't match the file contents exactly (including whitespace)
81+- The tool will fail if edits.old_string and edits.new_string are the same
82+- Since edits are applied in sequence, ensure that earlier edits don't affect the text that later edits are trying to find
83+
84+When making edits:
85+- Ensure all edits result in idiomatic, correct code
86+- Do not leave the code in a broken state
87+- Always use absolute file paths (starting with /)
88+- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
89+- Use replace_all for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
90+
91+If you want to create a new file, use:
92+- A new file path, including dir name if needed
93+- First edit: empty old_string and the new file's contents as new_string
94+- Subsequent edits: normal edit operations on the created content`
95+)
96+
97+func NewMultiEditTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service, workingDir string) BaseTool {
98+ return &multiEditTool{
99+ lspClients: lspClients,
100+ permissions: permissions,
101+ files: files,
102+ workingDir: workingDir,
103+ }
104+}
105+
106+func (m *multiEditTool) Name() string {
107+ return MultiEditToolName
108+}
109+
110+func (m *multiEditTool) Info() ToolInfo {
111+ return ToolInfo{
112+ Name: MultiEditToolName,
113+ Description: multiEditDescription,
114+ Parameters: map[string]any{
115+ "file_path": map[string]any{
116+ "type": "string",
117+ "description": "The absolute path to the file to modify",
118+ },
119+ "edits": map[string]any{
120+ "type": "array",
121+ "items": map[string]any{
122+ "type": "object",
123+ "properties": map[string]any{
124+ "old_string": map[string]any{
125+ "type": "string",
126+ "description": "The text to replace",
127+ },
128+ "new_string": map[string]any{
129+ "type": "string",
130+ "description": "The text to replace it with",
131+ },
132+ "replace_all": map[string]any{
133+ "type": "boolean",
134+ "default": false,
135+ "description": "Replace all occurrences of old_string (default false).",
136+ },
137+ },
138+ "required": []string{"old_string", "new_string"},
139+ "additionalProperties": false,
140+ },
141+ "minItems": 1,
142+ "description": "Array of edit operations to perform sequentially on the file",
143+ },
144+ },
145+ Required: []string{"file_path", "edits"},
146+ }
147+}
148+
149+func (m *multiEditTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
150+ var params MultiEditParams
151+ if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
152+ return NewTextErrorResponse("invalid parameters"), nil
153+ }
154+
155+ if params.FilePath == "" {
156+ return NewTextErrorResponse("file_path is required"), nil
157+ }
158+
159+ if len(params.Edits) == 0 {
160+ return NewTextErrorResponse("at least one edit operation is required"), nil
161+ }
162+
163+ if !filepath.IsAbs(params.FilePath) {
164+ params.FilePath = filepath.Join(m.workingDir, params.FilePath)
165+ }
166+
167+ // Validate all edits before applying any
168+ if err := m.validateEdits(params.Edits); err != nil {
169+ return NewTextErrorResponse(err.Error()), nil
170+ }
171+
172+ var response ToolResponse
173+ var err error
174+
175+ // Handle file creation case (first edit has empty old_string)
176+ if len(params.Edits) > 0 && params.Edits[0].OldString == "" {
177+ response, err = m.processMultiEditWithCreation(ctx, params, call)
178+ } else {
179+ response, err = m.processMultiEditExistingFile(ctx, params, call)
180+ }
181+
182+ if err != nil {
183+ return response, err
184+ }
185+
186+ if response.IsError {
187+ return response, nil
188+ }
189+
190+ // Wait for LSP diagnostics and add them to the response
191+ waitForLspDiagnostics(ctx, params.FilePath, m.lspClients)
192+ text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
193+ text += getDiagnostics(params.FilePath, m.lspClients)
194+ response.Content = text
195+ return response, nil
196+}
197+
198+func (m *multiEditTool) validateEdits(edits []MultiEditOperation) error {
199+ for i, edit := range edits {
200+ if edit.OldString == edit.NewString {
201+ return fmt.Errorf("edit %d: old_string and new_string are identical", i+1)
202+ }
203+ // Only the first edit can have empty old_string (for file creation)
204+ if i > 0 && edit.OldString == "" {
205+ return fmt.Errorf("edit %d: only the first edit can have empty old_string (for file creation)", i+1)
206+ }
207+ }
208+ return nil
209+}
210+
211+func (m *multiEditTool) processMultiEditWithCreation(ctx context.Context, params MultiEditParams, call ToolCall) (ToolResponse, error) {
212+ // First edit creates the file
213+ firstEdit := params.Edits[0]
214+ if firstEdit.OldString != "" {
215+ return NewTextErrorResponse("first edit must have empty old_string for file creation"), nil
216+ }
217+
218+ // Check if file already exists
219+ if _, err := os.Stat(params.FilePath); err == nil {
220+ return NewTextErrorResponse(fmt.Sprintf("file already exists: %s", params.FilePath)), nil
221+ } else if !os.IsNotExist(err) {
222+ return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
223+ }
224+
225+ // Create parent directories
226+ dir := filepath.Dir(params.FilePath)
227+ if err := os.MkdirAll(dir, 0o755); err != nil {
228+ return ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
229+ }
230+
231+ // Start with the content from the first edit
232+ currentContent := firstEdit.NewString
233+
234+ // Apply remaining edits to the content
235+ for i := 1; i < len(params.Edits); i++ {
236+ edit := params.Edits[i]
237+ newContent, err := m.applyEditToContent(currentContent, edit)
238+ if err != nil {
239+ return NewTextErrorResponse(fmt.Sprintf("edit %d failed: %s", i+1, err.Error())), nil
240+ }
241+ currentContent = newContent
242+ }
243+
244+ // Get session and message IDs
245+ sessionID, messageID := GetContextValues(ctx)
246+ if sessionID == "" || messageID == "" {
247+ return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
248+ }
249+
250+ // Check permissions
251+ _, additions, removals := diff.GenerateDiff("", currentContent, strings.TrimPrefix(params.FilePath, m.workingDir))
252+ rootDir := m.workingDir
253+ permissionPath := filepath.Dir(params.FilePath)
254+ if strings.HasPrefix(params.FilePath, rootDir) {
255+ permissionPath = rootDir
256+ }
257+
258+ p := m.permissions.Request(permission.CreatePermissionRequest{
259+ SessionID: sessionID,
260+ Path: permissionPath,
261+ ToolCallID: call.ID,
262+ ToolName: MultiEditToolName,
263+ Action: "write",
264+ Description: fmt.Sprintf("Create file %s with %d edits", params.FilePath, len(params.Edits)),
265+ Params: MultiEditPermissionsParams{
266+ FilePath: params.FilePath,
267+ OldContent: "",
268+ NewContent: currentContent,
269+ },
270+ })
271+ if !p {
272+ return ToolResponse{}, permission.ErrorPermissionDenied
273+ }
274+
275+ // Write the file
276+ err := os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
277+ if err != nil {
278+ return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
279+ }
280+
281+ // Update file history
282+ _, err = m.files.Create(ctx, sessionID, params.FilePath, "")
283+ if err != nil {
284+ return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
285+ }
286+
287+ _, err = m.files.CreateVersion(ctx, sessionID, params.FilePath, currentContent)
288+ if err != nil {
289+ slog.Debug("Error creating file history version", "error", err)
290+ }
291+
292+ recordFileWrite(params.FilePath)
293+ recordFileRead(params.FilePath)
294+
295+ return WithResponseMetadata(
296+ NewTextResponse(fmt.Sprintf("File created with %d edits: %s", len(params.Edits), params.FilePath)),
297+ MultiEditResponseMetadata{
298+ OldContent: "",
299+ NewContent: currentContent,
300+ Additions: additions,
301+ Removals: removals,
302+ EditsApplied: len(params.Edits),
303+ },
304+ ), nil
305+}
306+
307+func (m *multiEditTool) processMultiEditExistingFile(ctx context.Context, params MultiEditParams, call ToolCall) (ToolResponse, error) {
308+ // Validate file exists and is readable
309+ fileInfo, err := os.Stat(params.FilePath)
310+ if err != nil {
311+ if os.IsNotExist(err) {
312+ return NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil
313+ }
314+ return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
315+ }
316+
317+ if fileInfo.IsDir() {
318+ return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
319+ }
320+
321+ // Check if file was read before editing
322+ if getLastReadTime(params.FilePath).IsZero() {
323+ return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
324+ }
325+
326+ // Check if file was modified since last read
327+ modTime := fileInfo.ModTime()
328+ lastRead := getLastReadTime(params.FilePath)
329+ if modTime.After(lastRead) {
330+ return NewTextErrorResponse(
331+ fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
332+ params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
333+ )), nil
334+ }
335+
336+ // Read current file content
337+ content, err := os.ReadFile(params.FilePath)
338+ if err != nil {
339+ return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
340+ }
341+
342+ oldContent := string(content)
343+ currentContent := oldContent
344+
345+ // Apply all edits sequentially
346+ for i, edit := range params.Edits {
347+ newContent, err := m.applyEditToContent(currentContent, edit)
348+ if err != nil {
349+ return NewTextErrorResponse(fmt.Sprintf("edit %d failed: %s", i+1, err.Error())), nil
350+ }
351+ currentContent = newContent
352+ }
353+
354+ // Check if content actually changed
355+ if oldContent == currentContent {
356+ return NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil
357+ }
358+
359+ // Get session and message IDs
360+ sessionID, messageID := GetContextValues(ctx)
361+ if sessionID == "" || messageID == "" {
362+ return ToolResponse{}, fmt.Errorf("session ID and message ID are required for editing file")
363+ }
364+
365+ // Generate diff and check permissions
366+ _, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, m.workingDir))
367+ rootDir := m.workingDir
368+ permissionPath := filepath.Dir(params.FilePath)
369+ if strings.HasPrefix(params.FilePath, rootDir) {
370+ permissionPath = rootDir
371+ }
372+
373+ p := m.permissions.Request(permission.CreatePermissionRequest{
374+ SessionID: sessionID,
375+ Path: permissionPath,
376+ ToolCallID: call.ID,
377+ ToolName: MultiEditToolName,
378+ Action: "write",
379+ Description: fmt.Sprintf("Apply %d edits to file %s", len(params.Edits), params.FilePath),
380+ Params: MultiEditPermissionsParams{
381+ FilePath: params.FilePath,
382+ OldContent: oldContent,
383+ NewContent: currentContent,
384+ },
385+ })
386+ if !p {
387+ return ToolResponse{}, permission.ErrorPermissionDenied
388+ }
389+
390+ // Write the updated content
391+ err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
392+ if err != nil {
393+ return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
394+ }
395+
396+ // Update file history
397+ file, err := m.files.GetByPathAndSession(ctx, params.FilePath, sessionID)
398+ if err != nil {
399+ _, err = m.files.Create(ctx, sessionID, params.FilePath, oldContent)
400+ if err != nil {
401+ return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
402+ }
403+ }
404+ if file.Content != oldContent {
405+ // User manually changed the content, store an intermediate version
406+ _, err = m.files.CreateVersion(ctx, sessionID, params.FilePath, oldContent)
407+ if err != nil {
408+ slog.Debug("Error creating file history version", "error", err)
409+ }
410+ }
411+
412+ // Store the new version
413+ _, err = m.files.CreateVersion(ctx, sessionID, params.FilePath, currentContent)
414+ if err != nil {
415+ slog.Debug("Error creating file history version", "error", err)
416+ }
417+
418+ recordFileWrite(params.FilePath)
419+ recordFileRead(params.FilePath)
420+
421+ return WithResponseMetadata(
422+ NewTextResponse(fmt.Sprintf("Applied %d edits to file: %s", len(params.Edits), params.FilePath)),
423+ MultiEditResponseMetadata{
424+ OldContent: oldContent,
425+ NewContent: currentContent,
426+ Additions: additions,
427+ Removals: removals,
428+ EditsApplied: len(params.Edits),
429+ },
430+ ), nil
431+}
432+
433+func (m *multiEditTool) applyEditToContent(content string, edit MultiEditOperation) (string, error) {
434+ if edit.OldString == "" && edit.NewString == "" {
435+ return content, nil
436+ }
437+
438+ if edit.OldString == "" {
439+ return "", fmt.Errorf("old_string cannot be empty for content replacement")
440+ }
441+
442+ var newContent string
443+ var replacementCount int
444+
445+ if edit.ReplaceAll {
446+ newContent = strings.ReplaceAll(content, edit.OldString, edit.NewString)
447+ replacementCount = strings.Count(content, edit.OldString)
448+ if replacementCount == 0 {
449+ return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
450+ }
451+ } else {
452+ index := strings.Index(content, edit.OldString)
453+ if index == -1 {
454+ return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
455+ }
456+
457+ lastIndex := strings.LastIndex(content, edit.OldString)
458+ if index != lastIndex {
459+ return "", fmt.Errorf("old_string appears multiple times in the content. Please provide more context to ensure a unique match, or set replace_all to true")
460+ }
461+
462+ newContent = content[:index] + edit.NewString + content[index+len(edit.OldString):]
463+ replacementCount = 1
464+ }
465+
466+ return newContent, nil
467+}
internal/llm/tools/write.go+1
@@ -181,6 +181,7 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
181 permission.CreatePermissionRequest{
181 permission.CreatePermissionRequest{
182 SessionID: sessionID,
182 SessionID: sessionID,
183 Path: permissionPath,
183 Path: permissionPath,
184+ ToolCallID: call.ID,
184 ToolName: WriteToolName,
185 ToolName: WriteToolName,
185 Action: "write",
186 Action: "write",
186 Description: fmt.Sprintf("Create file %s", filePath),
187 Description: fmt.Sprintf("Create file %s", filePath),
internal/permission/permission.go+75−11
@@ -1,6 +1,7 @@
1package permission
1package permission
2
2
3import (
3import (
4+ "context"
4 "errors"
5 "errors"
5 "path/filepath"
6 "path/filepath"
6 "slices"
7 "slices"
@@ -15,6 +16,7 @@ var ErrorPermissionDenied = errors.New("permission denied")
15
16
16type CreatePermissionRequest struct {
17type CreatePermissionRequest struct {
17 SessionID string `json:"session_id"`
18 SessionID string `json:"session_id"`
19+ ToolCallID string `json:"tool_call_id"`
18 ToolName string `json:"tool_name"`
20 ToolName string `json:"tool_name"`
19 Description string `json:"description"`
21 Description string `json:"description"`
20 Action string `json:"action"`
22 Action string `json:"action"`
@@ -22,9 +24,16 @@ type CreatePermissionRequest struct {
22 Path string `json:"path"`
24 Path string `json:"path"`
23}
25}
24
26
27+type PermissionNotification struct {
28+ ToolCallID string `json:"tool_call_id"`
29+ Granted bool `json:"granted"`
30+ Denied bool `json:"denied"`
31+}
32+
25type PermissionRequest struct {
33type PermissionRequest struct {
26 ID string `json:"id"`
34 ID string `json:"id"`
27 SessionID string `json:"session_id"`
35 SessionID string `json:"session_id"`
36+ ToolCallID string `json:"tool_call_id"`
28 ToolName string `json:"tool_name"`
37 ToolName string `json:"tool_name"`
29 Description string `json:"description"`
38 Description string `json:"description"`
30 Action string `json:"action"`
39 Action string `json:"action"`
@@ -39,22 +48,32 @@ type Service interface {
39 Deny(permission PermissionRequest)
48 Deny(permission PermissionRequest)
40 Request(opts CreatePermissionRequest) bool
49 Request(opts CreatePermissionRequest) bool
41 AutoApproveSession(sessionID string)
50 AutoApproveSession(sessionID string)
51+ SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[PermissionNotification]
42}
52}
43
53
44type permissionService struct {
54type permissionService struct {
45 *pubsub.Broker[PermissionRequest]
55 *pubsub.Broker[PermissionRequest]
46
56
57+ notificationBroker *pubsub.Broker[PermissionNotification]
47 workingDir string
58 workingDir string
48 sessionPermissions []PermissionRequest
59 sessionPermissions []PermissionRequest
49 sessionPermissionsMu sync.RWMutex
60 sessionPermissionsMu sync.RWMutex
50 pendingRequests *csync.Map[string, chan bool]
61 pendingRequests *csync.Map[string, chan bool]
51− autoApproveSessions []string
62+ autoApproveSessions map[string]bool
52 autoApproveSessionsMu sync.RWMutex
63 autoApproveSessionsMu sync.RWMutex
53 skip bool
64 skip bool
54 allowedTools []string
65 allowedTools []string
66+
67+ // used to make sure we only process one request at a time
68+ requestMu sync.Mutex
69+ activeRequest *PermissionRequest
55}
70}
56
71
57func (s *permissionService) GrantPersistent(permission PermissionRequest) {
72func (s *permissionService) GrantPersistent(permission PermissionRequest) {
73+ s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
74+ ToolCallID: permission.ToolCallID,
75+ Granted: true,
76+ })
58 respCh, ok := s.pendingRequests.Get(permission.ID)
77 respCh, ok := s.pendingRequests.Get(permission.ID)
59 if ok {
78 if ok {
60 respCh <- true
79 respCh <- true
@@ -63,20 +82,41 @@ func (s *permissionService) GrantPersistent(permission PermissionRequest) {
63 s.sessionPermissionsMu.Lock()
82 s.sessionPermissionsMu.Lock()
64 s.sessionPermissions = append(s.sessionPermissions, permission)
83 s.sessionPermissions = append(s.sessionPermissions, permission)
65 s.sessionPermissionsMu.Unlock()
84 s.sessionPermissionsMu.Unlock()
85+
86+ if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
87+ s.activeRequest = nil
88+ }
66}
89}
67
90
68func (s *permissionService) Grant(permission PermissionRequest) {
91func (s *permissionService) Grant(permission PermissionRequest) {
92+ s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
93+ ToolCallID: permission.ToolCallID,
94+ Granted: true,
95+ })
69 respCh, ok := s.pendingRequests.Get(permission.ID)
96 respCh, ok := s.pendingRequests.Get(permission.ID)
70 if ok {
97 if ok {
71 respCh <- true
98 respCh <- true
72 }
99 }
100+
101+ if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
102+ s.activeRequest = nil
103+ }
73}
104}
74
105
75func (s *permissionService) Deny(permission PermissionRequest) {
106func (s *permissionService) Deny(permission PermissionRequest) {
107+ s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
108+ ToolCallID: permission.ToolCallID,
109+ Granted: false,
110+ Denied: true,
111+ })
76 respCh, ok := s.pendingRequests.Get(permission.ID)
112 respCh, ok := s.pendingRequests.Get(permission.ID)
77 if ok {
113 if ok {
78 respCh <- false
114 respCh <- false
79 }
115 }
116+
117+ if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
118+ s.activeRequest = nil
119+ }
80}
120}
81
121
82func (s *permissionService) Request(opts CreatePermissionRequest) bool {
122func (s *permissionService) Request(opts CreatePermissionRequest) bool {
@@ -84,6 +124,13 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
84 return true
124 return true
85 }
125 }
86
126
127+ // tell the UI that a permission was requested
128+ s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
129+ ToolCallID: opts.ToolCallID,
130+ })
131+ s.requestMu.Lock()
132+ defer s.requestMu.Unlock()
133+
87 // Check if the tool/action combination is in the allowlist
134 // Check if the tool/action combination is in the allowlist
88 commandKey := opts.ToolName + ":" + opts.Action
135 commandKey := opts.ToolName + ":" + opts.Action
89 if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) {
136 if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) {
@@ -91,7 +138,7 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
91 }
138 }
92
139
93 s.autoApproveSessionsMu.RLock()
140 s.autoApproveSessionsMu.RLock()
94− autoApprove := slices.Contains(s.autoApproveSessions, opts.SessionID)
141+ autoApprove := s.autoApproveSessions[opts.SessionID]
95 s.autoApproveSessionsMu.RUnlock()
142 s.autoApproveSessionsMu.RUnlock()
96
143
97 if autoApprove {
144 if autoApprove {
@@ -106,6 +153,7 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
106 ID: uuid.New().String(),
153 ID: uuid.New().String(),
107 Path: dir,
154 Path: dir,
108 SessionID: opts.SessionID,
155 SessionID: opts.SessionID,
156+ ToolCallID: opts.ToolCallID,
109 ToolName: opts.ToolName,
157 ToolName: opts.ToolName,
110 Description: opts.Description,
158 Description: opts.Description,
111 Action: opts.Action,
159 Action: opts.Action,
@@ -121,30 +169,46 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
121 }
169 }
122 s.sessionPermissionsMu.RUnlock()
170 s.sessionPermissionsMu.RUnlock()
123
171
124− respCh := make(chan bool, 1)
172+ s.sessionPermissionsMu.RLock()
173+ for _, p := range s.sessionPermissions {
174+ if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path {
175+ s.sessionPermissionsMu.RUnlock()
176+ return true
177+ }
178+ }
179+ s.sessionPermissionsMu.RUnlock()
180+
181+ s.activeRequest = &permission
125
182
183+ respCh := make(chan bool, 1)
126 s.pendingRequests.Set(permission.ID, respCh)
184 s.pendingRequests.Set(permission.ID, respCh)
127 defer s.pendingRequests.Del(permission.ID)
185 defer s.pendingRequests.Del(permission.ID)
128
186
187+ // Publish the request
129 s.Publish(pubsub.CreatedEvent, permission)
188 s.Publish(pubsub.CreatedEvent, permission)
130
189
131− // Wait for the response indefinitely
132 return <-respCh
190 return <-respCh
133}
191}
134
192
135func (s *permissionService) AutoApproveSession(sessionID string) {
193func (s *permissionService) AutoApproveSession(sessionID string) {
136 s.autoApproveSessionsMu.Lock()
194 s.autoApproveSessionsMu.Lock()
137− s.autoApproveSessions = append(s.autoApproveSessions, sessionID)
195+ s.autoApproveSessions[sessionID] = true
138 s.autoApproveSessionsMu.Unlock()
196 s.autoApproveSessionsMu.Unlock()
139}
197}
140
198
199+func (s *permissionService) SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[PermissionNotification] {
200+ return s.notificationBroker.Subscribe(ctx)
201+}
202+
141func NewPermissionService(workingDir string, skip bool, allowedTools []string) Service {
203func NewPermissionService(workingDir string, skip bool, allowedTools []string) Service {
142 return &permissionService{
204 return &permissionService{
143− Broker: pubsub.NewBroker[PermissionRequest](),
205+ Broker: pubsub.NewBroker[PermissionRequest](),
206+ notificationBroker: pubsub.NewBroker[PermissionNotification](),
144− workingDir: workingDir,
207+ workingDir: workingDir,
145− sessionPermissions: make([]PermissionRequest, 0),
208+ sessionPermissions: make([]PermissionRequest, 0),
209+ autoApproveSessions: make(map[string]bool),
146− skip: skip,
210+ skip: skip,
147− allowedTools: allowedTools,
211+ allowedTools: allowedTools,
148− pendingRequests: csync.NewMap[string, chan bool](),
212+ pendingRequests: csync.NewMap[string, chan bool](),
149 }
213 }
150}
214}
internal/permission/permission_test.go+159
@@ -1,7 +1,10 @@
1package permission
1package permission
2
2
3import (
3import (
4+ "sync"
4 "testing"
5 "testing"
6+
7+ "github.com/stretchr/testify/assert"
5)
8)
6
9
7func TestPermissionService_AllowedCommands(t *testing.T) {
10func TestPermissionService_AllowedCommands(t *testing.T) {
@@ -90,3 +93,159 @@ func TestPermissionService_SkipMode(t *testing.T) {
90 t.Error("expected permission to be granted in skip mode")
93 t.Error("expected permission to be granted in skip mode")
91 }
94 }
92}
95}
96+
97+func TestPermissionService_SequentialProperties(t *testing.T) {
98+ t.Run("Sequential permission requests with persistent grants", func(t *testing.T) {
99+ service := NewPermissionService("/tmp", false, []string{})
100+
101+ req1 := CreatePermissionRequest{
102+ SessionID: "session1",
103+ ToolName: "file_tool",
104+ Description: "Read file",
105+ Action: "read",
106+ Params: map[string]string{"file": "test.txt"},
107+ Path: "/tmp/test.txt",
108+ }
109+
110+ var result1 bool
111+ var wg sync.WaitGroup
112+ wg.Add(1)
113+
114+ events := service.Subscribe(t.Context())
115+
116+ go func() {
117+ defer wg.Done()
118+ result1 = service.Request(req1)
119+ }()
120+
121+ var permissionReq PermissionRequest
122+ event := <-events
123+
124+ permissionReq = event.Payload
125+ service.GrantPersistent(permissionReq)
126+
127+ wg.Wait()
128+ assert.True(t, result1, "First request should be granted")
129+
130+ // Second identical request should be automatically approved due to persistent permission
131+ req2 := CreatePermissionRequest{
132+ SessionID: "session1",
133+ ToolName: "file_tool",
134+ Description: "Read file again",
135+ Action: "read",
136+ Params: map[string]string{"file": "test.txt"},
137+ Path: "/tmp/test.txt",
138+ }
139+ result2 := service.Request(req2)
140+ assert.True(t, result2, "Second request should be auto-approved")
141+ })
142+ t.Run("Sequential requests with temporary grants", func(t *testing.T) {
143+ service := NewPermissionService("/tmp", false, []string{})
144+
145+ req := CreatePermissionRequest{
146+ SessionID: "session2",
147+ ToolName: "file_tool",
148+ Description: "Write file",
149+ Action: "write",
150+ Params: map[string]string{"file": "test.txt"},
151+ Path: "/tmp/test.txt",
152+ }
153+
154+ events := service.Subscribe(t.Context())
155+ var result1 bool
156+ var wg sync.WaitGroup
157+ wg.Add(1)
158+
159+ go func() {
160+ defer wg.Done()
161+ result1 = service.Request(req)
162+ }()
163+
164+ var permissionReq PermissionRequest
165+ event := <-events
166+ permissionReq = event.Payload
167+
168+ service.Grant(permissionReq)
169+ wg.Wait()
170+ assert.True(t, result1, "First request should be granted")
171+
172+ var result2 bool
173+ wg.Add(1)
174+
175+ go func() {
176+ defer wg.Done()
177+ result2 = service.Request(req)
178+ }()
179+
180+ event = <-events
181+ permissionReq = event.Payload
182+ service.Deny(permissionReq)
183+ wg.Wait()
184+ assert.False(t, result2, "Second request should be denied")
185+ })
186+ t.Run("Concurrent requests with different outcomes", func(t *testing.T) {
187+ service := NewPermissionService("/tmp", false, []string{})
188+
189+ events := service.Subscribe(t.Context())
190+
191+ var wg sync.WaitGroup
192+ results := make([]bool, 0)
193+
194+ requests := []CreatePermissionRequest{
195+ {
196+ SessionID: "concurrent1",
197+ ToolName: "tool1",
198+ Action: "action1",
199+ Path: "/tmp/file1.txt",
200+ Description: "First concurrent request",
201+ },
202+ {
203+ SessionID: "concurrent2",
204+ ToolName: "tool2",
205+ Action: "action2",
206+ Path: "/tmp/file2.txt",
207+ Description: "Second concurrent request",
208+ },
209+ {
210+ SessionID: "concurrent3",
211+ ToolName: "tool3",
212+ Action: "action3",
213+ Path: "/tmp/file3.txt",
214+ Description: "Third concurrent request",
215+ },
216+ }
217+
218+ for i, req := range requests {
219+ wg.Add(1)
220+ go func(index int, request CreatePermissionRequest) {
221+ defer wg.Done()
222+ results = append(results, service.Request(request))
223+ }(i, req)
224+ }
225+
226+ for range 3 {
227+ event := <-events
228+ switch event.Payload.ToolName {
229+ case "tool1":
230+ service.Grant(event.Payload)
231+ case "tool2":
232+ service.GrantPersistent(event.Payload)
233+ case "tool3":
234+ service.Deny(event.Payload)
235+ }
236+ }
237+ wg.Wait()
238+ grantedCount := 0
239+ for _, result := range results {
240+ if result {
241+ grantedCount++
242+ }
243+ }
244+
245+ assert.Equal(t, 2, grantedCount, "Should have 2 granted and 1 denied")
246+ secondReq := requests[1]
247+ secondReq.Description = "Repeat of second request"
248+ result := service.Request(secondReq)
249+ assert.True(t, result, "Repeated request should be auto-approved due to persistent permission")
250+ })
251+}
internal/tui/components/chat/chat.go+28−5
@@ -9,6 +9,7 @@ import (
9 "github.com/charmbracelet/crush/internal/app"
9 "github.com/charmbracelet/crush/internal/app"
10 "github.com/charmbracelet/crush/internal/llm/agent"
10 "github.com/charmbracelet/crush/internal/llm/agent"
11 "github.com/charmbracelet/crush/internal/message"
11 "github.com/charmbracelet/crush/internal/message"
12+ "github.com/charmbracelet/crush/internal/permission"
12 "github.com/charmbracelet/crush/internal/pubsub"
13 "github.com/charmbracelet/crush/internal/pubsub"
13 "github.com/charmbracelet/crush/internal/session"
14 "github.com/charmbracelet/crush/internal/session"
14 "github.com/charmbracelet/crush/internal/tui/components/chat/messages"
15 "github.com/charmbracelet/crush/internal/tui/components/chat/messages"
@@ -85,6 +86,8 @@ func (m *messageListCmp) Init() tea.Cmd {
85// Update handles incoming messages and updates the component state.
86// Update handles incoming messages and updates the component state.
86func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
87func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
87 switch msg := msg.(type) {
88 switch msg := msg.(type) {
89+ case pubsub.Event[permission.PermissionNotification]:
90+ return m, m.handlePermissionRequest(msg.Payload)
88 case SessionSelectedMsg:
91 case SessionSelectedMsg:
89 if msg.ID != m.session.ID {
92 if msg.ID != m.session.ID {
90 cmd := m.SetSession(msg)
93 cmd := m.SetSession(msg)
@@ -124,6 +127,19 @@ func (m *messageListCmp) View() string {
124 )
127 )
125}
128}
126
129
130+func (m *messageListCmp) handlePermissionRequest(permission permission.PermissionNotification) tea.Cmd {
131+ items := m.listCmp.Items()
132+ if toolCallIndex := m.findToolCallByID(items, permission.ToolCallID); toolCallIndex != NotFound {
133+ toolCall := items[toolCallIndex].(messages.ToolCallCmp)
134+ toolCall.SetPermissionRequested()
135+ if permission.Granted {
136+ toolCall.SetPermissionGranted()
137+ }
138+ m.listCmp.UpdateItem(toolCall.ID(), toolCall)
139+ }
140+ return nil
141+}
142+
127// handleChildSession handles messages from child sessions (agent tools).
143// handleChildSession handles messages from child sessions (agent tools).
128func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) tea.Cmd {
144func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) tea.Cmd {
129 var cmds []tea.Cmd
145 var cmds []tea.Cmd
@@ -158,6 +174,7 @@ func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message])
158 nestedCall := messages.NewToolCallCmp(
174 nestedCall := messages.NewToolCallCmp(
159 event.Payload.ID,
175 event.Payload.ID,
160 tc,
176 tc,
177+ m.app.Permissions,
161 messages.WithToolCallNested(true),
178 messages.WithToolCallNested(true),
162 )
179 )
163 cmds = append(cmds, nestedCall.Init())
180 cmds = append(cmds, nestedCall.Init())
@@ -199,7 +216,12 @@ func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message])
199 if event.Payload.SessionID != m.session.ID {
216 if event.Payload.SessionID != m.session.ID {
200 return m.handleChildSession(event)
217 return m.handleChildSession(event)
201 }
218 }
219+ switch event.Payload.Role {
220+ case message.Assistant:
202− return m.handleUpdateAssistantMessage(event.Payload)
221+ return m.handleUpdateAssistantMessage(event.Payload)
222+ case message.Tool:
223+ return m.handleToolMessage(event.Payload)
224+ }
203 }
225 }
204 return nil
226 return nil
205}
227}
@@ -371,7 +393,7 @@ func (m *messageListCmp) updateOrAddToolCall(msg message.Message, tc message.Too
371 }
393 }
372
394
373 // Add new tool call if not found
395 // Add new tool call if not found
374− return m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
396+ return m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions))
375}
397}
376
398
377// handleNewAssistantMessage processes new assistant messages and their tool calls.
399// handleNewAssistantMessage processes new assistant messages and their tool calls.
@@ -390,7 +412,7 @@ func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd
390
412
391 // Add tool calls
413 // Add tool calls
392 for _, tc := range msg.ToolCalls() {
414 for _, tc := range msg.ToolCalls() {
393− cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
415+ cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions))
394 cmds = append(cmds, cmd)
416 cmds = append(cmds, cmd)
395 }
417 }
396
418
@@ -473,11 +495,12 @@ func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResult
473 // Add tool calls with their results and status
495 // Add tool calls with their results and status
474 for _, tc := range msg.ToolCalls() {
496 for _, tc := range msg.ToolCalls() {
475 options := m.buildToolCallOptions(tc, msg, toolResultMap)
497 options := m.buildToolCallOptions(tc, msg, toolResultMap)
476− uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, options...))
498+ uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions, options...))
477 // If this tool call is the agent tool, fetch nested tool calls
499 // If this tool call is the agent tool, fetch nested tool calls
478 if tc.Name == agent.AgentToolName {
500 if tc.Name == agent.AgentToolName {
479 nestedMessages, _ := m.app.Messages.List(context.Background(), tc.ID)
501 nestedMessages, _ := m.app.Messages.List(context.Background(), tc.ID)
502+ nestedToolResultMap := m.buildToolResultMap(nestedMessages)
480− nestedUIMessages := m.convertMessagesToUI(nestedMessages, make(map[string]message.ToolResult))
503+ nestedUIMessages := m.convertMessagesToUI(nestedMessages, nestedToolResultMap)
481 nestedToolCalls := make([]messages.ToolCallCmp, 0, len(nestedUIMessages))
504 nestedToolCalls := make([]messages.ToolCallCmp, 0, len(nestedUIMessages))
482 for _, nestedMsg := range nestedUIMessages {
505 for _, nestedMsg := range nestedUIMessages {
483 if toolCall, ok := nestedMsg.(messages.ToolCallCmp); ok {
506 if toolCall, ok := nestedMsg.(messages.ToolCallCmp); ok {
internal/tui/components/chat/editor/editor.go+55−3
@@ -2,8 +2,10 @@ package editor
2
2
3import (
3import (
4 "fmt"
4 "fmt"
5+ "net/http"
5 "os"
6 "os"
6 "os/exec"
7 "os/exec"
8+ "path/filepath"
7 "runtime"
9 "runtime"
8 "slices"
10 "slices"
9 "strings"
11 "strings"
@@ -20,6 +22,7 @@ import (
20 "github.com/charmbracelet/crush/internal/tui/components/completions"
22 "github.com/charmbracelet/crush/internal/tui/components/completions"
21 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
23 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
22 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
24 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
25+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
23 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
26 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
24 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
27 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
25 "github.com/charmbracelet/crush/internal/tui/styles"
28 "github.com/charmbracelet/crush/internal/tui/styles"
@@ -36,6 +39,7 @@ type Editor interface {
36
39
37 SetSession(session session.Session) tea.Cmd
40 SetSession(session session.Session) tea.Cmd
38 IsCompletionsOpen() bool
41 IsCompletionsOpen() bool
42+ HasAttachments() bool
39 Cursor() *tea.Cursor
43 Cursor() *tea.Cursor
40}
44}
41
45
@@ -80,7 +84,7 @@ const (
80 maxAttachments = 5
84 maxAttachments = 5
81)
85)
82
86
83−type openEditorMsg struct {
87+type OpenEditorMsg struct {
84 Text string
88 Text string
85}
89}
86
90
@@ -119,7 +123,7 @@ func (m *editorCmp) openEditor(value string) tea.Cmd {
119 return util.ReportWarn("Message is empty")
123 return util.ReportWarn("Message is empty")
120 }
124 }
121 os.Remove(tmpfile.Name())
125 os.Remove(tmpfile.Name())
122− return openEditorMsg{
126+ return OpenEditorMsg{
123 Text: strings.TrimSpace(string(content)),
127 Text: strings.TrimSpace(string(content)),
124 }
128 }
125 })
129 })
@@ -204,9 +208,53 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
204 m.completionsStartIndex = 0
208 m.completionsStartIndex = 0
205 }
209 }
206 }
210 }
207− case openEditorMsg:
211+
212+ case commands.OpenExternalEditorMsg:
213+ if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
214+ return m, util.ReportWarn("Agent is working, please wait...")
215+ }
216+ return m, m.openEditor(m.textarea.Value())
217+ case OpenEditorMsg:
208 m.textarea.SetValue(msg.Text)
218 m.textarea.SetValue(msg.Text)
209 m.textarea.MoveToEnd()
219 m.textarea.MoveToEnd()
220+ case tea.PasteMsg:
221+ path := strings.ReplaceAll(string(msg), "\\ ", " ")
222+ // try to get an image
223+ path, err := filepath.Abs(path)
224+ if err != nil {
225+ m.textarea, cmd = m.textarea.Update(msg)
226+ return m, cmd
227+ }
228+ isAllowedType := false
229+ for _, ext := range filepicker.AllowedTypes {
230+ if strings.HasSuffix(path, ext) {
231+ isAllowedType = true
232+ break
233+ }
234+ }
235+ if !isAllowedType {
236+ m.textarea, cmd = m.textarea.Update(msg)
237+ return m, cmd
238+ }
239+ tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
240+ if tooBig {
241+ m.textarea, cmd = m.textarea.Update(msg)
242+ return m, cmd
243+ }
244+
245+ content, err := os.ReadFile(path)
246+ if err != nil {
247+ m.textarea, cmd = m.textarea.Update(msg)
248+ return m, cmd
249+ }
250+ mimeBufferSize := min(512, len(content))
251+ mimeType := http.DetectContentType(content[:mimeBufferSize])
252+ fileName := filepath.Base(path)
253+ attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
254+ return m, util.CmdHandler(filepicker.FilePickedMsg{
255+ Attachment: attachment,
256+ })
257+
210 case tea.KeyPressMsg:
258 case tea.KeyPressMsg:
211 cur := m.textarea.Cursor()
259 cur := m.textarea.Cursor()
212 curIdx := m.textarea.Width()*cur.Y + cur.X
260 curIdx := m.textarea.Width()*cur.Y + cur.X
@@ -442,6 +490,10 @@ func (c *editorCmp) IsCompletionsOpen() bool {
442 return c.isCompletionsOpen
490 return c.isCompletionsOpen
443}
491}
444
492
493+func (c *editorCmp) HasAttachments() bool {
494+ return len(c.attachments) > 0
495+}
496+
445func New(app *app.App) Editor {
497func New(app *app.App) Editor {
446 t := styles.CurrentTheme()
498 t := styles.CurrentTheme()
447 ta := textarea.New()
499 ta := textarea.New()
internal/tui/components/chat/editor/keys.go+2−2
@@ -22,8 +22,8 @@ func DefaultEditorKeyMap() EditorKeyMap {
22 key.WithHelp("enter", "send"),
22 key.WithHelp("enter", "send"),
23 ),
23 ),
24 OpenEditor: key.NewBinding(
24 OpenEditor: key.NewBinding(
25− key.WithKeys("ctrl+v"),
25+ key.WithKeys("ctrl+o"),
26− key.WithHelp("ctrl+v", "open editor"),
26+ key.WithHelp("ctrl+o", "open editor"),
27 ),
27 ),
28 Newline: key.NewBinding(
28 Newline: key.NewBinding(
29 key.WithKeys("shift+enter", "ctrl+j"),
29 key.WithKeys("shift+enter", "ctrl+j"),
internal/tui/components/chat/messages/messages.go+12
@@ -6,6 +6,7 @@ import (
6 "strings"
6 "strings"
7 "time"
7 "time"
8
8
9+ "github.com/charmbracelet/bubbles/v2/key"
9 "github.com/charmbracelet/bubbles/v2/viewport"
10 "github.com/charmbracelet/bubbles/v2/viewport"
10 tea "github.com/charmbracelet/bubbletea/v2"
11 tea "github.com/charmbracelet/bubbletea/v2"
11 "github.com/charmbracelet/catwalk/pkg/catwalk"
12 "github.com/charmbracelet/catwalk/pkg/catwalk"
@@ -13,6 +14,7 @@ import (
13 "github.com/charmbracelet/x/ansi"
14 "github.com/charmbracelet/x/ansi"
14 "github.com/google/uuid"
15 "github.com/google/uuid"
15
16
17+ "github.com/atotto/clipboard"
16 "github.com/charmbracelet/crush/internal/config"
18 "github.com/charmbracelet/crush/internal/config"
17 "github.com/charmbracelet/crush/internal/message"
19 "github.com/charmbracelet/crush/internal/message"
18 "github.com/charmbracelet/crush/internal/tui/components/anim"
20 "github.com/charmbracelet/crush/internal/tui/components/anim"
@@ -23,6 +25,8 @@ import (
23 "github.com/charmbracelet/crush/internal/tui/util"
25 "github.com/charmbracelet/crush/internal/tui/util"
24)
26)
25
27
28+var copyKey = key.NewBinding(key.WithKeys("c", "y", "C", "Y"), key.WithHelp("c/y", "copy"))
29+
26// MessageCmp defines the interface for message components in the chat interface.
30// MessageCmp defines the interface for message components in the chat interface.
27// It combines standard UI model interfaces with message-specific functionality.
31// It combines standard UI model interfaces with message-specific functionality.
28type MessageCmp interface {
32type MessageCmp interface {
@@ -94,6 +98,14 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
94 m.anim = u.(*anim.Anim)
98 m.anim = u.(*anim.Anim)
95 return m, cmd
99 return m, cmd
96 }
100 }
101+ case tea.KeyPressMsg:
102+ if key.Matches(msg, copyKey) {
103+ err := clipboard.WriteAll(m.message.Content().Text)
104+ if err != nil {
105+ return m, util.ReportError(fmt.Errorf("failed to copy message content to clipboard: %w", err))
106+ }
107+ return m, util.ReportInfo("Message copied to clipboard")
108+ }
97 }
109 }
98 return m, nil
110 return m, nil
99}
111}
internal/tui/components/chat/messages/renderer.go+59−1
@@ -166,6 +166,7 @@ func init() {
166 registry.register(tools.DownloadToolName, func() renderer { return downloadRenderer{} })
166 registry.register(tools.DownloadToolName, func() renderer { return downloadRenderer{} })
167 registry.register(tools.ViewToolName, func() renderer { return viewRenderer{} })
167 registry.register(tools.ViewToolName, func() renderer { return viewRenderer{} })
168 registry.register(tools.EditToolName, func() renderer { return editRenderer{} })
168 registry.register(tools.EditToolName, func() renderer { return editRenderer{} })
169+ registry.register(tools.MultiEditToolName, func() renderer { return multiEditRenderer{} })
169 registry.register(tools.WriteToolName, func() renderer { return writeRenderer{} })
170 registry.register(tools.WriteToolName, func() renderer { return writeRenderer{} })
170 registry.register(tools.FetchToolName, func() renderer { return fetchRenderer{} })
171 registry.register(tools.FetchToolName, func() renderer { return fetchRenderer{} })
171 registry.register(tools.GlobToolName, func() renderer { return globRenderer{} })
172 registry.register(tools.GlobToolName, func() renderer { return globRenderer{} })
@@ -294,6 +295,57 @@ func (er editRenderer) Render(v *toolCallCmp) string {
294 return renderPlainContent(v, v.result.Content)
295 return renderPlainContent(v, v.result.Content)
295 }
296 }
296
297
298+ formatter := core.DiffFormatter().
299+ Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
300+ After(fsext.PrettyPath(params.FilePath), meta.NewContent).
301+ Width(v.textWidth() - 2) // -2 for padding
302+ if v.textWidth() > 120 {
303+ formatter = formatter.Split()
304+ }
305+ // add a message to the bottom if the content was truncated
306+ formatted := formatter.String()
307+ if lipgloss.Height(formatted) > responseContextHeight {
308+ contentLines := strings.Split(formatted, "\n")
309+ truncateMessage := t.S().Muted.
310+ Background(t.BgBaseLighter).
311+ PaddingLeft(2).
312+ Width(v.textWidth() - 2).
313+ Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight))
314+ formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage
315+ }
316+ return formatted
317+ })
318+}
319+
320+// -----------------------------------------------------------------------------
321+// Multi-Edit renderer
322+// -----------------------------------------------------------------------------
323+
324+// multiEditRenderer handles multiple file edits with diff visualization
325+type multiEditRenderer struct {
326+ baseRenderer
327+}
328+
329+// Render displays the multi-edited file with a formatted diff of changes
330+func (mer multiEditRenderer) Render(v *toolCallCmp) string {
331+ t := styles.CurrentTheme()
332+ var params tools.MultiEditParams
333+ var args []string
334+ if err := mer.unmarshalParams(v.call.Input, &params); err == nil {
335+ file := fsext.PrettyPath(params.FilePath)
336+ editsCount := len(params.Edits)
337+ args = newParamBuilder().
338+ addMain(file).
339+ addKeyValue("edits", fmt.Sprintf("%d", editsCount)).
340+ build()
341+ }
342+
343+ return mer.renderWithParams(v, "Multi-Edit", args, func() string {
344+ var meta tools.MultiEditResponseMetadata
345+ if err := mer.unmarshalParams(v.result.Metadata, &meta); err != nil {
346+ return renderPlainContent(v, v.result.Content)
347+ }
348+
297 formatter := core.DiffFormatter().
349 formatter := core.DiffFormatter().
298 Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
350 Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
299 After(fsext.PrettyPath(params.FilePath), meta.NewContent).
351 After(fsext.PrettyPath(params.FilePath), meta.NewContent).
@@ -672,7 +724,11 @@ func earlyState(header string, v *toolCallCmp) (string, bool) {
672 case v.cancelled:
724 case v.cancelled:
673 message = t.S().Base.Foreground(t.FgSubtle).Render("Canceled.")
725 message = t.S().Base.Foreground(t.FgSubtle).Render("Canceled.")
674 case v.result.ToolCallID == "":
726 case v.result.ToolCallID == "":
727+ if v.permissionRequested && !v.permissionGranted {
728+ message = t.S().Base.Foreground(t.FgSubtle).Render("Requesting for permission...")
729+ } else {
675− message = t.S().Base.Foreground(t.FgSubtle).Render("Waiting for tool to start...")
730+ message = t.S().Base.Foreground(t.FgSubtle).Render("Waiting for tool response...")
731+ }
676 default:
732 default:
677 return "", false
733 return "", false
678 }
734 }
@@ -799,6 +855,8 @@ func prettifyToolName(name string) string {
799 return "Download"
855 return "Download"
800 case tools.EditToolName:
856 case tools.EditToolName:
801 return "Edit"
857 return "Edit"
858+ case tools.MultiEditToolName:
859+ return "Multi-Edit"
802 case tools.FetchToolName:
860 case tools.FetchToolName:
803 return "Fetch"
861 return "Fetch"
804 case tools.GlobToolName:
862 case tools.GlobToolName:
internal/tui/components/chat/messages/tool.go+496−5
@@ -1,10 +1,21 @@
1package messages
1package messages
2
2
3import (
3import (
4+ "encoding/json"
4 "fmt"
5 "fmt"
6+ "path/filepath"
7+ "strings"
8+ "time"
5
9
10+ "github.com/atotto/clipboard"
11+ "github.com/charmbracelet/bubbles/v2/key"
6 tea "github.com/charmbracelet/bubbletea/v2"
12 tea "github.com/charmbracelet/bubbletea/v2"
13+ "github.com/charmbracelet/crush/internal/diff"
14+ "github.com/charmbracelet/crush/internal/fsext"
15+ "github.com/charmbracelet/crush/internal/llm/agent"
16+ "github.com/charmbracelet/crush/internal/llm/tools"
7 "github.com/charmbracelet/crush/internal/message"
17 "github.com/charmbracelet/crush/internal/message"
18+ "github.com/charmbracelet/crush/internal/permission"
8 "github.com/charmbracelet/crush/internal/tui/components/anim"
19 "github.com/charmbracelet/crush/internal/tui/components/anim"
9 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
20 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
10 "github.com/charmbracelet/crush/internal/tui/styles"
21 "github.com/charmbracelet/crush/internal/tui/styles"
@@ -30,6 +41,8 @@ type ToolCallCmp interface {
30 SetNestedToolCalls([]ToolCallCmp) // Set nested tool calls
41 SetNestedToolCalls([]ToolCallCmp) // Set nested tool calls
31 SetIsNested(bool) // Set whether this tool call is nested
42 SetIsNested(bool) // Set whether this tool call is nested
32 ID() string
43 ID() string
44+ SetPermissionRequested() // Mark permission request
45+ SetPermissionGranted() // Mark permission granted
33}
46}
34
47
35// toolCallCmp implements the ToolCallCmp interface for displaying tool calls.
48// toolCallCmp implements the ToolCallCmp interface for displaying tool calls.
@@ -40,10 +53,12 @@ type toolCallCmp struct {
40 isNested bool // Whether this tool call is nested within another
53 isNested bool // Whether this tool call is nested within another
41
54
42 // Tool call data and state
55 // Tool call data and state
43− parentMessageID string // ID of the message that initiated this tool call
56+ parentMessageID string // ID of the message that initiated this tool call
44− call message.ToolCall // The tool call being executed
57+ call message.ToolCall // The tool call being executed
45− result message.ToolResult // The result of the tool execution
58+ result message.ToolResult // The result of the tool execution
46− cancelled bool // Whether the tool call was cancelled
59+ cancelled bool // Whether the tool call was cancelled
60+ permissionRequested bool
61+ permissionGranted bool
47
62
48 // Animation state for pending tool calls
63 // Animation state for pending tool calls
49 spinning bool // Whether to show loading animation
64 spinning bool // Whether to show loading animation
@@ -81,9 +96,21 @@ func WithToolCallNestedCalls(calls []ToolCallCmp) ToolCallOption {
81 }
96 }
82}
97}
83
98
99+func WithToolPermissionRequested() ToolCallOption {
100+ return func(m *toolCallCmp) {
101+ m.permissionRequested = true
102+ }
103+}
104+
105+func WithToolPermissionGranted() ToolCallOption {
106+ return func(m *toolCallCmp) {
107+ m.permissionGranted = true
108+ }
109+}
110+
84// NewToolCallCmp creates a new tool call component with the given parent message ID,
111// NewToolCallCmp creates a new tool call component with the given parent message ID,
85// tool call, and optional configuration
112// tool call, and optional configuration
86−func NewToolCallCmp(parentMessageID string, tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
113+func NewToolCallCmp(parentMessageID string, tc message.ToolCall, permissions permission.Service, opts ...ToolCallOption) ToolCallCmp {
87 m := &toolCallCmp{
114 m := &toolCallCmp{
88 call: tc,
115 call: tc,
89 parentMessageID: parentMessageID,
116 parentMessageID: parentMessageID,
@@ -137,6 +164,10 @@ func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
137 cmds = append(cmds, cmd)
164 cmds = append(cmds, cmd)
138 }
165 }
139 return m, tea.Batch(cmds...)
166 return m, tea.Batch(cmds...)
167+ case tea.KeyPressMsg:
168+ if key.Matches(msg, copyKey) {
169+ return m, m.copyTool()
170+ }
140 }
171 }
141 return m, nil
172 return m, nil
142}
173}
@@ -165,6 +196,456 @@ func (m *toolCallCmp) SetCancelled() {
165 m.cancelled = true
196 m.cancelled = true
166}
197}
167
198
199+func (m *toolCallCmp) copyTool() tea.Cmd {
200+ content := m.formatToolForCopy()
201+ err := clipboard.WriteAll(content)
202+ if err != nil {
203+ return util.ReportError(fmt.Errorf("failed to copy tool content to clipboard: %w", err))
204+ }
205+ return util.ReportInfo("Tool content copied to clipboard")
206+}
207+
208+func (m *toolCallCmp) formatToolForCopy() string {
209+ var parts []string
210+
211+ toolName := prettifyToolName(m.call.Name)
212+ parts = append(parts, fmt.Sprintf("## %s Tool Call", toolName))
213+
214+ if m.call.Input != "" {
215+ params := m.formatParametersForCopy()
216+ if params != "" {
217+ parts = append(parts, "### Parameters:")
218+ parts = append(parts, params)
219+ }
220+ }
221+
222+ if m.result.ToolCallID != "" {
223+ if m.result.IsError {
224+ parts = append(parts, "### Error:")
225+ parts = append(parts, m.result.Content)
226+ } else {
227+ parts = append(parts, "### Result:")
228+ content := m.formatResultForCopy()
229+ if content != "" {
230+ parts = append(parts, content)
231+ }
232+ }
233+ } else if m.cancelled {
234+ parts = append(parts, "### Status:")
235+ parts = append(parts, "Cancelled")
236+ } else {
237+ parts = append(parts, "### Status:")
238+ parts = append(parts, "Pending...")
239+ }
240+
241+ return strings.Join(parts, "\n\n")
242+}
243+
244+func (m *toolCallCmp) formatParametersForCopy() string {
245+ switch m.call.Name {
246+ case tools.BashToolName:
247+ var params tools.BashParams
248+ if json.Unmarshal([]byte(m.call.Input), &params) == nil {
249+ cmd := strings.ReplaceAll(params.Command, "\n", " ")
250+ cmd = strings.ReplaceAll(cmd, "\t", " ")
251+ return fmt.Sprintf("**Command:** %s", cmd)
252+ }
253+ case tools.ViewToolName:
254+ var params tools.ViewParams
255+ if json.Unmarshal([]byte(m.call.Input), &params) == nil {
256+ var parts []string
257+ parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
258+ if params.Limit > 0 {
259+ parts = append(parts, fmt.Sprintf("**Limit:** %d", params.Limit))
260+ }
261+ if params.Offset > 0 {
262+ parts = append(parts, fmt.Sprintf("**Offset:** %d", params.Offset))
263+ }
264+ return strings.Join(parts, "\n")
265+ }
266+ case tools.EditToolName:
267+ var params tools.EditParams
268+ if json.Unmarshal([]byte(m.call.Input), &params) == nil {
269+ return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
270+ }
271+ case tools.MultiEditToolName:
272+ var params tools.MultiEditParams
273+ if json.Unmarshal([]byte(m.call.Input), &params) == nil {
274+ var parts []string
275+ parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
276+ parts = append(parts, fmt.Sprintf("**Edits:** %d", len(params.Edits)))
277+ return strings.Join(parts, "\n")
278+ }
279+ case tools.WriteToolName:
280+ var params tools.WriteParams
281+ if json.Unmarshal([]byte(m.call.Input), &params) == nil {
282+ return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
283+ }
284+ case tools.FetchToolName:
285+ var params tools.FetchParams
286+ if json.Unmarshal([]byte(m.call.Input), &params) == nil {
287+ var parts []string
288+ parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
289+ if params.Format != "" {
290+ parts = append(parts, fmt.Sprintf("**Format:** %s", params.Format))
291+ }
292+ if params.Timeout > 0 {
293+ parts = append(parts, fmt.Sprintf("**Timeout:** %s", (time.Duration(params.Timeout)*time.Second).String()))
294+ }
295+ return strings.Join(parts, "\n")
296+ }
297+ case tools.GrepToolName:
298+ var params tools.GrepParams
299+ if json.Unmarshal([]byte(m.call.Input), &params) == nil {
300+ var parts []string
301+ parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
302+ if params.Path != "" {
303+ parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
304+ }
305+ if params.Include != "" {
306+ parts = append(parts, fmt.Sprintf("**Include:** %s", params.Include))
307+ }
308+ if params.LiteralText {
309+ parts = append(parts, "**Literal:** true")
310+ }
311+ return strings.Join(parts, "\n")
312+ }
313+ case tools.GlobToolName:
314+ var params tools.GlobParams
315+ if json.Unmarshal([]byte(m.call.Input), &params) == nil {
316+ var parts []string
317+ parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
318+ if params.Path != "" {
319+ parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
320+ }
321+ return strings.Join(parts, "\n")
322+ }
323+ case tools.LSToolName:
324+ var params tools.LSParams
325+ if json.Unmarshal([]byte(m.call.Input), &params) == nil {
326+ path := params.Path
327+ if path == "" {
328+ path = "."
329+ }
330+ return fmt.Sprintf("**Path:** %s", fsext.PrettyPath(path))
331+ }
332+ case tools.DownloadToolName:
333+ var params tools.DownloadParams
334+ if json.Unmarshal([]byte(m.call.Input), &params) == nil {
335+ var parts []string
336+ parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
337+ parts = append(parts, fmt.Sprintf("**File Path:** %s", fsext.PrettyPath(params.FilePath)))
338+ if params.Timeout > 0 {
339+ parts = append(parts, fmt.Sprintf("**Timeout:** %s", (time.Duration(params.Timeout)*time.Second).String()))
340+ }
341+ return strings.Join(parts, "\n")
342+ }
343+ case tools.SourcegraphToolName:
344+ var params tools.SourcegraphParams
345+ if json.Unmarshal([]byte(m.call.Input), &params) == nil {
346+ var parts []string
347+ parts = append(parts, fmt.Sprintf("**Query:** %s", params.Query))
348+ if params.Count > 0 {
349+ parts = append(parts, fmt.Sprintf("**Count:** %d", params.Count))
350+ }
351+ if params.ContextWindow > 0 {
352+ parts = append(parts, fmt.Sprintf("**Context:** %d", params.ContextWindow))
353+ }
354+ return strings.Join(parts, "\n")
355+ }
356+ case tools.DiagnosticsToolName:
357+ return "**Project:** diagnostics"
358+ case agent.AgentToolName:
359+ var params agent.AgentParams
360+ if json.Unmarshal([]byte(m.call.Input), &params) == nil {
361+ return fmt.Sprintf("**Task:**\n%s", params.Prompt)
362+ }
363+ }
364+
365+ var params map[string]any
366+ if json.Unmarshal([]byte(m.call.Input), &params) == nil {
367+ var parts []string
368+ for key, value := range params {
369+ displayKey := strings.ReplaceAll(key, "_", " ")
370+ if len(displayKey) > 0 {
371+ displayKey = strings.ToUpper(displayKey[:1]) + displayKey[1:]
372+ }
373+ parts = append(parts, fmt.Sprintf("**%s:** %v", displayKey, value))
374+ }
375+ return strings.Join(parts, "\n")
376+ }
377+
378+ return ""
379+}
380+
381+func (m *toolCallCmp) formatResultForCopy() string {
382+ switch m.call.Name {
383+ case tools.BashToolName:
384+ return m.formatBashResultForCopy()
385+ case tools.ViewToolName:
386+ return m.formatViewResultForCopy()
387+ case tools.EditToolName:
388+ return m.formatEditResultForCopy()
389+ case tools.MultiEditToolName:
390+ return m.formatMultiEditResultForCopy()
391+ case tools.WriteToolName:
392+ return m.formatWriteResultForCopy()
393+ case tools.FetchToolName:
394+ return m.formatFetchResultForCopy()
395+ case agent.AgentToolName:
396+ return m.formatAgentResultForCopy()
397+ case tools.DownloadToolName, tools.GrepToolName, tools.GlobToolName, tools.LSToolName, tools.SourcegraphToolName, tools.DiagnosticsToolName:
398+ return fmt.Sprintf("```\n%s\n```", m.result.Content)
399+ default:
400+ return m.result.Content
401+ }
402+}
403+
404+func (m *toolCallCmp) formatBashResultForCopy() string {
405+ var meta tools.BashResponseMetadata
406+ if m.result.Metadata != "" {
407+ json.Unmarshal([]byte(m.result.Metadata), &meta)
408+ }
409+
410+ output := meta.Output
411+ if output == "" && m.result.Content != tools.BashNoOutput {
412+ output = m.result.Content
413+ }
414+
415+ if output == "" {
416+ return ""
417+ }
418+
419+ return fmt.Sprintf("```bash\n%s\n```", output)
420+}
421+
422+func (m *toolCallCmp) formatViewResultForCopy() string {
423+ var meta tools.ViewResponseMetadata
424+ if m.result.Metadata != "" {
425+ json.Unmarshal([]byte(m.result.Metadata), &meta)
426+ }
427+
428+ if meta.Content == "" {
429+ return m.result.Content
430+ }
431+
432+ lang := ""
433+ if meta.FilePath != "" {
434+ ext := strings.ToLower(filepath.Ext(meta.FilePath))
435+ switch ext {
436+ case ".go":
437+ lang = "go"
438+ case ".js", ".mjs":
439+ lang = "javascript"
440+ case ".ts":
441+ lang = "typescript"
442+ case ".py":
443+ lang = "python"
444+ case ".rs":
445+ lang = "rust"
446+ case ".java":
447+ lang = "java"
448+ case ".c":
449+ lang = "c"
450+ case ".cpp", ".cc", ".cxx":
451+ lang = "cpp"
452+ case ".sh", ".bash":
453+ lang = "bash"
454+ case ".json":
455+ lang = "json"
456+ case ".yaml", ".yml":
457+ lang = "yaml"
458+ case ".xml":
459+ lang = "xml"
460+ case ".html":
461+ lang = "html"
462+ case ".css":
463+ lang = "css"
464+ case ".md":
465+ lang = "markdown"
466+ }
467+ }
468+
469+ var result strings.Builder
470+ if lang != "" {
471+ result.WriteString(fmt.Sprintf("```%s\n", lang))
472+ } else {
473+ result.WriteString("```\n")
474+ }
475+ result.WriteString(meta.Content)
476+ result.WriteString("\n```")
477+
478+ return result.String()
479+}
480+
481+func (m *toolCallCmp) formatEditResultForCopy() string {
482+ var meta tools.EditResponseMetadata
483+ if m.result.Metadata == "" {
484+ return m.result.Content
485+ }
486+
487+ if json.Unmarshal([]byte(m.result.Metadata), &meta) != nil {
488+ return m.result.Content
489+ }
490+
491+ var params tools.EditParams
492+ json.Unmarshal([]byte(m.call.Input), &params)
493+
494+ var result strings.Builder
495+
496+ if meta.OldContent != "" || meta.NewContent != "" {
497+ fileName := params.FilePath
498+ if fileName != "" {
499+ fileName = fsext.PrettyPath(fileName)
500+ }
501+ diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
502+
503+ result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals))
504+ result.WriteString("```diff\n")
505+ result.WriteString(diffContent)
506+ result.WriteString("\n```")
507+ }
508+
509+ return result.String()
510+}
511+
512+func (m *toolCallCmp) formatMultiEditResultForCopy() string {
513+ var meta tools.MultiEditResponseMetadata
514+ if m.result.Metadata == "" {
515+ return m.result.Content
516+ }
517+
518+ if json.Unmarshal([]byte(m.result.Metadata), &meta) != nil {
519+ return m.result.Content
520+ }
521+
522+ var params tools.MultiEditParams
523+ json.Unmarshal([]byte(m.call.Input), &params)
524+
525+ var result strings.Builder
526+ if meta.OldContent != "" || meta.NewContent != "" {
527+ fileName := params.FilePath
528+ if fileName != "" {
529+ fileName = fsext.PrettyPath(fileName)
530+ }
531+ diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
532+
533+ result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals))
534+ result.WriteString("```diff\n")
535+ result.WriteString(diffContent)
536+ result.WriteString("\n```")
537+ }
538+
539+ return result.String()
540+}
541+
542+func (m *toolCallCmp) formatWriteResultForCopy() string {
543+ var params tools.WriteParams
544+ if json.Unmarshal([]byte(m.call.Input), &params) != nil {
545+ return m.result.Content
546+ }
547+
548+ lang := ""
549+ if params.FilePath != "" {
550+ ext := strings.ToLower(filepath.Ext(params.FilePath))
551+ switch ext {
552+ case ".go":
553+ lang = "go"
554+ case ".js", ".mjs":
555+ lang = "javascript"
556+ case ".ts":
557+ lang = "typescript"
558+ case ".py":
559+ lang = "python"
560+ case ".rs":
561+ lang = "rust"
562+ case ".java":
563+ lang = "java"
564+ case ".c":
565+ lang = "c"
566+ case ".cpp", ".cc", ".cxx":
567+ lang = "cpp"
568+ case ".sh", ".bash":
569+ lang = "bash"
570+ case ".json":
571+ lang = "json"
572+ case ".yaml", ".yml":
573+ lang = "yaml"
574+ case ".xml":
575+ lang = "xml"
576+ case ".html":
577+ lang = "html"
578+ case ".css":
579+ lang = "css"
580+ case ".md":
581+ lang = "markdown"
582+ }
583+ }
584+
585+ var result strings.Builder
586+ result.WriteString(fmt.Sprintf("File: %s\n", fsext.PrettyPath(params.FilePath)))
587+ if lang != "" {
588+ result.WriteString(fmt.Sprintf("```%s\n", lang))
589+ } else {
590+ result.WriteString("```\n")
591+ }
592+ result.WriteString(params.Content)
593+ result.WriteString("\n```")
594+
595+ return result.String()
596+}
597+
598+func (m *toolCallCmp) formatFetchResultForCopy() string {
599+ var params tools.FetchParams
600+ if json.Unmarshal([]byte(m.call.Input), &params) != nil {
601+ return m.result.Content
602+ }
603+
604+ var result strings.Builder
605+ if params.URL != "" {
606+ result.WriteString(fmt.Sprintf("URL: %s\n", params.URL))
607+ }
608+
609+ switch params.Format {
610+ case "html":
611+ result.WriteString("```html\n")
612+ case "text":
613+ result.WriteString("```\n")
614+ default: // markdown
615+ result.WriteString("```markdown\n")
616+ }
617+ result.WriteString(m.result.Content)
618+ result.WriteString("\n```")
619+
620+ return result.String()
621+}
622+
623+func (m *toolCallCmp) formatAgentResultForCopy() string {
624+ var result strings.Builder
625+
626+ if len(m.nestedToolCalls) > 0 {
627+ result.WriteString("### Nested Tool Calls:\n")
628+ for i, nestedCall := range m.nestedToolCalls {
629+ nestedContent := nestedCall.(*toolCallCmp).formatToolForCopy()
630+ indentedContent := strings.ReplaceAll(nestedContent, "\n", "\n ")
631+ result.WriteString(fmt.Sprintf("%d. %s\n", i+1, indentedContent))
632+ if i < len(m.nestedToolCalls)-1 {
633+ result.WriteString("\n")
634+ }
635+ }
636+
637+ if m.result.Content != "" {
638+ result.WriteString("\n### Final Result:\n")
639+ }
640+ }
641+
642+ if m.result.Content != "" {
643+ result.WriteString(fmt.Sprintf("```markdown\n%s\n```", m.result.Content))
644+ }
645+
646+ return result.String()
647+}
648+
168// SetToolCall updates the tool call data and stops spinning if finished
649// SetToolCall updates the tool call data and stops spinning if finished
169func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
650func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
170 m.call = call
651 m.call = call
@@ -316,3 +797,13 @@ func (m *toolCallCmp) Spinning() bool {
316func (m *toolCallCmp) ID() string {
797func (m *toolCallCmp) ID() string {
317 return m.call.ID
798 return m.call.ID
318}
799}
800+
801+// SetPermissionRequested marks that a permission request was made for this tool call
802+func (m *toolCallCmp) SetPermissionRequested() {
803+ m.permissionRequested = true
804+}
805+
806+// SetPermissionGranted marks that permission was granted for this tool call
807+func (m *toolCallCmp) SetPermissionGranted() {
808+ m.permissionGranted = true
809+}
internal/tui/components/chat/splash/splash.go+20
@@ -562,6 +562,8 @@ func (s *splashCmp) infoSection() string {
562 lipgloss.Left,
562 lipgloss.Left,
563 s.cwd(),
563 s.cwd(),
564 "",
564 "",
565+ s.currentModelBlock(),
566+ "",
565 lipgloss.JoinHorizontal(lipgloss.Left, s.lspBlock(), s.mcpBlock()),
567 lipgloss.JoinHorizontal(lipgloss.Left, s.lspBlock(), s.mcpBlock()),
566 "",
568 "",
567 ),
569 ),
@@ -740,6 +742,24 @@ func (s *splashCmp) mcpBlock() string {
740 )
742 )
741}
743}
742
744
745+func (s *splashCmp) currentModelBlock() string {
746+ cfg := config.Get()
747+ agentCfg := cfg.Agents["coder"]
748+ model := config.Get().GetModelByType(agentCfg.Model)
749+ t := styles.CurrentTheme()
750+ modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
751+ modelName := t.S().Text.Render(model.Name)
752+ modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
753+ parts := []string{
754+ modelInfo,
755+ }
756+
757+ return lipgloss.JoinVertical(
758+ lipgloss.Left,
759+ parts...,
760+ )
761+}
762+
743func (s *splashCmp) IsShowingAPIKey() bool {
763func (s *splashCmp) IsShowingAPIKey() bool {
744 return s.needsAPIKey
764 return s.needsAPIKey
745}
765}
internal/tui/components/core/status/status.go+10−3
@@ -7,6 +7,7 @@ import (
7 tea "github.com/charmbracelet/bubbletea/v2"
7 tea "github.com/charmbracelet/bubbletea/v2"
8 "github.com/charmbracelet/crush/internal/tui/styles"
8 "github.com/charmbracelet/crush/internal/tui/styles"
9 "github.com/charmbracelet/crush/internal/tui/util"
9 "github.com/charmbracelet/crush/internal/tui/util"
10+ "github.com/charmbracelet/lipgloss/v2"
10 "github.com/charmbracelet/x/ansi"
11 "github.com/charmbracelet/x/ansi"
11)
12)
12
13
@@ -72,13 +73,19 @@ func (m *statusCmp) infoMsg() string {
72 switch m.info.Type {
73 switch m.info.Type {
73 case util.InfoTypeError:
74 case util.InfoTypeError:
74 infoType = t.S().Base.Background(t.Red).Padding(0, 1).Render("ERROR")
75 infoType = t.S().Base.Background(t.Red).Padding(0, 1).Render("ERROR")
76+ widthLeft := m.width - (lipgloss.Width(infoType) + 2)
77+ info := ansi.Truncate(m.info.Msg, widthLeft, "…")
75− message = t.S().Base.Background(t.Error).Width(m.width).Foreground(t.White).Padding(0, 1).Render(m.info.Msg)
78+ message = t.S().Base.Background(t.Error).Width(widthLeft+2).Foreground(t.White).Padding(0, 1).Render(info)
76 case util.InfoTypeWarn:
79 case util.InfoTypeWarn:
77 infoType = t.S().Base.Foreground(t.BgOverlay).Background(t.Yellow).Padding(0, 1).Render("WARNING")
80 infoType = t.S().Base.Foreground(t.BgOverlay).Background(t.Yellow).Padding(0, 1).Render("WARNING")
81+ widthLeft := m.width - (lipgloss.Width(infoType) + 2)
82+ info := ansi.Truncate(m.info.Msg, widthLeft, "…")
78− message = t.S().Base.Foreground(t.BgOverlay).Width(m.width).Background(t.Warning).Padding(0, 1).Render(m.info.Msg)
83+ message = t.S().Base.Foreground(t.BgOverlay).Width(widthLeft+2).Background(t.Warning).Padding(0, 1).Render(info)
79 default:
84 default:
80 infoType = t.S().Base.Foreground(t.BgOverlay).Background(t.Green).Padding(0, 1).Render("OKAY!")
85 infoType = t.S().Base.Foreground(t.BgOverlay).Background(t.Green).Padding(0, 1).Render("OKAY!")
86+ widthLeft := m.width - (lipgloss.Width(infoType) + 2)
87+ info := ansi.Truncate(m.info.Msg, widthLeft, "…")
81− message = t.S().Base.Background(t.Success).Width(m.width).Foreground(t.White).Padding(0, 1).Render(m.info.Msg)
88+ message = t.S().Base.Background(t.Success).Width(widthLeft+2).Foreground(t.White).Padding(0, 1).Render(info)
82 }
89 }
83 return ansi.Truncate(infoType+message, m.width, "…")
90 return ansi.Truncate(infoType+message, m.width, "…")
84}
91}
internal/tui/components/dialogs/commands/commands.go+82−20
@@ -1,6 +1,8 @@
1package commands
1package commands
2
2
3import (
3import (
4+ "os"
5+
4 "github.com/charmbracelet/bubbles/v2/help"
6 "github.com/charmbracelet/bubbles/v2/help"
5 "github.com/charmbracelet/bubbles/v2/key"
7 "github.com/charmbracelet/bubbles/v2/key"
6 tea "github.com/charmbracelet/bubbletea/v2"
8 tea "github.com/charmbracelet/bubbletea/v2"
@@ -58,11 +60,16 @@ type commandDialogCmp struct {
58}
60}
59
61
60type (
62type (
61− SwitchSessionsMsg struct{}
63+ SwitchSessionsMsg struct{}
64+ NewSessionsMsg struct{}
62− SwitchModelMsg struct{}
65+ SwitchModelMsg struct{}
66+ QuitMsg struct{}
67+ OpenFilePickerMsg struct{}
68+ ToggleHelpMsg struct{}
63− ToggleCompactModeMsg struct{}
69+ ToggleCompactModeMsg struct{}
64− ToggleThinkingMsg struct{}
70+ ToggleThinkingMsg struct{}
71+ OpenExternalEditorMsg struct{}
65− CompactMsg struct {
72+ CompactMsg struct {
66 SessionID string
73 SessionID string
67 }
74 }
68)
75)
@@ -248,13 +255,29 @@ func (c *commandDialogCmp) Position() (int, int) {
248func (c *commandDialogCmp) defaultCommands() []Command {
255func (c *commandDialogCmp) defaultCommands() []Command {
249 commands := []Command{
256 commands := []Command{
250 {
257 {
251− ID: "init",
258+ ID: "new_session",
252− Title: "Initialize Project",
259+ Title: "New Session",
253− Description: "Create/Update the CRUSH.md memory file",
260+ Description: "start a new session",
261+ Shortcut: "ctrl+n",
254 Handler: func(cmd Command) tea.Cmd {
262 Handler: func(cmd Command) tea.Cmd {
255− return util.CmdHandler(chat.SendMsg{
263+ return util.CmdHandler(NewSessionsMsg{})
256− Text: prompt.Initialize(),
264+ },
257− })
265+ },
266+ {
267+ ID: "switch_session",
268+ Title: "Switch Session",
269+ Description: "Switch to a different session",
270+ Shortcut: "ctrl+s",
271+ Handler: func(cmd Command) tea.Cmd {
272+ return util.CmdHandler(SwitchSessionsMsg{})
273+ },
274+ },
275+ {
276+ ID: "switch_model",
277+ Title: "Switch Model",
278+ Description: "Switch to a different model",
279+ Handler: func(cmd Command) tea.Cmd {
280+ return util.CmdHandler(SwitchModelMsg{})
258 },
281 },
259 },
282 },
260 }
283 }
@@ -307,23 +330,62 @@ func (c *commandDialogCmp) defaultCommands() []Command {
307 },
330 },
308 })
331 })
309 }
332 }
333+ if c.sessionID != "" {
334+ agentCfg := config.Get().Agents["coder"]
335+ model := config.Get().GetModelByType(agentCfg.Model)
336+ if model.SupportsImages {
337+ commands = append(commands, Command{
338+ ID: "file_picker",
339+ Title: "Open File Picker",
340+ Shortcut: "ctrl+f",
341+ Description: "Open file picker",
342+ Handler: func(cmd Command) tea.Cmd {
343+ return util.CmdHandler(OpenFilePickerMsg{})
344+ },
345+ })
346+ }
347+ }
348+
349+ // Add external editor command if $EDITOR is available
350+ if os.Getenv("EDITOR") != "" {
351+ commands = append(commands, Command{
352+ ID: "open_external_editor",
353+ Title: "Open External Editor",
354+ Shortcut: "ctrl+o",
355+ Description: "Open external editor to compose message",
356+ Handler: func(cmd Command) tea.Cmd {
357+ return util.CmdHandler(OpenExternalEditorMsg{})
358+ },
359+ })
360+ }
310
361
311 return append(commands, []Command{
362 return append(commands, []Command{
312 {
363 {
313− ID: "switch_session",
364+ ID: "toggle_help",
314− Title: "Switch Session",
365+ Title: "Toggle Help",
315− Description: "Switch to a different session",
316− Shortcut: "ctrl+s",
366+ Shortcut: "ctrl+g",
367+ Description: "Toggle help",
317 Handler: func(cmd Command) tea.Cmd {
368 Handler: func(cmd Command) tea.Cmd {
318− return util.CmdHandler(SwitchSessionsMsg{})
369+ return util.CmdHandler(ToggleHelpMsg{})
319 },
370 },
320 },
371 },
321 {
372 {
322− ID: "switch_model",
373+ ID: "init",
323− Title: "Switch Model",
374+ Title: "Initialize Project",
324− Description: "Switch to a different model",
375+ Description: "Create/Update the CRUSH.md memory file",
325 Handler: func(cmd Command) tea.Cmd {
376 Handler: func(cmd Command) tea.Cmd {
326− return util.CmdHandler(SwitchModelMsg{})
377+ return util.CmdHandler(chat.SendMsg{
378+ Text: prompt.Initialize(),
379+ })
380+ },
381+ },
382+ {
383+ ID: "quit",
384+ Title: "Quit",
385+ Description: "Quit",
386+ Shortcut: "ctrl+c",
387+ Handler: func(cmd Command) tea.Cmd {
388+ return util.CmdHandler(QuitMsg{})
327 },
389 },
328 },
390 },
329 }...)
391 }...)
internal/tui/components/dialogs/filepicker/filepicker.go+6−4
@@ -21,7 +21,7 @@ import (
21)
21)
22
22
23const (
23const (
24− maxAttachmentSize = int64(5 * 1024 * 1024) // 5MB
24+ MaxAttachmentSize = int64(5 * 1024 * 1024) // 5MB
25 FilePickerID = "filepicker"
25 FilePickerID = "filepicker"
26 fileSelectionHight = 10
26 fileSelectionHight = 10
27)
27)
@@ -45,10 +45,12 @@ type model struct {
45 help help.Model
45 help help.Model
46}
46}
47
47
48+var AllowedTypes = []string{".jpg", ".jpeg", ".png"}
49+
48func NewFilePickerCmp(workingDir string) FilePicker {
50func NewFilePickerCmp(workingDir string) FilePicker {
49 t := styles.CurrentTheme()
51 t := styles.CurrentTheme()
50 fp := filepicker.New()
52 fp := filepicker.New()
51− fp.AllowedTypes = []string{".jpg", ".jpeg", ".png"}
53+ fp.AllowedTypes = AllowedTypes
52
54
53 if workingDir != "" {
55 if workingDir != "" {
54 fp.CurrentDirectory = workingDir
56 fp.CurrentDirectory = workingDir
@@ -127,7 +129,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
127 return m, tea.Sequence(
129 return m, tea.Sequence(
128 util.CmdHandler(dialogs.CloseDialogMsg{}),
130 util.CmdHandler(dialogs.CloseDialogMsg{}),
129 func() tea.Msg {
131 func() tea.Msg {
130− isFileLarge, err := ValidateFileSize(path, maxAttachmentSize)
132+ isFileLarge, err := IsFileTooBig(path, MaxAttachmentSize)
131 if err != nil {
133 if err != nil {
132 return util.ReportError(fmt.Errorf("unable to read the image: %w", err))
134 return util.ReportError(fmt.Errorf("unable to read the image: %w", err))
133 }
135 }
@@ -222,7 +224,7 @@ func (m *model) Position() (int, int) {
222 return row, col
224 return row, col
223}
225}
224
226
225−func ValidateFileSize(filePath string, sizeLimit int64) (bool, error) {
227+func IsFileTooBig(filePath string, sizeLimit int64) (bool, error) {
226 fileInfo, err := os.Stat(filePath)
228 fileInfo, err := os.Stat(filePath)
227 if err != nil {
229 if err != nil {
228 return false, fmt.Errorf("error getting file info: %w", err)
230 return false, fmt.Errorf("error getting file info: %w", err)
internal/tui/components/dialogs/permissions/permissions.go+42−1
@@ -84,7 +84,7 @@ func (p *permissionDialogCmp) Init() tea.Cmd {
84}
84}
85
85
86func (p *permissionDialogCmp) supportsDiffView() bool {
86func (p *permissionDialogCmp) supportsDiffView() bool {
87− return p.permission.ToolName == tools.EditToolName || p.permission.ToolName == tools.WriteToolName
87+ return p.permission.ToolName == tools.EditToolName || p.permission.ToolName == tools.WriteToolName || p.permission.ToolName == tools.MultiEditToolName
88}
88}
89
89
90func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
90func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -305,6 +305,20 @@ func (p *permissionDialogCmp) renderHeader() string {
305 ),
305 ),
306 baseStyle.Render(strings.Repeat(" ", p.width)),
306 baseStyle.Render(strings.Repeat(" ", p.width)),
307 )
307 )
308+ case tools.MultiEditToolName:
309+ params := p.permission.Params.(tools.MultiEditPermissionsParams)
310+ fileKey := t.S().Muted.Render("File")
311+ filePath := t.S().Text.
312+ Width(p.width - lipgloss.Width(fileKey)).
313+ Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
314+ headerParts = append(headerParts,
315+ lipgloss.JoinHorizontal(
316+ lipgloss.Left,
317+ fileKey,
318+ filePath,
319+ ),
320+ baseStyle.Render(strings.Repeat(" ", p.width)),
321+ )
308 case tools.FetchToolName:
322 case tools.FetchToolName:
309 headerParts = append(headerParts, t.S().Muted.Width(p.width).Bold(true).Render("URL"))
323 headerParts = append(headerParts, t.S().Muted.Width(p.width).Bold(true).Render("URL"))
310 }
324 }
@@ -329,6 +343,8 @@ func (p *permissionDialogCmp) getOrGenerateContent() string {
329 content = p.generateEditContent()
343 content = p.generateEditContent()
330 case tools.WriteToolName:
344 case tools.WriteToolName:
331 content = p.generateWriteContent()
345 content = p.generateWriteContent()
346+ case tools.MultiEditToolName:
347+ content = p.generateMultiEditContent()
332 case tools.FetchToolName:
348 case tools.FetchToolName:
333 content = p.generateFetchContent()
349 content = p.generateFetchContent()
334 default:
350 default:
@@ -435,6 +451,28 @@ func (p *permissionDialogCmp) generateDownloadContent() string {
435 return ""
451 return ""
436}
452}
437
453
454+func (p *permissionDialogCmp) generateMultiEditContent() string {
455+ if pr, ok := p.permission.Params.(tools.MultiEditPermissionsParams); ok {
456+ // Use the cache for diff rendering
457+ formatter := core.DiffFormatter().
458+ Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
459+ After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
460+ Height(p.contentViewPort.Height()).
461+ Width(p.contentViewPort.Width()).
462+ XOffset(p.diffXOffset).
463+ YOffset(p.diffYOffset)
464+ if p.useDiffSplitMode() {
465+ formatter = formatter.Split()
466+ } else {
467+ formatter = formatter.Unified()
468+ }
469+
470+ diff := formatter.String()
471+ return diff
472+ }
473+ return ""
474+}
475+
438func (p *permissionDialogCmp) generateFetchContent() string {
476func (p *permissionDialogCmp) generateFetchContent() string {
439 t := styles.CurrentTheme()
477 t := styles.CurrentTheme()
440 baseStyle := t.S().Base.Background(t.BgSubtle)
478 baseStyle := t.S().Base.Background(t.BgSubtle)
@@ -579,6 +617,9 @@ func (p *permissionDialogCmp) SetSize() tea.Cmd {
579 case tools.WriteToolName:
617 case tools.WriteToolName:
580 p.width = int(float64(p.wWidth) * 0.8)
618 p.width = int(float64(p.wWidth) * 0.8)
581 p.height = int(float64(p.wHeight) * 0.8)
619 p.height = int(float64(p.wHeight) * 0.8)
620+ case tools.MultiEditToolName:
621+ p.width = int(float64(p.wWidth) * 0.8)
622+ p.height = int(float64(p.wHeight) * 0.8)
582 case tools.FetchToolName:
623 case tools.FetchToolName:
583 p.width = int(float64(p.wWidth) * 0.8)
624 p.width = int(float64(p.wWidth) * 0.8)
584 p.height = int(float64(p.wHeight) * 0.3)
625 p.height = int(float64(p.wHeight) * 0.3)
internal/tui/exp/list/filterable.go+1−1
@@ -94,7 +94,7 @@ func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption
94 for _, opt := range opts {
94 for _, opt := range opts {
95 opt(f.filterableOptions)
95 opt(f.filterableOptions)
96 }
96 }
97− f.list = New[T](items, f.listOptions...).(*list[T])
97+ f.list = New(items, f.listOptions...).(*list[T])
98
98
99 f.updateKeyMaps()
99 f.updateKeyMaps()
100 f.items = slices.Collect(f.list.items.Seq())
100 f.items = slices.Collect(f.list.items.Seq())
internal/tui/exp/list/list.go+12
@@ -236,6 +236,18 @@ func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
236 case key.Matches(msg, l.keyMap.Home):
236 case key.Matches(msg, l.keyMap.Home):
237 return l, l.GoToTop()
237 return l, l.GoToTop()
238 }
238 }
239+ s := l.SelectedItem()
240+ if s == nil {
241+ return l, nil
242+ }
243+ item := *s
244+ var cmds []tea.Cmd
245+ updated, cmd := item.Update(msg)
246+ cmds = append(cmds, cmd)
247+ if u, ok := updated.(T); ok {
248+ cmds = append(cmds, l.UpdateItem(u.ID(), u))
249+ }
250+ return l, tea.Batch(cmds...)
239 }
251 }
240 }
252 }
241 return l, nil
253 return l, nil
internal/tui/keys.go+5
@@ -8,6 +8,7 @@ type KeyMap struct {
8 Quit key.Binding
8 Quit key.Binding
9 Help key.Binding
9 Help key.Binding
10 Commands key.Binding
10 Commands key.Binding
11+ Suspend key.Binding
11 Sessions key.Binding
12 Sessions key.Binding
12
13
13 pageBindings []key.Binding
14 pageBindings []key.Binding
@@ -27,6 +28,10 @@ func DefaultKeyMap() KeyMap {
27 key.WithKeys("ctrl+p"),
28 key.WithKeys("ctrl+p"),
28 key.WithHelp("ctrl+p", "commands"),
29 key.WithHelp("ctrl+p", "commands"),
29 ),
30 ),
31+ Suspend: key.NewBinding(
32+ key.WithKeys("ctrl+z"),
33+ key.WithHelp("ctrl+z", "suspend"),
34+ ),
30 Sessions: key.NewBinding(
35 Sessions: key.NewBinding(
31 key.WithKeys("ctrl+s"),
36 key.WithKeys("ctrl+s"),
32 key.WithHelp("ctrl+s", "sessions"),
37 key.WithHelp("ctrl+s", "sessions"),
internal/tui/page/chat/chat.go+35−5
@@ -12,6 +12,7 @@ import (
12 "github.com/charmbracelet/crush/internal/config"
12 "github.com/charmbracelet/crush/internal/config"
13 "github.com/charmbracelet/crush/internal/history"
13 "github.com/charmbracelet/crush/internal/history"
14 "github.com/charmbracelet/crush/internal/message"
14 "github.com/charmbracelet/crush/internal/message"
15+ "github.com/charmbracelet/crush/internal/permission"
15 "github.com/charmbracelet/crush/internal/pubsub"
16 "github.com/charmbracelet/crush/internal/pubsub"
16 "github.com/charmbracelet/crush/internal/session"
17 "github.com/charmbracelet/crush/internal/session"
17 "github.com/charmbracelet/crush/internal/tui/components/anim"
18 "github.com/charmbracelet/crush/internal/tui/components/anim"
@@ -36,8 +37,7 @@ import (
36var ChatPageID page.PageID = "chat"
37var ChatPageID page.PageID = "chat"
37
38
38type (
39type (
39− OpenFilePickerMsg struct{}
40− ChatFocusedMsg struct {
40+ ChatFocusedMsg struct {
41 Focused bool
41 Focused bool
42 }
42 }
43 CancelTimerExpiredMsg struct{}
43 CancelTimerExpiredMsg struct{}
@@ -178,6 +178,10 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
178 case CancelTimerExpiredMsg:
178 case CancelTimerExpiredMsg:
179 p.isCanceling = false
179 p.isCanceling = false
180 return p, nil
180 return p, nil
181+ case editor.OpenEditorMsg:
182+ u, cmd := p.editor.Update(msg)
183+ p.editor = u.(editor.Editor)
184+ return p, cmd
181 case chat.SendMsg:
185 case chat.SendMsg:
182 return p, p.sendMessage(msg.Text, msg.Attachments)
186 return p, p.sendMessage(msg.Text, msg.Attachments)
183 case chat.SessionSelectedMsg:
187 case chat.SessionSelectedMsg:
@@ -200,6 +204,10 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
200 return p, tea.Batch(p.SetSize(p.width, p.height), cmd)
204 return p, tea.Batch(p.SetSize(p.width, p.height), cmd)
201 case commands.ToggleThinkingMsg:
205 case commands.ToggleThinkingMsg:
202 return p, p.toggleThinking()
206 return p, p.toggleThinking()
207+ case commands.OpenExternalEditorMsg:
208+ u, cmd := p.editor.Update(msg)
209+ p.editor = u.(editor.Editor)
210+ return p, cmd
203 case pubsub.Event[session.Session]:
211 case pubsub.Event[session.Session]:
204 u, cmd := p.header.Update(msg)
212 u, cmd := p.header.Update(msg)
205 p.header = u.(header.Header)
213 p.header = u.(header.Header)
@@ -253,6 +261,11 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
253 p.sidebar = u.(sidebar.Sidebar)
261 p.sidebar = u.(sidebar.Sidebar)
254 cmds = append(cmds, cmd)
262 cmds = append(cmds, cmd)
255 return p, tea.Batch(cmds...)
263 return p, tea.Batch(cmds...)
264+ case pubsub.Event[permission.PermissionNotification]:
265+ u, cmd := p.chat.Update(msg)
266+ p.chat = u.(chat.MessageListCmp)
267+ cmds = append(cmds, cmd)
268+ return p, tea.Batch(cmds...)
256
269
257 case commands.CommandRunCustomMsg:
270 case commands.CommandRunCustomMsg:
258 if p.app.CoderAgent.IsBusy() {
271 if p.app.CoderAgent.IsBusy() {
@@ -278,15 +291,23 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
278 p.isProjectInit = false
291 p.isProjectInit = false
279 p.focusedPane = PanelTypeEditor
292 p.focusedPane = PanelTypeEditor
280 return p, p.SetSize(p.width, p.height)
293 return p, p.SetSize(p.width, p.height)
294+ case commands.NewSessionsMsg:
295+ if p.app.CoderAgent.IsBusy() {
296+ return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
297+ }
298+ return p, p.newSession()
281 case tea.KeyPressMsg:
299 case tea.KeyPressMsg:
282 switch {
300 switch {
283 case key.Matches(msg, p.keyMap.NewSession):
301 case key.Matches(msg, p.keyMap.NewSession):
302+ if p.app.CoderAgent.IsBusy() {
303+ return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
304+ }
284 return p, p.newSession()
305 return p, p.newSession()
285 case key.Matches(msg, p.keyMap.AddAttachment):
306 case key.Matches(msg, p.keyMap.AddAttachment):
286 agentCfg := config.Get().Agents["coder"]
307 agentCfg := config.Get().Agents["coder"]
287 model := config.Get().GetModelByType(agentCfg.Model)
308 model := config.Get().GetModelByType(agentCfg.Model)
288 if model.SupportsImages {
309 if model.SupportsImages {
289− return p, util.CmdHandler(OpenFilePickerMsg{})
310+ return p, util.CmdHandler(commands.OpenFilePickerMsg{})
290 } else {
311 } else {
291 return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
312 return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
292 }
313 }
@@ -812,6 +833,10 @@ func (p *chatPage) Help() help.KeyMap {
812 key.WithKeys("up", "down"),
833 key.WithKeys("up", "down"),
813 key.WithHelp("↑↓", "scroll"),
834 key.WithHelp("↑↓", "scroll"),
814 ),
835 ),
836+ key.NewBinding(
837+ key.WithKeys("c", "y"),
838+ key.WithHelp("c/y", "copy"),
839+ ),
815 )
840 )
816 fullList = append(fullList,
841 fullList = append(fullList,
817 []key.Binding{
842 []key.Binding{
@@ -875,9 +900,13 @@ func (p *chatPage) Help() help.KeyMap {
875 key.WithHelp("/", "add file"),
900 key.WithHelp("/", "add file"),
876 ),
901 ),
877 key.NewBinding(
902 key.NewBinding(
878− key.WithKeys("ctrl+v"),
903+ key.WithKeys("ctrl+o"),
879− key.WithHelp("ctrl+v", "open editor"),
904+ key.WithHelp("ctrl+o", "open editor"),
880 ),
905 ),
906+ })
907+
908+ if p.editor.HasAttachments() {
909+ fullList = append(fullList, []key.Binding{
881 key.NewBinding(
910 key.NewBinding(
882 key.WithKeys("ctrl+r"),
911 key.WithKeys("ctrl+r"),
883 key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
912 key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
@@ -891,6 +920,7 @@ func (p *chatPage) Help() help.KeyMap {
891 key.WithHelp("esc", "cancel delete mode"),
920 key.WithHelp("esc", "cancel delete mode"),
892 ),
921 ),
893 })
922 })
923+ }
894 }
924 }
895 shortList = append(shortList,
925 shortList = append(shortList,
896 // Quit
926 // Quit
internal/tui/tui.go+29−2
@@ -3,6 +3,7 @@ package tui
3import (
3import (
4 "context"
4 "context"
5 "fmt"
5 "fmt"
6+ "log/slog"
6 "strings"
7 "strings"
7 "time"
8 "time"
8
9
@@ -170,7 +171,14 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
170 return a, util.CmdHandler(dialogs.OpenDialogMsg{
171 return a, util.CmdHandler(dialogs.OpenDialogMsg{
171 Model: compact.NewCompactDialogCmp(a.app.CoderAgent, msg.SessionID, true),
172 Model: compact.NewCompactDialogCmp(a.app.CoderAgent, msg.SessionID, true),
172 })
173 })
173−
174+ case commands.QuitMsg:
175+ return a, util.CmdHandler(dialogs.OpenDialogMsg{
176+ Model: quit.NewQuitDialog(),
177+ })
178+ case commands.ToggleHelpMsg:
179+ a.status.ToggleFullHelp()
180+ a.showingFullHelp = !a.showingFullHelp
181+ return a, a.handleWindowResize(a.wWidth, a.wHeight)
174 // Model Switch
182 // Model Switch
175 case models.ModelSelectedMsg:
183 case models.ModelSelectedMsg:
176 config.Get().UpdatePreferredModel(msg.ModelType, msg.Model)
184 config.Get().UpdatePreferredModel(msg.ModelType, msg.Model)
@@ -187,7 +195,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
187 return a, util.ReportInfo(fmt.Sprintf("%s model changed to %s", modelTypeName, msg.Model.Model))
195 return a, util.ReportInfo(fmt.Sprintf("%s model changed to %s", modelTypeName, msg.Model.Model))
188
196
189 // File Picker
197 // File Picker
190− case chat.OpenFilePickerMsg:
198+ case commands.OpenFilePickerMsg:
191 if a.dialog.ActiveDialogID() == filepicker.FilePickerID {
199 if a.dialog.ActiveDialogID() == filepicker.FilePickerID {
192 // If the commands dialog is already open, close it
200 // If the commands dialog is already open, close it
193 return a, util.CmdHandler(dialogs.CloseDialogMsg{})
201 return a, util.CmdHandler(dialogs.CloseDialogMsg{})
@@ -196,6 +204,11 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
196 Model: filepicker.NewFilePickerCmp(a.app.Config().WorkingDir()),
204 Model: filepicker.NewFilePickerCmp(a.app.Config().WorkingDir()),
197 })
205 })
198 // Permissions
206 // Permissions
207+ case pubsub.Event[permission.PermissionNotification]:
208+ // forward to page
209+ updated, cmd := a.pages[a.currentPage].Update(msg)
210+ a.pages[a.currentPage] = updated.(util.Model)
211+ return a, cmd
199 case pubsub.Event[permission.PermissionRequest]:
212 case pubsub.Event[permission.PermissionRequest]:
200 return a, util.CmdHandler(dialogs.OpenDialogMsg{
213 return a, util.CmdHandler(dialogs.OpenDialogMsg{
201 Model: permissions.NewPermissionDialogCmp(msg.Payload),
214 Model: permissions.NewPermissionDialogCmp(msg.Payload),
@@ -246,8 +259,17 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
246 return a, tea.Batch(cmds...)
259 return a, tea.Batch(cmds...)
247 // Key Press Messages
260 // Key Press Messages
248 case tea.KeyPressMsg:
261 case tea.KeyPressMsg:
262+
263+ slog.Info("TUI Update", "msg", msg, "key", msg.String())
249 return a, a.handleKeyPressMsg(msg)
264 return a, a.handleKeyPressMsg(msg)
250
265
266+ case tea.MouseWheelMsg:
267+ if !a.dialog.HasDialogs() {
268+ updated, pageCmd := a.pages[a.currentPage].Update(msg)
269+ a.pages[a.currentPage] = updated.(util.Model)
270+ cmds = append(cmds, pageCmd)
271+ }
272+ return a, tea.Batch(cmds...)
251 case tea.PasteMsg:
273 case tea.PasteMsg:
252 if a.dialog.HasDialogs() {
274 if a.dialog.HasDialogs() {
253 u, dialogCmd := a.dialog.Update(msg)
275 u, dialogCmd := a.dialog.Update(msg)
@@ -370,6 +392,11 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
370 },
392 },
371 )
393 )
372 return tea.Sequence(cmds...)
394 return tea.Sequence(cmds...)
395+ case key.Matches(msg, a.keyMap.Suspend):
396+ if a.app.CoderAgent.IsBusy() {
397+ return util.ReportWarn("Agent is busy, please wait...")
398+ }
399+ return tea.Suspend
373 default:
400 default:
374 if a.dialog.HasDialogs() {
401 if a.dialog.HasDialogs() {
375 u, dialogCmd := a.dialog.Update(msg)
402 u, dialogCmd := a.dialog.Update(msg)