Skip to content

Commit 58e478d

Browse files
committed
refactor(tui): simplify tool factory and consolidate render helpers
Reduce boilerplate and surface area in pkg/tui/components/tool without changing behavior. Build, lint, and the full test suite still pass. - tool: collapse the Registry+Factory abstraction into a single package-level lookup table in factory.go and delete registry.go. The previous types (Registry, Factory, Registration, NewRegistry, NewFactory, the RWMutex) were never instantiated externally and added indirection for a static map. Resulting tool.New is the same API as before. - toolcommon: introduce NoArgsRenderer for tools that only need the "icon + name" header (user_prompt, todo_*). userprompt and todotool/component.go now collapse to a one-line New(). - toolcommon: add Pluralize helper and use it from listdirectory, directorytree, and searchfilescontent (each had its own copy/variant). The unit test moves to toolcommon alongside the helper. - toolcommon: drop the unused Base getters (Message, SessionState, Width, Height, Spinner). All renderers receive these directly via the Renderer signature. Assisted-By: docker-agent
1 parent b0b1b12 commit 58e478d

11 files changed

Lines changed: 103 additions & 221 deletions

File tree

pkg/tui/components/tool/directorytree/directorytree.go

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package directorytree
22

33
import (
4-
"fmt"
54
"strings"
65

76
"github.com/docker/docker-agent/pkg/tools/builtin"
@@ -27,18 +26,16 @@ func extractResult(msg *types.Message) string {
2726
return ""
2827
}
2928

30-
fileCount := meta.FileCount
31-
dirCount := meta.DirCount
32-
if fileCount+dirCount == 0 {
29+
if meta.FileCount+meta.DirCount == 0 {
3330
return "empty"
3431
}
3532

3633
var parts []string
37-
if fileCount > 0 {
38-
parts = append(parts, formatCount(fileCount, "file", "files"))
34+
if meta.FileCount > 0 {
35+
parts = append(parts, toolcommon.Pluralize(meta.FileCount, "file", "files"))
3936
}
40-
if dirCount > 0 {
41-
parts = append(parts, formatCount(dirCount, "dir", "dirs"))
37+
if meta.DirCount > 0 {
38+
parts = append(parts, toolcommon.Pluralize(meta.DirCount, "dir", "dirs"))
4239
}
4340

4441
result := strings.Join(parts, ", ")
@@ -47,10 +44,3 @@ func extractResult(msg *types.Message) string {
4744
}
4845
return result
4946
}
50-
51-
func formatCount(count int, singular, plural string) string {
52-
if count == 1 {
53-
return fmt.Sprintf("%d %s", count, singular)
54-
}
55-
return fmt.Sprintf("%d %s", count, plural)
56-
}

pkg/tui/components/tool/factory.go

Lines changed: 35 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
// Package tool builds the TUI view for a tool call message.
2+
//
3+
// A small lookup table (builders) maps each tool's name to a constructor.
4+
// Lookup order is: exact tool name, then "category:<category>", then a
5+
// defaulttool fallback.
16
package tool
27

38
import (
@@ -21,74 +26,41 @@ import (
2126
"github.com/docker/docker-agent/pkg/tui/types"
2227
)
2328

24-
// Factory creates tool components using the registry.
25-
// It looks up registered component builders and falls back to a default component
26-
// if no specific builder is registered for a tool.
27-
type Factory struct {
28-
registry *Registry
29-
}
29+
// builder constructs the layout.Model for a tool message.
30+
type builder func(msg *types.Message, sessionState service.SessionStateReader) layout.Model
3031

31-
func NewFactory(registry *Registry) *Factory {
32-
return &Factory{
33-
registry: registry,
34-
}
32+
// builders maps a tool name (or a "category:<name>" key) to its renderer.
33+
// Tools sharing the same visual representation point at the same builder.
34+
var builders = map[string]builder{
35+
builtin.ToolNameTransferTask: transfertask.New,
36+
builtin.ToolNameHandoff: handoff.New,
37+
builtin.ToolNameEditFile: editfile.New,
38+
builtin.ToolNameWriteFile: writefile.New,
39+
builtin.ToolNameReadFile: readfile.New,
40+
builtin.ToolNameReadMultipleFiles: readmultiplefiles.New,
41+
builtin.ToolNameListDirectory: listdirectory.New,
42+
builtin.ToolNameDirectoryTree: directorytree.New,
43+
builtin.ToolNameSearchFilesContent: searchfilescontent.New,
44+
builtin.ToolNameShell: shell.New,
45+
builtin.ToolNameUserPrompt: userprompt.New,
46+
builtin.ToolNameFetch: api.New,
47+
"category:api": api.New,
48+
builtin.ToolNameCreateTodo: todotool.New,
49+
builtin.ToolNameCreateTodos: todotool.New,
50+
builtin.ToolNameUpdateTodos: todotool.New,
51+
builtin.ToolNameListTodos: todotool.New,
3552
}
3653

37-
func (f *Factory) Create(msg *types.Message, sessionState service.SessionStateReader) layout.Model {
38-
toolName := msg.ToolCall.Function.Name
39-
40-
// First try to match by exact tool name
41-
if builder, ok := f.registry.Get(toolName); ok {
42-
return builder(msg, sessionState)
54+
// New returns the appropriate tool view for the given message.
55+
// Lookup order: exact tool name, then "category:<category>", then default.
56+
func New(msg *types.Message, sessionState service.SessionStateReader) layout.Model {
57+
if b, ok := builders[msg.ToolCall.Function.Name]; ok {
58+
return b(msg, sessionState)
4359
}
44-
45-
// Then try to match by category
46-
if msg.ToolDefinition.Category != "" {
47-
if builder, ok := f.registry.Get("category:" + msg.ToolDefinition.Category); ok {
48-
return builder(msg, sessionState)
60+
if cat := msg.ToolDefinition.Category; cat != "" {
61+
if b, ok := builders["category:"+cat]; ok {
62+
return b(msg, sessionState)
4963
}
5064
}
51-
5265
return defaulttool.New(msg, sessionState)
5366
}
54-
55-
var (
56-
defaultRegistry = newDefaultRegistry()
57-
defaultFactory = NewFactory(defaultRegistry)
58-
)
59-
60-
func newDefaultRegistry() *Registry {
61-
registry := NewRegistry()
62-
63-
// Define tool registrations declaratively.
64-
// Tools with the same visual representation share a builder.
65-
registry.Register([]Registration{
66-
{[]string{builtin.ToolNameTransferTask}, transfertask.New},
67-
{[]string{builtin.ToolNameHandoff}, handoff.New},
68-
{[]string{builtin.ToolNameEditFile}, editfile.New},
69-
{[]string{builtin.ToolNameWriteFile}, writefile.New},
70-
{[]string{builtin.ToolNameReadFile}, readfile.New},
71-
{[]string{builtin.ToolNameReadMultipleFiles}, readmultiplefiles.New},
72-
{[]string{builtin.ToolNameListDirectory}, listdirectory.New},
73-
{[]string{builtin.ToolNameDirectoryTree}, directorytree.New},
74-
{[]string{builtin.ToolNameSearchFilesContent}, searchfilescontent.New},
75-
{[]string{builtin.ToolNameShell}, shell.New},
76-
{[]string{builtin.ToolNameUserPrompt}, userprompt.New},
77-
{[]string{builtin.ToolNameFetch, "category:api"}, api.New},
78-
{
79-
[]string{
80-
builtin.ToolNameCreateTodo,
81-
builtin.ToolNameCreateTodos,
82-
builtin.ToolNameUpdateTodos,
83-
builtin.ToolNameListTodos,
84-
},
85-
todotool.New,
86-
},
87-
})
88-
89-
return registry
90-
}
91-
92-
func New(msg *types.Message, sessionState service.SessionStateReader) layout.Model {
93-
return defaultFactory.Create(msg, sessionState)
94-
}

pkg/tui/components/tool/listdirectory/listdirectory.go

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package listdirectory
22

33
import (
4-
"fmt"
54
"strings"
65

76
"github.com/docker/docker-agent/pkg/tools/builtin"
@@ -35,10 +34,10 @@ func extractResult(msg *types.Message) string {
3534

3635
var parts []string
3736
if fileCount > 0 {
38-
parts = append(parts, formatCount(fileCount, "file", "files"))
37+
parts = append(parts, toolcommon.Pluralize(fileCount, "file", "files"))
3938
}
4039
if dirCount > 0 {
41-
parts = append(parts, formatCount(dirCount, "directory", "directories"))
40+
parts = append(parts, toolcommon.Pluralize(dirCount, "directory", "directories"))
4241
}
4342

4443
result := strings.Join(parts, " and ")
@@ -47,11 +46,3 @@ func extractResult(msg *types.Message) string {
4746
}
4847
return result
4948
}
50-
51-
// formatCount returns a formatted count with proper singular/plural form.
52-
func formatCount(count int, singular, plural string) string {
53-
if count == 1 {
54-
return fmt.Sprintf("%d %s", count, singular)
55-
}
56-
return fmt.Sprintf("%d %s", count, plural)
57-
}

pkg/tui/components/tool/listdirectory/listdirectory_test.go

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -71,32 +71,6 @@ func TestExtractResult(t *testing.T) {
7171
}
7272
}
7373

74-
func TestFormatCount(t *testing.T) {
75-
tests := []struct {
76-
count int
77-
singular string
78-
plural string
79-
expected string
80-
}{
81-
{0, "file", "files", "0 files"},
82-
{1, "file", "files", "1 file"},
83-
{2, "file", "files", "2 files"},
84-
{100, "file", "files", "100 files"},
85-
{1, "directory", "directories", "1 directory"},
86-
{2, "directory", "directories", "2 directories"},
87-
}
88-
89-
for _, tt := range tests {
90-
t.Run(tt.expected, func(t *testing.T) {
91-
result := formatCount(tt.count, tt.singular, tt.plural)
92-
if result != tt.expected {
93-
t.Errorf("formatCount(%d, %q, %q) = %q, want %q",
94-
tt.count, tt.singular, tt.plural, result, tt.expected)
95-
}
96-
})
97-
}
98-
}
99-
10074
func TestShortenPath(t *testing.T) {
10175
tests := []struct {
10276
name string

pkg/tui/components/tool/registry.go

Lines changed: 0 additions & 53 deletions
This file was deleted.

pkg/tui/components/tool/searchfilescontent/searchfilescontent.go

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,8 @@ func extractResult(msg *types.Message) string {
4848
return "no matches"
4949
}
5050

51-
matchWord := "match"
52-
if meta.MatchCount != 1 {
53-
matchWord = "matches"
54-
}
55-
56-
fileWord := "file"
57-
if meta.FileCount != 1 {
58-
fileWord = "files"
59-
}
60-
61-
return fmt.Sprintf("%d %s in %d %s", meta.MatchCount, matchWord, meta.FileCount, fileWord)
51+
return fmt.Sprintf("%s in %s",
52+
toolcommon.Pluralize(meta.MatchCount, "match", "matches"),
53+
toolcommon.Pluralize(meta.FileCount, "file", "files"),
54+
)
6255
}
Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package todotool
22

33
import (
4-
"github.com/docker/docker-agent/pkg/tui/components/spinner"
54
"github.com/docker/docker-agent/pkg/tui/components/toolcommon"
65
"github.com/docker/docker-agent/pkg/tui/core/layout"
76
"github.com/docker/docker-agent/pkg/tui/service"
@@ -10,11 +9,8 @@ import (
109

1110
// New creates a new unified todo component.
1211
// This component handles create, create_multiple, list, and update operations.
13-
// The TODOs are displayed in the sidebar.
12+
// The TODOs themselves are displayed in the sidebar; here we only show the
13+
// tool call header (icon + name).
1414
func New(msg *types.Message, sessionState service.SessionStateReader) layout.Model {
15-
return toolcommon.NewBase(msg, sessionState, render)
16-
}
17-
18-
func render(msg *types.Message, s spinner.Spinner, sessionState service.SessionStateReader, width, _ int) string {
19-
return toolcommon.RenderTool(msg, s, "", "", width, sessionState.HideToolResults())
15+
return toolcommon.NewBase(msg, sessionState, toolcommon.NoArgsRenderer)
2016
}
Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,12 @@
11
package userprompt
22

33
import (
4-
"github.com/docker/docker-agent/pkg/tui/components/spinner"
54
"github.com/docker/docker-agent/pkg/tui/components/toolcommon"
65
"github.com/docker/docker-agent/pkg/tui/core/layout"
76
"github.com/docker/docker-agent/pkg/tui/service"
87
"github.com/docker/docker-agent/pkg/tui/types"
98
)
109

11-
// New creates a component for the user_prompt tool call.
12-
// It intentionally does not render the tool call's arguments (the question,
13-
// title or schema). It only indicates that a question is being asked to the
14-
// user, via the tool's status icon and display name.
1510
func New(msg *types.Message, sessionState service.SessionStateReader) layout.Model {
16-
return toolcommon.NewBase(msg, sessionState, render)
17-
}
18-
19-
func render(msg *types.Message, s spinner.Spinner, sessionState service.SessionStateReader, width, _ int) string {
20-
return toolcommon.RenderTool(msg, s, "", "", width, sessionState.HideToolResults())
11+
return toolcommon.NewBase(msg, sessionState, toolcommon.NoArgsRenderer)
2112
}

pkg/tui/components/toolcommon/base.go

Lines changed: 7 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -58,31 +58,6 @@ func NewBaseWithCollapsed(msg *types.Message, sessionState service.SessionStateR
5858
}
5959
}
6060

61-
// Message returns the tool message.
62-
func (b *Base) Message() *types.Message {
63-
return b.message
64-
}
65-
66-
// SessionState returns the session state reader.
67-
func (b *Base) SessionState() service.SessionStateReader {
68-
return b.sessionState
69-
}
70-
71-
// Width returns the current width.
72-
func (b *Base) Width() int {
73-
return b.width
74-
}
75-
76-
// Height returns the current height.
77-
func (b *Base) Height() int {
78-
return b.height
79-
}
80-
81-
// Spinner returns the spinner.
82-
func (b *Base) Spinner() spinner.Spinner {
83-
return b.spinner
84-
}
85-
8661
func (b *Base) SetSize(width, height int) tea.Cmd {
8762
b.width = width
8863
b.height = height
@@ -150,6 +125,13 @@ func (b *Base) isSpinnerActive() bool {
150125
b.message.ToolStatus == types.ToolStatusRunning
151126
}
152127

128+
// NoArgsRenderer is a Renderer that displays only the tool name and status,
129+
// without arguments or a result. Useful for tools whose arguments aren't
130+
// worth surfacing in the UI (e.g. user_prompt, todo helpers).
131+
func NoArgsRenderer(msg *types.Message, s spinner.Spinner, sessionState service.SessionStateReader, width, _ int) string {
132+
return RenderTool(msg, s, "", "", width, sessionState.HideToolResults())
133+
}
134+
153135
// SimpleRenderer creates a renderer that extracts a single string argument
154136
// and renders it with RenderTool. This covers the most common case where
155137
// tools just display one argument (like path, command, etc.).

0 commit comments

Comments
 (0)