Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pkg/tui/components/tool/editfile/editfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func render(msg *types.Message, s spinner.Spinner, sessionState *service.Session
if msg.ToolCall.Function.Arguments != "" {
contentWidth := width - styles.ToolCallResult.GetHorizontalFrameSize()
content += "\n" + styles.ToolCallResult.Render(
renderEditFile(msg.ToolCall, contentWidth, sessionState.SplitDiffView(), msg.ToolStatus))
renderEditFile(msg.ToolCall, contentWidth, sessionState.SplitDiffView(), msg.ToolStatus, sessionState.InConfirmationDialog()))
}

if (msg.ToolStatus == types.ToolStatusError) && msg.Content != "" {
Expand Down
96 changes: 91 additions & 5 deletions pkg/tui/components/tool/editfile/render.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package editfile

import (
"crypto/sha256"
"encoding/json"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
Expand Down Expand Up @@ -44,12 +47,13 @@ type linePair struct {
newLineNum int
}

func renderEditFile(toolCall tools.ToolCall, width int, splitView bool, toolStatus types.ToolStatus) string {
var args builtin.EditFileArgs
if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil {
return ""
}
type editFileRenderer interface {
Render(args builtin.EditFileArgs, width int, splitView bool, toolStatus types.ToolStatus) string
}

type builtinEditFileRenderer struct{}

func (r *builtinEditFileRenderer) Render(args builtin.EditFileArgs, width int, splitView bool, toolStatus types.ToolStatus) string {
var output strings.Builder
for i, edit := range args.Edits {
if i > 0 {
Expand All @@ -71,6 +75,88 @@ func renderEditFile(toolCall tools.ToolCall, width int, splitView bool, toolStat
return output.String()
}

type vscodeLikeRenderer struct {
externalTool string
}

func externalDiffSignature(externalTool, path string, oldContent, newContent []byte) string {
sha := sha256.New()
sha.Write([]byte(externalTool))
sha.Write([]byte(path))
sha.Write(oldContent)
sha.Write(newContent)
return fmt.Sprintf("%x", sha.Sum(nil))
}

var activeDiffs sync.Map

func ensureExternalDiffShownActive(externalTool, path, diffSignature string, newContent []byte) {
do := func() {
defer activeDiffs.Delete(diffSignature)
tempDir, err := os.MkdirTemp("", "cagent-editfile-*")
if err != nil {
slog.Warn("Error creating temp dir for external diff", "error", err)
return
}
defer os.RemoveAll(tempDir)

tempFilePath := filepath.Join(tempDir, filepath.Base(path))

if err := os.WriteFile(tempFilePath, newContent, 0o644); err != nil {
slog.Warn("Error writing temp file for external diff", "error", err)
return
}
// wd is for diff + wait for editor to close. This makes sure we delete the temp dir only after editing is done.
if err := exec.Command(externalTool, "-wd", path, tempFilePath).Run(); err != nil {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM: No context for command execution

exec.Command(externalTool, "-wd", path, tempFilePath).Run() runs without a context for cancellation. If the editor hangs or the user wants to exit the application, there's no way to terminate this command.

Recommended fix: Use exec.CommandContext(ctx, externalTool, "-wd", path, tempFilePath).Run() with a cancellable context.

slog.Warn("Error running external diff tool", "error", err)
}
}
todo, loaded := activeDiffs.LoadOrStore(diffSignature, do)
// if already loaded, do nothing else, start the diff process
if !loaded {
go todo.(func())()
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM: Goroutine leak - no cancellation mechanism

The goroutine go todo.(func())() launches an external editor process with no way to cancel it. If the application exits while the editor is still open:

  1. The goroutine cannot be stopped
  2. Temp files may not be cleaned up if the process is killed
  3. The application cannot gracefully shut down while editors are open

Recommended fix: Pass a context that can be cancelled on application shutdown. Store running goroutines and cancel them during cleanup.

}

func (r *vscodeLikeRenderer) Render(args builtin.EditFileArgs, width int, splitView bool, toolStatus types.ToolStatus) string {
// assumes we are printing confirmation

// generate a file with all edits applied
originalContent, err := os.ReadFile(args.Path)
if err != nil {
return styles.CenterStyle.Render(fmt.Sprintf("Error reading original file: %v", err))
}
modifiedContent := string(originalContent)
for _, edit := range args.Edits {
modifiedContent = strings.Replace(modifiedContent, edit.OldText, edit.NewText, 1)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 HIGH: Incorrect edit application logic

Applying edits sequentially using strings.Replace(modifiedContent, edit.OldText, edit.NewText, 1) in a loop is incorrect. Each edit is applied to the result of the previous edit, which means:

  1. If edit Remove release/signing workflow #2's OldText is near edit Telemetry #1's changes, it may not be found in the modified content
  2. Edits that overlap or affect nearby regions will produce incorrect results
  3. The order of edits will matter when it shouldn't

This will cause the VSCode diff viewer to show incorrect changes to the user.

Recommended fix: Apply edits based on their original positions in the file, not sequentially. Consider using a proper diff/patch algorithm or applying edits from end-to-beginning to avoid position shifts.

}

diffSignature := externalDiffSignature(r.externalTool, args.Path, originalContent, []byte(modifiedContent))

ensureExternalDiffShownActive(r.externalTool, args.Path, diffSignature, []byte(modifiedContent))

return styles.CenterStyle.Render("Please review changes in your code editor")
}

func resolveEditFileRenderer(toolStatus types.ToolStatus, isInConfirmationDialog bool) editFileRenderer {
if toolStatus == types.ToolStatusConfirmation && isInConfirmationDialog && os.Getenv("TERM_PROGRAM") == "vscode" {
return &vscodeLikeRenderer{
externalTool: "code",
}
}
return &builtinEditFileRenderer{}
}

func renderEditFile(toolCall tools.ToolCall, width int, splitView bool, toolStatus types.ToolStatus, isInConfirmationDialog bool) string {
var args builtin.EditFileArgs
if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil {
return ""
}

renderer := resolveEditFileRenderer(toolStatus, isInConfirmationDialog)
return renderer.Render(args, width, splitView, toolStatus)
}

func computeDiff(path, oldText, newText string, toolStatus types.ToolStatus) []*udiff.Hunk {
currentContent, err := os.ReadFile(path)
if err != nil {
Expand Down
6 changes: 6 additions & 0 deletions pkg/tui/dialog/tool_confirmation.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,15 @@ func NewToolConfirmationDialog(msg *runtime.ToolCallConfirmationEvent, sessionSt

// Init initializes the tool confirmation dialog
func (d *toolConfirmationDialog) Init() tea.Cmd {
undo := d.sessionState.SetInConfirmationDialog()
defer undo()
return d.scrollView.Init()
}

// Update handles messages for the tool confirmation dialog
func (d *toolConfirmationDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
undo := d.sessionState.SetInConfirmationDialog()
defer undo()
switch msg := msg.(type) {
case tea.WindowSizeMsg:
cmd := d.SetSize(msg.Width, msg.Height)
Expand Down Expand Up @@ -170,6 +174,8 @@ func (d *toolConfirmationDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) {

// View renders the tool confirmation dialog
func (d *toolConfirmationDialog) View() string {
undo := d.sessionState.SetInConfirmationDialog()
defer undo()
dialogWidth := d.Width() * 70 / 100

// Content width (accounting for padding and borders)
Expand Down
18 changes: 15 additions & 3 deletions pkg/tui/service/sessionstate.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ type SessionState struct {
hideToolResults bool
sessionTitle string

previousMessage *types.Message
currentAgentName string
availableAgents []runtime.AgentDetails
previousMessage *types.Message
currentAgentName string
availableAgents []runtime.AgentDetails
inConfirmationDialog bool
}

func NewSessionState(s *session.Session) *SessionState {
Expand Down Expand Up @@ -108,3 +109,14 @@ func (s *SessionState) GetCurrentAgent() runtime.AgentDetails {

return runtime.AgentDetails{}
}

func (s *SessionState) InConfirmationDialog() bool {
return s.inConfirmationDialog
}

func (s *SessionState) SetInConfirmationDialog() func() {
s.inConfirmationDialog = true
return func() {
s.inConfirmationDialog = false
}
}