Skip to content

Commit 9f4415a

Browse files
authored
Merge pull request #2291 from docker/board/fix-message-in-docker-agent-pr-2239-4d14d575
tui: show elapsed time and warning for long-running tool calls
2 parents 32feddb + 8a3b969 commit 9f4415a

5 files changed

Lines changed: 126 additions & 2 deletions

File tree

pkg/tui/components/messages/messages.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"strconv"
66
"strings"
77
"sync/atomic"
8+
"time"
89

910
"charm.land/bubbles/v2/help"
1011
"charm.land/bubbles/v2/key"
@@ -1333,6 +1334,10 @@ func (m *model) AddOrUpdateToolCall(agentName string, toolCall tools.ToolCall, t
13331334
msg := m.messages[i]
13341335
if msg.Type == types.MessageTypeToolCall && msg.ToolCall.ID == toolCall.ID {
13351336
msg.ToolStatus = status
1337+
if status == types.ToolStatusRunning && msg.StartedAt == nil {
1338+
now := time.Now()
1339+
msg.StartedAt = &now
1340+
}
13361341
if toolCall.Function.Arguments != "" {
13371342
if status == types.ToolStatusPending {
13381343
msg.ToolCall.Function.Arguments += toolCall.Function.Arguments

pkg/tui/components/reasoningblock/reasoningblock.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,10 @@ func (m *Model) UpdateToolCall(toolCallID string, status types.ToolStatus, args
205205
continue
206206
}
207207
entry.msg.ToolStatus = status
208+
if status == types.ToolStatusRunning && entry.msg.StartedAt == nil {
209+
now := time.Now()
210+
entry.msg.StartedAt = &now
211+
}
208212
if args != "" {
209213
if status == types.ToolStatusPending {
210214
entry.msg.ToolCall.Function.Arguments += args

pkg/tui/components/toolcommon/common.go

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"fmt"
66
"strings"
7+
"time"
78

89
"charm.land/lipgloss/v2"
910

@@ -101,13 +102,24 @@ func ExtractField[T any](field func(T) string) func(string) string {
101102
}
102103
}
103104

105+
// LongRunningThreshold is the duration after which a running tool call
106+
// displays a warning hint that it may be blocked on external input.
107+
const LongRunningThreshold = 60 * time.Second
108+
104109
func Icon(msg *types.Message, inProgress spinner.Spinner) string {
105110
switch msg.ToolStatus {
106111
case types.ToolStatusRunning, types.ToolStatusPending:
107112
// Animated spinner for both executing and streaming tool calls.
108113
// With centralized animation ticks, all spinners share a single tick
109114
// so there's no performance penalty for multiple animated spinners.
110-
return styles.NoStyle.MarginLeft(2).Render(inProgress.View())
115+
icon := styles.NoStyle.MarginLeft(2).Render(inProgress.View())
116+
if msg.StartedAt != nil {
117+
elapsed := time.Since(*msg.StartedAt)
118+
if elapsed >= time.Second {
119+
icon += " " + styles.ToolMessageStyle.Render(formatDuration(elapsed))
120+
}
121+
}
122+
return icon
111123
case types.ToolStatusCompleted:
112124
return styles.ToolCompletedIcon.Render("✓")
113125
case types.ToolStatusError:
@@ -119,6 +131,35 @@ func Icon(msg *types.Message, inProgress spinner.Spinner) string {
119131
}
120132
}
121133

134+
// LongRunningWarning returns a warning string if the tool call has been
135+
// running longer than LongRunningThreshold, or empty string otherwise.
136+
func LongRunningWarning(msg *types.Message) string {
137+
if msg.StartedAt == nil {
138+
return ""
139+
}
140+
if msg.ToolStatus != types.ToolStatusRunning {
141+
return ""
142+
}
143+
if time.Since(*msg.StartedAt) < LongRunningThreshold {
144+
return ""
145+
}
146+
return "⚠ Tool call running for over 60s. The tool may be waiting for external input. Press Esc to cancel."
147+
}
148+
149+
// formatDuration formats a duration as a human-readable string like "5s", "1m30s", "2m15s".
150+
func formatDuration(d time.Duration) string {
151+
d = d.Truncate(time.Second)
152+
if d < time.Minute {
153+
return fmt.Sprintf("%ds", int(d.Seconds()))
154+
}
155+
m := int(d.Minutes())
156+
s := int(d.Seconds()) % 60
157+
if s == 0 {
158+
return fmt.Sprintf("%dm", m)
159+
}
160+
return fmt.Sprintf("%dm%02ds", m, s)
161+
}
162+
122163
func FormatToolResult(content string, width int) string {
123164
var formattedContent string
124165
var m map[string]any
@@ -153,6 +194,8 @@ func RenderTool(msg *types.Message, inProgress spinner.Spinner, args, result str
153194
icon := Icon(msg, inProgress)
154195
name := nameStyle.Render(msg.ToolDefinition.DisplayName())
155196

197+
warning := LongRunningWarning(msg)
198+
156199
if header, ok := RenderFriendlyHeader(msg, inProgress); ok {
157200
content := header
158201
if args != "" {
@@ -173,6 +216,9 @@ func RenderTool(msg *types.Message, inProgress spinner.Spinner, args, result str
173216
content += " " + renderedResult
174217
}
175218
}
219+
if warning != "" {
220+
content += "\n" + styles.WarningStyle.MarginLeft(styles.ToolCompletedIcon.GetMarginLeft()).Render(warning)
221+
}
176222
return styles.RenderComposite(styles.ToolMessageStyle.Width(width), content)
177223
}
178224

@@ -199,6 +245,9 @@ func RenderTool(msg *types.Message, inProgress spinner.Spinner, args, result str
199245
content += " " + renderedResult
200246
}
201247
}
248+
if warning != "" {
249+
content += "\n" + styles.WarningStyle.MarginLeft(styles.ToolCompletedIcon.GetMarginLeft()).Render(warning)
250+
}
202251

203252
return styles.RenderComposite(styles.ToolMessageStyle.Width(width), content)
204253
}

pkg/tui/components/toolcommon/common_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ package toolcommon
22

33
import (
44
"testing"
5+
"time"
56

67
"github.com/stretchr/testify/assert"
78
"github.com/stretchr/testify/require"
9+
10+
"github.com/docker/docker-agent/pkg/tui/types"
811
)
912

1013
func TestTryFixPartialJSON(t *testing.T) {
@@ -713,3 +716,57 @@ func BenchmarkRuneWidth(b *testing.B) {
713716
}
714717
})
715718
}
719+
720+
func TestFormatDuration(t *testing.T) {
721+
tests := []struct {
722+
d time.Duration
723+
want string
724+
}{
725+
{0, "0s"},
726+
{500 * time.Millisecond, "0s"},
727+
{1 * time.Second, "1s"},
728+
{45 * time.Second, "45s"},
729+
{60 * time.Second, "1m"},
730+
{90 * time.Second, "1m30s"},
731+
{135 * time.Second, "2m15s"},
732+
{5 * time.Minute, "5m"},
733+
}
734+
for _, tt := range tests {
735+
t.Run(tt.want, func(t *testing.T) {
736+
got := formatDuration(tt.d)
737+
if got != tt.want {
738+
t.Errorf("formatDuration(%v) = %q, want %q", tt.d, got, tt.want)
739+
}
740+
})
741+
}
742+
}
743+
744+
func TestLongRunningWarning(t *testing.T) {
745+
t.Run("no StartedAt", func(t *testing.T) {
746+
msg := &types.Message{ToolStatus: types.ToolStatusRunning}
747+
if w := LongRunningWarning(msg); w != "" {
748+
t.Errorf("expected empty warning, got %q", w)
749+
}
750+
})
751+
t.Run("under threshold", func(t *testing.T) {
752+
now := time.Now()
753+
msg := &types.Message{ToolStatus: types.ToolStatusRunning, StartedAt: &now}
754+
if w := LongRunningWarning(msg); w != "" {
755+
t.Errorf("expected empty warning, got %q", w)
756+
}
757+
})
758+
t.Run("over threshold", func(t *testing.T) {
759+
past := time.Now().Add(-2 * time.Minute)
760+
msg := &types.Message{ToolStatus: types.ToolStatusRunning, StartedAt: &past}
761+
if w := LongRunningWarning(msg); w == "" {
762+
t.Error("expected warning for long-running tool call")
763+
}
764+
})
765+
t.Run("completed tool no warning", func(t *testing.T) {
766+
past := time.Now().Add(-2 * time.Minute)
767+
msg := &types.Message{ToolStatus: types.ToolStatusCompleted, StartedAt: &past}
768+
if w := LongRunningWarning(msg); w != "" {
769+
t.Errorf("expected no warning for completed tool, got %q", w)
770+
}
771+
})
772+
}

pkg/tui/types/types.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package types
22

33
import (
44
"strings"
5+
"time"
56

67
"github.com/docker/docker-agent/pkg/tools"
78
)
@@ -48,6 +49,9 @@ type Message struct {
4849
ToolDefinition tools.Tool // Definition of the tool being called
4950
ToolStatus ToolStatus // Status for tool calls
5051
ToolResult *tools.ToolCallResult // Result of tool call (when completed)
52+
// StartedAt records when a tool call entered ToolStatusRunning.
53+
// Used to display elapsed time for long-running tool calls.
54+
StartedAt *time.Time
5155
// SessionPosition is the index of this message in session.Messages (when known).
5256
// Used for operations like branching on edits.
5357
SessionPosition *int
@@ -102,13 +106,18 @@ func Welcome(content string) *Message {
102106
}
103107

104108
func ToolCallMessage(agentName string, toolCall tools.ToolCall, toolDef tools.Tool, status ToolStatus) *Message {
105-
return &Message{
109+
msg := &Message{
106110
Type: MessageTypeToolCall,
107111
Sender: agentName,
108112
ToolCall: toolCall,
109113
ToolDefinition: toolDef,
110114
ToolStatus: status,
111115
}
116+
if status == ToolStatusRunning {
117+
now := time.Now()
118+
msg.StartedAt = &now
119+
}
120+
return msg
112121
}
113122

114123
func Loading(description string) *Message {

0 commit comments

Comments
 (0)