swarm_cli 2.0.0.pre.1 → 2.0.0

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.
@@ -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