swarm_cli 2.0.0.pre.1 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/swarm_cli/cli.rb +21 -1
- data/lib/swarm_cli/commands/migrate.rb +55 -0
- data/lib/swarm_cli/commands/run.rb +81 -24
- data/lib/swarm_cli/config_loader.rb +97 -0
- data/lib/swarm_cli/formatters/human_formatter.rb +404 -629
- data/lib/swarm_cli/interactive_repl.rb +640 -0
- data/lib/swarm_cli/migrate_options.rb +54 -0
- data/lib/swarm_cli/migrator.rb +132 -0
- data/lib/swarm_cli/options.rb +53 -17
- data/lib/swarm_cli/ui/components/agent_badge.rb +33 -0
- data/lib/swarm_cli/ui/components/content_block.rb +120 -0
- data/lib/swarm_cli/ui/components/divider.rb +57 -0
- data/lib/swarm_cli/ui/components/panel.rb +62 -0
- data/lib/swarm_cli/ui/components/usage_stats.rb +70 -0
- data/lib/swarm_cli/ui/formatters/cost.rb +49 -0
- data/lib/swarm_cli/ui/formatters/number.rb +58 -0
- data/lib/swarm_cli/ui/formatters/text.rb +77 -0
- data/lib/swarm_cli/ui/formatters/time.rb +73 -0
- data/lib/swarm_cli/ui/icons.rb +59 -0
- data/lib/swarm_cli/ui/renderers/event_renderer.rb +188 -0
- data/lib/swarm_cli/ui/state/agent_color_cache.rb +45 -0
- data/lib/swarm_cli/ui/state/depth_tracker.rb +40 -0
- data/lib/swarm_cli/ui/state/spinner_manager.rb +170 -0
- data/lib/swarm_cli/ui/state/usage_tracker.rb +62 -0
- data/lib/swarm_cli/version.rb +1 -1
- data/lib/swarm_cli.rb +3 -1
- metadata +23 -17
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmCLI
|
4
|
+
module UI
|
5
|
+
module Formatters
|
6
|
+
# Text manipulation utilities for clean display
|
7
|
+
class Text
|
8
|
+
class << self
|
9
|
+
# Strip <system-reminder> tags from content
|
10
|
+
def strip_system_reminders(text)
|
11
|
+
return "" if text.nil?
|
12
|
+
|
13
|
+
text.gsub(%r{<system-reminder>.*?</system-reminder>}m, "").strip
|
14
|
+
end
|
15
|
+
|
16
|
+
# Truncate text to specified character/line limits
|
17
|
+
# Returns [display_text, truncation_message]
|
18
|
+
def truncate(text, chars: nil, lines: nil)
|
19
|
+
return [text, nil] if text.nil? || text.empty?
|
20
|
+
|
21
|
+
text_lines = text.split("\n")
|
22
|
+
truncated = false
|
23
|
+
truncation_parts = []
|
24
|
+
|
25
|
+
# Apply line limit
|
26
|
+
if lines && text_lines.length > lines
|
27
|
+
text_lines = text_lines.first(lines)
|
28
|
+
hidden_lines = text.split("\n").length - lines
|
29
|
+
truncation_parts << "#{hidden_lines} more lines"
|
30
|
+
truncated = true
|
31
|
+
end
|
32
|
+
|
33
|
+
result_text = text_lines.join("\n")
|
34
|
+
|
35
|
+
# Apply character limit
|
36
|
+
if chars && result_text.length > chars
|
37
|
+
result_text = result_text[0...chars]
|
38
|
+
hidden_chars = text.length - chars
|
39
|
+
truncation_parts << "#{hidden_chars} more chars"
|
40
|
+
truncated = true
|
41
|
+
end
|
42
|
+
|
43
|
+
truncation_msg = truncated ? "... (#{truncation_parts.join(", ")})" : nil
|
44
|
+
|
45
|
+
[result_text, truncation_msg]
|
46
|
+
end
|
47
|
+
|
48
|
+
# Wrap text to specified width
|
49
|
+
def wrap(text, width:)
|
50
|
+
return "" if text.nil? || text.empty?
|
51
|
+
|
52
|
+
text.split("\n").flat_map do |line|
|
53
|
+
wrap_line(line, width)
|
54
|
+
end.join("\n")
|
55
|
+
end
|
56
|
+
|
57
|
+
# Indent all lines in text
|
58
|
+
def indent(text, level: 0, char: " ")
|
59
|
+
return "" if text.nil? || text.empty?
|
60
|
+
|
61
|
+
prefix = char * level
|
62
|
+
text.split("\n").map { |line| "#{prefix}#{line}" }.join("\n")
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
# Word wrap a single line
|
68
|
+
def wrap_line(line, width)
|
69
|
+
return [line] if line.length <= width
|
70
|
+
|
71
|
+
line.scan(/.{1,#{width}}(?:\s+|$)/).map(&:strip)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmCLI
|
4
|
+
module UI
|
5
|
+
module Formatters
|
6
|
+
# Time and duration formatting utilities
|
7
|
+
class Time
|
8
|
+
class << self
|
9
|
+
# Format timestamp as [HH:MM:SS]
|
10
|
+
# Time.now → "[12:34:56]"
|
11
|
+
def timestamp(time)
|
12
|
+
return "" if time.nil?
|
13
|
+
|
14
|
+
case time
|
15
|
+
when ::Time
|
16
|
+
time.strftime("[%H:%M:%S]")
|
17
|
+
when String
|
18
|
+
parsed = ::Time.parse(time)
|
19
|
+
parsed.strftime("[%H:%M:%S]")
|
20
|
+
else
|
21
|
+
""
|
22
|
+
end
|
23
|
+
rescue StandardError
|
24
|
+
""
|
25
|
+
end
|
26
|
+
|
27
|
+
# Format duration in human-readable form
|
28
|
+
# 0.5 → "500ms"
|
29
|
+
# 2.3 → "2.3s"
|
30
|
+
# 65 → "1m 5s"
|
31
|
+
# 3665 → "1h 1m 5s"
|
32
|
+
def duration(seconds)
|
33
|
+
return "0ms" if seconds.nil? || seconds.zero?
|
34
|
+
|
35
|
+
if seconds < 1
|
36
|
+
"#{(seconds * 1000).round}ms"
|
37
|
+
elsif seconds < 60
|
38
|
+
"#{seconds.round(2)}s"
|
39
|
+
elsif seconds < 3600
|
40
|
+
minutes = (seconds / 60).floor
|
41
|
+
secs = (seconds % 60).round
|
42
|
+
"#{minutes}m #{secs}s"
|
43
|
+
else
|
44
|
+
hours = (seconds / 3600).floor
|
45
|
+
minutes = ((seconds % 3600) / 60).floor
|
46
|
+
secs = (seconds % 60).round
|
47
|
+
"#{hours}h #{minutes}m #{secs}s"
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# Format relative time (future enhancement)
|
52
|
+
# Time.now - 120 → "2 minutes ago"
|
53
|
+
def relative(time)
|
54
|
+
return "" if time.nil?
|
55
|
+
|
56
|
+
seconds_ago = ::Time.now - time
|
57
|
+
|
58
|
+
case seconds_ago
|
59
|
+
when 0...60
|
60
|
+
"#{seconds_ago.round}s ago"
|
61
|
+
when 60...3600
|
62
|
+
"#{(seconds_ago / 60).round}m ago"
|
63
|
+
when 3600...86400
|
64
|
+
"#{(seconds_ago / 3600).round}h ago"
|
65
|
+
else
|
66
|
+
"#{(seconds_ago / 86400).round}d ago"
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmCLI
|
4
|
+
module UI
|
5
|
+
# Icon definitions for terminal UI
|
6
|
+
# Centralized so all components use the same icons
|
7
|
+
module Icons
|
8
|
+
# Event type icons
|
9
|
+
THINKING = "💭"
|
10
|
+
RESPONSE = "💬"
|
11
|
+
SUCCESS = "✓"
|
12
|
+
ERROR = "✗"
|
13
|
+
INFO = "ℹ"
|
14
|
+
WARNING = "⚠️"
|
15
|
+
|
16
|
+
# Entity icons
|
17
|
+
AGENT = "🤖"
|
18
|
+
TOOL = "🔧"
|
19
|
+
DELEGATE = "📨"
|
20
|
+
RESULT = "📥"
|
21
|
+
HOOK = "🪝"
|
22
|
+
|
23
|
+
# Metric icons
|
24
|
+
LLM = "🧠"
|
25
|
+
TOKENS = "📊"
|
26
|
+
COST = "💰"
|
27
|
+
TIME = "⏱"
|
28
|
+
|
29
|
+
# Visual elements
|
30
|
+
SPARKLES = "✨"
|
31
|
+
ARROW_RIGHT = "→"
|
32
|
+
BULLET = "•"
|
33
|
+
COMPRESS = "🗜️"
|
34
|
+
|
35
|
+
# All icons as hash for backward compatibility
|
36
|
+
ALL = {
|
37
|
+
thinking: THINKING,
|
38
|
+
response: RESPONSE,
|
39
|
+
success: SUCCESS,
|
40
|
+
error: ERROR,
|
41
|
+
info: INFO,
|
42
|
+
warning: WARNING,
|
43
|
+
agent: AGENT,
|
44
|
+
tool: TOOL,
|
45
|
+
delegate: DELEGATE,
|
46
|
+
result: RESULT,
|
47
|
+
hook: HOOK,
|
48
|
+
llm: LLM,
|
49
|
+
tokens: TOKENS,
|
50
|
+
cost: COST,
|
51
|
+
time: TIME,
|
52
|
+
sparkles: SPARKLES,
|
53
|
+
arrow_right: ARROW_RIGHT,
|
54
|
+
bullet: BULLET,
|
55
|
+
compress: COMPRESS,
|
56
|
+
}.freeze
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,188 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmCLI
|
4
|
+
module UI
|
5
|
+
module Renderers
|
6
|
+
# High-level event rendering by composing lower-level components
|
7
|
+
# Returns formatted strings for each event type
|
8
|
+
class EventRenderer
|
9
|
+
def initialize(pastel:, agent_badge:, depth_tracker:)
|
10
|
+
@pastel = pastel
|
11
|
+
@agent_badge = agent_badge
|
12
|
+
@depth_tracker = depth_tracker
|
13
|
+
@usage_stats = Components::UsageStats.new(pastel: pastel)
|
14
|
+
@content_block = Components::ContentBlock.new(pastel: pastel)
|
15
|
+
@panel = Components::Panel.new(pastel: pastel)
|
16
|
+
end
|
17
|
+
|
18
|
+
# Render agent thinking event
|
19
|
+
# [12:34:56] 💭 architect (gpt-5-mini)
|
20
|
+
def agent_thinking(agent:, model:, timestamp:)
|
21
|
+
indent = @depth_tracker.indent(agent)
|
22
|
+
time = Formatters::Time.timestamp(timestamp)
|
23
|
+
agent_name = @agent_badge.render(agent, icon: UI::Icons::THINKING)
|
24
|
+
model_info = @pastel.dim("(#{model})")
|
25
|
+
|
26
|
+
"#{indent}#{@pastel.dim(time)} #{agent_name} #{model_info}"
|
27
|
+
end
|
28
|
+
|
29
|
+
# Render agent response event
|
30
|
+
# [12:34:56] 💬 architect responded:
|
31
|
+
def agent_response(agent:, timestamp:)
|
32
|
+
indent = @depth_tracker.indent(agent)
|
33
|
+
time = Formatters::Time.timestamp(timestamp)
|
34
|
+
agent_name = @agent_badge.render(agent, icon: UI::Icons::RESPONSE)
|
35
|
+
|
36
|
+
"#{indent}#{@pastel.dim(time)} #{agent_name} responded:"
|
37
|
+
end
|
38
|
+
|
39
|
+
# Render agent completion
|
40
|
+
# ✓ architect completed
|
41
|
+
def agent_completed(agent:)
|
42
|
+
indent = @depth_tracker.indent(agent)
|
43
|
+
agent_name = @agent_badge.render(agent)
|
44
|
+
|
45
|
+
"#{indent}#{@pastel.green("#{UI::Icons::SUCCESS} #{agent_name} completed")}"
|
46
|
+
end
|
47
|
+
|
48
|
+
# Render tool call event
|
49
|
+
# [12:34:56] architect 🔧 uses tool Read
|
50
|
+
def tool_call(agent:, tool:, timestamp:)
|
51
|
+
indent = @depth_tracker.indent(agent)
|
52
|
+
time = Formatters::Time.timestamp(timestamp)
|
53
|
+
agent_name = @agent_badge.render(agent)
|
54
|
+
tool_name = @pastel.bold.blue(tool)
|
55
|
+
|
56
|
+
"#{indent}#{@pastel.dim(time)} #{agent_name} #{@pastel.blue("#{UI::Icons::TOOL} uses tool")} #{tool_name}"
|
57
|
+
end
|
58
|
+
|
59
|
+
# Render tool result received
|
60
|
+
# [12:34:56] 📥 Tool result received by architect
|
61
|
+
def tool_result(agent:, timestamp:, tool: nil)
|
62
|
+
indent = @depth_tracker.indent(agent)
|
63
|
+
time = Formatters::Time.timestamp(timestamp)
|
64
|
+
|
65
|
+
"#{indent}#{@pastel.dim(time)} #{@pastel.green("#{UI::Icons::RESULT} Tool result")} received by #{agent}"
|
66
|
+
end
|
67
|
+
|
68
|
+
# Render delegation event
|
69
|
+
# [12:34:56] architect 📨 delegates to worker
|
70
|
+
def delegation(from:, to:, timestamp:)
|
71
|
+
indent = @depth_tracker.indent(from)
|
72
|
+
time = Formatters::Time.timestamp(timestamp)
|
73
|
+
from_name = @agent_badge.render(from)
|
74
|
+
to_name = @agent_badge.render(to)
|
75
|
+
|
76
|
+
"#{indent}#{@pastel.dim(time)} #{from_name} #{@pastel.yellow("#{UI::Icons::DELEGATE} delegates to")} #{to_name}"
|
77
|
+
end
|
78
|
+
|
79
|
+
# Render delegation result
|
80
|
+
# [12:34:56] 📥 Delegation result from worker → architect
|
81
|
+
def delegation_result(from:, to:, timestamp:)
|
82
|
+
indent = @depth_tracker.indent(to)
|
83
|
+
time = Formatters::Time.timestamp(timestamp)
|
84
|
+
from_name = @agent_badge.render(from)
|
85
|
+
to_name = @agent_badge.render(to)
|
86
|
+
|
87
|
+
"#{indent}#{@pastel.dim(time)} #{@pastel.green("#{UI::Icons::RESULT} Delegation result")} from #{from_name} #{@pastel.dim("→")} #{to_name}"
|
88
|
+
end
|
89
|
+
|
90
|
+
# Render hook execution
|
91
|
+
# [12:34:56] 🪝 Hook executed PreToolUse architect
|
92
|
+
def hook_executed(hook_event:, agent:, timestamp:, success:, blocked:)
|
93
|
+
indent = @depth_tracker.indent(agent)
|
94
|
+
time = Formatters::Time.timestamp(timestamp)
|
95
|
+
hook_display = @pastel.cyan(hook_event)
|
96
|
+
agent_name = @agent_badge.render(agent)
|
97
|
+
|
98
|
+
status = if blocked
|
99
|
+
@pastel.red("BLOCKED")
|
100
|
+
elsif success
|
101
|
+
@pastel.green("executed")
|
102
|
+
else
|
103
|
+
@pastel.yellow("warning")
|
104
|
+
end
|
105
|
+
|
106
|
+
color = if blocked
|
107
|
+
:red
|
108
|
+
else
|
109
|
+
(success ? :green : :yellow)
|
110
|
+
end
|
111
|
+
icon_colored = @pastel.public_send(color, UI::Icons::HOOK)
|
112
|
+
|
113
|
+
"#{indent}#{@pastel.dim(time)} #{icon_colored} Hook #{status} #{hook_display} #{agent_name}"
|
114
|
+
end
|
115
|
+
|
116
|
+
# Render usage stats line
|
117
|
+
# 5,922 tokens │ $0.0016 │ 1.5% used, 394,078 remaining
|
118
|
+
def usage_stats(tokens:, cost:, context_pct: nil, remaining: nil, cumulative: nil, indent: 0)
|
119
|
+
prefix = " " * indent
|
120
|
+
stats = @usage_stats.render(
|
121
|
+
tokens: tokens,
|
122
|
+
cost: cost,
|
123
|
+
context_pct: context_pct,
|
124
|
+
remaining: remaining,
|
125
|
+
cumulative: cumulative,
|
126
|
+
)
|
127
|
+
|
128
|
+
"#{prefix} #{stats}"
|
129
|
+
end
|
130
|
+
|
131
|
+
# Render tool list
|
132
|
+
# Tools available: Read, Write, Bash
|
133
|
+
def tools_available(tools, indent: 0)
|
134
|
+
return "" if tools.nil? || tools.empty?
|
135
|
+
|
136
|
+
prefix = " " * indent
|
137
|
+
tools_list = tools.join(", ")
|
138
|
+
|
139
|
+
"#{prefix} #{@pastel.dim("Tools available: #{tools_list}")}"
|
140
|
+
end
|
141
|
+
|
142
|
+
# Render delegation list
|
143
|
+
# Can delegate to: frontend_dev, backend_dev
|
144
|
+
def delegates_to(agents, indent: 0, color_cache:)
|
145
|
+
return "" if agents.nil? || agents.empty?
|
146
|
+
|
147
|
+
prefix = " " * indent
|
148
|
+
agent_badge = Components::AgentBadge.new(pastel: @pastel, color_cache: color_cache)
|
149
|
+
delegates_list = agent_badge.render_list(agents)
|
150
|
+
|
151
|
+
"#{prefix} #{@pastel.dim("Can delegate to:")} #{delegates_list}"
|
152
|
+
end
|
153
|
+
|
154
|
+
# Render thinking text (italic, indented)
|
155
|
+
def thinking_text(content, indent: 0)
|
156
|
+
return "" if content.nil? || content.empty?
|
157
|
+
|
158
|
+
# Strip system reminders
|
159
|
+
text = Formatters::Text.strip_system_reminders(content)
|
160
|
+
return "" if text.empty?
|
161
|
+
|
162
|
+
prefix = " " * indent
|
163
|
+
|
164
|
+
text.split("\n").map do |line|
|
165
|
+
"#{prefix} #{@pastel.italic(line)}"
|
166
|
+
end.join("\n")
|
167
|
+
end
|
168
|
+
|
169
|
+
# Render tool arguments
|
170
|
+
def tool_arguments(args, indent: 0, truncate: false)
|
171
|
+
@content_block.render_hash(args, indent: indent, label: "Arguments", truncate: truncate)
|
172
|
+
end
|
173
|
+
|
174
|
+
# Render tool result content
|
175
|
+
def tool_result_content(content, indent: 0, truncate: false)
|
176
|
+
@content_block.render_text(
|
177
|
+
content,
|
178
|
+
indent: indent,
|
179
|
+
color: :bright_green,
|
180
|
+
truncate: truncate,
|
181
|
+
max_lines: 2,
|
182
|
+
max_chars: 300,
|
183
|
+
)
|
184
|
+
end
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
188
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmCLI
|
4
|
+
module UI
|
5
|
+
module State
|
6
|
+
# Caches agent name → color assignments for consistent coloring
|
7
|
+
class AgentColorCache
|
8
|
+
# Professional color palette inspired by modern CLIs
|
9
|
+
PALETTE = [
|
10
|
+
:cyan,
|
11
|
+
:magenta,
|
12
|
+
:yellow,
|
13
|
+
:blue,
|
14
|
+
:green,
|
15
|
+
:bright_cyan,
|
16
|
+
:bright_magenta,
|
17
|
+
].freeze
|
18
|
+
|
19
|
+
def initialize
|
20
|
+
@cache = {}
|
21
|
+
@next_index = 0
|
22
|
+
end
|
23
|
+
|
24
|
+
# Get color for agent (cached)
|
25
|
+
def get(agent_name)
|
26
|
+
@cache[agent_name] ||= assign_next_color
|
27
|
+
end
|
28
|
+
|
29
|
+
# Reset cache (for testing)
|
30
|
+
def reset
|
31
|
+
@cache.clear
|
32
|
+
@next_index = 0
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def assign_next_color
|
38
|
+
color = PALETTE[@next_index % PALETTE.size]
|
39
|
+
@next_index += 1
|
40
|
+
color
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmCLI
|
4
|
+
module UI
|
5
|
+
module State
|
6
|
+
# Tracks agent depth for hierarchical indentation display
|
7
|
+
class DepthTracker
|
8
|
+
def initialize
|
9
|
+
@depths = {}
|
10
|
+
@seen_agents = []
|
11
|
+
end
|
12
|
+
|
13
|
+
# Get indentation depth for agent
|
14
|
+
def get(agent_name)
|
15
|
+
@depths[agent_name] ||= calculate_depth(agent_name)
|
16
|
+
end
|
17
|
+
|
18
|
+
# Get indent string for agent
|
19
|
+
def indent(agent_name, char: " ")
|
20
|
+
char * get(agent_name)
|
21
|
+
end
|
22
|
+
|
23
|
+
# Reset tracker (for testing)
|
24
|
+
def reset
|
25
|
+
@depths.clear
|
26
|
+
@seen_agents.clear
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def calculate_depth(agent_name)
|
32
|
+
@seen_agents << agent_name unless @seen_agents.include?(agent_name)
|
33
|
+
|
34
|
+
# First agent is depth 0, all others are depth 1
|
35
|
+
@seen_agents.size == 1 ? 0 : 1
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,170 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmCLI
|
4
|
+
module UI
|
5
|
+
module State
|
6
|
+
# Manages active spinners with elapsed time display
|
7
|
+
class SpinnerManager
|
8
|
+
def initialize
|
9
|
+
@active_spinners = {}
|
10
|
+
@time_updaters = {}
|
11
|
+
end
|
12
|
+
|
13
|
+
# Start a spinner with elapsed time tracking
|
14
|
+
#
|
15
|
+
# @param key [Symbol, String] Unique key for this spinner
|
16
|
+
# @param message [String] Spinner message
|
17
|
+
# @param format [Symbol] Spinner format (:dots, :pulse, etc.)
|
18
|
+
# @return [TTY::Spinner] The spinner instance
|
19
|
+
def start(key, message, format: :dots)
|
20
|
+
# Stop any existing spinner with this key
|
21
|
+
stop(key) if @active_spinners[key]
|
22
|
+
|
23
|
+
# Create spinner with elapsed time token
|
24
|
+
spinner = TTY::Spinner.new(
|
25
|
+
"[:spinner] #{message} (:elapsed)",
|
26
|
+
format: format,
|
27
|
+
hide_cursor: true,
|
28
|
+
)
|
29
|
+
|
30
|
+
spinner.auto_spin
|
31
|
+
|
32
|
+
# Spawn thread to update elapsed time every 1 second
|
33
|
+
# This is 10x slower than spinner animation (100ms), preventing flicker
|
34
|
+
@time_updaters[key] = Thread.new do
|
35
|
+
loop do
|
36
|
+
elapsed = spinner.duration
|
37
|
+
break unless elapsed
|
38
|
+
|
39
|
+
formatted_time = format_duration(elapsed)
|
40
|
+
spinner.update(elapsed: formatted_time)
|
41
|
+
sleep(1.0) # 1s refresh rate - smooth without flicker
|
42
|
+
rescue StandardError
|
43
|
+
break
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
@active_spinners[key] = spinner
|
48
|
+
spinner
|
49
|
+
end
|
50
|
+
|
51
|
+
# Stop spinner with success
|
52
|
+
#
|
53
|
+
# @param key [Symbol, String] Spinner key
|
54
|
+
# @param message [String] Success message
|
55
|
+
def success(key, message = "completed")
|
56
|
+
spinner = @active_spinners[key]
|
57
|
+
return unless spinner
|
58
|
+
|
59
|
+
# Kill time updater
|
60
|
+
kill_updater(key)
|
61
|
+
|
62
|
+
# Show final time
|
63
|
+
final_time = format_duration(spinner.duration || 0)
|
64
|
+
spinner.success("#{message} (#{final_time})")
|
65
|
+
|
66
|
+
cleanup(key)
|
67
|
+
end
|
68
|
+
|
69
|
+
# Stop spinner with error
|
70
|
+
#
|
71
|
+
# @param key [Symbol, String] Spinner key
|
72
|
+
# @param message [String] Error message
|
73
|
+
def error(key, message = "failed")
|
74
|
+
spinner = @active_spinners[key]
|
75
|
+
return unless spinner
|
76
|
+
|
77
|
+
# Kill time updater
|
78
|
+
kill_updater(key)
|
79
|
+
|
80
|
+
# Show final time
|
81
|
+
final_time = format_duration(spinner.duration || 0)
|
82
|
+
spinner.error("#{message} (#{final_time})")
|
83
|
+
|
84
|
+
cleanup(key)
|
85
|
+
end
|
86
|
+
|
87
|
+
# Stop spinner without success/error (just stop)
|
88
|
+
#
|
89
|
+
# @param key [Symbol, String] Spinner key
|
90
|
+
def stop(key)
|
91
|
+
spinner = @active_spinners[key]
|
92
|
+
return unless spinner
|
93
|
+
|
94
|
+
kill_updater(key)
|
95
|
+
spinner.stop
|
96
|
+
cleanup(key)
|
97
|
+
end
|
98
|
+
|
99
|
+
# Stop all active spinners
|
100
|
+
def stop_all
|
101
|
+
@active_spinners.keys.each { |key| stop(key) }
|
102
|
+
end
|
103
|
+
|
104
|
+
# Check if a spinner is active
|
105
|
+
#
|
106
|
+
# @param key [Symbol, String] Spinner key
|
107
|
+
# @return [Boolean]
|
108
|
+
def active?(key)
|
109
|
+
@active_spinners.key?(key)
|
110
|
+
end
|
111
|
+
|
112
|
+
# Pause all active spinners (for interactive debugging)
|
113
|
+
#
|
114
|
+
# This temporarily stops spinner animation while preserving state,
|
115
|
+
# allowing interactive sessions like binding.irb to run cleanly.
|
116
|
+
#
|
117
|
+
# @return [void]
|
118
|
+
def pause_all
|
119
|
+
@active_spinners.each_value do |spinner|
|
120
|
+
spinner.stop if spinner.spinning?
|
121
|
+
end
|
122
|
+
|
123
|
+
# Keep time updaters running (they'll safely handle stopped spinners)
|
124
|
+
end
|
125
|
+
|
126
|
+
# Resume all paused spinners
|
127
|
+
#
|
128
|
+
# Restarts spinner animation for all spinners that were paused.
|
129
|
+
#
|
130
|
+
# @return [void]
|
131
|
+
def resume_all
|
132
|
+
@active_spinners.each_value do |spinner|
|
133
|
+
spinner.auto_spin unless spinner.spinning?
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
private
|
138
|
+
|
139
|
+
def kill_updater(key)
|
140
|
+
updater = @time_updaters[key]
|
141
|
+
return unless updater
|
142
|
+
|
143
|
+
updater.kill if updater.alive?
|
144
|
+
@time_updaters.delete(key)
|
145
|
+
end
|
146
|
+
|
147
|
+
def cleanup(key)
|
148
|
+
@active_spinners.delete(key)
|
149
|
+
@time_updaters.delete(key)
|
150
|
+
end
|
151
|
+
|
152
|
+
def format_duration(seconds)
|
153
|
+
if seconds < 1
|
154
|
+
"#{(seconds * 1000).round}ms"
|
155
|
+
elsif seconds < 60
|
156
|
+
"#{seconds.round}s"
|
157
|
+
elsif seconds < 3600
|
158
|
+
minutes = (seconds / 60).floor
|
159
|
+
secs = (seconds % 60).round
|
160
|
+
"#{minutes}m #{secs}s"
|
161
|
+
else
|
162
|
+
hours = (seconds / 3600).floor
|
163
|
+
minutes = ((seconds % 3600) / 60).floor
|
164
|
+
"#{hours}h #{minutes}m"
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|