Skip to content

Commit 8a3b969

Browse files
committed
tui: show elapsed time and warning for long-running tool calls
Add elapsed time display and a long-running warning to tool calls in the TUI, giving users visibility into how long calls have been running. - Add StartedAt timestamp to Message, set when a tool enters ToolStatusRunning - Display elapsed time next to the spinner for running tools (e.g. 2m15s) - Show a warning after 60s with a hint to press Esc to cancel - Use generic wording (not MCP-specific) since tools can come from various sources - Add unit tests for formatDuration and LongRunningWarning Assisted-By: docker-agent
1 parent 2021b77 commit 8a3b969

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"
@@ -1300,6 +1301,10 @@ func (m *model) AddOrUpdateToolCall(agentName string, toolCall tools.ToolCall, t
13001301
msg := m.messages[i]
13011302
if msg.Type == types.MessageTypeToolCall && msg.ToolCall.ID == toolCall.ID {
13021303
msg.ToolStatus = status
1304+
if status == types.ToolStatusRunning && msg.StartedAt == nil {
1305+
now := time.Now()
1306+
msg.StartedAt = &now
1307+
}
13031308
if toolCall.Function.Arguments != "" {
13041309
if status == types.ToolStatusPending {
13051310
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
)
@@ -45,6 +46,9 @@ type Message struct {
4546
ToolDefinition tools.Tool // Definition of the tool being called
4647
ToolStatus ToolStatus // Status for tool calls
4748
ToolResult *tools.ToolCallResult // Result of tool call (when completed)
49+
// StartedAt records when a tool call entered ToolStatusRunning.
50+
// Used to display elapsed time for long-running tool calls.
51+
StartedAt *time.Time
4852
// SessionPosition is the index of this message in session.Messages (when known).
4953
// Used for operations like branching on edits.
5054
SessionPosition *int
@@ -99,13 +103,18 @@ func Welcome(content string) *Message {
99103
}
100104

101105
func ToolCallMessage(agentName string, toolCall tools.ToolCall, toolDef tools.Tool, status ToolStatus) *Message {
102-
return &Message{
106+
msg := &Message{
103107
Type: MessageTypeToolCall,
104108
Sender: agentName,
105109
ToolCall: toolCall,
106110
ToolDefinition: toolDef,
107111
ToolStatus: status,
108112
}
113+
if status == ToolStatusRunning {
114+
now := time.Now()
115+
msg.StartedAt = &now
116+
}
117+
return msg
109118
}
110119

111120
func Loading(description string) *Message {

0 commit comments

Comments
 (0)