Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions pkg/tui/components/messages/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"strconv"
"strings"
"sync/atomic"
"time"

"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions pkg/tui/components/reasoningblock/reasoningblock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 50 additions & 1 deletion pkg/tui/components/toolcommon/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"strings"
"time"

"charm.land/lipgloss/v2"

Expand Down Expand Up @@ -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:
Expand All @@ -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."
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a tool is not necessarily part of a MCP server @dgageot

}

// 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
Expand Down Expand Up @@ -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 != "" {
Expand All @@ -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)
}

Expand All @@ -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)
}
Expand Down
57 changes: 57 additions & 0 deletions pkg/tui/components/toolcommon/common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
})
}
11 changes: 10 additions & 1 deletion pkg/tui/types/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package types

import (
"strings"
"time"

"github.com/docker/docker-agent/pkg/tools"
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Loading