diff --git a/pkg/tui/components/tool/api/apitool.go b/pkg/tui/components/tool/api/apitool.go index 1fa9327884..d48569dc41 100644 --- a/pkg/tui/components/tool/api/apitool.go +++ b/pkg/tui/components/tool/api/apitool.go @@ -15,7 +15,7 @@ import ( ) func New(msg *types.Message, sessionState service.SessionStateReader) layout.Model { - return toolcommon.NewBase(msg, sessionState, render) + return toolcommon.NewBaseWithCollapsed(msg, sessionState, render, renderCollapsed) } func render(msg *types.Message, s spinner.Spinner, sessionState service.SessionStateReader, width, _ int) string { @@ -24,11 +24,7 @@ func render(msg *types.Message, s spinner.Spinner, sessionState service.SessionS return toolcommon.RenderTool(msg, s, "", "", width, sessionState.HideToolResults()) } - // Extract argument summary for the tool call display - var params string - if argsText := formatArgs(args); argsText != "" { - params = "(" + argsText + ")" - } + params := formatParams(args) // Add inline result/progress after the tool name switch msg.ToolStatus { @@ -45,6 +41,22 @@ func render(msg *types.Message, s spinner.Spinner, sessionState service.SessionS return toolcommon.RenderTool(msg, s, params, "", width, sessionState.HideToolResults()) } +func renderCollapsed(msg *types.Message, s spinner.Spinner, sessionState service.SessionStateReader, width, _ int) string { + var args map[string]any + if err := json.Unmarshal([]byte(msg.ToolCall.Function.Arguments), &args); err != nil { + return toolcommon.RenderTool(msg, s, "", "", width, sessionState.HideToolResults()) + } + + return toolcommon.RenderTool(msg, s, formatParams(args), "", width, sessionState.HideToolResults()) +} + +func formatParams(args map[string]any) string { + if argsText := formatArgs(args); argsText != "" { + return "(" + argsText + ")" + } + return "" +} + // extractEndpoint tries to find the endpoint/URL being called. func extractEndpoint(args map[string]any) string { if endpoint, ok := args["endpoint"].(string); ok { diff --git a/pkg/tui/components/tool/defaulttool/defaulttool.go b/pkg/tui/components/tool/defaulttool/defaulttool.go index f74b20aede..0997e8834f 100644 --- a/pkg/tui/components/tool/defaulttool/defaulttool.go +++ b/pkg/tui/components/tool/defaulttool/defaulttool.go @@ -11,7 +11,7 @@ import ( // New creates a new default tool component. // It provides a standard visualization with tool name, arguments, and results. func New(msg *types.Message, sessionState service.SessionStateReader) layout.Model { - return toolcommon.NewBase(msg, sessionState, render) + return toolcommon.NewBaseWithCollapsed(msg, sessionState, render, renderCollapsed) } func render(msg *types.Message, s spinner.Spinner, sessionState service.SessionStateReader, width, _ int) string { @@ -31,3 +31,11 @@ func render(msg *types.Message, s spinner.Spinner, sessionState service.SessionS return toolcommon.RenderTool(msg, s, argsContent, resultContent, width, sessionState.HideToolResults()) } + +func renderCollapsed(msg *types.Message, s spinner.Spinner, sessionState service.SessionStateReader, width, _ int) string { + var argsContent string + if msg.ToolCall.Function.Arguments != "" { + argsContent = renderToolArgsSummary(msg.ToolCall, width-4-len(msg.ToolDefinition.DisplayName())) + } + return toolcommon.RenderTool(msg, s, argsContent, "", width, sessionState.HideToolResults()) +} diff --git a/pkg/tui/components/tool/defaulttool/render.go b/pkg/tui/components/tool/defaulttool/render.go index 31fc20f2e8..73fc64042f 100644 --- a/pkg/tui/components/tool/defaulttool/render.go +++ b/pkg/tui/components/tool/defaulttool/render.go @@ -8,6 +8,7 @@ import ( "charm.land/lipgloss/v2" "github.com/docker/docker-agent/pkg/tools" + "github.com/docker/docker-agent/pkg/tui/components/toolcommon" "github.com/docker/docker-agent/pkg/tui/styles" ) @@ -17,19 +18,7 @@ type kv struct { } func renderToolArgs(toolCall tools.ToolCall, shortWidth, width int) string { - args, err := decodeArguments(toolCall.Function.Arguments) - if err != nil { - return "" - } - - // Filter out the friendly description parameter - filteredArgs := make([]kv, 0, len(args)) - for _, arg := range args { - if arg.Key != tools.DescriptionParam { - filteredArgs = append(filteredArgs, arg) - } - } - + filteredArgs := filteredToolArgs(toolCall) if len(filteredArgs) == 0 { return "" } @@ -58,6 +47,39 @@ func renderToolArgs(toolCall tools.ToolCall, shortWidth, width int) string { return "\n" + styles.ToolCallArgs.Width(width).Render(strings.TrimSuffix(md.String(), "\n")) } +func renderToolArgsSummary(toolCall tools.ToolCall, width int) string { + filteredArgs := filteredToolArgs(toolCall) + if len(filteredArgs) == 0 { + return "" + } + + arg := filteredArgs[0] + summary := fmt.Sprintf("%s=%s", arg.Key, formatValueInline(arg.Value)) + if len(filteredArgs) > 1 { + summary += fmt.Sprintf(" (+%d more)", len(filteredArgs)-1) + } + return toolcommon.TruncateText(summary, max(width, 1)) +} + +func filteredToolArgs(toolCall tools.ToolCall) []kv { + args, err := decodeArguments(toolCall.Function.Arguments) + if err != nil { + return nil + } + + filteredArgs := make([]kv, 0, len(args)) + for _, arg := range args { + if arg.Key != tools.DescriptionParam { + filteredArgs = append(filteredArgs, arg) + } + } + return filteredArgs +} + +func formatValueInline(value any) string { + return strings.Join(strings.Fields(formatValue(value)), " ") +} + // formatValue formats a value for display. // Single-element arrays are kept on one line, while larger arrays are indented. func formatValue(value any) string { diff --git a/pkg/tui/components/tool/directorytree/directorytree.go b/pkg/tui/components/tool/directorytree/directorytree.go index 1be65fabb0..2868e8387c 100644 --- a/pkg/tui/components/tool/directorytree/directorytree.go +++ b/pkg/tui/components/tool/directorytree/directorytree.go @@ -5,17 +5,32 @@ import ( pathx "github.com/docker/docker-agent/pkg/path" "github.com/docker/docker-agent/pkg/tools/builtin/filesystem" + "github.com/docker/docker-agent/pkg/tui/components/spinner" "github.com/docker/docker-agent/pkg/tui/components/toolcommon" "github.com/docker/docker-agent/pkg/tui/core/layout" "github.com/docker/docker-agent/pkg/tui/service" + "github.com/docker/docker-agent/pkg/tui/styles" "github.com/docker/docker-agent/pkg/tui/types" ) func New(msg *types.Message, sessionState service.SessionStateReader) layout.Model { - return toolcommon.NewBase(msg, sessionState, toolcommon.SimpleRendererWithResult( - toolcommon.ExtractField(func(a filesystem.DirectoryTreeArgs) string { return pathx.ShortenHome(a.Path) }), - extractResult, - )) + extractPath := toolcommon.ExtractField(func(a filesystem.DirectoryTreeArgs) string { return pathx.ShortenHome(a.Path) }) + renderPath := toolcommon.SimpleRenderer(extractPath) + return toolcommon.NewBaseWithCollapsed( + msg, + sessionState, + render, + toolcommon.CollapsedRenderer(renderPath), + ) +} + +func render(msg *types.Message, s spinner.Spinner, sessionState service.SessionStateReader, width, _ int) string { + path := toolcommon.ExtractField(func(a filesystem.DirectoryTreeArgs) string { return pathx.ShortenHome(a.Path) })(msg.ToolCall.Function.Arguments) + header := toolcommon.RenderTool(msg, s, path, extractResult(msg), width, sessionState.HideToolResults()) + if sessionState.HideToolResults() || msg.Content == "" { + return header + } + return header + "\n" + styles.ToolCallResult.Render(toolcommon.FormatToolResult(msg.Content, width)) } func extractResult(msg *types.Message) string { diff --git a/pkg/tui/components/tool/listdirectory/listdirectory.go b/pkg/tui/components/tool/listdirectory/listdirectory.go index c9432c4d40..6adf9c06f0 100644 --- a/pkg/tui/components/tool/listdirectory/listdirectory.go +++ b/pkg/tui/components/tool/listdirectory/listdirectory.go @@ -5,17 +5,39 @@ import ( pathx "github.com/docker/docker-agent/pkg/path" "github.com/docker/docker-agent/pkg/tools/builtin/filesystem" + "github.com/docker/docker-agent/pkg/tui/components/spinner" "github.com/docker/docker-agent/pkg/tui/components/toolcommon" "github.com/docker/docker-agent/pkg/tui/core/layout" "github.com/docker/docker-agent/pkg/tui/service" + "github.com/docker/docker-agent/pkg/tui/styles" "github.com/docker/docker-agent/pkg/tui/types" ) +const maxVisibleEntries = 10 + func New(msg *types.Message, sessionState service.SessionStateReader) layout.Model { - return toolcommon.NewBase(msg, sessionState, toolcommon.SimpleRendererWithResult( - toolcommon.ExtractField(func(a filesystem.ListDirectoryArgs) string { return pathx.ShortenHome(a.Path) }), - extractResult, - )) + extractPath := toolcommon.ExtractField(func(a filesystem.ListDirectoryArgs) string { return pathx.ShortenHome(a.Path) }) + renderPath := toolcommon.SimpleRenderer(extractPath) + return toolcommon.NewBaseWithCollapsed( + msg, + sessionState, + render, + toolcommon.CollapsedRenderer(renderPath), + ) +} + +func render(msg *types.Message, s spinner.Spinner, sessionState service.SessionStateReader, width, _ int) string { + path := toolcommon.ExtractField(func(a filesystem.ListDirectoryArgs) string { return pathx.ShortenHome(a.Path) })(msg.ToolCall.Function.Arguments) + header := toolcommon.RenderTool(msg, s, path, extractResult(msg), width, sessionState.HideToolResults()) + if sessionState.HideToolResults() { + return header + } + + details := renderEntries(msg) + if details == "" { + return header + } + return header + "\n" + styles.ToolCallResult.Render(details) } func extractResult(msg *types.Message) string { @@ -47,3 +69,28 @@ func extractResult(msg *types.Message) string { } return result } + +func renderEntries(msg *types.Message) string { + if msg.ToolResult == nil || msg.ToolResult.Meta == nil { + return "" + } + meta, ok := msg.ToolResult.Meta.(filesystem.ListDirectoryMeta) + if !ok { + return "" + } + + entries := make([]string, 0, len(meta.Dirs)+len(meta.Files)+1) + for _, dir := range meta.Dirs { + entries = append(entries, "DIR "+dir) + } + for _, file := range meta.Files { + entries = append(entries, "FILE "+file) + } + if len(entries) == 0 { + return "" + } + if len(entries) > maxVisibleEntries { + entries = append(entries[:maxVisibleEntries], "…") + } + return strings.Join(entries, "\n") +} diff --git a/pkg/tui/components/tool/readfile/readfile.go b/pkg/tui/components/tool/readfile/readfile.go index e277a4da7e..963a603c1c 100644 --- a/pkg/tui/components/tool/readfile/readfile.go +++ b/pkg/tui/components/tool/readfile/readfile.go @@ -2,20 +2,43 @@ package readfile import ( "fmt" + "strings" pathx "github.com/docker/docker-agent/pkg/path" "github.com/docker/docker-agent/pkg/tools/builtin/filesystem" + "github.com/docker/docker-agent/pkg/tui/components/spinner" "github.com/docker/docker-agent/pkg/tui/components/toolcommon" "github.com/docker/docker-agent/pkg/tui/core/layout" "github.com/docker/docker-agent/pkg/tui/service" + "github.com/docker/docker-agent/pkg/tui/styles" "github.com/docker/docker-agent/pkg/tui/types" ) +const maxPreviewLines = 10 + func New(msg *types.Message, sessionState service.SessionStateReader) layout.Model { - return toolcommon.NewBase(msg, sessionState, toolcommon.SimpleRendererWithResult( - toolcommon.ExtractField(func(a filesystem.ReadFileArgs) string { return pathx.ShortenHome(a.Path) }), - extractResult, - )) + extractPath := toolcommon.ExtractField(func(a filesystem.ReadFileArgs) string { return pathx.ShortenHome(a.Path) }) + renderPath := toolcommon.SimpleRenderer(extractPath) + return toolcommon.NewBaseWithCollapsed( + msg, + sessionState, + render, + toolcommon.CollapsedRenderer(renderPath), + ) +} + +func render(msg *types.Message, s spinner.Spinner, sessionState service.SessionStateReader, width, _ int) string { + path := toolcommon.ExtractField(func(a filesystem.ReadFileArgs) string { return pathx.ShortenHome(a.Path) })(msg.ToolCall.Function.Arguments) + header := toolcommon.RenderTool(msg, s, path, extractResult(msg), width, sessionState.HideToolResults()) + if sessionState.HideToolResults() || msg.ToolStatus == types.ToolStatusError { + return header + } + + preview := formatLastLines(msg.Content, width) + if preview == "" { + return header + } + return header + "\n" + styles.ToolCallResult.Render(preview) } func extractResult(msg *types.Message) string { @@ -31,3 +54,26 @@ func extractResult(msg *types.Message) string { } return fmt.Sprintf("%d lines", meta.LineCount) } + +func formatLastLines(content string, width int) string { + content = strings.TrimRight(content, "\n") + if content == "" { + return "" + } + + lines := strings.Split(content, "\n") + truncated := len(lines) > maxPreviewLines + if truncated { + lines = lines[len(lines)-maxPreviewLines:] + } + + availableWidth := max(width-styles.ToolCallResult.GetHorizontalFrameSize(), 10) + wrapped := make([]string, 0, len(lines)+1) + if truncated { + wrapped = append(wrapped, "…") + } + for _, line := range lines { + wrapped = append(wrapped, toolcommon.WrapLines(line, availableWidth)...) + } + return strings.Join(wrapped, "\n") +} diff --git a/pkg/tui/components/tool/readmultiplefiles/readmultiplefiles.go b/pkg/tui/components/tool/readmultiplefiles/readmultiplefiles.go index 82d2a1ec42..956c5b9b4b 100644 --- a/pkg/tui/components/tool/readmultiplefiles/readmultiplefiles.go +++ b/pkg/tui/components/tool/readmultiplefiles/readmultiplefiles.go @@ -18,7 +18,7 @@ import ( ) func New(msg *types.Message, sessionState service.SessionStateReader) layout.Model { - return toolcommon.NewBase(msg, sessionState, render) + return toolcommon.NewBaseWithCollapsed(msg, sessionState, render, renderCollapsed) } func render(msg *types.Message, s spinner.Spinner, sessionState service.SessionStateReader, width, _ int) string { @@ -34,12 +34,7 @@ func render(msg *types.Message, s spinner.Spinner, sessionState service.SessionS } // For completed/error state, render each file line - var meta *filesystem.ReadMultipleFilesMeta - if msg.ToolResult != nil { - if m, ok := msg.ToolResult.Meta.(filesystem.ReadMultipleFilesMeta); ok { - meta = &m - } - } + meta := readMultipleFilesMeta(msg) // Each file on its own line with checkmark var content strings.Builder @@ -81,6 +76,29 @@ func render(msg *types.Message, s spinner.Spinner, sessionState service.SessionS return styles.RenderComposite(styles.ToolMessageStyle.Width(width), content.String()) } +func renderCollapsed(msg *types.Message, s spinner.Spinner, sessionState service.SessionStateReader, width, _ int) string { + var args filesystem.ReadMultipleFilesArgs + if err := json.Unmarshal([]byte(msg.ToolCall.Function.Arguments), &args); err != nil { + return toolcommon.RenderTool(msg, s, "", "", width, sessionState.HideToolResults()) + } + + summary := formatFilesCount(args.Paths) + if meta := readMultipleFilesMeta(msg); meta != nil && len(meta.Files) > 0 { + summary = formatResultCount(meta) + } + return toolcommon.RenderTool(msg, s, summary, "", width, sessionState.HideToolResults()) +} + +func readMultipleFilesMeta(msg *types.Message) *filesystem.ReadMultipleFilesMeta { + if msg.ToolResult == nil { + return nil + } + if meta, ok := msg.ToolResult.Meta.(filesystem.ReadMultipleFilesMeta); ok { + return &meta + } + return nil +} + type fileSummary struct { path string output string @@ -130,3 +148,39 @@ func formatFilesList(filePaths []string) string { return strings.Join(shortened, ", ") } + +func formatFilesCount(filePaths []string) string { + if len(filePaths) == 0 { + return "" + } + if len(filePaths) == 1 { + return pathx.ShortenHome(filePaths[0]) + } + return toolcommon.Pluralize(len(filePaths), "file", "files") +} + +func formatResultCount(meta *filesystem.ReadMultipleFilesMeta) string { + if meta == nil || len(meta.Files) == 0 { + return "" + } + + failed := 0 + for _, file := range meta.Files { + if file.Error != "" { + failed++ + } + } + + read := len(meta.Files) - failed + switch { + case failed == 0: + return toolcommon.Pluralize(read, "file", "files") + case read == 0: + return toolcommon.Pluralize(failed, "file", "files") + " failed" + default: + return fmt.Sprintf("%s read, %s failed", + toolcommon.Pluralize(read, "file", "files"), + toolcommon.Pluralize(failed, "file", "files"), + ) + } +} diff --git a/pkg/tui/components/tool/searchfilescontent/searchfilescontent.go b/pkg/tui/components/tool/searchfilescontent/searchfilescontent.go index 8bba4dc9a7..6fac9738c6 100644 --- a/pkg/tui/components/tool/searchfilescontent/searchfilescontent.go +++ b/pkg/tui/components/tool/searchfilescontent/searchfilescontent.go @@ -5,17 +5,30 @@ import ( pathx "github.com/docker/docker-agent/pkg/path" "github.com/docker/docker-agent/pkg/tools/builtin/filesystem" + "github.com/docker/docker-agent/pkg/tui/components/spinner" "github.com/docker/docker-agent/pkg/tui/components/toolcommon" "github.com/docker/docker-agent/pkg/tui/core/layout" "github.com/docker/docker-agent/pkg/tui/service" + "github.com/docker/docker-agent/pkg/tui/styles" "github.com/docker/docker-agent/pkg/tui/types" ) func New(msg *types.Message, sessionState service.SessionStateReader) layout.Model { - return toolcommon.NewBase(msg, sessionState, toolcommon.SimpleRendererWithResult( - extractArgs, - extractResult, - )) + renderArgs := toolcommon.SimpleRenderer(extractArgs) + return toolcommon.NewBaseWithCollapsed( + msg, + sessionState, + render, + toolcommon.CollapsedRenderer(renderArgs), + ) +} + +func render(msg *types.Message, s spinner.Spinner, sessionState service.SessionStateReader, width, _ int) string { + header := toolcommon.RenderTool(msg, s, extractArgs(msg.ToolCall.Function.Arguments), extractResult(msg), width, sessionState.HideToolResults()) + if sessionState.HideToolResults() || msg.Content == "" { + return header + } + return header + "\n" + styles.ToolCallResult.Render(toolcommon.FormatToolResult(msg.Content, width)) } func extractArgs(args string) string { diff --git a/pkg/tui/components/tool/shell/shell.go b/pkg/tui/components/tool/shell/shell.go index ae0e7acfbb..151346585a 100644 --- a/pkg/tui/components/tool/shell/shell.go +++ b/pkg/tui/components/tool/shell/shell.go @@ -15,21 +15,27 @@ import ( const maxVisibleShellOutputLines = 20 func New(msg *types.Message, sessionState service.SessionStateReader) layout.Model { - return toolcommon.NewBase(msg, sessionState, renderShell) + return toolcommon.NewBaseWithCollapsed(msg, sessionState, renderShell, renderShellCollapsed) } func renderShell(msg *types.Message, s spinner.Spinner, sessionState service.SessionStateReader, width, _ int) string { - arg := "" - if msg.ToolCall.Function.Arguments != "" { - arg = toolcommon.ExtractField(func(a builtinshell.RunShellArgs) string { return a.Cmd })(msg.ToolCall.Function.Arguments) - } - result := "" if msg.Content != "" { result = formatShellOutput(msg.Content, width) } - return toolcommon.RenderTool(msg, s, arg, result, width, sessionState.HideToolResults()) + return toolcommon.RenderTool(msg, s, extractCommand(msg.ToolCall.Function.Arguments), result, width, sessionState.HideToolResults()) +} + +func renderShellCollapsed(msg *types.Message, s spinner.Spinner, sessionState service.SessionStateReader, width, _ int) string { + return toolcommon.RenderTool(msg, s, extractCommand(msg.ToolCall.Function.Arguments), "", width, sessionState.HideToolResults()) +} + +func extractCommand(arguments string) string { + if arguments == "" { + return "" + } + return toolcommon.ExtractField(func(a builtinshell.RunShellArgs) string { return a.Cmd })(arguments) } func formatShellOutput(output string, width int) string { diff --git a/pkg/tui/components/tool/todotool/component.go b/pkg/tui/components/tool/todotool/component.go index e14d547ec1..81f6bf41b4 100644 --- a/pkg/tui/components/tool/todotool/component.go +++ b/pkg/tui/components/tool/todotool/component.go @@ -1,16 +1,146 @@ package todotool import ( + "encoding/json" + "fmt" + "strings" + + "github.com/docker/docker-agent/pkg/tools/builtin/todo" + "github.com/docker/docker-agent/pkg/tui/components/spinner" "github.com/docker/docker-agent/pkg/tui/components/toolcommon" "github.com/docker/docker-agent/pkg/tui/core/layout" "github.com/docker/docker-agent/pkg/tui/service" + "github.com/docker/docker-agent/pkg/tui/styles" "github.com/docker/docker-agent/pkg/tui/types" ) +const maxVisibleTodos = 10 + // New creates a new unified todo component. // This component handles create, create_multiple, list, and update operations. -// The TODOs themselves are displayed in the sidebar; here we only show the -// tool call header (icon + name). func New(msg *types.Message, sessionState service.SessionStateReader) layout.Model { - return toolcommon.NewBase(msg, sessionState, toolcommon.NoArgsRenderer) + return toolcommon.NewBaseWithCollapsed(msg, sessionState, render, toolcommon.NoArgsRenderer) +} + +func render(msg *types.Message, s spinner.Spinner, sessionState service.SessionStateReader, width, _ int) string { + header := toolcommon.NoArgsRenderer(msg, s, sessionState, width, 0) + if sessionState.HideToolResults() { + return header + } + + details := renderDetails(msg, width) + if details == "" { + return header + } + return header + "\n" + styles.ToolCallResult.Render(details) +} + +func renderDetails(msg *types.Message, width int) string { + if msg.Content != "" { + if details := renderOutputDetails(msg, width); details != "" { + return details + } + } + + switch msg.ToolCall.Function.Name { + case todo.ToolNameCreateTodo: + args, err := toolcommon.ParseArgs[todo.CreateTodoArgs](msg.ToolCall.Function.Arguments) + if err != nil || args.Description == "" { + return "" + } + return formatTodoList([]todo.Todo{{Description: args.Description, Status: "pending"}}, width) + case todo.ToolNameCreateTodos: + args, err := toolcommon.ParseArgs[todo.CreateTodosArgs](msg.ToolCall.Function.Arguments) + if err != nil || len(args.Descriptions) == 0 { + return "" + } + todos := make([]todo.Todo, 0, len(args.Descriptions)) + for _, description := range args.Descriptions { + todos = append(todos, todo.Todo{Description: description, Status: "pending"}) + } + return formatTodoList(todos, width) + case todo.ToolNameUpdateTodos: + args, err := toolcommon.ParseArgs[todo.UpdateTodosArgs](msg.ToolCall.Function.Arguments) + if err != nil || len(args.Updates) == 0 { + return "" + } + return formatUpdates(args.Updates, width) + } + return "" +} + +func renderOutputDetails(msg *types.Message, width int) string { + switch msg.ToolCall.Function.Name { + case todo.ToolNameCreateTodo: + var out todo.CreateTodoOutput + if err := json.Unmarshal([]byte(msg.Content), &out); err == nil && out.Created.Description != "" { + return formatTodoList([]todo.Todo{out.Created}, width) + } + case todo.ToolNameCreateTodos: + var out todo.CreateTodosOutput + if err := json.Unmarshal([]byte(msg.Content), &out); err == nil && len(out.Created) > 0 { + return formatTodoList(out.Created, width) + } + case todo.ToolNameUpdateTodos: + var out todo.UpdateTodosOutput + if err := json.Unmarshal([]byte(msg.Content), &out); err == nil && len(out.AllTodos) > 0 { + return formatTodoList(out.AllTodos, width) + } + case todo.ToolNameListTodos: + var out todo.ListTodosOutput + if err := json.Unmarshal([]byte(msg.Content), &out); err == nil && len(out.Todos) > 0 { + return formatTodoList(out.Todos, width) + } + } + return "" +} + +func formatTodoList(todos []todo.Todo, width int) string { + if len(todos) == 0 { + return "" + } + + truncated := len(todos) > maxVisibleTodos + if truncated { + todos = todos[:maxVisibleTodos] + } + + lines := make([]string, 0, len(todos)+1) + for _, item := range todos { + icon, _ := renderTodoIcon(item.Status) + line := icon + " " + item.Description + if item.ID != "" { + line += " (" + item.ID + ")" + } + lines = append(lines, line) + } + if truncated { + lines = append(lines, "…") + } + return wrapDetailLines(lines, width) +} + +func formatUpdates(updates []todo.Update, width int) string { + truncated := len(updates) > maxVisibleTodos + if truncated { + updates = updates[:maxVisibleTodos] + } + + lines := make([]string, 0, len(updates)+1) + for _, update := range updates { + lines = append(lines, fmt.Sprintf("%s → %s", update.ID, update.Status)) + } + if truncated { + lines = append(lines, "…") + } + return wrapDetailLines(lines, width) +} + +func wrapDetailLines(lines []string, width int) string { + availableWidth := max(width-styles.ToolCallResult.GetHorizontalFrameSize(), 10) + wrapped := make([]string, 0, len(lines)) + for _, line := range lines { + wrapped = append(wrapped, toolcommon.WrapLines(line, availableWidth)...) + } + return strings.Join(wrapped, "\n") } diff --git a/pkg/tui/components/tool/writefile/writefile.go b/pkg/tui/components/tool/writefile/writefile.go index f698fe1118..0efca5aec4 100644 --- a/pkg/tui/components/tool/writefile/writefile.go +++ b/pkg/tui/components/tool/writefile/writefile.go @@ -1,15 +1,68 @@ package writefile import ( + "strings" + + pathx "github.com/docker/docker-agent/pkg/path" "github.com/docker/docker-agent/pkg/tools/builtin/filesystem" + "github.com/docker/docker-agent/pkg/tui/components/spinner" "github.com/docker/docker-agent/pkg/tui/components/toolcommon" "github.com/docker/docker-agent/pkg/tui/core/layout" "github.com/docker/docker-agent/pkg/tui/service" + "github.com/docker/docker-agent/pkg/tui/styles" "github.com/docker/docker-agent/pkg/tui/types" ) +const maxPreviewLines = 10 + func New(msg *types.Message, sessionState service.SessionStateReader) layout.Model { - return toolcommon.NewBase(msg, sessionState, toolcommon.SimpleRenderer( - toolcommon.ExtractField(func(a filesystem.WriteFileArgs) string { return a.Path }), - )) + renderPath := toolcommon.SimpleRenderer( + toolcommon.ExtractField(func(a filesystem.WriteFileArgs) string { return pathx.ShortenHome(a.Path) }), + ) + return toolcommon.NewBaseWithCollapsed(msg, sessionState, render, toolcommon.CollapsedRenderer(renderPath)) +} + +func render(msg *types.Message, s spinner.Spinner, sessionState service.SessionStateReader, width, _ int) string { + args, err := toolcommon.ParseArgs[filesystem.WriteFileArgs](msg.ToolCall.Function.Arguments) + if err != nil { + return toolcommon.RenderTool(msg, s, "", "", width, sessionState.HideToolResults()) + } + + result := "" + if msg.ToolStatus == types.ToolStatusCompleted || msg.ToolStatus == types.ToolStatusError { + result = msg.Content + } + header := toolcommon.RenderTool(msg, s, pathx.ShortenHome(args.Path), result, width, sessionState.HideToolResults()) + if sessionState.HideToolResults() || msg.ToolStatus == types.ToolStatusError { + return header + } + + preview := formatLastLines(args.Content, width) + if preview == "" { + return header + } + return header + "\n" + styles.ToolCallResult.Render(preview) +} + +func formatLastLines(content string, width int) string { + content = strings.TrimRight(content, "\n") + if content == "" { + return "" + } + + lines := strings.Split(content, "\n") + truncated := len(lines) > maxPreviewLines + if truncated { + lines = lines[len(lines)-maxPreviewLines:] + } + + availableWidth := max(width-styles.ToolCallResult.GetHorizontalFrameSize(), 10) + wrapped := make([]string, 0, len(lines)+1) + if truncated { + wrapped = append(wrapped, "…") + } + for _, line := range lines { + wrapped = append(wrapped, toolcommon.WrapLines(line, availableWidth)...) + } + return strings.Join(wrapped, "\n") }