feat(tools): add diff view for denied tools

Kieran Klukas created

Change summary

internal/agent/tools/edit.go      | 27 ++++++++++++-
internal/agent/tools/multiedit.go | 22 ++++++++++-
internal/agent/tools/write.go     |  8 +++
internal/ui/chat/file.go          | 63 +++++++++++++++++++++++---------
internal/ui/chat/tools.go         |  8 +++
internal/ui/styles/quickstyle.go  |  4 ++
internal/ui/styles/styles.go      |  4 ++
internal/ui/styles/themes.go      |  2 +
8 files changed, 112 insertions(+), 26 deletions(-)

Detailed changes

internal/agent/tools/edit.go 🔗

@@ -154,7 +154,14 @@ func createNewFile(edit editContext, filePath, content string, call fantasy.Tool
 		return fantasy.ToolResponse{}, err
 	}
 	if !p {
-		return NewPermissionDeniedResponse(), nil
+		resp := NewPermissionDeniedResponse()
+		resp = fantasy.WithResponseMetadata(resp, EditResponseMetadata{
+			OldContent: "",
+			NewContent: content,
+			Additions:  additions,
+			Removals:   removals,
+		})
+		return resp, nil
 	}
 
 	err = os.WriteFile(filePath, []byte(content), 0o644)
@@ -276,7 +283,14 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
 		return fantasy.ToolResponse{}, err
 	}
 	if !p {
-		return NewPermissionDeniedResponse(), nil
+		resp := NewPermissionDeniedResponse()
+		resp = fantasy.WithResponseMetadata(resp, EditResponseMetadata{
+			OldContent: oldContent,
+			NewContent: newContent,
+			Additions:  additions,
+			Removals:   removals,
+		})
+		return resp, nil
 	}
 
 	if isCrlf {
@@ -410,7 +424,14 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
 		return fantasy.ToolResponse{}, err
 	}
 	if !p {
-		return NewPermissionDeniedResponse(), nil
+		resp := NewPermissionDeniedResponse()
+		resp = fantasy.WithResponseMetadata(resp, EditResponseMetadata{
+			OldContent: oldContent,
+			NewContent: newContent,
+			Additions:  additions,
+			Removals:   removals,
+		})
+		return resp, nil
 	}
 
 	if isCrlf {

internal/agent/tools/multiedit.go 🔗

@@ -196,7 +196,16 @@ func processMultiEditWithCreation(edit editContext, params MultiEditParams, call
 		return fantasy.ToolResponse{}, err
 	}
 	if !p {
-		return NewPermissionDeniedResponse(), nil
+		resp := NewPermissionDeniedResponse()
+		resp = fantasy.WithResponseMetadata(resp, MultiEditResponseMetadata{
+			OldContent:   "",
+			NewContent:   currentContent,
+			Additions:    additions,
+			Removals:     removals,
+			EditsApplied: editsApplied,
+			EditsFailed:  failedEdits,
+		})
+		return resp, nil
 	}
 
 	// Write the file
@@ -340,7 +349,16 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
 		return fantasy.ToolResponse{}, err
 	}
 	if !p {
-		return NewPermissionDeniedResponse(), nil
+		resp := NewPermissionDeniedResponse()
+		resp = fantasy.WithResponseMetadata(resp, MultiEditResponseMetadata{
+			OldContent:   oldContent,
+			NewContent:   currentContent,
+			Additions:    additions,
+			Removals:     removals,
+			EditsApplied: editsApplied,
+			EditsFailed:  failedEdits,
+		})
+		return resp, nil
 	}
 
 	if isCrlf {

internal/agent/tools/write.go 🔗

@@ -125,7 +125,13 @@ func NewWriteTool(
 				return fantasy.ToolResponse{}, err
 			}
 			if !p {
-				return NewPermissionDeniedResponse(), nil
+				resp := NewPermissionDeniedResponse()
+				resp = fantasy.WithResponseMetadata(resp, WriteResponseMetadata{
+					Diff:      diff,
+					Additions: additions,
+					Removals:  removals,
+				})
+				return resp, nil
 			}
 
 			err = os.WriteFile(filePath, []byte(params.Content), 0o644)

internal/ui/chat/file.go 🔗

@@ -139,17 +139,31 @@ func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
 		return header
 	}
 
-	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
-		return joinToolParts(header, earlyState)
+	if !opts.HasResult() {
+		if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+			return joinToolParts(header, earlyState)
+		}
+		return header
 	}
 
-	if params.Content == "" {
-		return header
+	// On error with diff metadata (e.g. denied permission), show error + diff.
+	if opts.Result.IsError {
+		var meta tools.WriteResponseMetadata
+		if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil && meta.Diff != "" {
+			errLine := toolErrorContent(sty, opts.Result, cappedWidth)
+			diff := toolOutputDiffContentFromUnified(sty, meta.Diff, cappedWidth, opts.ExpandedContent)
+			return strings.Join([]string{header, "", errLine, "", diff}, "\n")
+		}
+		return joinToolParts(header, toolErrorContent(sty, opts.Result, cappedWidth))
 	}
 
 	// Render code content with syntax highlighting.
-	body := toolOutputCodeContent(sty, params.FilePath, params.Content, 0, cappedWidth, opts.ExpandedContent)
-	return joinToolParts(header, body)
+	if params.Content != "" {
+		body := toolOutputCodeContent(sty, params.FilePath, params.Content, 0, cappedWidth, opts.ExpandedContent)
+		return joinToolParts(header, body)
+	}
+
+	return header
 }
 
 // -----------------------------------------------------------------------------
@@ -194,11 +208,10 @@ func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
 		return header
 	}
 
