Skip to content

Commit ec1aaa0

Browse files
authored
Merge pull request docker#1496 from krissetto/improve-diff-rendering
Cache file reads for diff rendering
2 parents 8f9cdda + df8514b commit ec1aaa0

File tree

5 files changed

+201
-3
lines changed

5 files changed

+201
-3
lines changed

pkg/tui/components/reasoningblock/reasoningblock.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -519,7 +519,13 @@ func (m *Model) renderCollapsed() string {
519519
if len(visibleTools) > 0 {
520520
parts = append(parts, "") // blank line before tools
521521
for _, entry := range visibleTools {
522-
toolView := entry.view.View()
522+
// Prefer CollapsedView() for simplified rendering in collapsed state
523+
var toolView string
524+
if cv, ok := entry.view.(layout.CollapsedViewer); ok {
525+
toolView = cv.CollapsedView()
526+
} else {
527+
toolView = entry.view.View()
528+
}
523529
if entry.fadeProgress > 0 {
524530
// Strip existing ANSI codes and apply faded color based on progress
525531
// (wrapping styled content doesn't override inner colors)

pkg/tui/components/tool/editfile/editfile.go

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ type ToggleDiffViewMsg struct{}
1717

1818
// New creates the edit_file tool UI model.
1919
func New(msg *types.Message, sessionState *service.SessionState) layout.Model {
20-
return toolcommon.NewBase(msg, sessionState, render)
20+
return toolcommon.NewBaseWithCollapsed(msg, sessionState, render, renderCollapsed)
2121
}
2222

2323
// render displays the edit_file tool output in the TUI.
@@ -101,3 +101,51 @@ func render(
101101

102102
return content
103103
}
104+
105+
// renderCollapsed renders a simplified view for collapsed reasoning blocks.
106+
// Shows only the file path and +N / -M line counts.
107+
func renderCollapsed(
108+
msg *types.Message,
109+
s spinner.Spinner,
110+
_ *service.SessionState,
111+
width,
112+
_ int,
113+
) string {
114+
var args builtin.EditFileArgs
115+
if err := json.Unmarshal([]byte(msg.ToolCall.Function.Arguments), &args); err != nil {
116+
return ""
117+
}
118+
119+
// Error state
120+
if msg.ToolStatus == types.ToolStatusError {
121+
if msg.Content == "" {
122+
return ""
123+
}
124+
line := fmt.Sprintf(
125+
"%s%s %s",
126+
toolcommon.Icon(msg, s),
127+
styles.ToolNameError.Render(msg.ToolDefinition.DisplayName()),
128+
styles.ToolErrorMessageStyle.Render(msg.Content),
129+
)
130+
return styles.BaseStyle.MaxWidth(width).Render(line)
131+
}
132+
133+
// Count added/removed lines
134+
added, removed := countDiffLines(msg.ToolCall, msg.ToolStatus)
135+
var diffSummary string
136+
if added > 0 || removed > 0 {
137+
addStr := styles.DiffAddStyle.Render(fmt.Sprintf("+%d", added))
138+
remStr := styles.DiffRemoveStyle.Render(fmt.Sprintf("-%d", removed))
139+
diffSummary = fmt.Sprintf(" %s / %s", addStr, remStr)
140+
}
141+
142+
line := fmt.Sprintf(
143+
"%s%s %s%s",
144+
toolcommon.Icon(msg, s),
145+
styles.ToolName.Render(msg.ToolDefinition.DisplayName()),
146+
styles.ToolMessageStyle.Render(toolcommon.ShortenPath(args.Path)),
147+
diffSummary,
148+
)
149+
150+
return styles.BaseStyle.MaxWidth(width).Render(line)
151+
}

pkg/tui/components/tool/editfile/render.go

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,24 @@ const (
2727
minWidth = 80
2828
)
2929

30+
type toolRenderCache struct {
31+
// Line counts - computed once, never change
32+
added int
33+
removed int
34+
lineCounted bool
35+
36+
// Rendered output - invalidated when width/splitView/status changes
37+
rendered string
38+
renderCached bool
39+
renderedWidth int
40+
renderedSplit bool
41+
renderedStatus types.ToolStatus
42+
}
43+
3044
var (
45+
cache = make(map[string]*toolRenderCache) // keyed by toolCallID
46+
cacheMu sync.RWMutex
47+
3148
lexerCache = make(map[string]chroma.Lexer)
3249
lexerCacheMu sync.RWMutex
3350
)
@@ -44,7 +61,53 @@ type linePair struct {
4461
newLineNum int
4562
}
4663

64+
func getOrCreateCache(toolCallID string) *toolRenderCache {
65+
cacheMu.RLock()
66+
if c, ok := cache[toolCallID]; ok {
67+
cacheMu.RUnlock()
68+
return c
69+
}
70+
cacheMu.RUnlock()
71+
72+
cacheMu.Lock()
73+
defer cacheMu.Unlock()
74+
// Double-check after acquiring write lock
75+
if c, ok := cache[toolCallID]; ok {
76+
return c
77+
}
78+
c := &toolRenderCache{}
79+
cache[toolCallID] = c
80+
return c
81+
}
82+
4783
func renderEditFile(toolCall tools.ToolCall, width int, splitView bool, toolStatus types.ToolStatus) string {
84+
c := getOrCreateCache(toolCall.ID)
85+
86+
cacheMu.RLock()
87+
if c.renderCached &&
88+
c.renderedWidth == width &&
89+
c.renderedSplit == splitView &&
90+
c.renderedStatus == toolStatus {
91+
result := c.rendered
92+
cacheMu.RUnlock()
93+
return result
94+
}
95+
cacheMu.RUnlock()
96+
97+
result := renderEditFileUncached(toolCall, width, splitView, toolStatus)
98+
99+
cacheMu.Lock()
100+
c.rendered = result
101+
c.renderCached = true
102+
c.renderedWidth = width
103+
c.renderedSplit = splitView
104+
c.renderedStatus = toolStatus
105+
cacheMu.Unlock()
106+
107+
return result
108+
}
109+
110+
func renderEditFileUncached(toolCall tools.ToolCall, width int, splitView bool, toolStatus types.ToolStatus) string {
48111
var args builtin.EditFileArgs
49112
if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil {
50113
return ""
@@ -71,6 +134,56 @@ func renderEditFile(toolCall tools.ToolCall, width int, splitView bool, toolStat
71134
return output.String()
72135
}
73136

137+
// countDiffLines returns the number of added and removed lines for the edit.
138+
// Results are cached per tool call since arguments are immutable.
139+
func countDiffLines(toolCall tools.ToolCall, _ types.ToolStatus) (added, removed int) {
140+
c := getOrCreateCache(toolCall.ID)
141+
142+
cacheMu.RLock()
143+
if c.lineCounted {
144+
added, removed = c.added, c.removed
145+
cacheMu.RUnlock()
146+
return added, removed
147+
}
148+
cacheMu.RUnlock()
149+
150+
added, removed = countDiffLinesUncached(toolCall)
151+
152+
cacheMu.Lock()
153+
c.added = added
154+
c.removed = removed
155+
c.lineCounted = true
156+
cacheMu.Unlock()
157+
158+
return added, removed
159+
}
160+
161+
func countDiffLinesUncached(toolCall tools.ToolCall) (added, removed int) {
162+
var args builtin.EditFileArgs
163+
if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil {
164+
return 0, 0
165+
}
166+
167+
for _, edit := range args.Edits {
168+
edits := udiff.Strings(edit.OldText, edit.NewText)
169+
diff, err := udiff.ToUnifiedDiff("old", "new", edit.OldText, edits, 0)
170+
if err != nil {
171+
continue
172+
}
173+
for _, hunk := range diff.Hunks {
174+
for _, line := range hunk.Lines {
175+
switch line.Kind {
176+
case udiff.Insert:
177+
added++
178+
case udiff.Delete:
179+
removed++
180+
}
181+
}
182+
}
183+
}
184+
return added, removed
185+
}
186+
74187
func computeDiff(path, oldText, newText string, toolStatus types.ToolStatus) []*udiff.Hunk {
75188
currentContent, err := os.ReadFile(path)
76189
if err != nil {
@@ -91,7 +204,6 @@ func computeDiff(path, oldText, newText string, toolStatus types.ToolStatus) []*
91204
oldContent = strings.Replace(newContent, newText, oldText, 1)
92205
}
93206

94-
// Now compute diff between old and new
95207
edits := udiff.Strings(oldContent, newContent)
96208

97209
diff, err := udiff.ToUnifiedDiff("old", "new", oldContent, edits, 3)

pkg/tui/components/toolcommon/base.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import (
1414
// It receives the message, spinner, session state, and available width/height.
1515
type Renderer func(msg *types.Message, s spinner.Spinner, sessionState *service.SessionState, width, height int) string
1616

17+
// CollapsedRenderer is a function that renders a simplified view for collapsed reasoning blocks.
18+
type CollapsedRenderer func(msg *types.Message, s spinner.Spinner, sessionState *service.SessionState, width, height int) string
19+
1720
// Base provides common boilerplate for tool components.
1821
// It handles spinner management, sizing, and delegates rendering to a custom function.
1922
type Base struct {
@@ -23,6 +26,7 @@ type Base struct {
2326
height int
2427
sessionState *service.SessionState
2528
render Renderer
29+
collapsedRenderer CollapsedRenderer
2630
spinnerRegistered bool // tracks whether spinner is registered with coordinator
2731
}
2832

@@ -38,6 +42,19 @@ func NewBase(msg *types.Message, sessionState *service.SessionState, render Rend
3842
}
3943
}
4044

45+
// NewBaseWithCollapsed creates a new base tool component with both regular and collapsed renderers.
46+
func NewBaseWithCollapsed(msg *types.Message, sessionState *service.SessionState, render Renderer, collapsedRender CollapsedRenderer) *Base {
47+
return &Base{
48+
message: msg,
49+
spinner: spinner.New(spinner.ModeSpinnerOnly, styles.SpinnerDotsAccentStyle),
50+
width: 80,
51+
height: 1,
52+
sessionState: sessionState,
53+
render: render,
54+
collapsedRenderer: collapsedRender,
55+
}
56+
}
57+
4158
// Message returns the tool message.
4259
func (b *Base) Message() *types.Message {
4360
return b.message
@@ -107,6 +124,15 @@ func (b *Base) View() string {
107124
return b.render(b.message, b.spinner, b.sessionState, b.width, b.height)
108125
}
109126

127+
// CollapsedView returns a simplified view for use in collapsed reasoning blocks.
128+
// Falls back to the regular View() if no collapsed renderer is provided.
129+
func (b *Base) CollapsedView() string {
130+
if b.collapsedRenderer != nil {
131+
return b.collapsedRenderer(b.message, b.spinner, b.sessionState, b.width, b.height)
132+
}
133+
return b.View()
134+
}
135+
110136
func (b *Base) isSpinnerActive() bool {
111137
return b.message.ToolStatus == types.ToolStatusPending ||
112138
b.message.ToolStatus == types.ToolStatusRunning

pkg/tui/core/layout/layout.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,9 @@ type Model interface {
3434
View() string
3535
Sizeable
3636
}
37+
38+
// CollapsedViewer is implemented by components that provide a simplified view
39+
// for use in collapsed reasoning blocks.
40+
type CollapsedViewer interface {
41+
CollapsedView() string
42+
}

0 commit comments

Comments
 (0)