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+
104109func 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+
122163func 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}
0 commit comments