on main help sign in
feat(tools): add diff view for denied tools
d14f3b1Kieran Klukas
internal/agent/tools/edit.go+24−3
@@ -154,7 +154,14 @@ func createNewFile(edit editContext, filePath, content string, call fantasy.Tool
154 return fantasy.ToolResponse{}, err
154 return fantasy.ToolResponse{}, err
155 }
155 }
156 if !p {
156 if !p {
157− return NewPermissionDeniedResponse(), nil
157+ resp := NewPermissionDeniedResponse()
158+ resp = fantasy.WithResponseMetadata(resp, EditResponseMetadata{
159+ OldContent: "",
160+ NewContent: content,
161+ Additions: additions,
162+ Removals: removals,
163+ })
164+ return resp, nil
158 }
165 }
159
166
160 err = os.WriteFile(filePath, []byte(content), 0o644)
167 err = os.WriteFile(filePath, []byte(content), 0o644)
@@ -276,7 +283,14 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
276 return fantasy.ToolResponse{}, err
283 return fantasy.ToolResponse{}, err
277 }
284 }
278 if !p {
285 if !p {
279− return NewPermissionDeniedResponse(), nil
286+ resp := NewPermissionDeniedResponse()
287+ resp = fantasy.WithResponseMetadata(resp, EditResponseMetadata{
288+ OldContent: oldContent,
289+ NewContent: newContent,
290+ Additions: additions,
291+ Removals: removals,
292+ })
293+ return resp, nil
280 }
294 }
281
295
282 if isCrlf {
296 if isCrlf {
@@ -410,7 +424,14 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
410 return fantasy.ToolResponse{}, err
424 return fantasy.ToolResponse{}, err
411 }
425 }
412 if !p {
426 if !p {
413− return NewPermissionDeniedResponse(), nil
427+ resp := NewPermissionDeniedResponse()
428+ resp = fantasy.WithResponseMetadata(resp, EditResponseMetadata{
429+ OldContent: oldContent,
430+ NewContent: newContent,
431+ Additions: additions,
432+ Removals: removals,
433+ })
434+ return resp, nil
414 }
435 }
415
436
416 if isCrlf {
437 if isCrlf {
internal/agent/tools/multiedit.go+20−2
@@ -196,7 +196,16 @@ func processMultiEditWithCreation(edit editContext, params MultiEditParams, call
196 return fantasy.ToolResponse{}, err
196 return fantasy.ToolResponse{}, err
197 }
197 }
198 if !p {
198 if !p {
199− return NewPermissionDeniedResponse(), nil
199+ resp := NewPermissionDeniedResponse()
200+ resp = fantasy.WithResponseMetadata(resp, MultiEditResponseMetadata{
201+ OldContent: "",
202+ NewContent: currentContent,
203+ Additions: additions,
204+ Removals: removals,
205+ EditsApplied: editsApplied,
206+ EditsFailed: failedEdits,
207+ })
208+ return resp, nil
200 }
209 }
201
210
202 // Write the file
211 // Write the file
@@ -340,7 +349,16 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
340 return fantasy.ToolResponse{}, err
349 return fantasy.ToolResponse{}, err
341 }
350 }
342 if !p {
351 if !p {
343− return NewPermissionDeniedResponse(), nil
352+ resp := NewPermissionDeniedResponse()
353+ resp = fantasy.WithResponseMetadata(resp, MultiEditResponseMetadata{
354+ OldContent: oldContent,
355+ NewContent: currentContent,
356+ Additions: additions,
357+ Removals: removals,
358+ EditsApplied: editsApplied,
359+ EditsFailed: failedEdits,
360+ })
361+ return resp, nil
344 }
362 }
345
363
346 if isCrlf {
364 if isCrlf {
internal/agent/tools/write.go+7−1
@@ -125,7 +125,13 @@ func NewWriteTool(
125 return fantasy.ToolResponse{}, err
125 return fantasy.ToolResponse{}, err
126 }
126 }
127 if !p {
127 if !p {
128− return NewPermissionDeniedResponse(), nil
128+ resp := NewPermissionDeniedResponse()
129+ resp = fantasy.WithResponseMetadata(resp, WriteResponseMetadata{
130+ Diff: diff,
131+ Additions: additions,
132+ Removals: removals,
133+ })
134+ return resp, nil
129 }
135 }
130
136
131 err = os.WriteFile(filePath, []byte(params.Content), 0o644)
137 err = os.WriteFile(filePath, []byte(params.Content), 0o644)
internal/ui/chat/file.go+44−19
@@ -139,17 +139,31 @@ func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
139 return header
139 return header
140 }
140 }
141
141
142+ if !opts.HasResult() {
142− if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
143+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
143− return joinToolParts(header, earlyState)
144+ return joinToolParts(header, earlyState)
145+ }
146+ return header
144 }
147 }
145
148
146− if params.Content == "" {
149+ // On error with diff metadata (e.g. denied permission), show error + diff.
147− return header
150+ if opts.Result.IsError {
151+ var meta tools.WriteResponseMetadata
152+ if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil && meta.Diff != "" {
153+ errLine := toolErrorContent(sty, opts.Result, cappedWidth)
154+ diff := toolOutputDiffContentFromUnified(sty, meta.Diff, cappedWidth, opts.ExpandedContent)
155+ return strings.Join([]string{header, "", errLine, "", diff}, "\n")
156+ }
157+ return joinToolParts(header, toolErrorContent(sty, opts.Result, cappedWidth))
148 }
158 }
149
159
150 // Render code content with syntax highlighting.
160 // Render code content with syntax highlighting.
161+ if params.Content != "" {
151− body := toolOutputCodeContent(sty, params.FilePath, params.Content, 0, cappedWidth, opts.ExpandedContent)
162+ body := toolOutputCodeContent(sty, params.FilePath, params.Content, 0, cappedWidth, opts.ExpandedContent)
152− return joinToolParts(header, body)
163+ return joinToolParts(header, body)
164+ }
165+
166+ return header
153}
167}
154
168
155// -----------------------------------------------------------------------------
169// -----------------------------------------------------------------------------
@@ -194,11 +208,10 @@ func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
194 return header
208 return header
195 }
209 }
196
210
197− if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok {
198− return joinToolParts(header, earlyState)
199− }
200−
201 if !opts.HasResult() {
211 if !opts.HasResult() {
212+ if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok {
213+ return joinToolParts(header, earlyState)
214+ }
202 return header
215 return header
203 }
216 }
204
217
@@ -210,9 +223,15 @@ func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
210 return joinToolParts(header, body)
223 return joinToolParts(header, body)
211 }
224 }
212
225
213− // Render diff.
214− body := toolOutputDiffContent(sty, file, meta.OldContent, meta.NewContent, width, opts.ExpandedContent)
226+ diff := toolOutputDiffContent(sty, file, meta.OldContent, meta.NewContent, width, opts.ExpandedContent)
227+
228+ // On error (e.g. denied permission), show error above the diff.
229+ if opts.Result.IsError {
230+ errLine := toolErrorContent(sty, opts.Result, width)
231+ return strings.Join([]string{header, "", errLine, "", diff}, "\n")
232+ }
233+
215− return joinToolParts(header, body)
234+ return joinToolParts(header, diff)
216}
235}
217
236
218// -----------------------------------------------------------------------------
237// -----------------------------------------------------------------------------
@@ -262,11 +281,10 @@ func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, o
262 return header
281 return header
263 }
282 }
264
283
265− if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok {
266− return joinToolParts(header, earlyState)
267− }
268−
269 if !opts.HasResult() {
284 if !opts.HasResult() {
285+ if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok {
286+ return joinToolParts(header, earlyState)
287+ }
270 return header
288 return header
271 }
289 }
272
290
@@ -279,8 +297,15 @@ func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, o
279 }
297 }
280
298
281 // Render diff with optional failed edits note.
299 // Render diff with optional failed edits note.
282− body := toolOutputMultiEditDiffContent(sty, file, meta, len(params.Edits), width, opts.ExpandedContent)
300+ diff := toolOutputMultiEditDiffContent(sty, file, meta, len(params.Edits), width, opts.ExpandedContent)
301+
302+ // On error (e.g. denied permission), show error above the diff.
303+ if opts.Result.IsError {
304+ errLine := toolErrorContent(sty, opts.Result, width)
305+ return strings.Join([]string{header, "", errLine, "", diff}, "\n")
306+ }
307+
283− return joinToolParts(header, body)
308+ return joinToolParts(header, diff)
284}
309}
285
310
286// -----------------------------------------------------------------------------
311// -----------------------------------------------------------------------------
internal/ui/chat/tools.go+7−1
@@ -536,12 +536,18 @@ func toolEarlyStateContent(sty *styles.Styles, opts *ToolRenderOpts, width int)
536 return msg, true
536 return msg, true
537}
537}
538
538
539−// toolErrorContent formats an error message with ERROR tag.
539+// toolErrorContent formats an error message with an ERROR or WARN tag.
540func toolErrorContent(sty *styles.Styles, result *message.ToolResult, width int) string {
540func toolErrorContent(sty *styles.Styles, result *message.ToolResult, width int) string {
541 if result == nil {
541 if result == nil {
542 return ""
542 return ""
543 }
543 }
544 errContent := strings.ReplaceAll(result.Content, "\n", " ")
544 errContent := strings.ReplaceAll(result.Content, "\n", " ")
545+ if strings.Contains(errContent, "User denied permission") {
546+ deniedTag := sty.Tool.WarnTag.Render("WARN")
547+ deniedTagWidth := lipgloss.Width(deniedTag)
548+ errContent = ansi.Truncate(errContent, width-deniedTagWidth-3, "…")
549+ return fmt.Sprintf("%s %s", deniedTag, sty.Tool.WarnMessage.Render(errContent))
550+ }
545 errTag := sty.Tool.ErrorTag.Render("ERROR")
551 errTag := sty.Tool.ErrorTag.Render("ERROR")
546 tagWidth := lipgloss.Width(errTag)
552 tagWidth := lipgloss.Width(errTag)
547 errContent = ansi.Truncate(errContent, width-tagWidth-3, "…")
553 errContent = ansi.Truncate(errContent, width-tagWidth-3, "…")
internal/ui/styles/quickstyle.go+4
@@ -47,6 +47,7 @@ type quickStyleOpts struct {
47 error color.Color
47 error color.Color
48 warning color.Color
48 warning color.Color
49 warningSubtle color.Color
49 warningSubtle color.Color
50+ denied color.Color
50 busy color.Color
51 busy color.Color
51 info color.Color
52 info color.Color
52 infoMoreSubtle color.Color
53 infoMoreSubtle color.Color
@@ -622,6 +623,9 @@ func quickStyle(o quickStyleOpts) Styles {
622 s.Tool.ErrorTag = base.Padding(0, 1).Background(o.destructive).Foreground(o.onPrimary)
623 s.Tool.ErrorTag = base.Padding(0, 1).Background(o.destructive).Foreground(o.onPrimary)
623 s.Tool.ErrorMessage = base.Foreground(o.fgSubtle)
624 s.Tool.ErrorMessage = base.Foreground(o.fgSubtle)
624
625
626+ s.Tool.WarnTag = base.Padding(0, 1).Background(o.denied).Foreground(o.bgBase).Bold(true)
627+ s.Tool.WarnMessage = base.Foreground(o.fgSubtle)
628+
625 // Diff and multi-edit styles
629 // Diff and multi-edit styles
626 s.Tool.DiffTruncation = muted.Background(o.bgLeastVisible).PaddingLeft(2)
630 s.Tool.DiffTruncation = muted.Background(o.bgLeastVisible).PaddingLeft(2)
627 s.Tool.NoteTag = base.Padding(0, 1).Background(o.info).Foreground(o.onPrimary)
631 s.Tool.NoteTag = base.Padding(0, 1).Background(o.info).Foreground(o.onPrimary)
internal/ui/styles/styles.go+4
@@ -292,6 +292,10 @@ type Styles struct {
292 ErrorTag lipgloss.Style // ERROR tag
292 ErrorTag lipgloss.Style // ERROR tag
293 ErrorMessage lipgloss.Style // Error message text
293 ErrorMessage lipgloss.Style // Error message text
294
294
295+ // Warning styles (used for permission denied)
296+ WarnTag lipgloss.Style // WARN tag
297+ WarnMessage lipgloss.Style // Warning message text
298+
295 // Diff styles
299 // Diff styles
296 DiffTruncation lipgloss.Style // Diff truncation message with padding
300 DiffTruncation lipgloss.Style // Diff truncation message with padding
297
301
internal/ui/styles/themes.go+2
@@ -41,6 +41,7 @@ func CharmtonePantera() Styles {
41 error: charmtone.Sriracha,
41 error: charmtone.Sriracha,
42 warningSubtle: charmtone.Zest,
42 warningSubtle: charmtone.Zest,
43 warning: charmtone.Mustard,
43 warning: charmtone.Mustard,
44+ denied: charmtone.Tang,
44 busy: charmtone.Citron,
45 busy: charmtone.Citron,
45 info: charmtone.Malibu,
46 info: charmtone.Malibu,
46 infoMoreSubtle: charmtone.Sardine,
47 infoMoreSubtle: charmtone.Sardine,
@@ -76,6 +77,7 @@ func HypercrushObsidiana() Styles {
76 error: charmtone.Sriracha,
77 error: charmtone.Sriracha,
77 warningSubtle: charmtone.Zest,
78 warningSubtle: charmtone.Zest,
78 warning: charmtone.Mustard,
79 warning: charmtone.Mustard,
80+ denied: charmtone.Tang,
79 busy: charmtone.Citron,
81 busy: charmtone.Citron,
80 info: charmtone.Malibu,
82 info: charmtone.Malibu,
81 infoMoreSubtle: charmtone.Sardine,
83 infoMoreSubtle: charmtone.Sardine,