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.
- checksums.yaml +4 -4
- data/lib/swarm_cli/cli.rb +21 -0
- 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 +627 -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,132 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmCLI
|
4
|
+
# Migrator converts Claude Swarm v1 YAML configurations to SwarmSDK v2 format.
|
5
|
+
#
|
6
|
+
# Key transformations:
|
7
|
+
# - version: 1 → 2
|
8
|
+
# - swarm.main → swarm.lead
|
9
|
+
# - swarm.instances → swarm.agents
|
10
|
+
# - For each agent:
|
11
|
+
# - prompt → system_prompt
|
12
|
+
# - connections → delegates_to
|
13
|
+
# - mcps → mcp_servers
|
14
|
+
# - allowed_tools → tools
|
15
|
+
# - vibe → bypass_permissions
|
16
|
+
# - reasoning_effort → parameters.reasoning
|
17
|
+
#
|
18
|
+
class Migrator
|
19
|
+
attr_reader :input_path
|
20
|
+
|
21
|
+
def initialize(input_path)
|
22
|
+
@input_path = input_path
|
23
|
+
end
|
24
|
+
|
25
|
+
def migrate
|
26
|
+
# Read and parse v1 YAML
|
27
|
+
v1_config = YAML.load_file(input_path)
|
28
|
+
|
29
|
+
# Validate it's a v1 config
|
30
|
+
unless v1_config["version"] == 1
|
31
|
+
raise SwarmCLI::ExecutionError, "Input file is not a v1 configuration (version: #{v1_config["version"]})"
|
32
|
+
end
|
33
|
+
|
34
|
+
# Build v2 config
|
35
|
+
v2_config = {
|
36
|
+
"version" => 2,
|
37
|
+
"swarm" => migrate_swarm(v1_config["swarm"]),
|
38
|
+
}
|
39
|
+
|
40
|
+
# Convert to YAML string
|
41
|
+
YAML.dump(v2_config)
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
def migrate_swarm(swarm)
|
47
|
+
v2_swarm = {
|
48
|
+
"name" => swarm["name"],
|
49
|
+
"lead" => swarm["main"], # main → lead
|
50
|
+
}
|
51
|
+
|
52
|
+
# Migrate instances → agents
|
53
|
+
if swarm["instances"]
|
54
|
+
v2_swarm["agents"] = migrate_agents(swarm["instances"])
|
55
|
+
end
|
56
|
+
|
57
|
+
v2_swarm
|
58
|
+
end
|
59
|
+
|
60
|
+
def migrate_agents(instances)
|
61
|
+
agents = {}
|
62
|
+
|
63
|
+
instances.each do |name, config|
|
64
|
+
agents[name] = migrate_agent(config)
|
65
|
+
end
|
66
|
+
|
67
|
+
agents
|
68
|
+
end
|
69
|
+
|
70
|
+
def migrate_agent(config)
|
71
|
+
agent = {}
|
72
|
+
|
73
|
+
# Copy fields that stay the same
|
74
|
+
agent["description"] = config["description"] if config["description"]
|
75
|
+
agent["directory"] = config["directory"] if config["directory"]
|
76
|
+
agent["model"] = config["model"] if config["model"]
|
77
|
+
|
78
|
+
# Migrate connections → delegates_to
|
79
|
+
agent["delegates_to"] = if config["connections"]
|
80
|
+
config["connections"]
|
81
|
+
elsif config.key?("connections") && config["connections"].nil?
|
82
|
+
# Explicit nil becomes empty array
|
83
|
+
[]
|
84
|
+
else
|
85
|
+
# No connections field - add empty array for clarity
|
86
|
+
[]
|
87
|
+
end
|
88
|
+
|
89
|
+
# Migrate prompt → system_prompt
|
90
|
+
agent["system_prompt"] = config["prompt"] if config["prompt"]
|
91
|
+
|
92
|
+
# Migrate mcps → mcp_servers
|
93
|
+
if config["mcps"]
|
94
|
+
agent["mcp_servers"] = config["mcps"]
|
95
|
+
end
|
96
|
+
|
97
|
+
# Migrate allowed_tools → tools
|
98
|
+
if config["allowed_tools"]
|
99
|
+
agent["tools"] = config["allowed_tools"]
|
100
|
+
end
|
101
|
+
|
102
|
+
# Migrate vibe → bypass_permissions
|
103
|
+
if config["vibe"]
|
104
|
+
agent["bypass_permissions"] = config["vibe"]
|
105
|
+
end
|
106
|
+
|
107
|
+
# Migrate reasoning_effort → parameters.reasoning
|
108
|
+
if config["reasoning_effort"]
|
109
|
+
agent["parameters"] ||= {}
|
110
|
+
agent["parameters"]["reasoning"] = config["reasoning_effort"]
|
111
|
+
end
|
112
|
+
|
113
|
+
# Copy any other fields (like provider, base_url, etc. if they exist)
|
114
|
+
# These are rare in v1 but handle them gracefully
|
115
|
+
["provider", "base_url", "api_version"].each do |field|
|
116
|
+
agent[field] = config[field] if config[field]
|
117
|
+
end
|
118
|
+
|
119
|
+
# Handle parameters field - merge if it exists
|
120
|
+
if config["parameters"]
|
121
|
+
agent["parameters"] ||= {}
|
122
|
+
agent["parameters"].merge!(config["parameters"])
|
123
|
+
end
|
124
|
+
|
125
|
+
# Copy tools and permissions if they exist (rare in v1)
|
126
|
+
agent["tools"] = config["tools"] if config["tools"] && !config["allowed_tools"]
|
127
|
+
agent["permissions"] = config["permissions"] if config["permissions"]
|
128
|
+
|
129
|
+
agent
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
data/lib/swarm_cli/options.rb
CHANGED
@@ -8,9 +8,11 @@ module SwarmCLI
|
|
8
8
|
program "swarm"
|
9
9
|
command "run"
|
10
10
|
desc "Execute a swarm with AI agents"
|
11
|
-
example "swarm run team.yml
|
12
|
-
example "
|
13
|
-
example "swarm run team.yml
|
11
|
+
example "swarm run team.yml # Interactive REPL"
|
12
|
+
example "swarm run team.yml 'Build a REST API' # REPL with initial message"
|
13
|
+
example "echo 'Build API' | swarm run team.yml # REPL with piped message"
|
14
|
+
example "swarm run team.yml -p 'Build a REST API' # Non-interactive mode"
|
15
|
+
example "echo 'Build API' | swarm run team.yml -p # Non-interactive from stdin"
|
14
16
|
end
|
15
17
|
|
16
18
|
argument :config_file do
|
@@ -18,10 +20,15 @@ module SwarmCLI
|
|
18
20
|
required
|
19
21
|
end
|
20
22
|
|
21
|
-
|
23
|
+
argument :prompt_text do
|
24
|
+
desc "Initial message for REPL or prompt for execution (optional)"
|
25
|
+
optional
|
26
|
+
end
|
27
|
+
|
28
|
+
flag :prompt do
|
22
29
|
short "-p"
|
23
|
-
long "--prompt
|
24
|
-
desc "
|
30
|
+
long "--prompt"
|
31
|
+
desc "Run in non-interactive mode (reads from argument or stdin)"
|
25
32
|
end
|
26
33
|
|
27
34
|
option :output_format do
|
@@ -62,24 +69,53 @@ module SwarmCLI
|
|
62
69
|
def validate!
|
63
70
|
errors = []
|
64
71
|
|
65
|
-
# At least one of prompt or stdin must be provided
|
66
|
-
if !prompt && $stdin.tty?
|
67
|
-
errors << "Prompt is required. Use -p or provide input via stdin."
|
68
|
-
end
|
69
|
-
|
70
72
|
# Config file must exist
|
71
73
|
if config_file && !File.exist?(config_file)
|
72
74
|
errors << "Configuration file not found: #{config_file}"
|
73
75
|
end
|
74
76
|
|
77
|
+
# Interactive mode cannot be used with JSON output
|
78
|
+
if interactive_mode? && output_format == "json"
|
79
|
+
errors << "Interactive mode is not compatible with --output-format json"
|
80
|
+
end
|
81
|
+
|
82
|
+
# Non-interactive mode requires a prompt
|
83
|
+
if non_interactive_mode? && !has_prompt_source?
|
84
|
+
errors << "Non-interactive mode (-p) requires a prompt (provide as argument or via stdin)"
|
85
|
+
end
|
86
|
+
|
75
87
|
unless errors.empty?
|
76
88
|
raise SwarmCLI::ExecutionError, errors.join("\n")
|
77
89
|
end
|
78
90
|
end
|
79
91
|
|
92
|
+
def interactive_mode?
|
93
|
+
# Interactive (REPL) mode when -p flag is NOT present
|
94
|
+
!params[:prompt]
|
95
|
+
end
|
96
|
+
|
97
|
+
def non_interactive_mode?
|
98
|
+
# Non-interactive mode when -p flag IS present
|
99
|
+
params[:prompt] == true
|
100
|
+
end
|
101
|
+
|
102
|
+
def initial_message
|
103
|
+
# For REPL mode - get initial message from argument or stdin (if piped)
|
104
|
+
return unless interactive_mode?
|
105
|
+
|
106
|
+
if params[:prompt_text] && !params[:prompt_text].empty?
|
107
|
+
params[:prompt_text]
|
108
|
+
elsif !$stdin.tty?
|
109
|
+
$stdin.read.strip
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
80
113
|
def prompt_text
|
81
|
-
|
82
|
-
|
114
|
+
# For non-interactive mode - get prompt from argument or stdin
|
115
|
+
raise SwarmCLI::ExecutionError, "Cannot get prompt_text in interactive mode" if interactive_mode?
|
116
|
+
|
117
|
+
@prompt_text ||= if params[:prompt_text] && !params[:prompt_text].empty?
|
118
|
+
params[:prompt_text]
|
83
119
|
elsif !$stdin.tty?
|
84
120
|
$stdin.read.strip
|
85
121
|
else
|
@@ -87,15 +123,15 @@ module SwarmCLI
|
|
87
123
|
end
|
88
124
|
end
|
89
125
|
|
126
|
+
def has_prompt_source?
|
127
|
+
(params[:prompt_text] && !params[:prompt_text].empty?) || !$stdin.tty?
|
128
|
+
end
|
129
|
+
|
90
130
|
# Convenience accessors that delegate to params
|
91
131
|
def config_file
|
92
132
|
params[:config_file]
|
93
133
|
end
|
94
134
|
|
95
|
-
def prompt
|
96
|
-
params[:prompt]
|
97
|
-
end
|
98
|
-
|
99
135
|
def output_format
|
100
136
|
params[:output_format]
|
101
137
|
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmCLI
|
4
|
+
module UI
|
5
|
+
module Components
|
6
|
+
# Renders agent names with consistent colors using cache
|
7
|
+
class AgentBadge
|
8
|
+
def initialize(pastel:, color_cache:)
|
9
|
+
@pastel = pastel
|
10
|
+
@color_cache = color_cache
|
11
|
+
end
|
12
|
+
|
13
|
+
# Render agent name with cached color
|
14
|
+
# architect → "architect" (in cyan)
|
15
|
+
def render(agent_name, icon: nil, bold: false)
|
16
|
+
color = @color_cache.get(agent_name)
|
17
|
+
text = icon ? "#{icon} #{agent_name}" : agent_name.to_s
|
18
|
+
|
19
|
+
styled = @pastel.public_send(color, text)
|
20
|
+
styled = @pastel.bold(styled) if bold
|
21
|
+
|
22
|
+
styled
|
23
|
+
end
|
24
|
+
|
25
|
+
# Render agent list (comma-separated, each colored)
|
26
|
+
# [architect, worker] → "architect, worker" (each colored differently)
|
27
|
+
def render_list(agent_names, separator: ", ")
|
28
|
+
agent_names.map { |name| render(name) }.join(separator)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,120 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmCLI
|
4
|
+
module UI
|
5
|
+
module Components
|
6
|
+
# Renders multi-line content blocks with indentation
|
7
|
+
class ContentBlock
|
8
|
+
def initialize(pastel:)
|
9
|
+
@pastel = pastel
|
10
|
+
end
|
11
|
+
|
12
|
+
# Render key-value pairs as indented block
|
13
|
+
# Arguments:
|
14
|
+
# file_path: "config.yml"
|
15
|
+
# mode: "read"
|
16
|
+
def render_hash(data, indent: 0, label: nil, truncate: false, max_value_length: 300)
|
17
|
+
return "" if data.nil? || data.empty?
|
18
|
+
|
19
|
+
lines = []
|
20
|
+
prefix = " " * indent
|
21
|
+
|
22
|
+
# Optional label
|
23
|
+
lines << "#{prefix}#{@pastel.dim("#{label}:")}" if label
|
24
|
+
|
25
|
+
# Render each key-value pair
|
26
|
+
data.each do |key, value|
|
27
|
+
formatted_value = format_value(value, truncate: truncate, max_length: max_value_length)
|
28
|
+
lines << "#{prefix} #{@pastel.cyan("#{key}:")} #{formatted_value}"
|
29
|
+
end
|
30
|
+
|
31
|
+
lines.join("\n")
|
32
|
+
end
|
33
|
+
|
34
|
+
# Render multi-line text with indentation
|
35
|
+
def render_text(text, indent: 0, color: :white, truncate: false, max_lines: nil, max_chars: nil)
|
36
|
+
return "" if text.nil? || text.empty?
|
37
|
+
|
38
|
+
prefix = " " * indent
|
39
|
+
content = text
|
40
|
+
|
41
|
+
# Strip system reminders
|
42
|
+
content = Formatters::Text.strip_system_reminders(content)
|
43
|
+
return "" if content.empty?
|
44
|
+
|
45
|
+
# Apply truncation if requested
|
46
|
+
if truncate
|
47
|
+
content, truncation_msg = Formatters::Text.truncate(
|
48
|
+
content,
|
49
|
+
lines: max_lines,
|
50
|
+
chars: max_chars,
|
51
|
+
)
|
52
|
+
end
|
53
|
+
|
54
|
+
# Render lines
|
55
|
+
lines = content.split("\n").map do |line|
|
56
|
+
"#{prefix} #{@pastel.public_send(color, line)}"
|
57
|
+
end
|
58
|
+
|
59
|
+
# Add truncation message if present
|
60
|
+
lines << "#{prefix} #{@pastel.dim(truncation_msg)}" if truncation_msg
|
61
|
+
|
62
|
+
lines.join("\n")
|
63
|
+
end
|
64
|
+
|
65
|
+
# Render list items
|
66
|
+
# • Item 1
|
67
|
+
# • Item 2
|
68
|
+
def render_list(items, indent: 0, bullet: UI::Icons::BULLET, color: :white)
|
69
|
+
return "" if items.nil? || items.empty?
|
70
|
+
|
71
|
+
prefix = " " * indent
|
72
|
+
|
73
|
+
items.map do |item|
|
74
|
+
"#{prefix} #{@pastel.public_send(color, "#{bullet} #{item}")}"
|
75
|
+
end.join("\n")
|
76
|
+
end
|
77
|
+
|
78
|
+
private
|
79
|
+
|
80
|
+
def format_value(value, truncate:, max_length:)
|
81
|
+
case value
|
82
|
+
when String
|
83
|
+
format_string_value(value, truncate: truncate, max_length: max_length)
|
84
|
+
when Array
|
85
|
+
@pastel.dim("[#{value.join(", ")}]")
|
86
|
+
when Hash
|
87
|
+
formatted = value.map { |k, v| "#{k}: #{v}" }.join(", ")
|
88
|
+
@pastel.dim("{#{formatted}}")
|
89
|
+
when Numeric
|
90
|
+
@pastel.white(value.to_s)
|
91
|
+
when TrueClass, FalseClass
|
92
|
+
@pastel.white(value.to_s)
|
93
|
+
when NilClass
|
94
|
+
@pastel.dim("nil")
|
95
|
+
else
|
96
|
+
@pastel.white(value.to_s)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def format_string_value(value, truncate:, max_length:)
|
101
|
+
return @pastel.white(value) unless truncate
|
102
|
+
return @pastel.white(value) if value.length <= max_length
|
103
|
+
|
104
|
+
lines = value.split("\n")
|
105
|
+
|
106
|
+
if lines.length > 3
|
107
|
+
# Multi-line content - show first 3 lines
|
108
|
+
preview = lines.first(3).join("\n")
|
109
|
+
line_info = "(#{lines.length} lines, #{value.length} chars)"
|
110
|
+
"#{@pastel.white(preview)}\n #{@pastel.dim("... #{line_info}")}"
|
111
|
+
else
|
112
|
+
# Single/few lines - character truncation
|
113
|
+
preview = value[0...max_length]
|
114
|
+
"#{@pastel.white(preview)}\n #{@pastel.dim("... (#{value.length} chars)")}"
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmCLI
|
4
|
+
module UI
|
5
|
+
module Components
|
6
|
+
# Divider rendering for visual separation
|
7
|
+
# Only horizontal lines - no side borders per design constraint
|
8
|
+
class Divider
|
9
|
+
def initialize(pastel:, terminal_width: 80)
|
10
|
+
@pastel = pastel
|
11
|
+
@terminal_width = terminal_width
|
12
|
+
end
|
13
|
+
|
14
|
+
# Full-width divider line
|
15
|
+
# ────────────────────────────────────────────────────────────
|
16
|
+
def full(char: "─", color: :dim)
|
17
|
+
@pastel.public_send(color, char * @terminal_width)
|
18
|
+
end
|
19
|
+
|
20
|
+
# Event separator (dotted, indented)
|
21
|
+
# ····························································
|
22
|
+
def event(indent: 0, char: "·")
|
23
|
+
prefix = " " * indent
|
24
|
+
line = char * 60
|
25
|
+
"#{prefix}#{@pastel.dim(line)}"
|
26
|
+
end
|
27
|
+
|
28
|
+
# Section divider with centered label
|
29
|
+
# ───────── Section Name ─────────
|
30
|
+
def section(label, char: "─", color: :dim)
|
31
|
+
label_width = label.length + 2 # Add spaces around label
|
32
|
+
total_line_width = @terminal_width
|
33
|
+
side_width = (total_line_width - label_width) / 2
|
34
|
+
|
35
|
+
left = char * side_width
|
36
|
+
right = char * (total_line_width - label_width - side_width)
|
37
|
+
|
38
|
+
@pastel.public_send(color, "#{left} #{label} #{right}")
|
39
|
+
end
|
40
|
+
|
41
|
+
# Top border only (no sides)
|
42
|
+
# ────────────────────────────────────────────────────────────
|
43
|
+
# Content here
|
44
|
+
def top(char: "─", color: :dim)
|
45
|
+
@pastel.public_send(color, char * @terminal_width)
|
46
|
+
end
|
47
|
+
|
48
|
+
# Bottom border only (no sides)
|
49
|
+
# Content here
|
50
|
+
# ────────────────────────────────────────────────────────────
|
51
|
+
def bottom(char: "─", color: :dim)
|
52
|
+
@pastel.public_send(color, char * @terminal_width)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmCLI
|
4
|
+
module UI
|
5
|
+
module Components
|
6
|
+
# Renders highlighted panels for warnings, info, errors
|
7
|
+
# Uses top/bottom borders only (no sides per design constraint)
|
8
|
+
class Panel
|
9
|
+
TYPE_CONFIGS = {
|
10
|
+
warning: { color: :yellow, icon: UI::Icons::WARNING },
|
11
|
+
error: { color: :red, icon: UI::Icons::ERROR },
|
12
|
+
info: { color: :cyan, icon: UI::Icons::INFO },
|
13
|
+
success: { color: :green, icon: UI::Icons::SUCCESS },
|
14
|
+
}.freeze
|
15
|
+
|
16
|
+
def initialize(pastel:, terminal_width: 80)
|
17
|
+
@pastel = pastel
|
18
|
+
@terminal_width = terminal_width
|
19
|
+
end
|
20
|
+
|
21
|
+
# Render panel with top/bottom borders
|
22
|
+
#
|
23
|
+
# ⚠️ CONTEXT WARNING
|
24
|
+
# Context usage: 81.4% (threshold: 80%)
|
25
|
+
# Tokens remaining: 74,523
|
26
|
+
#
|
27
|
+
def render(type:, title:, lines:, indent: 0)
|
28
|
+
config = TYPE_CONFIGS[type] || TYPE_CONFIGS[:info]
|
29
|
+
prefix = " " * indent
|
30
|
+
|
31
|
+
output = []
|
32
|
+
|
33
|
+
# Title line with icon
|
34
|
+
icon = config[:icon]
|
35
|
+
colored_title = @pastel.public_send(config[:color], "#{icon} #{title}")
|
36
|
+
output << "#{prefix}#{colored_title}"
|
37
|
+
|
38
|
+
# Content lines
|
39
|
+
lines.each do |line|
|
40
|
+
output << "#{prefix} #{line}"
|
41
|
+
end
|
42
|
+
|
43
|
+
output << "" # Blank line after panel
|
44
|
+
|
45
|
+
output.join("\n")
|
46
|
+
end
|
47
|
+
|
48
|
+
# Render compact panel (single line)
|
49
|
+
# ⚠️ Context approaching limit (81.4%)
|
50
|
+
def render_compact(type:, message:, indent: 0)
|
51
|
+
config = TYPE_CONFIGS[type] || TYPE_CONFIGS[:info]
|
52
|
+
prefix = " " * indent
|
53
|
+
|
54
|
+
icon = config[:icon]
|
55
|
+
colored_msg = @pastel.public_send(config[:color], "#{icon} #{message}")
|
56
|
+
|
57
|
+
"#{prefix}#{colored_msg}"
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmCLI
|
4
|
+
module UI
|
5
|
+
module Components
|
6
|
+
# Renders usage statistics (tokens, cost, context percentage)
|
7
|
+
class UsageStats
|
8
|
+
def initialize(pastel:)
|
9
|
+
@pastel = pastel
|
10
|
+
end
|
11
|
+
|
12
|
+
# Render usage line with all available metrics
|
13
|
+
# 5,922 tokens │ $0.0016 │ 1.5% used, 394,078 remaining
|
14
|
+
def render(tokens:, cost:, context_pct: nil, remaining: nil, cumulative: nil)
|
15
|
+
parts = []
|
16
|
+
|
17
|
+
# Token count (always shown)
|
18
|
+
parts << "#{Formatters::Number.format(tokens)} tokens"
|
19
|
+
|
20
|
+
# Cost (always shown if > 0)
|
21
|
+
parts << Formatters::Cost.format(cost, pastel: @pastel) if cost > 0
|
22
|
+
|
23
|
+
# Context tracking (if available)
|
24
|
+
if context_pct
|
25
|
+
colored_pct = color_context_percentage(context_pct)
|
26
|
+
|
27
|
+
parts << if remaining
|
28
|
+
"#{colored_pct} used, #{Formatters::Number.compact(remaining)} remaining"
|
29
|
+
else
|
30
|
+
"#{colored_pct} used"
|
31
|
+
end
|
32
|
+
elsif cumulative
|
33
|
+
# Model doesn't have context limit, show cumulative
|
34
|
+
parts << "#{Formatters::Number.compact(cumulative)} cumulative"
|
35
|
+
end
|
36
|
+
|
37
|
+
@pastel.dim(parts.join(" #{@pastel.dim("│")} "))
|
38
|
+
end
|
39
|
+
|
40
|
+
# Render compact stats for prompt display
|
41
|
+
# 15.2K tokens • $0.045 • 3.8% context
|
42
|
+
def render_compact(tokens:, cost:, context_pct: nil)
|
43
|
+
parts = []
|
44
|
+
|
45
|
+
parts << "#{Formatters::Number.compact(tokens)} tokens" if tokens > 0
|
46
|
+
parts << Formatters::Cost.format_plain(cost) if cost > 0
|
47
|
+
parts << "#{context_pct} context" if context_pct
|
48
|
+
|
49
|
+
parts.join(" • ")
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
def color_context_percentage(percentage_string)
|
55
|
+
percentage = percentage_string.to_s.gsub("%", "").to_f
|
56
|
+
|
57
|
+
color = if percentage < 50
|
58
|
+
:green
|
59
|
+
elsif percentage < 80
|
60
|
+
:yellow
|
61
|
+
else
|
62
|
+
:red
|
63
|
+
end
|
64
|
+
|
65
|
+
@pastel.public_send(color, percentage_string)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmCLI
|
4
|
+
module UI
|
5
|
+
module Formatters
|
6
|
+
# Cost formatting with color coding
|
7
|
+
class Cost
|
8
|
+
class << self
|
9
|
+
# Format cost with appropriate precision and color
|
10
|
+
# Small costs: green, $0.001234
|
11
|
+
# Medium costs: yellow, $0.1234
|
12
|
+
# Large costs: red, $12.34
|
13
|
+
def format(cost, pastel:)
|
14
|
+
return pastel.dim("$0.0000") if cost.nil? || cost.zero?
|
15
|
+
|
16
|
+
formatted = if cost < 0.01
|
17
|
+
Kernel.format("%.6f", cost)
|
18
|
+
elsif cost < 1.0
|
19
|
+
Kernel.format("%.4f", cost)
|
20
|
+
else
|
21
|
+
Kernel.format("%.2f", cost)
|
22
|
+
end
|
23
|
+
|
24
|
+
if cost < 0.01
|
25
|
+
pastel.green("$#{formatted}")
|
26
|
+
elsif cost < 1.0
|
27
|
+
pastel.yellow("$#{formatted}")
|
28
|
+
else
|
29
|
+
pastel.red("$#{formatted}")
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
# Format cost without color (for plain text)
|
34
|
+
def format_plain(cost)
|
35
|
+
return "$0.0000" if cost.nil? || cost.zero?
|
36
|
+
|
37
|
+
if cost < 0.01
|
38
|
+
Kernel.format("$%.6f", cost)
|
39
|
+
elsif cost < 1.0
|
40
|
+
Kernel.format("$%.4f", cost)
|
41
|
+
else
|
42
|
+
Kernel.format("$%.2f", cost)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmCLI
|
4
|
+
module UI
|
5
|
+
module Formatters
|
6
|
+
# Number formatting utilities for terminal display
|
7
|
+
class Number
|
8
|
+
class << self
|
9
|
+
# Format number with thousand separators
|
10
|
+
# 5922 → "5,922"
|
11
|
+
# 1500000 → "1,500,000"
|
12
|
+
def format(num)
|
13
|
+
return "0" if num.nil? || num.zero?
|
14
|
+
|
15
|
+
num.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
16
|
+
end
|
17
|
+
|
18
|
+
# Format number with compact units (K, M, B)
|
19
|
+
# 5922 → "5.9K"
|
20
|
+
# 1500000 → "1.5M"
|
21
|
+
# 1500000000 → "1.5B"
|
22
|
+
def compact(num)
|
23
|
+
return "0" if num.nil? || num.zero?
|
24
|
+
|
25
|
+
case num
|
26
|
+
when 0...1_000
|
27
|
+
num.to_s
|
28
|
+
when 1_000...1_000_000
|
29
|
+
"#{(num / 1_000.0).round(1)}K"
|
30
|
+
when 1_000_000...1_000_000_000
|
31
|
+
"#{(num / 1_000_000.0).round(1)}M"
|
32
|
+
else
|
33
|
+
"#{(num / 1_000_000_000.0).round(1)}B"
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# Format bytes with units (KB, MB, GB)
|
38
|
+
# 1024 → "1.0 KB"
|
39
|
+
# 1500000 → "1.4 MB"
|
40
|
+
def bytes(num)
|
41
|
+
return "0 B" if num.nil? || num.zero?
|
42
|
+
|
43
|
+
case num
|
44
|
+
when 0...1024
|
45
|
+
"#{num} B"
|
46
|
+
when 1024...1024**2
|
47
|
+
"#{(num / 1024.0).round(1)} KB"
|
48
|
+
when 1024**2...1024**3
|
49
|
+
"#{(num / 1024.0**2).round(1)} MB"
|
50
|
+
else
|
51
|
+
"#{(num / 1024.0**3).round(1)} GB"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|