brute_cli 0.1.1 → 0.1.2
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/exe/brute +22 -45
- data/lib/brute_cli/emoji.rb +31 -0
- data/lib/brute_cli/repl.rb +419 -0
- data/lib/brute_cli/styles.rb +45 -0
- data/lib/brute_cli/version.rb +1 -1
- data/lib/brute_cli.rb +12 -14
- metadata +38 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5d935729b509640bc8f34ab0250ba7aa4760c66f600e2f8ce4dc74ad2e9e756a
|
|
4
|
+
data.tar.gz: 7b61d6668976f55bfd334d718b6beb274ffb29a6cd85de1cd4178b7da59e211b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 750488466360f678c3ec34a637980dbb727e64f8b24cd27e86f015503a14b9dedd36da7eca002f905f4e7ddd191b42a9fe2d163bad229c83765d2a26de9f8ecf
|
|
7
|
+
data.tar.gz: 7917a11383995c907541543b4893f2067ab50ae16389b56054255e866a3f35f7b223e3b37d69c4b4b97876e245c9ef1bb843567598cf05024b497fde2fe43880
|
data/exe/brute
CHANGED
|
@@ -15,73 +15,50 @@ OptionParser.new do |opts|
|
|
|
15
15
|
opts.on("-h", "--help", "Show help") { puts opts; exit }
|
|
16
16
|
end.parse!
|
|
17
17
|
|
|
18
|
+
# ── List sessions ──
|
|
19
|
+
|
|
18
20
|
if options[:list]
|
|
19
21
|
sessions = Brute::Session.list
|
|
20
22
|
if sessions.empty?
|
|
21
23
|
puts "No saved sessions."
|
|
22
24
|
else
|
|
23
|
-
|
|
25
|
+
title_style = Lipgloss::Style.new.bold(true).foreground(BruteCLI::Styles::PURPLE)
|
|
26
|
+
id_style = Lipgloss::Style.new.foreground(BruteCLI::Styles::CYAN)
|
|
27
|
+
time_style = Lipgloss::Style.new.foreground(BruteCLI::Styles::DIM)
|
|
28
|
+
|
|
29
|
+
sessions.each do |s|
|
|
30
|
+
id = id_style.render(s[:id][0..7])
|
|
31
|
+
title = title_style.render(s[:title] || "(untitled)")
|
|
32
|
+
time = time_style.render(s[:saved_at].to_s)
|
|
33
|
+
puts " #{id} #{title} #{time}"
|
|
34
|
+
end
|
|
24
35
|
end
|
|
25
36
|
exit
|
|
26
37
|
end
|
|
27
38
|
|
|
28
|
-
#
|
|
29
|
-
session = Brute::Session.new(id: options[:session_id])
|
|
30
|
-
orch = nil
|
|
31
|
-
|
|
32
|
-
make_agent = -> {
|
|
33
|
-
return orch if orch
|
|
34
|
-
orch = Brute.agent(
|
|
35
|
-
cwd: options[:cwd],
|
|
36
|
-
session: session,
|
|
37
|
-
on_content: ->(text) { print text },
|
|
38
|
-
on_reasoning: ->(_text) { },
|
|
39
|
-
on_tool_call: ->(name, args) {
|
|
40
|
-
$stderr.puts "\n--- [tool] #{name} ---"
|
|
41
|
-
if args.is_a?(Hash)
|
|
42
|
-
args.each { |k, v| $stderr.puts " #{k}: #{v.to_s[0..100]}" }
|
|
43
|
-
end
|
|
44
|
-
},
|
|
45
|
-
on_tool_result: ->(name, result) {
|
|
46
|
-
if result.is_a?(Hash) && result[:error]
|
|
47
|
-
$stderr.puts " [FAILED] #{result[:error]}"
|
|
48
|
-
else
|
|
49
|
-
$stderr.puts " [ok]"
|
|
50
|
-
end
|
|
51
|
-
},
|
|
52
|
-
)
|
|
53
|
-
session.restore(orch.context) if options[:session_id]
|
|
54
|
-
orch
|
|
55
|
-
}
|
|
39
|
+
# ── Collect prompt ──
|
|
56
40
|
|
|
57
41
|
prompt = ARGV.join(" ")
|
|
58
42
|
prompt = $stdin.read.strip if prompt.empty? && !$stdin.tty?
|
|
59
43
|
|
|
44
|
+
# ── Run ──
|
|
45
|
+
|
|
46
|
+
repl = BruteCLI::REPL.new(options)
|
|
47
|
+
|
|
60
48
|
begin
|
|
61
49
|
if prompt.empty?
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
$stderr.print "brute> "
|
|
65
|
-
input = $stdin.gets&.chomp
|
|
66
|
-
break if input.nil? || input.strip == "exit"
|
|
67
|
-
next if input.strip.empty?
|
|
68
|
-
puts
|
|
69
|
-
make_agent.call.run(input)
|
|
70
|
-
puts "\n\n"
|
|
71
|
-
end
|
|
50
|
+
# Interactive mode
|
|
51
|
+
repl.run_interactive
|
|
72
52
|
else
|
|
73
|
-
|
|
53
|
+
# Single prompt mode
|
|
54
|
+
repl.run_once(prompt)
|
|
74
55
|
puts
|
|
75
56
|
end
|
|
76
57
|
rescue Interrupt
|
|
77
58
|
$stderr.puts "\nAborted."
|
|
78
59
|
exit 130
|
|
79
|
-
rescue RuntimeError, LLM::RateLimitError, LLM::ServerError => e
|
|
80
|
-
BruteCLI.error(e.message)
|
|
81
|
-
$stderr.puts " #{e.backtrace.first}".red if ENV["BRUTE_DEBUG"]
|
|
82
|
-
exit 1
|
|
83
60
|
rescue => e
|
|
84
61
|
BruteCLI.error(e.message)
|
|
85
|
-
$stderr.puts " #{e.backtrace
|
|
62
|
+
$stderr.puts " #{e.backtrace&.first}" if ENV["BRUTE_DEBUG"]
|
|
86
63
|
exit 1
|
|
87
64
|
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'gemoji'
|
|
4
|
+
|
|
5
|
+
module BruteCLI
|
|
6
|
+
module Emoji
|
|
7
|
+
def self.find(name)
|
|
8
|
+
::Emoji.find_by_alias(name)&.raw || ''
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
EYES = find('eyes')
|
|
12
|
+
PENCIL = find('pencil2')
|
|
13
|
+
PAGE = find('page_facing_up')
|
|
14
|
+
COMPUTER = find('computer')
|
|
15
|
+
SPARKLES = find('sparkles')
|
|
16
|
+
GLOBE = find('globe_with_meridians')
|
|
17
|
+
WASTEBASKET = find('wastebasket')
|
|
18
|
+
REWIND = find('rewind')
|
|
19
|
+
DIAMOND = find('diamond_shape_with_a_dot_inside')
|
|
20
|
+
GEAR = find('gear')
|
|
21
|
+
MAG = find('mag')
|
|
22
|
+
HAMMER = find('hammer_and_wrench')
|
|
23
|
+
PACKAGE = find('package')
|
|
24
|
+
CLIPBOARD = find('clipboard')
|
|
25
|
+
CHECK = find('white_check_mark')
|
|
26
|
+
CROSS = find('x')
|
|
27
|
+
WRITING = find('writing_hand')
|
|
28
|
+
ROBOT = find('robot')
|
|
29
|
+
FOLDER = find('file_folder')
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
require 'io/console'
|
|
5
|
+
require 'reline'
|
|
6
|
+
require 'tty-spinner'
|
|
7
|
+
require 'brute_cli/styles'
|
|
8
|
+
|
|
9
|
+
module BruteCLI
|
|
10
|
+
class REPL
|
|
11
|
+
def initialize(options = {})
|
|
12
|
+
@options = options
|
|
13
|
+
@agent = nil
|
|
14
|
+
@session = nil
|
|
15
|
+
@width = detect_width
|
|
16
|
+
@content_buf = +''
|
|
17
|
+
@spinner = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run_once(prompt)
|
|
21
|
+
ensure_agent!
|
|
22
|
+
execute(prompt)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def run_interactive
|
|
26
|
+
print_banner
|
|
27
|
+
resolve_provider_info
|
|
28
|
+
setup_reline
|
|
29
|
+
|
|
30
|
+
loop do
|
|
31
|
+
result = read_prompt
|
|
32
|
+
break if result.nil?
|
|
33
|
+
next if result.empty?
|
|
34
|
+
break if %w[exit quit].include?(result)
|
|
35
|
+
|
|
36
|
+
ensure_agent!
|
|
37
|
+
execute(result)
|
|
38
|
+
$stdout.puts
|
|
39
|
+
end
|
|
40
|
+
rescue Interrupt
|
|
41
|
+
$stdout.puts
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# ── Reline ──
|
|
47
|
+
|
|
48
|
+
def setup_reline
|
|
49
|
+
subtitle = build_subtitle
|
|
50
|
+
|
|
51
|
+
Reline.prompt_proc = proc { |lines|
|
|
52
|
+
lines.map.with_index do |_, i|
|
|
53
|
+
i == 0 ? Styles::PROMPT.render('>') + ' ' : Styles::DIM_TEXT.render('.') + ' '
|
|
54
|
+
end
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
Reline.add_dialog_proc(:brute_status, lambda {
|
|
58
|
+
Reline::DialogRenderInfo.new(
|
|
59
|
+
pos: Reline::CursorPos.new(0, cursor_pos.y > 0 ? 3 : 1),
|
|
60
|
+
contents: [subtitle],
|
|
61
|
+
width: screen_width
|
|
62
|
+
)
|
|
63
|
+
}, nil)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def read_prompt
|
|
67
|
+
input = Reline.readmultiline(Styles::PROMPT.render('>') + ' ', true) { |t| !t.rstrip.end_with?('\\') }
|
|
68
|
+
return nil if input.nil?
|
|
69
|
+
|
|
70
|
+
input.gsub(/\\\n/, "\n").strip
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# ── Provider ──
|
|
74
|
+
|
|
75
|
+
def resolve_provider_info
|
|
76
|
+
provider = begin
|
|
77
|
+
Brute.provider
|
|
78
|
+
rescue StandardError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
@provider_name = provider&.name&.to_s
|
|
82
|
+
@model_name = provider&.default_model&.to_s
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def model_short
|
|
86
|
+
@model_name&.sub(/^claude-/, '')&.sub(/-\d{8}$/, '') || @model_name
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def build_subtitle
|
|
90
|
+
parts = []
|
|
91
|
+
parts << stat_span(@provider_name, model_short) if @provider_name && model_short
|
|
92
|
+
parts << stat_span('agent', 'brute')
|
|
93
|
+
' ' + parts.join(Styles::DIM_TEXT.render(' · '))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def stat_span(label, value)
|
|
97
|
+
Styles::DIM_TEXT.render("#{label} ") + Styles::STAT_VALUE.render(value)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# ── Agent ──
|
|
101
|
+
|
|
102
|
+
def ensure_agent!
|
|
103
|
+
return if @agent
|
|
104
|
+
|
|
105
|
+
@session = Brute::Session.new(id: @options[:session_id])
|
|
106
|
+
@agent = Brute.agent(
|
|
107
|
+
cwd: @options[:cwd] || Dir.pwd,
|
|
108
|
+
session: @session,
|
|
109
|
+
logger: Logger.new(File::NULL),
|
|
110
|
+
on_content: method(:on_content),
|
|
111
|
+
on_reasoning: method(:on_reasoning),
|
|
112
|
+
on_tool_call: method(:on_tool_call),
|
|
113
|
+
on_tool_result: method(:on_tool_result)
|
|
114
|
+
)
|
|
115
|
+
@session.restore(@agent.context) if @options[:session_id]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# ── Execute ──
|
|
119
|
+
|
|
120
|
+
def execute(prompt)
|
|
121
|
+
@content_buf = +''
|
|
122
|
+
|
|
123
|
+
print_model_line
|
|
124
|
+
start_spinner('Thinking...')
|
|
125
|
+
|
|
126
|
+
begin
|
|
127
|
+
@agent.run(prompt)
|
|
128
|
+
rescue Interrupt
|
|
129
|
+
stop_spinner
|
|
130
|
+
flush_content
|
|
131
|
+
styled_puts Styles::DIM_TEXT.render(' Aborted.')
|
|
132
|
+
print_stats_bar
|
|
133
|
+
return
|
|
134
|
+
rescue StandardError => e
|
|
135
|
+
stop_spinner
|
|
136
|
+
flush_content
|
|
137
|
+
print_error(e)
|
|
138
|
+
print_stats_bar
|
|
139
|
+
return
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
stop_spinner
|
|
143
|
+
flush_content
|
|
144
|
+
print_stats_bar
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def print_model_line
|
|
148
|
+
parts = []
|
|
149
|
+
parts << stat_span(@provider_name, model_short) if @provider_name && model_short
|
|
150
|
+
parts << stat_span('agent', 'brute')
|
|
151
|
+
styled_puts ' ' + parts.join(Styles::DIM_TEXT.render(' · '))
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# ── Spinner ──
|
|
155
|
+
|
|
156
|
+
RAINBOW = [
|
|
157
|
+
"\e[38;2;255;56;96m", "\e[38;2;255;165;0m",
|
|
158
|
+
"\e[38;2;255;220;0m", "\e[38;2;0;219;68m",
|
|
159
|
+
"\e[38;2;0;186;255m", "\e[38;2;107;80;255m",
|
|
160
|
+
"\e[38;2;255;96;255m"
|
|
161
|
+
].freeze
|
|
162
|
+
RESET = "\e[0m"
|
|
163
|
+
|
|
164
|
+
def nyan_frames
|
|
165
|
+
bar = '━' * 12
|
|
166
|
+
bar.length.times.map do |offset|
|
|
167
|
+
bar.chars.map.with_index { |c, i| RAINBOW[(i + offset) % RAINBOW.length] + c }.join + RESET
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def start_spinner(label)
|
|
172
|
+
stop_spinner
|
|
173
|
+
@spinner = TTY::Spinner.new(
|
|
174
|
+
" :spinner #{label}",
|
|
175
|
+
frames: nyan_frames,
|
|
176
|
+
interval: 8,
|
|
177
|
+
output: $stdout
|
|
178
|
+
)
|
|
179
|
+
@spinner.auto_spin
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def stop_spinner
|
|
183
|
+
return unless @spinner
|
|
184
|
+
|
|
185
|
+
@spinner.stop('') if @spinner.spinning?
|
|
186
|
+
@spinner = nil
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# ── Callbacks ──
|
|
190
|
+
|
|
191
|
+
def on_content(text)
|
|
192
|
+
stop_spinner
|
|
193
|
+
@content_buf << text
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def on_reasoning(_text); end
|
|
197
|
+
|
|
198
|
+
INLINE_TOOLS = %w[read fs_search todo_read todo_write fetch].freeze
|
|
199
|
+
TOOL_ICONS = {
|
|
200
|
+
'read' => Emoji::EYES, 'patch' => Emoji::HAMMER, 'write' => Emoji::WRITING,
|
|
201
|
+
'shell' => Emoji::COMPUTER, 'fs_search' => Emoji::MAG, 'fetch' => Emoji::GLOBE,
|
|
202
|
+
'todo_read' => Emoji::CLIPBOARD, 'todo_write' => Emoji::CLIPBOARD,
|
|
203
|
+
'remove' => Emoji::WASTEBASKET, 'undo' => Emoji::REWIND, 'delegate' => Emoji::ROBOT
|
|
204
|
+
}.freeze
|
|
205
|
+
|
|
206
|
+
def on_tool_call(name, args)
|
|
207
|
+
stop_spinner
|
|
208
|
+
flush_content
|
|
209
|
+
@pending_tool = { name: name, args: args }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def on_tool_result(name, result)
|
|
213
|
+
stop_spinner
|
|
214
|
+
tool = @pending_tool || { name: name, args: {} }
|
|
215
|
+
|
|
216
|
+
if INLINE_TOOLS.include?(tool[:name])
|
|
217
|
+
print_inline_tool(tool, result)
|
|
218
|
+
else
|
|
219
|
+
print_block_tool(tool, result)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
@pending_tool = nil
|
|
223
|
+
start_spinner('Thinking...')
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# ── Output ──
|
|
227
|
+
|
|
228
|
+
def flush_content
|
|
229
|
+
return if @content_buf.strip.empty?
|
|
230
|
+
|
|
231
|
+
rendered = glamour_render(@content_buf)
|
|
232
|
+
$stdout.puts
|
|
233
|
+
$stdout.puts rendered
|
|
234
|
+
$stdout.flush
|
|
235
|
+
@content_buf = +''
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def glamour_render(text)
|
|
239
|
+
width = [@width - 4, 40].max
|
|
240
|
+
Glamour.render(text.strip, style: 'auto', width: width).rstrip
|
|
241
|
+
rescue StandardError => _e
|
|
242
|
+
text
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def print_inline_tool(tool, result)
|
|
246
|
+
icon = TOOL_ICONS[tool[:name].to_s] || Emoji::GEAR
|
|
247
|
+
name = tool[:name].to_s
|
|
248
|
+
summary = tool_summary(tool) || ''
|
|
249
|
+
|
|
250
|
+
if error_result?(result)
|
|
251
|
+
styled_puts " #{icon} #{Styles::TOOL_BADGE.render(name)} #{summary} #{Styles::TOOL_FAIL.render('FAILED')}"
|
|
252
|
+
else
|
|
253
|
+
styled_puts " #{icon} #{Styles::TOOL_BADGE.render(name)} #{summary}"
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def print_block_tool(tool, result)
|
|
258
|
+
icon = TOOL_ICONS[tool[:name].to_s] || Emoji::GEAR
|
|
259
|
+
name = tool[:name].to_s
|
|
260
|
+
title = "#{icon} #{Styles::TOOL_BADGE.render(name)}"
|
|
261
|
+
|
|
262
|
+
body_lines = []
|
|
263
|
+
|
|
264
|
+
summary = tool_summary(tool) || ''
|
|
265
|
+
body_lines << summary unless summary.empty?
|
|
266
|
+
|
|
267
|
+
# Diff
|
|
268
|
+
diff = result.is_a?(Hash) && (result[:diff] || result['diff'])
|
|
269
|
+
body_lines.concat(format_diff_lines(diff)) if diff && !diff.strip.empty?
|
|
270
|
+
|
|
271
|
+
# Shell output
|
|
272
|
+
stdout = result.is_a?(Hash) && (result[:stdout] || result['stdout'])
|
|
273
|
+
if stdout && !stdout.strip.empty?
|
|
274
|
+
lines = stdout.strip.lines.map(&:chomp)
|
|
275
|
+
lines = lines.first(15) + [Styles::DIM_TEXT.render('... (truncated)')] if lines.size > 15
|
|
276
|
+
body_lines.concat(lines.map { |l| Styles::DIM_TEXT.render(l) })
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Status
|
|
280
|
+
if error_result?(result)
|
|
281
|
+
msg = error_message(result)
|
|
282
|
+
msg = msg[0..70] + '...' if msg.length > 70
|
|
283
|
+
body_lines << "#{Styles::TOOL_FAIL.render('FAILED')} #{Styles::DIM_TEXT.render(msg)}"
|
|
284
|
+
else
|
|
285
|
+
body_lines << Styles::TOOL_OK.render('OK')
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
styled_puts render_titled_frame(title, body_lines)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def error_result?(result)
|
|
292
|
+
return false unless result.is_a?(Hash)
|
|
293
|
+
|
|
294
|
+
result[:error] || result['error']
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def error_message(result)
|
|
298
|
+
return '' unless result.is_a?(Hash)
|
|
299
|
+
|
|
300
|
+
(result[:message] || result['message'] || result[:error] || result['error']).to_s
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def tool_summary(tool)
|
|
304
|
+
args = tool[:args]
|
|
305
|
+
return '' unless args.is_a?(Hash) && !args.empty?
|
|
306
|
+
|
|
307
|
+
# Show the most relevant arg (file_path, command, etc.)
|
|
308
|
+
path = args['file_path'] || args[:file_path]
|
|
309
|
+
cmd = args['command'] || args[:command]
|
|
310
|
+
pattern = args['pattern'] || args[:pattern]
|
|
311
|
+
|
|
312
|
+
if path
|
|
313
|
+
Styles::DIM_TEXT.render(path.to_s)
|
|
314
|
+
elsif cmd
|
|
315
|
+
Styles::DIM_TEXT.render(cmd.to_s[0..60])
|
|
316
|
+
elsif pattern
|
|
317
|
+
Styles::DIM_TEXT.render("\"#{pattern}\"")
|
|
318
|
+
else
|
|
319
|
+
''
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def format_diff_lines(diff_text)
|
|
324
|
+
diff_text.lines.map do |line|
|
|
325
|
+
l = line.chomp
|
|
326
|
+
case l[0]
|
|
327
|
+
when '+' then Styles::DIFF_ADDED.render(l)
|
|
328
|
+
when '-' then Styles::DIFF_REMOVED.render(l)
|
|
329
|
+
when '@' then Styles::DIFF_HUNK.render(l)
|
|
330
|
+
else Styles::DIFF_CONTEXT.render(l)
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def render_titled_frame(title, body_lines)
|
|
336
|
+
m = Styles::SEPARATOR
|
|
337
|
+
title_w = visible_width(title)
|
|
338
|
+
body_w = body_lines.map { |l| visible_width(l) }.max || 0
|
|
339
|
+
inner_w = [title_w, body_w].max + 2
|
|
340
|
+
|
|
341
|
+
top = m.render('╭─ ') + title + ' ' + m.render('─' * [inner_w - title_w - 1, 0].max + '╮')
|
|
342
|
+
bot = m.render('╰' + '─' * (inner_w + 2) + '╯')
|
|
343
|
+
mid = body_lines.map do |l|
|
|
344
|
+
pad = inner_w - visible_width(l)
|
|
345
|
+
m.render('│') + ' ' + l + ' ' * [pad, 0].max + ' ' + m.render('│')
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
([top] + mid + [bot]).join("\n")
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def visible_width(str)
|
|
352
|
+
str.gsub(/\e\[[0-9;]*m/, '').gsub(/\p{Emoji_Presentation}|\p{Emoji}\uFE0F?/, 'XX').length
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# ── Stats ──
|
|
356
|
+
|
|
357
|
+
def print_stats_bar
|
|
358
|
+
metadata = @agent&.env&.dig(:metadata) || {}
|
|
359
|
+
tokens = metadata[:tokens] || {}
|
|
360
|
+
timing = metadata[:timing] || {}
|
|
361
|
+
tool_calls = metadata[:tool_calls] || 0
|
|
362
|
+
parts = []
|
|
363
|
+
parts << format_stat('tokens', format_tokens(tokens))
|
|
364
|
+
parts << format_stat('time', format_time(timing[:total_elapsed] || 0))
|
|
365
|
+
parts << format_stat('tools', tool_calls.to_s) if tool_calls > 0
|
|
366
|
+
$stdout.puts
|
|
367
|
+
styled_puts separator
|
|
368
|
+
styled_puts ' ' + parts.join(Styles::DIM_TEXT.render(' | '))
|
|
369
|
+
styled_puts separator
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def format_stat(l, v)
|
|
373
|
+
Styles::DIM_TEXT.render("#{l} ") + Styles::STAT_VALUE.render(v)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def format_tokens(t)
|
|
377
|
+
total = t[:total] || 0
|
|
378
|
+
return '0' if total == 0
|
|
379
|
+
|
|
380
|
+
"#{total} (#{t[:total_input] || 0}in/#{t[:total_output] || 0}out)"
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def format_time(s)
|
|
384
|
+
s < 60 ? "#{s.round(1)}s" : "#{(s / 60).floor}m#{(s % 60).round(1)}s"
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# ── Error ──
|
|
388
|
+
|
|
389
|
+
def print_error(err)
|
|
390
|
+
styled_puts "\n#{Styles::ERROR_BADGE.render('ERROR')} #{Styles::ERROR_REASON.render(err.message)}"
|
|
391
|
+
styled_puts Styles::DIM_TEXT.render(" #{err.backtrace.first}") if ENV['BRUTE_DEBUG'] && err.backtrace&.first
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# ── UI ──
|
|
395
|
+
|
|
396
|
+
def print_banner
|
|
397
|
+
styled_puts separator
|
|
398
|
+
styled_puts Styles::DIM_TEXT.render(" brute #{Brute::VERSION} — interactive mode")
|
|
399
|
+
styled_puts separator
|
|
400
|
+
$stdout.puts
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def styled_puts(text)
|
|
404
|
+
$stdout.puts text
|
|
405
|
+
$stdout.flush
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def separator
|
|
409
|
+
Styles::SEPARATOR.render('─' * [@width, 40].max)
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def detect_width
|
|
413
|
+
_rows, cols = IO.console&.winsize
|
|
414
|
+
cols || 80
|
|
415
|
+
rescue StandardError
|
|
416
|
+
80
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'lipgloss'
|
|
4
|
+
|
|
5
|
+
module BruteCLI
|
|
6
|
+
module Styles
|
|
7
|
+
# Styles.foreground("#fff").bold(true) etc -- delegates to Lipgloss::Style.new
|
|
8
|
+
def self.method_missing(name, *args, &block)
|
|
9
|
+
Lipgloss::Style.new.send(name, *args, &block)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.respond_to_missing?(name, include_private = false)
|
|
13
|
+
Lipgloss::Style.new.respond_to?(name) || super
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Brand colors
|
|
17
|
+
PURPLE = '#6B50FF'
|
|
18
|
+
PINK = '#FF60FF'
|
|
19
|
+
CYAN = '#3EEFCF'
|
|
20
|
+
RED = '#FF5F87'
|
|
21
|
+
DIM = '#757575'
|
|
22
|
+
MUTED = '#585858'
|
|
23
|
+
WHITE = '#F1F1F1'
|
|
24
|
+
DARK_BG = '#1A1A2E'
|
|
25
|
+
|
|
26
|
+
PROMPT = foreground(PURPLE).bold(true)
|
|
27
|
+
DIM_TEXT = foreground(DIM)
|
|
28
|
+
SEPARATOR = foreground(MUTED)
|
|
29
|
+
STAT_VALUE = foreground(CYAN)
|
|
30
|
+
ERROR_BADGE = foreground(WHITE).background(RED).bold(true).padding_left(1).padding_right(1)
|
|
31
|
+
ERROR_REASON = foreground(RED)
|
|
32
|
+
TOOL_BADGE = foreground(DARK_BG).background(CYAN).bold(true).padding_left(1).padding_right(1)
|
|
33
|
+
TOOL_ARG_KEY = foreground(PURPLE)
|
|
34
|
+
TOOL_ARG_VAL = foreground(DIM)
|
|
35
|
+
TOOL_OK = foreground(DARK_BG).background(CYAN).padding_left(1).padding_right(1)
|
|
36
|
+
TOOL_FAIL = foreground(WHITE).background(RED).bold(true).padding_left(1).padding_right(1)
|
|
37
|
+
TOOL_FRAME = border_style(:rounded).border_foreground(MUTED).padding_left(1).padding_right(1)
|
|
38
|
+
|
|
39
|
+
DIFF_ADDED = foreground('#4fd6be')
|
|
40
|
+
DIFF_REMOVED = foreground('#c53b53')
|
|
41
|
+
DIFF_HUNK = foreground(CYAN).bold(true)
|
|
42
|
+
DIFF_CONTEXT = foreground(DIM)
|
|
43
|
+
TOOL_INLINE = foreground(DIM)
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/brute_cli/version.rb
CHANGED
data/lib/brute_cli.rb
CHANGED
|
@@ -1,29 +1,27 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require "colorize"
|
|
5
|
-
require "emoji"
|
|
3
|
+
require 'brute'
|
|
6
4
|
|
|
7
|
-
# Optionally load brute_flow if available.
|
|
8
5
|
begin
|
|
9
|
-
require
|
|
6
|
+
require 'brute_flow'
|
|
10
7
|
rescue LoadError
|
|
11
|
-
# brute_flow is optional for CLI usage
|
|
12
8
|
end
|
|
13
9
|
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
require 'tty-spinner'
|
|
11
|
+
require 'lipgloss'
|
|
12
|
+
require 'glamour'
|
|
16
13
|
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
require 'brute_cli/version'
|
|
15
|
+
require 'brute_cli/styles'
|
|
16
|
+
require 'brute_cli/emoji'
|
|
17
|
+
require 'brute_cli/repl'
|
|
19
18
|
|
|
20
|
-
|
|
19
|
+
module BruteCLI
|
|
21
20
|
def self.error(message)
|
|
22
|
-
|
|
21
|
+
warn "#{Styles::ERROR_BADGE.render('ERROR')} #{Styles::ERROR_REASON.render(message)}"
|
|
23
22
|
end
|
|
24
23
|
|
|
25
|
-
# Print a yellow warning to stderr.
|
|
26
24
|
def self.warn(message)
|
|
27
|
-
|
|
25
|
+
warn Styles::DIM_TEXT.render("warning: #{message}")
|
|
28
26
|
end
|
|
29
27
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: brute_cli
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brute Contributors
|
|
@@ -38,33 +38,61 @@ dependencies:
|
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '0.1'
|
|
40
40
|
- !ruby/object:Gem::Dependency
|
|
41
|
-
name:
|
|
41
|
+
name: gemoji
|
|
42
42
|
requirement: !ruby/object:Gem::Requirement
|
|
43
43
|
requirements:
|
|
44
44
|
- - "~>"
|
|
45
45
|
- !ruby/object:Gem::Version
|
|
46
|
-
version: '
|
|
46
|
+
version: '4.1'
|
|
47
47
|
type: :runtime
|
|
48
48
|
prerelease: false
|
|
49
49
|
version_requirements: !ruby/object:Gem::Requirement
|
|
50
50
|
requirements:
|
|
51
51
|
- - "~>"
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
|
-
version: '
|
|
53
|
+
version: '4.1'
|
|
54
54
|
- !ruby/object:Gem::Dependency
|
|
55
|
-
name:
|
|
55
|
+
name: glamour
|
|
56
56
|
requirement: !ruby/object:Gem::Requirement
|
|
57
57
|
requirements:
|
|
58
58
|
- - "~>"
|
|
59
59
|
- !ruby/object:Gem::Version
|
|
60
|
-
version: '
|
|
60
|
+
version: '0.2'
|
|
61
61
|
type: :runtime
|
|
62
62
|
prerelease: false
|
|
63
63
|
version_requirements: !ruby/object:Gem::Requirement
|
|
64
64
|
requirements:
|
|
65
65
|
- - "~>"
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
|
-
version: '
|
|
67
|
+
version: '0.2'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: lipgloss
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0.2'
|
|
75
|
+
type: :runtime
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0.2'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: tty-spinner
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '0.9'
|
|
89
|
+
type: :runtime
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '0.9'
|
|
68
96
|
description: Interactive command-line interface for the Brute coding agent. Supports
|
|
69
97
|
single-prompt, interactive, piped, and session modes.
|
|
70
98
|
executables:
|
|
@@ -74,6 +102,9 @@ extra_rdoc_files: []
|
|
|
74
102
|
files:
|
|
75
103
|
- exe/brute
|
|
76
104
|
- lib/brute_cli.rb
|
|
105
|
+
- lib/brute_cli/emoji.rb
|
|
106
|
+
- lib/brute_cli/repl.rb
|
|
107
|
+
- lib/brute_cli/styles.rb
|
|
77
108
|
- lib/brute_cli/version.rb
|
|
78
109
|
licenses:
|
|
79
110
|
- MIT
|