on main help sign in
chore: improve permissions & edit tool
6808721Kujtim Hoxha
internal/app/app.go+1
@@ -203,6 +203,7 @@ func (app *App) setupEvents() {
203 setupSubscriber(ctx, app.serviceEventsWG, "sessions", app.Sessions.Subscribe, app.events)
203 setupSubscriber(ctx, app.serviceEventsWG, "sessions", app.Sessions.Subscribe, app.events)
204 setupSubscriber(ctx, app.serviceEventsWG, "messages", app.Messages.Subscribe, app.events)
204 setupSubscriber(ctx, app.serviceEventsWG, "messages", app.Messages.Subscribe, app.events)
205 setupSubscriber(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events)
205 setupSubscriber(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events)
206+ setupSubscriber(ctx, app.serviceEventsWG, "permissions-notifications", app.Permissions.SubscribeNotifications, app.events)
206 setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events)
207 setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events)
207 cleanupFunc := func() {
208 cleanupFunc := func() {
208 cancel()
209 cancel()
internal/llm/agent/mcp-tools.go+1
@@ -100,6 +100,7 @@ func (b *mcpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolRes
100 p := b.permissions.Request(
100 p := b.permissions.Request(
101 permission.CreatePermissionRequest{
101 permission.CreatePermissionRequest{
102 SessionID: sessionID,
102 SessionID: sessionID,
103+ ToolCallID: params.ID,
103 Path: b.workingDir,
104 Path: b.workingDir,
104 ToolName: b.Info().Name,
105 ToolName: b.Info().Name,
105 Action: "execute",
106 Action: "execute",
internal/llm/prompt/coder.go+3−3
@@ -107,7 +107,7 @@ NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTAN
107
107
108# Tool usage policy
108# Tool usage policy
109- When doing file search, prefer to use the Agent tool in order to reduce context usage.
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.
110+- IMPORTANT: 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).
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.
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
112
113# Proactiveness
113# Proactiveness
@@ -217,7 +217,7 @@ NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTAN
217
217
218# Tool usage policy
218# Tool usage policy
219- When doing file search, prefer to use the Agent tool in order to reduce context usage.
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.
220+- IMPORTANT: 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).
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.
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
222
223VERY IMPORTANT NEVER use emojis in your responses.
223VERY IMPORTANT NEVER use emojis in your responses.
@@ -281,7 +281,7 @@ NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTAN
281
281
282## Tool Usage
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.
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).
284+- **Parallelism:** IMPORTANT: 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).
285- **Command Execution:** Use the ` + "`bash`" + ` tool for running shell commands, remembering the safety rule to explain modifying commands first.
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.
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.
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.
internal/llm/tools/bash.go+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),
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+76−10
@@ -1,7 +1,9 @@
1package permission
1package permission
2
2
3import (
3import (
4+ "context"
4 "errors"
5 "errors"
6+ "log/slog"
5 "path/filepath"
7 "path/filepath"
6 "slices"
8 "slices"
7 "sync"
9 "sync"
@@ -14,6 +16,7 @@ var ErrorPermissionDenied = errors.New("permission denied")
14
16
15type CreatePermissionRequest struct {
17type CreatePermissionRequest struct {
16 SessionID string `json:"session_id"`
18 SessionID string `json:"session_id"`
19+ ToolCallID string `json:"tool_call_id"`
17 ToolName string `json:"tool_name"`
20 ToolName string `json:"tool_name"`
18 Description string `json:"description"`
21 Description string `json:"description"`
19 Action string `json:"action"`
22 Action string `json:"action"`
@@ -21,9 +24,16 @@ type CreatePermissionRequest struct {
21 Path string `json:"path"`
24 Path string `json:"path"`
22}
25}
23
26
27+type PermissionNotification struct {
28+ ToolCallID string `json:"tool_call_id"`
29+ Granted bool `json:"granted"`
30+ Denied bool `json:"denied"`
31+}
32+
24type PermissionRequest struct {
33type PermissionRequest struct {
25 ID string `json:"id"`
34 ID string `json:"id"`
26 SessionID string `json:"session_id"`
35 SessionID string `json:"session_id"`
36+ ToolCallID string `json:"tool_call_id"`
27 ToolName string `json:"tool_name"`
37 ToolName string `json:"tool_name"`
28 Description string `json:"description"`
38 Description string `json:"description"`
29 Action string `json:"action"`
39 Action string `json:"action"`
@@ -38,22 +48,32 @@ type Service interface {
38 Deny(permission PermissionRequest)
48 Deny(permission PermissionRequest)
39 Request(opts CreatePermissionRequest) bool
49 Request(opts CreatePermissionRequest) bool
40 AutoApproveSession(sessionID string)
50 AutoApproveSession(sessionID string)
51+ SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[PermissionNotification]
41}
52}
42
53
43type permissionService struct {
54type permissionService struct {
44 *pubsub.Broker[PermissionRequest]
55 *pubsub.Broker[PermissionRequest]
45
56
57+ notificationBroker *pubsub.Broker[PermissionNotification]
46 workingDir string
58 workingDir string
47 sessionPermissions []PermissionRequest
59 sessionPermissions []PermissionRequest
48 sessionPermissionsMu sync.RWMutex
60 sessionPermissionsMu sync.RWMutex
49 pendingRequests sync.Map
61 pendingRequests sync.Map
50− autoApproveSessions []string
62+ autoApproveSessions map[string]bool
51 autoApproveSessionsMu sync.RWMutex
63 autoApproveSessionsMu sync.RWMutex
52 skip bool
64 skip bool
53 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
54}
70}
55
71
56func (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+ })
57 respCh, ok := s.pendingRequests.Load(permission.ID)
77 respCh, ok := s.pendingRequests.Load(permission.ID)
58 if ok {
78 if ok {
59 respCh.(chan bool) <- true
79 respCh.(chan bool) <- true
@@ -62,20 +82,41 @@ func (s *permissionService) GrantPersistent(permission PermissionRequest) {
62 s.sessionPermissionsMu.Lock()
82 s.sessionPermissionsMu.Lock()
63 s.sessionPermissions = append(s.sessionPermissions, permission)
83 s.sessionPermissions = append(s.sessionPermissions, permission)
64 s.sessionPermissionsMu.Unlock()
84 s.sessionPermissionsMu.Unlock()
85+
86+ if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
87+ s.activeRequest = nil
88+ }
65}
89}
66
90
67func (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+ })
68 respCh, ok := s.pendingRequests.Load(permission.ID)
96 respCh, ok := s.pendingRequests.Load(permission.ID)
69 if ok {
97 if ok {
70 respCh.(chan bool) <- true
98 respCh.(chan bool) <- true
71 }
99 }
100+
101+ if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
102+ s.activeRequest = nil
103+ }
72}
104}
73
105
74func (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+ })
75 respCh, ok := s.pendingRequests.Load(permission.ID)
112 respCh, ok := s.pendingRequests.Load(permission.ID)
76 if ok {
113 if ok {
77 respCh.(chan bool) <- false
114 respCh.(chan bool) <- false
78 }
115 }
116+
117+ if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
118+ s.activeRequest = nil
119+ }
79}
120}
80
121
81func (s *permissionService) Request(opts CreatePermissionRequest) bool {
122func (s *permissionService) Request(opts CreatePermissionRequest) bool {
@@ -83,6 +124,13 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
83 return true
124 return true
84 }
125 }
85
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+
86 // Check if the tool/action combination is in the allowlist
134 // Check if the tool/action combination is in the allowlist
87 commandKey := opts.ToolName + ":" + opts.Action
135 commandKey := opts.ToolName + ":" + opts.Action
88 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) {
@@ -90,7 +138,7 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
90 }
138 }
91
139
92 s.autoApproveSessionsMu.RLock()
140 s.autoApproveSessionsMu.RLock()
93− autoApprove := slices.Contains(s.autoApproveSessions, opts.SessionID)
141+ autoApprove := s.autoApproveSessions[opts.SessionID]
94 s.autoApproveSessionsMu.RUnlock()
142 s.autoApproveSessionsMu.RUnlock()
95
143
96 if autoApprove {
144 if autoApprove {
@@ -101,10 +149,12 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
101 if dir == "." {
149 if dir == "." {
102 dir = s.workingDir
150 dir = s.workingDir
103 }
151 }
152+ slog.Info("Requesting permission", "session_id", opts.SessionID, "tool_name", opts.ToolName, "action", opts.Action, "path", dir)
104 permission := PermissionRequest{
153 permission := PermissionRequest{
105 ID: uuid.New().String(),
154 ID: uuid.New().String(),
106 Path: dir,
155 Path: dir,
107 SessionID: opts.SessionID,
156 SessionID: opts.SessionID,
157+ ToolCallID: opts.ToolCallID,
108 ToolName: opts.ToolName,
158 ToolName: opts.ToolName,
109 Description: opts.Description,
159 Description: opts.Description,
110 Action: opts.Action,
160 Action: opts.Action,
@@ -120,29 +170,45 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
120 }
170 }
121 s.sessionPermissionsMu.RUnlock()
171 s.sessionPermissionsMu.RUnlock()
122
172
123− respCh := make(chan bool, 1)
173+ s.sessionPermissionsMu.RLock()
174+ for _, p := range s.sessionPermissions {
175+ if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path {
176+ s.sessionPermissionsMu.RUnlock()
177+ return true
178+ }
179+ }
180+ s.sessionPermissionsMu.RUnlock()
181+
182+ s.activeRequest = &permission
124
183
184+ respCh := make(chan bool, 1)
125 s.pendingRequests.Store(permission.ID, respCh)
185 s.pendingRequests.Store(permission.ID, respCh)
126 defer s.pendingRequests.Delete(permission.ID)
186 defer s.pendingRequests.Delete(permission.ID)
127
187
188+ // Publish the request
128 s.Publish(pubsub.CreatedEvent, permission)
189 s.Publish(pubsub.CreatedEvent, permission)
129
190
130− // Wait for the response indefinitely
131 return <-respCh
191 return <-respCh
132}
192}
133
193
134func (s *permissionService) AutoApproveSession(sessionID string) {
194func (s *permissionService) AutoApproveSession(sessionID string) {
135 s.autoApproveSessionsMu.Lock()
195 s.autoApproveSessionsMu.Lock()
136− s.autoApproveSessions = append(s.autoApproveSessions, sessionID)
196+ s.autoApproveSessions[sessionID] = true
137 s.autoApproveSessionsMu.Unlock()
197 s.autoApproveSessionsMu.Unlock()
138}
198}
139
199
200+func (s *permissionService) SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[PermissionNotification] {
201+ return s.notificationBroker.Subscribe(ctx)
202+}
203+
140func NewPermissionService(workingDir string, skip bool, allowedTools []string) Service {
204func NewPermissionService(workingDir string, skip bool, allowedTools []string) Service {
141 return &permissionService{
205 return &permissionService{
142− Broker: pubsub.NewBroker[PermissionRequest](),
206+ Broker: pubsub.NewBroker[PermissionRequest](),
207+ notificationBroker: pubsub.NewBroker[PermissionNotification](),
143− workingDir: workingDir,
208+ workingDir: workingDir,
144− sessionPermissions: make([]PermissionRequest, 0),
209+ sessionPermissions: make([]PermissionRequest, 0),
210+ autoApproveSessions: make(map[string]bool),
145− skip: skip,
211+ skip: skip,
146− allowedTools: allowedTools,
212+ allowedTools: allowedTools,
147 }
213 }
148}
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−4
@@ -2,6 +2,7 @@ package chat
2
2
3import (
3import (
4 "context"
4 "context"
5+ "log/slog"
5 "time"
6 "time"
6
7
7 "github.com/charmbracelet/bubbles/v2/key"
8 "github.com/charmbracelet/bubbles/v2/key"
@@ -9,6 +10,7 @@ import (
9 "github.com/charmbracelet/crush/internal/app"
10 "github.com/charmbracelet/crush/internal/app"
10 "github.com/charmbracelet/crush/internal/llm/agent"
11 "github.com/charmbracelet/crush/internal/llm/agent"
11 "github.com/charmbracelet/crush/internal/message"
12 "github.com/charmbracelet/crush/internal/message"
13+ "github.com/charmbracelet/crush/internal/permission"
12 "github.com/charmbracelet/crush/internal/pubsub"
14 "github.com/charmbracelet/crush/internal/pubsub"
13 "github.com/charmbracelet/crush/internal/session"
15 "github.com/charmbracelet/crush/internal/session"
14 "github.com/charmbracelet/crush/internal/tui/components/chat/messages"
16 "github.com/charmbracelet/crush/internal/tui/components/chat/messages"
@@ -85,6 +87,8 @@ func (m *messageListCmp) Init() tea.Cmd {
85// Update handles incoming messages and updates the component state.
87// Update handles incoming messages and updates the component state.
86func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
88func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
87 switch msg := msg.(type) {
89 switch msg := msg.(type) {
90+ case pubsub.Event[permission.PermissionNotification]:
91+ return m, m.handlePermissionRequest(msg.Payload)
88 case SessionSelectedMsg:
92 case SessionSelectedMsg:
89 if msg.ID != m.session.ID {
93 if msg.ID != m.session.ID {
90 cmd := m.SetSession(msg)
94 cmd := m.SetSession(msg)
@@ -124,6 +128,20 @@ func (m *messageListCmp) View() string {
124 )
128 )
125}
129}
126
130
131+func (m *messageListCmp) handlePermissionRequest(permission permission.PermissionNotification) tea.Cmd {
132+ items := m.listCmp.Items()
133+ slog.Info("Handling permission request", "tool_call_id", permission.ToolCallID, "granted", permission.Granted)
134+ if toolCallIndex := m.findToolCallByID(items, permission.ToolCallID); toolCallIndex != NotFound {
135+ toolCall := items[toolCallIndex].(messages.ToolCallCmp)
136+ toolCall.SetPermissionRequested()
137+ if permission.Granted {
138+ toolCall.SetPermissionGranted()
139+ }
140+ m.listCmp.UpdateItem(toolCall.ID(), toolCall)
141+ }
142+ return nil
143+}
144+
127// handleChildSession handles messages from child sessions (agent tools).
145// handleChildSession handles messages from child sessions (agent tools).
128func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) tea.Cmd {
146func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) tea.Cmd {
129 var cmds []tea.Cmd
147 var cmds []tea.Cmd
@@ -158,6 +176,7 @@ func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message])
158 nestedCall := messages.NewToolCallCmp(
176 nestedCall := messages.NewToolCallCmp(
159 event.Payload.ID,
177 event.Payload.ID,
160 tc,
178 tc,
179+ m.app.Permissions,
161 messages.WithToolCallNested(true),
180 messages.WithToolCallNested(true),
162 )
181 )
163 cmds = append(cmds, nestedCall.Init())
182 cmds = append(cmds, nestedCall.Init())
@@ -199,7 +218,12 @@ func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message])
199 if event.Payload.SessionID != m.session.ID {
218 if event.Payload.SessionID != m.session.ID {
200 return m.handleChildSession(event)
219 return m.handleChildSession(event)
201 }
220 }
221+ switch event.Payload.Role {
222+ case message.Assistant:
202− return m.handleUpdateAssistantMessage(event.Payload)
223+ return m.handleUpdateAssistantMessage(event.Payload)
224+ case message.Tool:
225+ return m.handleToolMessage(event.Payload)
226+ }
203 }
227 }
204 return nil
228 return nil
205}
229}
@@ -371,7 +395,7 @@ func (m *messageListCmp) updateOrAddToolCall(msg message.Message, tc message.Too
371 }
395 }
372
396
373 // Add new tool call if not found
397 // Add new tool call if not found
374− return m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
398+ return m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions))
375}
399}
376
400
377// handleNewAssistantMessage processes new assistant messages and their tool calls.
401// handleNewAssistantMessage processes new assistant messages and their tool calls.
@@ -390,7 +414,7 @@ func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd
390
414
391 // Add tool calls
415 // Add tool calls
392 for _, tc := range msg.ToolCalls() {
416 for _, tc := range msg.ToolCalls() {
393− cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
417+ cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions))
394 cmds = append(cmds, cmd)
418 cmds = append(cmds, cmd)
395 }
419 }
396
420
@@ -473,7 +497,7 @@ func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResult
473 // Add tool calls with their results and status
497 // Add tool calls with their results and status
474 for _, tc := range msg.ToolCalls() {
498 for _, tc := range msg.ToolCalls() {
475 options := m.buildToolCallOptions(tc, msg, toolResultMap)
499 options := m.buildToolCallOptions(tc, msg, toolResultMap)
476− uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, options...))
500+ 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
501 // If this tool call is the agent tool, fetch nested tool calls
478 if tc.Name == agent.AgentToolName {
502 if tc.Name == agent.AgentToolName {
479 nestedMessages, _ := m.app.Messages.List(context.Background(), tc.ID)
503 nestedMessages, _ := m.app.Messages.List(context.Background(), tc.ID)
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{} })
@@ -316,6 +317,57 @@ func (er editRenderer) Render(v *toolCallCmp) string {
316 })
317 })
317}
318}
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+
349+ formatter := core.DiffFormatter().
350+ Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
351+ After(fsext.PrettyPath(params.FilePath), meta.NewContent).
352+ Width(v.textWidth() - 2) // -2 for padding
353+ if v.textWidth() > 120 {
354+ formatter = formatter.Split()
355+ }
356+ // add a message to the bottom if the content was truncated
357+ formatted := formatter.String()
358+ if lipgloss.Height(formatted) > responseContextHeight {
359+ contentLines := strings.Split(formatted, "\n")
360+ truncateMessage := t.S().Muted.
361+ Background(t.BgBaseLighter).
362+ PaddingLeft(2).
363+ Width(v.textWidth() - 4).
364+ Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight))
365+ formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage
366+ }
367+ return formatted
368+ })
369+}
370+
319// -----------------------------------------------------------------------------
371// -----------------------------------------------------------------------------
320// Write renderer
372// Write renderer
321// -----------------------------------------------------------------------------
373// -----------------------------------------------------------------------------
@@ -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+32−5
@@ -5,6 +5,7 @@ import (
5
5
6 tea "github.com/charmbracelet/bubbletea/v2"
6 tea "github.com/charmbracelet/bubbletea/v2"
7 "github.com/charmbracelet/crush/internal/message"
7 "github.com/charmbracelet/crush/internal/message"
8+ "github.com/charmbracelet/crush/internal/permission"
8 "github.com/charmbracelet/crush/internal/tui/components/anim"
9 "github.com/charmbracelet/crush/internal/tui/components/anim"
9 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
10 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
10 "github.com/charmbracelet/crush/internal/tui/styles"
11 "github.com/charmbracelet/crush/internal/tui/styles"
@@ -30,6 +31,8 @@ type ToolCallCmp interface {
30 SetNestedToolCalls([]ToolCallCmp) // Set nested tool calls
31 SetNestedToolCalls([]ToolCallCmp) // Set nested tool calls
31 SetIsNested(bool) // Set whether this tool call is nested
32 SetIsNested(bool) // Set whether this tool call is nested
32 ID() string
33 ID() string
34+ SetPermissionRequested() // Mark permission request
35+ SetPermissionGranted() // Mark permission granted
33}
36}
34
37
35// toolCallCmp implements the ToolCallCmp interface for displaying tool calls.
38// toolCallCmp implements the ToolCallCmp interface for displaying tool calls.
@@ -40,10 +43,12 @@ type toolCallCmp struct {
40 isNested bool // Whether this tool call is nested within another
43 isNested bool // Whether this tool call is nested within another
41
44
42 // Tool call data and state
45 // Tool call data and state
43− parentMessageID string // ID of the message that initiated this tool call
46+ parentMessageID string // ID of the message that initiated this tool call
44− call message.ToolCall // The tool call being executed
47+ call message.ToolCall // The tool call being executed
45− result message.ToolResult // The result of the tool execution
48+ result message.ToolResult // The result of the tool execution
46− cancelled bool // Whether the tool call was cancelled
49+ cancelled bool // Whether the tool call was cancelled
50+ permissionRequested bool
51+ permissionGranted bool
47
52
48 // Animation state for pending tool calls
53 // Animation state for pending tool calls
49 spinning bool // Whether to show loading animation
54 spinning bool // Whether to show loading animation
@@ -81,9 +86,21 @@ func WithToolCallNestedCalls(calls []ToolCallCmp) ToolCallOption {
81 }
86 }
82}
87}
83
88
89+func WithToolPermissionRequested() ToolCallOption {
90+ return func(m *toolCallCmp) {
91+ m.permissionRequested = true
92+ }
93+}
94+
95+func WithToolPermissionGranted() ToolCallOption {
96+ return func(m *toolCallCmp) {
97+ m.permissionGranted = true
98+ }
99+}
100+
84// NewToolCallCmp creates a new tool call component with the given parent message ID,
101// NewToolCallCmp creates a new tool call component with the given parent message ID,
85// tool call, and optional configuration
102// tool call, and optional configuration
86−func NewToolCallCmp(parentMessageID string, tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
103+func NewToolCallCmp(parentMessageID string, tc message.ToolCall, permissions permission.Service, opts ...ToolCallOption) ToolCallCmp {
87 m := &toolCallCmp{
104 m := &toolCallCmp{
88 call: tc,
105 call: tc,
89 parentMessageID: parentMessageID,
106 parentMessageID: parentMessageID,
@@ -316,3 +333,13 @@ func (m *toolCallCmp) Spinning() bool {
316func (m *toolCallCmp) ID() string {
333func (m *toolCallCmp) ID() string {
317 return m.call.ID
334 return m.call.ID
318}
335}
336+
337+// SetPermissionRequested marks that a permission request was made for this tool call
338+func (m *toolCallCmp) SetPermissionRequested() {
339+ m.permissionRequested = true
340+}
341+
342+// SetPermissionGranted marks that permission was granted for this tool call
343+func (m *toolCallCmp) SetPermissionGranted() {
344+ m.permissionGranted = true
345+}
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/page/chat/chat.go+6
@@ -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"
@@ -251,6 +252,11 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
251 p.sidebar = u.(sidebar.Sidebar)
252 p.sidebar = u.(sidebar.Sidebar)
252 cmds = append(cmds, cmd)
253 cmds = append(cmds, cmd)
253 return p, tea.Batch(cmds...)
254 return p, tea.Batch(cmds...)
255+ case pubsub.Event[permission.PermissionNotification]:
256+ u, cmd := p.chat.Update(msg)
257+ p.chat = u.(chat.MessageListCmp)
258+ cmds = append(cmds, cmd)
259+ return p, tea.Batch(cmds...)
254
260
255 case commands.CommandRunCustomMsg:
261 case commands.CommandRunCustomMsg:
256 if p.app.CoderAgent.IsBusy() {
262 if p.app.CoderAgent.IsBusy() {
internal/tui/tui.go+5
@@ -205,6 +205,11 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
205 Model: filepicker.NewFilePickerCmp(a.app.Config().WorkingDir()),
205 Model: filepicker.NewFilePickerCmp(a.app.Config().WorkingDir()),
206 })
206 })
207 // Permissions
207 // Permissions
208+ case pubsub.Event[permission.PermissionNotification]:
209+ // forward to page
210+ updated, cmd := a.pages[a.currentPage].Update(msg)
211+ a.pages[a.currentPage] = updated.(util.Model)
212+ return a, cmd
208 case pubsub.Event[permission.PermissionRequest]:
213 case pubsub.Event[permission.PermissionRequest]:
209 return a, util.CmdHandler(dialogs.OpenDialogMsg{
214 return a, util.CmdHandler(dialogs.OpenDialogMsg{
210 Model: permissions.NewPermissionDialogCmp(msg.Payload),
215 Model: permissions.NewPermissionDialogCmp(msg.Payload),