-	if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok {
-		return joinToolParts(header, earlyState)
-	}
-
 	if !opts.HasResult() {
+		if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok {
+			return joinToolParts(header, earlyState)
+		}
 		return header
 	}
 
@@ -210,9 +223,15 @@ func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
 		return joinToolParts(header, body)
 	}
 
-	// Render diff.
-	body := toolOutputDiffContent(sty, file, meta.OldContent, meta.NewContent, width, opts.ExpandedContent)
-	return joinToolParts(header, body)
+	diff := toolOutputDiffContent(sty, file, meta.OldContent, meta.NewContent, width, opts.ExpandedContent)
+
+	// On error (e.g. denied permission), show error above the diff.
+	if opts.Result.IsError {
+		errLine := toolErrorContent(sty, opts.Result, width)
+		return strings.Join([]string{header, "", errLine, "", diff}, "\n")
+	}
+
+	return joinToolParts(header, diff)
 }
 
 // -----------------------------------------------------------------------------
@@ -262,11 +281,10 @@ func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, o
 		return header
 	}
 
-	if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok {
-		return joinToolParts(header, earlyState)
-	}
-
 	if !opts.HasResult() {
+		if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok {
+			return joinToolParts(header, earlyState)
+		}
 		return header
 	}
 
@@ -279,8 +297,15 @@ func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, o
 	}
 
 	// Render diff with optional failed edits note.
-	body := toolOutputMultiEditDiffContent(sty, file, meta, len(params.Edits), width, opts.ExpandedContent)
-	return joinToolParts(header, body)
+	diff := toolOutputMultiEditDiffContent(sty, file, meta, len(params.Edits), width, opts.ExpandedContent)
+
+	// On error (e.g. denied permission), show error above the diff.
+	if opts.Result.IsError {
+		errLine := toolErrorContent(sty, opts.Result, width)
+		return strings.Join([]string{header, "", errLine, "", diff}, "\n")
+	}
+
+	return joinToolParts(header, diff)
 }
 
 // -----------------------------------------------------------------------------

internal/ui/chat/tools.go 🔗

@@ -536,12 +536,18 @@ func toolEarlyStateContent(sty *styles.Styles, opts *ToolRenderOpts, width int)
 	return msg, true
 }
 
-// toolErrorContent formats an error message with ERROR tag.
+// toolErrorContent formats an error message with an ERROR or WARN tag.
 func toolErrorContent(sty *styles.Styles, result *message.ToolResult, width int) string {
 	if result == nil {
 		return ""
 	}
 	errContent := strings.ReplaceAll(result.Content, "\n", " ")
+	if strings.Contains(errContent, "User denied permission") {
+		deniedTag := sty.Tool.WarnTag.Render("WARN")
+		deniedTagWidth := lipgloss.Width(deniedTag)
+		errContent = ansi.Truncate(errContent, width-deniedTagWidth-3, "…")
+		return fmt.Sprintf("%s %s", deniedTag, sty.Tool.WarnMessage.Render(errContent))
+	}
 	errTag := sty.Tool.ErrorTag.Render("ERROR")
 	tagWidth := lipgloss.Width(errTag)
 	errContent = ansi.Truncate(errContent, width-tagWidth-3, "…")

internal/ui/styles/quickstyle.go 🔗

@@ -47,6 +47,7 @@ type quickStyleOpts struct {
 	error             color.Color
 	warning           color.Color
 	warningSubtle     color.Color
+	denied            color.Color
 	busy              color.Color
 	info              color.Color
 	infoMoreSubtle    color.Color
@@ -622,6 +623,9 @@ func quickStyle(o quickStyleOpts) Styles {
 	s.Tool.ErrorTag = base.Padding(0, 1).Background(o.destructive).Foreground(o.onPrimary)
 	s.Tool.ErrorMessage = base.Foreground(o.fgSubtle)
 
+	s.Tool.WarnTag = base.Padding(0, 1).Background(o.denied).Foreground(o.bgBase).Bold(true)
+	s.Tool.WarnMessage = base.Foreground(o.fgSubtle)
+
 	// Diff and multi-edit styles
 	s.Tool.DiffTruncation = muted.Background(o.bgLeastVisible).PaddingLeft(2)
 	s.Tool.NoteTag = base.Padding(0, 1).Background(o.info).Foreground(o.onPrimary)

internal/ui/styles/styles.go 🔗

@@ -292,6 +292,10 @@ type Styles struct {
 		ErrorTag     lipgloss.Style // ERROR tag
 		ErrorMessage lipgloss.Style // Error message text
 
+		// Warning styles (used for permission denied)
+		WarnTag     lipgloss.Style // WARN tag
+		WarnMessage lipgloss.Style // Warning message text
+
 		// Diff styles
 		DiffTruncation lipgloss.Style // Diff truncation message with padding
 

internal/ui/styles/themes.go 🔗

@@ -41,6 +41,7 @@ func CharmtonePantera() Styles {
 		error:             charmtone.Sriracha,
 		warningSubtle:     charmtone.Zest,
 		warning:           charmtone.Mustard,
+		denied:            charmtone.Tang,
 		busy:              charmtone.Citron,
 		info:              charmtone.Malibu,
 		infoMoreSubtle:    charmtone.Sardine,
@@ -76,6 +77,7 @@ func HypercrushObsidiana() Styles {
 		error:             charmtone.Sriracha,
 		warningSubtle:     charmtone.Zest,
 		warning:           charmtone.Mustard,
+		denied:            charmtone.Tang,
 		busy:              charmtone.Citron,
 		info:              charmtone.Malibu,
 		infoMoreSubtle:    charmtone.Sardine,