feat(tui): show changed file count on multi-file tool results (#238)
- commit
- 4c37e30c2bfbe51787227b5eaad34461209a68d1
- author
- bnema · · 2026-09-30 16:24 UTC
- committer
- GitHub · · 2026-09-30 16:24 UTC
- parent
- 4ce882c
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
2 files · +46 -0
M packages/tui/view.go +10 -0
| @@ -1127,6 +1127,7 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||
| 1127 | 1127 | if v.toolCallLabels != nil { | |
| 1128 | 1128 | label = v.toolCallLabels[tr.CallID] | |
| 1129 | 1129 | } | |
| 1130 | + | label += mutatedFilesSuffix(tr.Context.Mutates) | |
| 1130 | 1131 | // Each tool result owns a complete box: top edge with | |
| 1131 | 1132 | // the call's label, body rows wrapped in vertical | |
| 1132 | 1133 | // edges, bottom edge to close. The label is looked up | |
| @@ -2534,6 +2535,15 @@ func (v *View) renderBashResult(lines []string, width int, defaultColor Terminal | |||
| 2534 | 2535 | return out | |
| 2535 | 2536 | } | |
| 2536 | 2537 | ||
| 2538 | + | // mutatedFilesSuffix labels a tool result that changed several files, so a | |
| 2539 | + | // project-wide rewrite shows its scope. Single-file edits stay unlabeled. | |
| 2540 | + | func mutatedFilesSuffix(paths []string) string { | |
| 2541 | + | if len(paths) < 2 { | |
| 2542 | + | return "" | |
| 2543 | + | } | |
| 2544 | + | return fmt.Sprintf(" · %d files changed", len(paths)) | |
| 2545 | + | } | |
| 2546 | + | ||
| 2537 | 2547 | // astRewriteArgs reports whether a completed AST call requested mutation. | |
| 2538 | 2548 | func astRewriteArgs(name string, raw json.RawMessage) bool { | |
| 2539 | 2549 | if !strings.EqualFold(name, "ast") { | |
| @@ -1127,6 +1127,7 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||||
| 1127 | if v.toolCallLabels != nil { | 1127 | if v.toolCallLabels != nil { | ||
| 1128 | label = v.toolCallLabels[tr.CallID] | 1128 | label = v.toolCallLabels[tr.CallID] | ||
| 1129 | } | 1129 | } | ||
| 1130 | + | label += mutatedFilesSuffix(tr.Context.Mutates) | |||
| 1130 | // Each tool result owns a complete box: top edge with | 1131 | // Each tool result owns a complete box: top edge with | ||
| 1131 | // the call's label, body rows wrapped in vertical | 1132 | // the call's label, body rows wrapped in vertical | ||
| 1132 | // edges, bottom edge to close. The label is looked up | 1133 | // edges, bottom edge to close. The label is looked up | ||
| @@ -2534,6 +2535,15 @@ func (v *View) renderBashResult(lines []string, width int, defaultColor Terminal | |||||
| 2534 | return out | 2535 | return out | ||
| 2535 | } | 2536 | } | ||
| 2536 | 2537 | ||||
| 2538 | + | // mutatedFilesSuffix labels a tool result that changed several files, so a | |||
| 2539 | + | // project-wide rewrite shows its scope. Single-file edits stay unlabeled. | |||
| 2540 | + | func mutatedFilesSuffix(paths []string) string { | |||
| 2541 | + | if len(paths) < 2 { | |||
| 2542 | + | return "" | |||
| 2543 | + | } | |||
| 2544 | + | return fmt.Sprintf(" · %d files changed", len(paths)) | |||
| 2545 | + | } | |||
| 2546 | + | ||||
| 2537 | // astRewriteArgs reports whether a completed AST call requested mutation. | 2547 | // astRewriteArgs reports whether a completed AST call requested mutation. | ||
| 2538 | func astRewriteArgs(name string, raw json.RawMessage) bool { | 2548 | func astRewriteArgs(name string, raw json.RawMessage) bool { | ||
| 2539 | if !strings.EqualFold(name, "ast") { | 2549 | if !strings.EqualFold(name, "ast") { | ||
M packages/tui/view_tool_overlay_test.go +36 -0
| @@ -218,6 +218,42 @@ func TestCompletedASTRewriteRendersPersistentDiff(t *testing.T) { | |||
| 218 | 218 | } | |
| 219 | 219 | } | |
| 220 | 220 | ||
| 221 | + | func TestMultiFileRewriteLabelShowsChangedFileCount(t *testing.T) { | |
| 222 | + | args := json.RawMessage(`{"pattern":"old($A)","language":"go","path":"src","rewrite":"new($A)"}`) | |
| 223 | + | for _, tc := range []struct { | |
| 224 | + | name string | |
| 225 | + | mutates []string | |
| 226 | + | want bool | |
| 227 | + | }{ | |
| 228 | + | {"several files", []string{"src/a.go", "src/b.go", "src/c.go"}, true}, | |
| 229 | + | {"single file", []string{"src/a.go"}, false}, | |
| 230 | + | } { | |
| 231 | + | t.Run(tc.name, func(t *testing.T) { | |
| 232 | + | for _, flat := range []bool{false, true} { | |
| 233 | + | v := View{ | |
| 234 | + | Theme: Dark, | |
| 235 | + | FlatTools: flat, | |
| 236 | + | Messages: []provider.Message{ | |
| 237 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "toolu_ast", Name: "ast", Arguments: args}}}, | |
| 238 | + | {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{ | |
| 239 | + | CallID: "toolu_ast", | |
| 240 | + | Content: []provider.Content{provider.TextBlock{Text: "-old(value)\n+new(value)\n"}}, | |
| 241 | + | Context: provider.ToolContext{Mutates: tc.mutates}, | |
| 242 | + | }}}, | |
| 243 | + | }, | |
| 244 | + | } | |
| 245 | + | plain := stripANSI(strings.Join(v.Build(80), "\n")) | |
| 246 | + | if got := strings.Contains(plain, "ast src · 3 files changed"); got != tc.want { | |
| 247 | + | t.Fatalf("flat=%v: file count shown = %v, want %v:\n%s", flat, got, tc.want, plain) | |
| 248 | + | } | |
| 249 | + | if strings.Contains(plain, "files changed") != tc.want { | |
| 250 | + | t.Fatalf("flat=%v: unexpected file count label:\n%s", flat, plain) | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | }) | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | ||
| 221 | 257 | func TestASTSearchResultIsNotForcedIntoDiffRendering(t *testing.T) { | |
| 222 | 258 | args := json.RawMessage(`{"pattern":"old($A)","language":"go","path":"sample.go"}`) | |
| 223 | 259 | v := View{ | |
| @@ -218,6 +218,42 @@ func TestCompletedASTRewriteRendersPersistentDiff(t *testing.T) { | |||||
| 218 | } | 218 | } | ||
| 219 | } | 219 | } | ||
| 220 | 220 | ||||
| 221 | + | func TestMultiFileRewriteLabelShowsChangedFileCount(t *testing.T) { | |||
| 222 | + | args := json.RawMessage(`{"pattern":"old($A)","language":"go","path":"src","rewrite":"new($A)"}`) | |||
| 223 | + | for _, tc := range []struct { | |||
| 224 | + | name string | |||
| 225 | + | mutates []string | |||
| 226 | + | want bool | |||
| 227 | + | }{ | |||
| 228 | + | {"several files", []string{"src/a.go", "src/b.go", "src/c.go"}, true}, | |||
| 229 | + | {"single file", []string{"src/a.go"}, false}, | |||
| 230 | + | } { | |||
| 231 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 232 | + | for _, flat := range []bool{false, true} { | |||
| 233 | + | v := View{ | |||
| 234 | + | Theme: Dark, | |||
| 235 | + | FlatTools: flat, | |||
| 236 | + | Messages: []provider.Message{ | |||
| 237 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "toolu_ast", Name: "ast", Arguments: args}}}, | |||
| 238 | + | {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{ | |||
| 239 | + | CallID: "toolu_ast", | |||
| 240 | + | Content: []provider.Content{provider.TextBlock{Text: "-old(value)\n+new(value)\n"}}, | |||
| 241 | + | Context: provider.ToolContext{Mutates: tc.mutates}, | |||
| 242 | + | }}}, | |||
| 243 | + | }, | |||
| 244 | + | } | |||
| 245 | + | plain := stripANSI(strings.Join(v.Build(80), "\n")) | |||
| 246 | + | if got := strings.Contains(plain, "ast src · 3 files changed"); got != tc.want { | |||
| 247 | + | t.Fatalf("flat=%v: file count shown = %v, want %v:\n%s", flat, got, tc.want, plain) | |||
| 248 | + | } | |||
| 249 | + | if strings.Contains(plain, "files changed") != tc.want { | |||
| 250 | + | t.Fatalf("flat=%v: unexpected file count label:\n%s", flat, plain) | |||
| 251 | + | } | |||
| 252 | + | } | |||
| 253 | + | }) | |||
| 254 | + | } | |||
| 255 | + | } | |||
| 256 | + | ||||
| 221 | func TestASTSearchResultIsNotForcedIntoDiffRendering(t *testing.T) { | 257 | func TestASTSearchResultIsNotForcedIntoDiffRendering(t *testing.T) { | ||
| 222 | args := json.RawMessage(`{"pattern":"old($A)","language":"go","path":"sample.go"}`) | 258 | args := json.RawMessage(`{"pattern":"old($A)","language":"go","path":"sample.go"}`) | ||
| 223 | v := View{ | 259 | v := View{ | ||