on main help sign in
feat(tools): add diff view for denied tools
commitd14f3b1b754a8770d3851886d96803eb0a3884c3 authorKieran Klukas <kieran@dunkirk.sh> dateWed 13 May 2026, 16:52 (5 months ago) parentc8d0244
8 files changed+112−26
internal/agent/tools/edit.go+24−3 internal/agent/tools/multiedit.go+20−2 internal/agent/tools/write.go+7−1 internal/ui/chat/file.go+44−19 internal/ui/chat/tools.go+7−1 internal/ui/styles/quickstyle.go+4 internal/ui/styles/styles.go+4 internal/ui/styles/themes.go+2
internal/agent/tools/edit.go+24−3
@@ -154,7 +154,14 @@ func createNewFile(edit editContext, filePath, content string, call fantasy.Tool
154154 return fantasy.ToolResponse{}, err
155155 }
156156 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
158165 }
159166
160167 err = os.WriteFile(filePath, []byte(content), 0o644)
@@ -276,7 +283,14 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
276283 return fantasy.ToolResponse{}, err
277284 }
278285 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
280294 }
281295
282296 if isCrlf {
@@ -410,7 +424,14 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
410424 return fantasy.ToolResponse{}, err
411425 }
412426 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
414435 }
415436
416437 if isCrlf {
internal/agent/tools/multiedit.go+20−2
@@ -196,7 +196,16 @@ func processMultiEditWithCreation(edit editContext, params MultiEditParams, call
196196 return fantasy.ToolResponse{}, err
197197 }
198198 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
200209 }
201210
202211 // Write the file
@@ -340,7 +349,16 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
340349 return fantasy.ToolResponse{}, err
341350 }
342351 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
344362 }
345363
346364 if isCrlf {
internal/agent/tools/write.go+7−1
@@ -125,7 +125,13 @@ func NewWriteTool(
125125 return fantasy.ToolResponse{}, err
126126 }
127127 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
129135 }
130136
131137 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
139139 return header
140140 }
141141
142− if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
143− return joinToolParts(header, earlyState)
142+ if !opts.HasResult() {
143+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
144+ return joinToolParts(header, earlyState)
145+ }
146+ return header
144147 }
145148
146− if params.Content == "" {
147− return header
149+ // On error with diff metadata (e.g. denied permission), show error + diff.
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))
148158 }
149159
150160 // Render code content with syntax highlighting.
151− body := toolOutputCodeContent(sty, params.FilePath, params.Content, 0, cappedWidth, opts.ExpandedContent)
152− return joinToolParts(header, body)
161+ if params.Content != "" {
162+ body := toolOutputCodeContent(sty, params.FilePath, params.Content, 0, cappedWidth, opts.ExpandedContent)
163+ return joinToolParts(header, body)
164+ }
165+
166+ return header
153167}
154168
155169// -----------------------------------------------------------------------------
@@ -194,11 +208,10 @@ func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
194208 return header
195209 }
196210
197− if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok {
198− return joinToolParts(header, earlyState)
199− }
200−
201211 if !opts.HasResult() {
212+ if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok {
213+ return joinToolParts(header, earlyState)
214+ }
202215 return header
203216 }
204217
@@ -210,9 +223,15 @@ func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
210223 return joinToolParts(header, body)
211224 }
212225
213− // Render diff.
214− body := toolOutputDiffContent(sty, file, meta.OldContent, meta.NewContent, width, opts.ExpandedContent)
215− return joinToolParts(header, body)
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+
234+ return joinToolParts(header, diff)
216235}
217236
218237// -----------------------------------------------------------------------------
@@ -262,11 +281,10 @@ func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, o
262281 return header
263282 }
264283
265− if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok {
266− return joinToolParts(header, earlyState)
267− }
268−
269284 if !opts.HasResult() {
285+ if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok {
286+ return joinToolParts(header, earlyState)
287+ }
270288 return header
271289 }
272290
@@ -279,8 +297,15 @@ func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, o
279297 }
280298
281299 // Render diff with optional failed edits note.
282− body := toolOutputMultiEditDiffContent(sty, file, meta, len(params.Edits), width, opts.ExpandedContent)
283− return joinToolParts(header, body)
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+
308+ return joinToolParts(header, diff)
284309}
285310
286311// -----------------------------------------------------------------------------
internal/ui/chat/tools.go+7−1
@@ -536,12 +536,18 @@ func toolEarlyStateContent(sty *styles.Styles, opts *ToolRenderOpts, width int)
536536 return msg, true
537537}
538538
539−// toolErrorContent formats an error message with ERROR tag.
539+// toolErrorContent formats an error message with an ERROR or WARN tag.
540540func toolErrorContent(sty *styles.Styles, result *message.ToolResult, width int) string {
541541 if result == nil {
542542 return ""
543543 }
544544 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+ }
545551 errTag := sty.Tool.ErrorTag.Render("ERROR")
546552 tagWidth := lipgloss.Width(errTag)
547553 errContent = ansi.Truncate(errContent, width-tagWidth-3, "…")
internal/ui/styles/quickstyle.go+4
@@ -47,6 +47,7 @@ type quickStyleOpts struct {
4747 error color.Color
4848 warning color.Color
4949 warningSubtle color.Color
50+ denied color.Color
5051 busy color.Color
5152 info color.Color
5253 infoMoreSubtle color.Color
@@ -622,6 +623,9 @@ func quickStyle(o quickStyleOpts) Styles {
622623 s.Tool.ErrorTag = base.Padding(0, 1).Background(o.destructive).Foreground(o.onPrimary)
623624 s.Tool.ErrorMessage = base.Foreground(o.fgSubtle)
624625
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+
625629 // Diff and multi-edit styles
626630 s.Tool.DiffTruncation = muted.Background(o.bgLeastVisible).PaddingLeft(2)
627631 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 {
292292 ErrorTag lipgloss.Style // ERROR tag
293293 ErrorMessage lipgloss.Style // Error message text
294294
295+ // Warning styles (used for permission denied)
296+ WarnTag lipgloss.Style // WARN tag
297+ WarnMessage lipgloss.Style // Warning message text
298+
295299 // Diff styles
296300 DiffTruncation lipgloss.Style // Diff truncation message with padding
297301
internal/ui/styles/themes.go+2
@@ -41,6 +41,7 @@ func CharmtonePantera() Styles {
4141 error: charmtone.Sriracha,
4242 warningSubtle: charmtone.Zest,
4343 warning: charmtone.Mustard,
44+ denied: charmtone.Tang,
4445 busy: charmtone.Citron,
4546 info: charmtone.Malibu,
4647 infoMoreSubtle: charmtone.Sardine,
@@ -76,6 +77,7 @@ func HypercrushObsidiana() Styles {
7677 error: charmtone.Sriracha,
7778 warningSubtle: charmtone.Zest,
7879 warning: charmtone.Mustard,
80+ denied: charmtone.Tang,
7981 busy: charmtone.Citron,
8082 info: charmtone.Malibu,
8183 infoMoreSubtle: charmtone.Sardine,