diff --git a/pkg/tui/components/messages/messages.go b/pkg/tui/components/messages/messages.go index 694cca38b..553b6b8f8 100644 --- a/pkg/tui/components/messages/messages.go +++ b/pkg/tui/components/messages/messages.go @@ -5,6 +5,7 @@ import ( "strconv" "strings" "sync/atomic" + "time" "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" @@ -1300,6 +1301,10 @@ func (m *model) AddOrUpdateToolCall(agentName string, toolCall tools.ToolCall, t msg := m.messages[i] if msg.Type == types.MessageTypeToolCall && msg.ToolCall.ID == toolCall.ID { msg.ToolStatus = status + if status == types.ToolStatusRunning && msg.StartedAt == nil { + now := time.Now() + msg.StartedAt = &now + } if toolCall.Function.Arguments != "" { if status == types.ToolStatusPending { msg.ToolCall.Function.Arguments += toolCall.Function.Arguments diff --git a/pkg/tui/components/reasoningblock/reasoningblock.go b/pkg/tui/components/reasoningblock/reasoningblock.go index cd4778df5..3996030ae 100644 --- a/pkg/tui/components/reasoningblock/reasoningblock.go +++ b/pkg/tui/components/reasoningblock/reasoningblock.go @@ -205,6 +205,10 @@ func (m *Model) UpdateToolCall(toolCallID string, status types.ToolStatus, args continue } entry.msg.ToolStatus = status + if status == types.ToolStatusRunning && entry.msg.StartedAt == nil { + now := time.Now() + entry.msg.StartedAt = &now + } if args != "" { if status == types.ToolStatusPending { entry.msg.ToolCall.Function.Arguments += args diff --git a/pkg/tui/components/toolcommon/common.go b/pkg/tui/components/toolcommon/common.go index 589015bbc..d19ff2929 100644 --- a/pkg/tui/components/toolcommon/common.go +++ b/pkg/tui/components/toolcommon/common.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "strings" + "time" "charm.land/lipgloss/v2" @@ -101,13 +102,24 @@ func ExtractField[T any](field func(T) string) func(string) string { } } +// LongRunningThreshold is the duration after which a running tool call +// displays a warning hint that it may be blocked on external input. +const LongRunningThreshold = 60 * time.Second + func Icon(msg *types.Message, inProgress spinner.Spinner) string { switch msg.ToolStatus { case types.ToolStatusRunning, types.ToolStatusPending: // Animated spinner for both executing and streaming tool calls. // With centralized animation ticks, all spinners share a single tick // so there's no performance penalty for multiple animated spinners. - return styles.NoStyle.MarginLeft(2).Render(inProgress.View()) + icon := styles.NoStyle.MarginLeft(2).Render(inProgress.View()) + if msg.StartedAt != nil { + elapsed := time.Since(*msg.StartedAt) + if elapsed >= time.Second { + icon += " " + styles.ToolMessageStyle.Render(formatDuration(elapsed)) + } + } + return icon case types.ToolStatusCompleted: return styles.ToolCompletedIcon.Render("✓") case types.ToolStatusError: @@ -119,6 +131,35 @@ func Icon(msg *types.Message, inProgress spinner.Spinner) string { } } +// LongRunningWarning returns a warning string if the tool call has been +// running longer than LongRunningThreshold, or empty string otherwise. +func LongRunningWarning(msg *types.Message) string { + if msg.StartedAt == nil { + return "" + } + if msg.ToolStatus != types.ToolStatusRunning { + return "" + } + if time.Since(*msg.StartedAt) < LongRunningThreshold { + return "" + } + return "⚠ Tool call running for over 60s. The MCP server may be waiting for external input. Press Esc to cancel." +} + +// formatDuration formats a duration as a human-readable string like "5s", "1m30s", "2m15s". +func formatDuration(d time.Duration) string { + d = d.Truncate(time.Second) + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } + m := int(d.Minutes()) + s := int(d.Seconds()) % 60 + if s == 0 { + return fmt.Sprintf("%dm", m) + } + return fmt.Sprintf("%dm%02ds", m, s) +} + func FormatToolResult(content string, width int) string { var formattedContent string var m map[string]any @@ -153,6 +194,8 @@ func RenderTool(msg *types.Message, inProgress spinner.Spinner, args, result str icon := Icon(msg, inProgress) name := nameStyle.Render(msg.ToolDefinition.DisplayName()) + warning := LongRunningWarning(msg) + if header, ok := RenderFriendlyHeader(msg, inProgress); ok { content := header if args != "" { @@ -173,6 +216,9 @@ func RenderTool(msg *types.Message, inProgress spinner.Spinner, args, result str content += " " + renderedResult } } + if warning != "" { + content += "\n" + styles.WarningStyle.MarginLeft(styles.ToolCompletedIcon.GetMarginLeft()).Render(warning) + } return styles.RenderComposite(styles.ToolMessageStyle.Width(width), content) } @@ -199,6 +245,9 @@ func RenderTool(msg *types.Message, inProgress spinner.Spinner, args, result str content += " " + renderedResult } } + if warning != "" { + content += "\n" + styles.WarningStyle.MarginLeft(styles.ToolCompletedIcon.GetMarginLeft()).Render(warning) + } return styles.RenderComposite(styles.ToolMessageStyle.Width(width), content) } diff --git a/pkg/tui/components/toolcommon/common_test.go b/pkg/tui/components/toolcommon/common_test.go index 7d72718d0..d27cc2af0 100644 --- a/pkg/tui/components/toolcommon/common_test.go +++ b/pkg/tui/components/toolcommon/common_test.go @@ -2,9 +2,12 @@ package toolcommon import ( "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/tui/types" ) func TestTryFixPartialJSON(t *testing.T) { @@ -713,3 +716,57 @@ func BenchmarkRuneWidth(b *testing.B) { } }) } + +func TestFormatDuration(t *testing.T) { + tests := []struct { + d time.Duration + want string + }{ + {0, "0s"}, + {500 * time.Millisecond, "0s"}, + {1 * time.Second, "1s"}, + {45 * time.Second, "45s"}, + {60 * time.Second, "1m"}, + {90 * time.Second, "1m30s"}, + {135 * time.Second, "2m15s"}, + {5 * time.Minute, "5m"}, + } + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + got := formatDuration(tt.d) + if got != tt.want { + t.Errorf("formatDuration(%v) = %q, want %q", tt.d, got, tt.want) + } + }) + } +} + +func TestLongRunningWarning(t *testing.T) { + t.Run("no StartedAt", func(t *testing.T) { + msg := &types.Message{ToolStatus: types.ToolStatusRunning} + if w := LongRunningWarning(msg); w != "" { + t.Errorf("expected empty warning, got %q", w) + } + }) + t.Run("under threshold", func(t *testing.T) { + now := time.Now() + msg := &types.Message{ToolStatus: types.ToolStatusRunning, StartedAt: &now} + if w := LongRunningWarning(msg); w != "" { + t.Errorf("expected empty warning, got %q", w) + } + }) + t.Run("over threshold", func(t *testing.T) { + past := time.Now().Add(-2 * time.Minute) + msg := &types.Message{ToolStatus: types.ToolStatusRunning, StartedAt: &past} + if w := LongRunningWarning(msg); w == "" { + t.Error("expected warning for long-running tool call") + } + }) + t.Run("completed tool no warning", func(t *testing.T) { + past := time.Now().Add(-2 * time.Minute) + msg := &types.Message{ToolStatus: types.ToolStatusCompleted, StartedAt: &past} + if w := LongRunningWarning(msg); w != "" { + t.Errorf("expected no warning for completed tool, got %q", w) + } + }) +} diff --git a/pkg/tui/types/types.go b/pkg/tui/types/types.go index ae164f9cf..a99ca6344 100644 --- a/pkg/tui/types/types.go +++ b/pkg/tui/types/types.go @@ -2,6 +2,7 @@ package types import ( "strings" + "time" "github.com/docker/docker-agent/pkg/tools" ) @@ -45,6 +46,9 @@ type Message struct { ToolDefinition tools.Tool // Definition of the tool being called ToolStatus ToolStatus // Status for tool calls ToolResult *tools.ToolCallResult // Result of tool call (when completed) + // StartedAt records when a tool call entered ToolStatusRunning. + // Used to display elapsed time for long-running tool calls. + StartedAt *time.Time // SessionPosition is the index of this message in session.Messages (when known). // Used for operations like branching on edits. SessionPosition *int @@ -99,13 +103,18 @@ func Welcome(content string) *Message { } func ToolCallMessage(agentName string, toolCall tools.ToolCall, toolDef tools.Tool, status ToolStatus) *Message { - return &Message{ + msg := &Message{ Type: MessageTypeToolCall, Sender: agentName, ToolCall: toolCall, ToolDefinition: toolDef, ToolStatus: status, } + if status == ToolStatusRunning { + now := time.Now() + msg.StartedAt = &now + } + return msg } func Loading(description string) *Message {