brute_cli 0.3.0 → 0.4.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/exe/brute +11 -26
- data/lib/brute_cli/buffer_output/error.rb +27 -0
- data/lib/brute_cli/buffer_output/model_line.rb +37 -0
- data/lib/brute_cli/buffer_output/separator.rb +24 -0
- data/lib/brute_cli/buffer_output/stats_bar.rb +43 -0
- data/lib/brute_cli/buffer_output.rb +11 -0
- data/lib/brute_cli/configuration.rb +14 -0
- data/lib/brute_cli/emoji.rb +25 -22
- data/lib/brute_cli/execution.rb +260 -0
- data/lib/brute_cli/phase/content_phase.rb +29 -0
- data/lib/brute_cli/phase/tool_call.rb +21 -0
- data/lib/brute_cli/phase/tool_phase.rb +29 -0
- data/lib/brute_cli/phase.rb +10 -0
- data/lib/brute_cli/repl.rb +99 -444
- data/lib/brute_cli/spinner/dots.rb +24 -0
- data/lib/brute_cli/spinner/nyan.rb +53 -0
- data/lib/brute_cli/spinner/puff_puff_pass.rb +32 -0
- data/lib/brute_cli/spinner.rb +30 -0
- data/lib/brute_cli/styles.rb +3 -0
- data/lib/brute_cli/tool_output/delegate.rb +16 -0
- data/lib/brute_cli/tool_output/fetch.rb +16 -0
- data/lib/brute_cli/tool_output/fs_search.rb +16 -0
- data/lib/brute_cli/tool_output/patch.rb +20 -0
- data/lib/brute_cli/tool_output/question.rb +9 -0
- data/lib/brute_cli/tool_output/read.rb +16 -0
- data/lib/brute_cli/tool_output/remove.rb +16 -0
- data/lib/brute_cli/tool_output/shell.rb +25 -0
- data/lib/brute_cli/tool_output/todo_read.rb +16 -0
- data/lib/brute_cli/tool_output/todo_write.rb +16 -0
- data/lib/brute_cli/tool_output/undo.rb +16 -0
- data/lib/brute_cli/tool_output/write.rb +20 -0
- data/lib/brute_cli/tool_output.rb +141 -0
- data/lib/brute_cli/version.rb +1 -1
- data/lib/brute_cli.rb +15 -12
- metadata +32 -4
data/lib/brute_cli/repl.rb
CHANGED
|
@@ -1,52 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "logger"
|
|
4
|
-
require "io/console"
|
|
5
|
-
require "json"
|
|
6
|
-
require "pp"
|
|
7
3
|
require "reline"
|
|
8
|
-
require "tty-spinner"
|
|
9
4
|
require "brute_cli/styles"
|
|
10
5
|
require "brute_cli/question_screen"
|
|
11
6
|
|
|
12
7
|
module BruteCLI
|
|
8
|
+
# REPL wraps an Execution with an interactive read-eval-print loop:
|
|
9
|
+
# Reline prompt, Tab-based agent cycling, slash commands, fzf file picking,
|
|
10
|
+
# and banner display.
|
|
11
|
+
#
|
|
12
|
+
# For non-interactive (pipe / single-prompt) use, use Execution directly.
|
|
13
13
|
class REPL
|
|
14
|
-
AGENTS = %w[build plan bash ruby python nix].freeze
|
|
15
|
-
|
|
16
|
-
# Shell-mode agents: agent name → shell interpreter (model name).
|
|
17
|
-
# These agents use the Shell provider instead of the current LLM provider.
|
|
18
|
-
SHELL_AGENTS = {
|
|
19
|
-
"bash" => "bash",
|
|
20
|
-
"ruby" => "ruby",
|
|
21
|
-
"python" => "python",
|
|
22
|
-
"nix" => "nix",
|
|
23
|
-
}.freeze
|
|
24
|
-
|
|
25
14
|
def initialize(options = {})
|
|
26
|
-
@
|
|
27
|
-
@
|
|
28
|
-
@agent = nil
|
|
29
|
-
@session = nil
|
|
30
|
-
@selected_model = nil # user-chosen model override (nil = provider default)
|
|
31
|
-
@models_cache = nil # cached model list from provider API
|
|
32
|
-
@saved_provider = nil # stashed LLM provider when in shell-mode agent
|
|
33
|
-
@width = TTY::Screen.width
|
|
34
|
-
@content_buf = +""
|
|
35
|
-
@streamer = StreamFormatter.new(width: @width)
|
|
36
|
-
@spinner = nil
|
|
37
|
-
@last_output = nil # :separator, :content, or :tool — used to deduplicate separators
|
|
38
|
-
@mu = Mutex.new
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def run_once(prompt)
|
|
42
|
-
ensure_agent!
|
|
43
|
-
execute(prompt)
|
|
15
|
+
@execution = Execution.new(options)
|
|
16
|
+
@saved_provider = nil # stashed LLM provider when in shell-mode agent
|
|
44
17
|
end
|
|
45
18
|
|
|
46
|
-
|
|
47
|
-
|
|
19
|
+
# Start the interactive REPL loop.
|
|
20
|
+
def run
|
|
21
|
+
@execution.ensure_session!
|
|
48
22
|
print_banner
|
|
49
|
-
resolve_provider_info
|
|
23
|
+
@execution.resolve_provider_info
|
|
50
24
|
setup_reline
|
|
51
25
|
|
|
52
26
|
loop do
|
|
@@ -62,8 +36,7 @@ module BruteCLI
|
|
|
62
36
|
next
|
|
63
37
|
end
|
|
64
38
|
|
|
65
|
-
|
|
66
|
-
execute(result)
|
|
39
|
+
@execution.run(result)
|
|
67
40
|
end
|
|
68
41
|
rescue Interrupt
|
|
69
42
|
puts
|
|
@@ -155,73 +128,90 @@ module BruteCLI
|
|
|
155
128
|
end
|
|
156
129
|
|
|
157
130
|
def read_prompt
|
|
158
|
-
|
|
131
|
+
puts BufferOutput::ModelLine.new(
|
|
132
|
+
provider_name: @execution.provider_name,
|
|
133
|
+
model_short: @execution.model_short,
|
|
134
|
+
current_agent: @execution.current_agent,
|
|
135
|
+
)
|
|
159
136
|
input = Reline.readmultiline(current_prompt.colorize(ACCENT_BOLD) + " ", true) { |t| !t.rstrip.end_with?("\\") }
|
|
160
137
|
return nil if input.nil?
|
|
161
138
|
|
|
162
139
|
input.gsub(/\\\n/, "\n").strip
|
|
163
140
|
end
|
|
164
141
|
|
|
165
|
-
|
|
142
|
+
def current_prompt
|
|
143
|
+
"%"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# ── Interactive selection ──
|
|
147
|
+
# Model, provider, and agent selection are REPL concerns — Execution
|
|
148
|
+
# only knows the configuration it's given.
|
|
166
149
|
|
|
167
|
-
def
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
150
|
+
def cycle_agent(direction = :forward)
|
|
151
|
+
agents = Execution::AGENTS
|
|
152
|
+
shell_agents = Execution::SHELL_AGENTS
|
|
153
|
+
step = direction == :backward ? -1 : 1
|
|
154
|
+
idx = (agents.index(@execution.current_agent) + step) % agents.size
|
|
155
|
+
new_agent = agents[idx]
|
|
156
|
+
|
|
157
|
+
# Switch provider when entering/leaving shell-mode agents.
|
|
158
|
+
if (shell_model = shell_agents[new_agent])
|
|
159
|
+
activate_shell_agent!(shell_model)
|
|
171
160
|
else
|
|
172
|
-
|
|
173
|
-
@provider_name = provider&.name&.to_s
|
|
174
|
-
@model_name = @selected_model || provider&.default_model&.to_s
|
|
161
|
+
restore_llm_provider!
|
|
175
162
|
end
|
|
176
|
-
end
|
|
177
163
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
164
|
+
@execution.current_agent = new_agent
|
|
165
|
+
reset_agent!
|
|
166
|
+
@execution.resolve_provider_info
|
|
181
167
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
168
|
+
# Rewrite the model/status line sitting one line above the prompt.
|
|
169
|
+
# Save cursor, move up, clear line, print, restore cursor.
|
|
170
|
+
line = BufferOutput::ModelLine.new(
|
|
171
|
+
provider_name: @execution.provider_name,
|
|
172
|
+
model_short: @execution.model_short,
|
|
173
|
+
current_agent: @execution.current_agent,
|
|
174
|
+
).to_s
|
|
175
|
+
$stdout.print "\e[s\e[A\r\e[2K#{line}\e[u"
|
|
176
|
+
$stdout.flush
|
|
187
177
|
end
|
|
188
178
|
|
|
189
|
-
def
|
|
190
|
-
|
|
179
|
+
def select_model(model_id)
|
|
180
|
+
@execution.selected_model = model_id
|
|
181
|
+
reset_agent!
|
|
182
|
+
@execution.resolve_provider_info
|
|
183
|
+
@models_cache = nil
|
|
191
184
|
end
|
|
192
185
|
|
|
193
|
-
|
|
186
|
+
def select_provider(provider_name)
|
|
187
|
+
new_provider = Brute.provider_for(provider_name)
|
|
188
|
+
return nil unless new_provider
|
|
194
189
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
190
|
+
Brute.provider = new_provider
|
|
191
|
+
@execution.selected_model = nil
|
|
192
|
+
reset_agent!
|
|
193
|
+
@execution.resolve_provider_info
|
|
194
|
+
@models_cache = nil
|
|
195
|
+
new_provider
|
|
198
196
|
end
|
|
199
197
|
|
|
200
|
-
|
|
201
|
-
|
|
198
|
+
# Fetch available chat models from the current provider.
|
|
199
|
+
# Results are cached to avoid repeated API calls; cleared on provider change.
|
|
200
|
+
def fetch_models
|
|
201
|
+
return @models_cache if @models_cache
|
|
202
202
|
|
|
203
|
-
|
|
203
|
+
provider = Brute.provider
|
|
204
|
+
return [] unless provider
|
|
204
205
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
206
|
+
begin
|
|
207
|
+
all = provider.models.all
|
|
208
|
+
@models_cache = all.select(&:chat?).map { |m| m.id.to_s }.sort
|
|
209
|
+
rescue => e
|
|
210
|
+
puts "Failed to fetch models: #{e.message}".colorize(ERROR_FG)
|
|
211
|
+
@models_cache = [provider.default_model.to_s]
|
|
210
212
|
end
|
|
211
213
|
|
|
212
|
-
@
|
|
213
|
-
cwd: @options[:cwd] || Dir.pwd,
|
|
214
|
-
model: @selected_model,
|
|
215
|
-
agent_name: @current_agent,
|
|
216
|
-
session: @session,
|
|
217
|
-
logger: Logger.new(File::NULL),
|
|
218
|
-
on_content: method(:on_content),
|
|
219
|
-
on_reasoning: method(:on_reasoning),
|
|
220
|
-
on_tool_call: method(:on_tool_call),
|
|
221
|
-
on_tool_result: method(:on_tool_result),
|
|
222
|
-
# on_question: disabled until bubbletea terminal integration is fixed
|
|
223
|
-
)
|
|
224
|
-
@session.restore(@agent.context) if @options[:session_id]
|
|
214
|
+
@models_cache
|
|
225
215
|
end
|
|
226
216
|
|
|
227
217
|
# Swap the global provider to Shell with the given interpreter model.
|
|
@@ -232,7 +222,7 @@ module BruteCLI
|
|
|
232
222
|
@saved_provider = current
|
|
233
223
|
end
|
|
234
224
|
Brute.provider = Brute::Providers::Shell.new
|
|
235
|
-
@selected_model = shell_model
|
|
225
|
+
@execution.selected_model = shell_model
|
|
236
226
|
end
|
|
237
227
|
|
|
238
228
|
# Restore the saved LLM provider when leaving a shell-mode agent.
|
|
@@ -240,48 +230,21 @@ module BruteCLI
|
|
|
240
230
|
if @saved_provider
|
|
241
231
|
Brute.provider = @saved_provider
|
|
242
232
|
@saved_provider = nil
|
|
243
|
-
@selected_model = nil
|
|
233
|
+
@execution.selected_model = nil
|
|
244
234
|
end
|
|
245
235
|
end
|
|
246
236
|
|
|
247
237
|
# Force the agent to be recreated on next ensure_agent! call.
|
|
248
|
-
# Used after changing provider, model, or agent.
|
|
249
238
|
def reset_agent!
|
|
250
|
-
@agent = nil
|
|
251
|
-
@models_cache = nil
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
def current_prompt
|
|
255
|
-
"%"
|
|
239
|
+
@execution.agent = nil
|
|
256
240
|
end
|
|
257
241
|
|
|
258
|
-
def
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
# Pre-resolve provider info for the status line.
|
|
265
|
-
# Shell agents show "shell" provider + interpreter model;
|
|
266
|
-
# LLM agents show the current LLM provider + model.
|
|
267
|
-
if (shell_model = SHELL_AGENTS[@current_agent])
|
|
268
|
-
@provider_name = "shell"
|
|
269
|
-
@model_name = shell_model
|
|
270
|
-
else
|
|
271
|
-
# Peek at what the LLM provider will be (saved or current).
|
|
272
|
-
provider = @saved_provider || (Brute.provider rescue nil)
|
|
273
|
-
@provider_name = provider&.name&.to_s
|
|
274
|
-
@model_name = @selected_model || provider&.default_model&.to_s
|
|
242
|
+
def compact_conversation
|
|
243
|
+
agent = @execution.agent
|
|
244
|
+
if agent
|
|
245
|
+
metadata = agent.env&.dig(:metadata) || {}
|
|
246
|
+
metadata.dig(:tokens, :total) || 0
|
|
275
247
|
end
|
|
276
|
-
|
|
277
|
-
# Rewrite the model/status line sitting one line above the prompt.
|
|
278
|
-
# Save cursor, move up, clear line, print, restore cursor.
|
|
279
|
-
parts = []
|
|
280
|
-
parts << stat_span(@provider_name, model_short) if @provider_name && model_short
|
|
281
|
-
parts << stat_span("agent", @current_agent)
|
|
282
|
-
line = parts.join(" · ".colorize(DIM))
|
|
283
|
-
$stdout.print "\e[s\e[A\r\e[2K#{line}\e[u"
|
|
284
|
-
$stdout.flush
|
|
285
248
|
end
|
|
286
249
|
|
|
287
250
|
# ── Commands ──
|
|
@@ -314,12 +277,9 @@ module BruteCLI
|
|
|
314
277
|
end
|
|
315
278
|
|
|
316
279
|
def cmd_compact
|
|
317
|
-
|
|
280
|
+
tokens = compact_conversation
|
|
281
|
+
if tokens
|
|
318
282
|
puts "Compacting conversation...".colorize(DIM)
|
|
319
|
-
# Trigger compaction by sending a hint through the pipeline
|
|
320
|
-
# For now, just report the current token count
|
|
321
|
-
metadata = @agent.env&.dig(:metadata) || {}
|
|
322
|
-
tokens = metadata.dig(:tokens, :total) || 0
|
|
323
283
|
puts "Current token count: #{tokens}".colorize(DIM)
|
|
324
284
|
puts "Manual compaction not yet implemented.".colorize(DIM)
|
|
325
285
|
else
|
|
@@ -372,20 +332,14 @@ module BruteCLI
|
|
|
372
332
|
case action
|
|
373
333
|
when :set_model
|
|
374
334
|
model_id = args.first
|
|
375
|
-
|
|
376
|
-
reset_agent!
|
|
377
|
-
resolve_provider_info
|
|
335
|
+
select_model(model_id)
|
|
378
336
|
puts separator
|
|
379
337
|
puts "Model changed to: #{model_id.colorize(ACCENT)}"
|
|
380
338
|
puts separator
|
|
381
339
|
when :set_provider
|
|
382
340
|
provider_name = args.first
|
|
383
|
-
|
|
384
|
-
if
|
|
385
|
-
Brute.provider = new_provider
|
|
386
|
-
@selected_model = nil
|
|
387
|
-
reset_agent!
|
|
388
|
-
resolve_provider_info
|
|
341
|
+
result = select_provider(provider_name)
|
|
342
|
+
if result
|
|
389
343
|
puts separator
|
|
390
344
|
puts "Provider changed to: #{provider_name.colorize(ACCENT)}"
|
|
391
345
|
puts "Select a model:".colorize(DIM)
|
|
@@ -400,6 +354,7 @@ module BruteCLI
|
|
|
400
354
|
|
|
401
355
|
def build_main_menu
|
|
402
356
|
repl = self
|
|
357
|
+
execution = @execution
|
|
403
358
|
|
|
404
359
|
FzfMenu.new do
|
|
405
360
|
menu :main, "Brute" do
|
|
@@ -416,7 +371,7 @@ module BruteCLI
|
|
|
416
371
|
# Dynamic: models from the current provider
|
|
417
372
|
menu(:models, "Select Model") do |m|
|
|
418
373
|
models = repl.send(:fetch_models)
|
|
419
|
-
current =
|
|
374
|
+
current = execution.model_name
|
|
420
375
|
|
|
421
376
|
models.each do |id|
|
|
422
377
|
label = id == current ? "#{id} (current)" : id
|
|
@@ -429,7 +384,7 @@ module BruteCLI
|
|
|
429
384
|
# Dynamic: only providers with configured API keys
|
|
430
385
|
menu(:providers, "Select Provider") do |m|
|
|
431
386
|
configured = Brute.configured_providers
|
|
432
|
-
current =
|
|
387
|
+
current = execution.provider_name
|
|
433
388
|
|
|
434
389
|
configured.each do |name|
|
|
435
390
|
label = name == current ? "#{name} (current)" : name
|
|
@@ -441,310 +396,18 @@ module BruteCLI
|
|
|
441
396
|
end
|
|
442
397
|
end
|
|
443
398
|
|
|
444
|
-
# Fetch available chat models from the current provider.
|
|
445
|
-
# Results are cached for the session to avoid repeated API calls.
|
|
446
|
-
def fetch_models
|
|
447
|
-
return @models_cache if @models_cache
|
|
448
|
-
|
|
449
|
-
provider = Brute.provider
|
|
450
|
-
return [] unless provider
|
|
451
|
-
|
|
452
|
-
begin
|
|
453
|
-
all = provider.models.all
|
|
454
|
-
@models_cache = all.select(&:chat?).map { |m| m.id.to_s }.sort
|
|
455
|
-
rescue => e
|
|
456
|
-
puts "Failed to fetch models: #{e.message}".colorize(ERROR_FG)
|
|
457
|
-
# Fall back to just the default model
|
|
458
|
-
@models_cache = [provider.default_model.to_s]
|
|
459
|
-
end
|
|
460
|
-
|
|
461
|
-
@models_cache
|
|
462
|
-
end
|
|
463
|
-
|
|
464
|
-
# ── Execute ──
|
|
465
|
-
|
|
466
|
-
def execute(prompt)
|
|
467
|
-
@content_buf = +""
|
|
468
|
-
@streamer.reset
|
|
469
|
-
@last_output = nil
|
|
470
|
-
|
|
471
|
-
start_spinner("Thinking...")
|
|
472
|
-
|
|
473
|
-
begin
|
|
474
|
-
@agent.run(prompt)
|
|
475
|
-
rescue Interrupt
|
|
476
|
-
stop_spinner
|
|
477
|
-
flush_content
|
|
478
|
-
puts "Aborted.".colorize(DIM)
|
|
479
|
-
print_stats_bar
|
|
480
|
-
return
|
|
481
|
-
rescue => e
|
|
482
|
-
stop_spinner
|
|
483
|
-
flush_content
|
|
484
|
-
print_error(e)
|
|
485
|
-
print_stats_bar
|
|
486
|
-
return
|
|
487
|
-
end
|
|
488
|
-
|
|
489
|
-
stop_spinner
|
|
490
|
-
flush_content
|
|
491
|
-
print_stats_bar
|
|
492
|
-
end
|
|
493
|
-
|
|
494
|
-
def print_model_line
|
|
495
|
-
parts = []
|
|
496
|
-
parts << stat_span(@provider_name, model_short) if @provider_name && model_short
|
|
497
|
-
parts << stat_span("agent", @current_agent)
|
|
498
|
-
puts parts.join(" · ".colorize(DIM))
|
|
499
|
-
end
|
|
500
|
-
|
|
501
|
-
# ── Spinner ──
|
|
502
|
-
|
|
503
|
-
RAINBOW = [
|
|
504
|
-
"\e[38;2;255;56;96m", "\e[38;2;255;165;0m",
|
|
505
|
-
"\e[38;2;255;220;0m", "\e[38;2;0;219;68m",
|
|
506
|
-
"\e[38;2;0;186;255m", "\e[38;2;107;80;255m",
|
|
507
|
-
"\e[38;2;255;96;255m",
|
|
508
|
-
].freeze
|
|
509
|
-
RESET = "\e[0m"
|
|
510
|
-
|
|
511
|
-
def nyan_frames
|
|
512
|
-
bar = "━" * 12
|
|
513
|
-
bar.length.times.map do |offset|
|
|
514
|
-
bar.chars.map.with_index { |c, i| RAINBOW[(i + offset) % RAINBOW.length] + c }.join + RESET
|
|
515
|
-
end
|
|
516
|
-
end
|
|
517
|
-
|
|
518
|
-
def start_spinner(label)
|
|
519
|
-
stop_spinner
|
|
520
|
-
puts separator unless @last_output == :separator
|
|
521
|
-
@last_output = :separator
|
|
522
|
-
@spinner = TTY::Spinner.new(
|
|
523
|
-
":spinner #{label}",
|
|
524
|
-
frames: nyan_frames,
|
|
525
|
-
interval: 8,
|
|
526
|
-
output: $stdout,
|
|
527
|
-
clear: true,
|
|
528
|
-
)
|
|
529
|
-
@spinner.auto_spin
|
|
530
|
-
end
|
|
531
|
-
|
|
532
|
-
def stop_spinner
|
|
533
|
-
if @spinner
|
|
534
|
-
if @spinner.spinning?
|
|
535
|
-
@spinner.stop
|
|
536
|
-
end
|
|
537
|
-
@spinner = nil
|
|
538
|
-
end
|
|
539
|
-
end
|
|
540
|
-
|
|
541
|
-
# ── Callbacks ──
|
|
542
|
-
|
|
543
|
-
def on_content(text)
|
|
544
|
-
@mu.synchronize do
|
|
545
|
-
stop_spinner
|
|
546
|
-
@content_buf << text
|
|
547
|
-
@streamer << text
|
|
548
|
-
@last_output = :content
|
|
549
|
-
end
|
|
550
|
-
end
|
|
551
|
-
|
|
552
|
-
def on_reasoning(_text); end
|
|
553
|
-
|
|
554
|
-
TOOL_ICONS = {
|
|
555
|
-
"read" => Emoji::EYES, "patch" => Emoji::HAMMER, "write" => Emoji::WRITING,
|
|
556
|
-
"shell" => Emoji::COMPUTER, "fs_search" => Emoji::MAG, "fetch" => Emoji::GLOBE,
|
|
557
|
-
"todo_read" => Emoji::CLIPBOARD, "todo_write" => Emoji::CLIPBOARD,
|
|
558
|
-
"remove" => Emoji::WASTEBASKET, "undo" => Emoji::REWIND, "delegate" => Emoji::ROBOT,
|
|
559
|
-
"question" => Emoji::DIAMOND,
|
|
560
|
-
}.freeze
|
|
561
|
-
|
|
562
|
-
TODO_STATUS = {
|
|
563
|
-
"pending" => Emoji::SQUARE,
|
|
564
|
-
"in_progress" => Emoji::ARROWS,
|
|
565
|
-
"completed" => Emoji::CHECK,
|
|
566
|
-
"cancelled" => Emoji::CROSS,
|
|
567
|
-
}.freeze
|
|
568
|
-
|
|
569
|
-
def on_tool_call(name, args)
|
|
570
|
-
@mu.synchronize do
|
|
571
|
-
stop_spinner
|
|
572
|
-
flush_content
|
|
573
|
-
@pending_tool = { name: name, args: args }
|
|
574
|
-
end
|
|
575
|
-
end
|
|
576
|
-
|
|
577
|
-
def on_tool_result(name, result)
|
|
578
|
-
@mu.synchronize do
|
|
579
|
-
stop_spinner
|
|
580
|
-
tool = @pending_tool || { name: name, args: {} }
|
|
581
|
-
|
|
582
|
-
puts separator unless @last_output == :separator
|
|
583
|
-
print_tool_result(tool, result)
|
|
584
|
-
@last_output = :tool
|
|
585
|
-
|
|
586
|
-
@pending_tool = nil
|
|
587
|
-
start_spinner("Thinking...")
|
|
588
|
-
end
|
|
589
|
-
end
|
|
590
|
-
|
|
591
|
-
# TODO: Interactive question forms are disabled while the bubbletea
|
|
592
|
-
# terminal integration is being worked on. For now, auto-select the
|
|
593
|
-
# first option for each question so the agent can continue.
|
|
594
|
-
def on_question(questions, reply_queue)
|
|
595
|
-
answers = questions.map do |q|
|
|
596
|
-
q = q.respond_to?(:transform_keys) ? q.transform_keys(&:to_s) : q
|
|
597
|
-
options = (q["options"] || []).map { |o| o.respond_to?(:transform_keys) ? o.transform_keys(&:to_s) : o }
|
|
598
|
-
first = options.first
|
|
599
|
-
first ? [first["label"].to_s] : []
|
|
600
|
-
end
|
|
601
|
-
|
|
602
|
-
reply_queue.push(answers)
|
|
603
|
-
end
|
|
604
|
-
|
|
605
|
-
# ── Output ──
|
|
606
|
-
|
|
607
|
-
def flush_content
|
|
608
|
-
unless @content_buf.strip.empty?
|
|
609
|
-
@streamer.flush
|
|
610
|
-
@content_buf = +""
|
|
611
|
-
@last_output = :content
|
|
612
|
-
end
|
|
613
|
-
end
|
|
614
|
-
|
|
615
|
-
def render_markdown(text)
|
|
616
|
-
BruteCLI::Bat.markdown_mode(text.strip, width: @width)
|
|
617
|
-
end
|
|
618
|
-
|
|
619
|
-
def print_tool_result(tool, result)
|
|
620
|
-
icon = TOOL_ICONS[tool[:name].to_s] || Emoji::GEAR
|
|
621
|
-
name = tool[:name].to_s
|
|
622
|
-
summary = tool_summary(tool) || ""
|
|
623
|
-
|
|
624
|
-
puts "#{icon} #{name.colorize(ACCENT_BG)} #{summary}"
|
|
625
|
-
|
|
626
|
-
# Diff
|
|
627
|
-
diff = result.is_a?(Hash) && (result[:diff] || result["diff"])
|
|
628
|
-
if diff && !diff.strip.empty?
|
|
629
|
-
print BruteCLI::Bat.diff_mode(diff, width: @width)
|
|
630
|
-
end
|
|
631
|
-
|
|
632
|
-
# Shell output
|
|
633
|
-
stdout = result.is_a?(Hash) && (result[:stdout] || result["stdout"])
|
|
634
|
-
if stdout && !stdout.strip.empty?
|
|
635
|
-
lines = stdout.strip.lines.map(&:chomp)
|
|
636
|
-
lines = lines.first(15) + ["... (truncated)"] if lines.size > 15
|
|
637
|
-
lines.each { |l| puts l.colorize(DIM) }
|
|
638
|
-
end
|
|
639
|
-
|
|
640
|
-
# Todos
|
|
641
|
-
todos = extract_todos(tool, result)
|
|
642
|
-
if todos && !todos.empty?
|
|
643
|
-
format_todos(todos).each { |line| puts line }
|
|
644
|
-
end
|
|
645
|
-
|
|
646
|
-
# Error (only on failure)
|
|
647
|
-
if error_result?(result)
|
|
648
|
-
msg = error_message(result)
|
|
649
|
-
msg = msg[0..70] + "..." if msg.length > 70
|
|
650
|
-
puts "#{"FAILED".colorize(ERROR_BG)} #{msg.colorize(DIM)}"
|
|
651
|
-
end
|
|
652
|
-
end
|
|
653
|
-
|
|
654
|
-
def extract_todos(tool, result)
|
|
655
|
-
name = tool[:name].to_s
|
|
656
|
-
if name == "todo_write"
|
|
657
|
-
args = tool[:args]
|
|
658
|
-
args = args.is_a?(Hash) ? (args[:todos] || args["todos"]) : nil
|
|
659
|
-
elsif name == "todo_read"
|
|
660
|
-
result.is_a?(Hash) ? (result[:todos] || result["todos"]) : nil
|
|
661
|
-
end
|
|
662
|
-
end
|
|
663
|
-
|
|
664
|
-
def format_todos(todos)
|
|
665
|
-
todos.map do |t|
|
|
666
|
-
t = t.transform_keys(&:to_s) if t.is_a?(Hash)
|
|
667
|
-
status = t["status"].to_s
|
|
668
|
-
icon = TODO_STATUS[status] || Emoji::SQUARE
|
|
669
|
-
content = t["content"] || t["id"] || "?"
|
|
670
|
-
" #{icon} #{content}"
|
|
671
|
-
end
|
|
672
|
-
end
|
|
673
|
-
|
|
674
|
-
def error_result?(result)
|
|
675
|
-
result.is_a?(Hash) && (result[:error] || result["error"])
|
|
676
|
-
end
|
|
677
|
-
|
|
678
|
-
def error_message(result)
|
|
679
|
-
if result.is_a?(Hash)
|
|
680
|
-
(
|
|
681
|
-
result[:message] ||
|
|
682
|
-
result["message"] ||
|
|
683
|
-
result[:error] ||
|
|
684
|
-
result["error"]
|
|
685
|
-
).to_s
|
|
686
|
-
else
|
|
687
|
-
""
|
|
688
|
-
end
|
|
689
|
-
end
|
|
690
|
-
|
|
691
|
-
def tool_summary(tool)
|
|
692
|
-
args = tool[:args]
|
|
693
|
-
return "" unless args.is_a?(Hash) && !args.empty?
|
|
694
|
-
|
|
695
|
-
path = args["file_path"] || args[:file_path]
|
|
696
|
-
cmd = args["command"] || args[:command]
|
|
697
|
-
pattern = args["pattern"] || args[:pattern]
|
|
698
|
-
|
|
699
|
-
if path then path.to_s.colorize(DIM)
|
|
700
|
-
elsif cmd then cmd.to_s[0..60].colorize(DIM)
|
|
701
|
-
elsif pattern then "\"#{pattern}\"".colorize(DIM)
|
|
702
|
-
else ""
|
|
703
|
-
end
|
|
704
|
-
end
|
|
705
|
-
|
|
706
|
-
# ── Stats ──
|
|
707
|
-
|
|
708
|
-
def print_stats_bar
|
|
709
|
-
metadata = @agent&.env&.dig(:metadata) || {}
|
|
710
|
-
tokens = metadata[:tokens] || {}
|
|
711
|
-
timing = metadata[:timing] || {}
|
|
712
|
-
tool_calls = metadata[:tool_calls] || 0
|
|
713
|
-
sep = " | ".colorize(DIM)
|
|
714
|
-
parts = []
|
|
715
|
-
parts << stat_span("tokens", (tokens[:total] || 0).to_s)
|
|
716
|
-
parts << stat_span("in", (tokens[:total_input] || 0).to_s)
|
|
717
|
-
parts << stat_span("out", (tokens[:total_output] || 0).to_s)
|
|
718
|
-
parts << stat_span("time", format_time(timing[:total_elapsed] || 0))
|
|
719
|
-
parts << stat_span("tools", tool_calls.to_s) if tool_calls > 0
|
|
720
|
-
puts separator unless @last_output == :separator
|
|
721
|
-
puts parts.join(sep)
|
|
722
|
-
puts thick_separator
|
|
723
|
-
end
|
|
724
|
-
|
|
725
|
-
def format_time(s)
|
|
726
|
-
s < 60 ? "#{s.round(1)}s" : "#{(s / 60).floor}m#{(s % 60).round(1)}s"
|
|
727
|
-
end
|
|
728
|
-
|
|
729
|
-
# ── Error ──
|
|
730
|
-
|
|
731
|
-
def print_error(err)
|
|
732
|
-
puts "#{Emoji::CROSS} #{"ERROR".colorize(ERROR_BG)}"
|
|
733
|
-
parsed = JSON.parse(err.message) rescue err.message
|
|
734
|
-
pp parsed
|
|
735
|
-
end
|
|
736
|
-
|
|
737
399
|
# ── UI ──
|
|
738
400
|
|
|
739
401
|
def print_banner
|
|
740
402
|
puts separator
|
|
741
|
-
puts BruteCLI::
|
|
403
|
+
puts BruteCLI::MONIKER.chomp.colorize(DIM)
|
|
742
404
|
puts separator
|
|
743
405
|
puts "Version #{Brute::VERSION}".colorize(DIM)
|
|
744
|
-
|
|
745
|
-
|
|
406
|
+
session = @execution.instance_variable_get(:@session)
|
|
407
|
+
if session
|
|
408
|
+
session_dir = File.join(Dir.home, ".brute", "sessions", session.id)
|
|
746
409
|
puts separator
|
|
747
|
-
puts "session_id: ".colorize(DIM) +
|
|
410
|
+
puts "session_id: ".colorize(DIM) + session.id.colorize(ACCENT)
|
|
748
411
|
puts "session_log: ".colorize(DIM) + session_dir.colorize(ACCENT)
|
|
749
412
|
end
|
|
750
413
|
check_dependencies
|
|
@@ -765,6 +428,10 @@ module BruteCLI
|
|
|
765
428
|
end
|
|
766
429
|
end
|
|
767
430
|
|
|
431
|
+
def separator
|
|
432
|
+
BufferOutput::Separator.new(width: @execution.detect_width)
|
|
433
|
+
end
|
|
434
|
+
|
|
768
435
|
def fzf_on_path?
|
|
769
436
|
ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, "fzf")) }
|
|
770
437
|
end
|
|
@@ -783,17 +450,5 @@ module BruteCLI
|
|
|
783
450
|
rescue Errno::ENOENT
|
|
784
451
|
nil
|
|
785
452
|
end
|
|
786
|
-
|
|
787
|
-
def separator
|
|
788
|
-
("─" * [@width, 40].max).colorize(DIM)
|
|
789
|
-
end
|
|
790
|
-
|
|
791
|
-
def thick_separator
|
|
792
|
-
("═" * [@width, 40].max).colorize(DIM)
|
|
793
|
-
end
|
|
794
|
-
|
|
795
|
-
def detect_width
|
|
796
|
-
TTY::Screen.width
|
|
797
|
-
end
|
|
798
453
|
end
|
|
799
454
|
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tty-spinner"
|
|
4
|
+
|
|
5
|
+
module BruteCLI
|
|
6
|
+
module Spinner
|
|
7
|
+
class Dots < Base
|
|
8
|
+
def start
|
|
9
|
+
stop if spinning?
|
|
10
|
+
@tty = TTY::Spinner.new(output: $stdout, clear: true)
|
|
11
|
+
@tty.auto_spin
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def stop
|
|
15
|
+
@tty&.stop
|
|
16
|
+
@tty = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def spinning?
|
|
20
|
+
@tty&.spinning? || false
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|