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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/exe/brute +11 -26
  3. data/lib/brute_cli/buffer_output/error.rb +27 -0
  4. data/lib/brute_cli/buffer_output/model_line.rb +37 -0
  5. data/lib/brute_cli/buffer_output/separator.rb +24 -0
  6. data/lib/brute_cli/buffer_output/stats_bar.rb +43 -0
  7. data/lib/brute_cli/buffer_output.rb +11 -0
  8. data/lib/brute_cli/configuration.rb +14 -0
  9. data/lib/brute_cli/emoji.rb +25 -22
  10. data/lib/brute_cli/execution.rb +260 -0
  11. data/lib/brute_cli/phase/content_phase.rb +29 -0
  12. data/lib/brute_cli/phase/tool_call.rb +21 -0
  13. data/lib/brute_cli/phase/tool_phase.rb +29 -0
  14. data/lib/brute_cli/phase.rb +10 -0
  15. data/lib/brute_cli/repl.rb +99 -444
  16. data/lib/brute_cli/spinner/dots.rb +24 -0
  17. data/lib/brute_cli/spinner/nyan.rb +53 -0
  18. data/lib/brute_cli/spinner/puff_puff_pass.rb +32 -0
  19. data/lib/brute_cli/spinner.rb +30 -0
  20. data/lib/brute_cli/styles.rb +3 -0
  21. data/lib/brute_cli/tool_output/delegate.rb +16 -0
  22. data/lib/brute_cli/tool_output/fetch.rb +16 -0
  23. data/lib/brute_cli/tool_output/fs_search.rb +16 -0
  24. data/lib/brute_cli/tool_output/patch.rb +20 -0
  25. data/lib/brute_cli/tool_output/question.rb +9 -0
  26. data/lib/brute_cli/tool_output/read.rb +16 -0
  27. data/lib/brute_cli/tool_output/remove.rb +16 -0
  28. data/lib/brute_cli/tool_output/shell.rb +25 -0
  29. data/lib/brute_cli/tool_output/todo_read.rb +16 -0
  30. data/lib/brute_cli/tool_output/todo_write.rb +16 -0
  31. data/lib/brute_cli/tool_output/undo.rb +16 -0
  32. data/lib/brute_cli/tool_output/write.rb +20 -0
  33. data/lib/brute_cli/tool_output.rb +141 -0
  34. data/lib/brute_cli/version.rb +1 -1
  35. data/lib/brute_cli.rb +15 -12
  36. metadata +32 -4
@@ -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
- @options = options
27
- @current_agent = AGENTS.first
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
- def run_interactive
47
- ensure_session!
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
- ensure_agent!
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
- print_model_line
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
- # ── Provider ──
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 resolve_provider_info
168
- if (shell_model = SHELL_AGENTS[@current_agent])
169
- @provider_name = "shell"
170
- @model_name = shell_model
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
- provider = Brute.provider rescue nil
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
- def model_short
179
- @model_name&.sub(/^claude-/, "")&.sub(/-\d{8}$/, "") || @model_name
180
- end
164
+ @execution.current_agent = new_agent
165
+ reset_agent!
166
+ @execution.resolve_provider_info
181
167
 
182
- def build_subtitle
183
- parts = []
184
- parts << stat_span(@provider_name, model_short) if @provider_name && model_short
185
- parts << stat_span("agent", @current_agent)
186
- parts.join(" · ".colorize(DIM))
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 stat_span(label, value)
190
- "#{label} ".colorize(DIM) + value.to_s.colorize(ACCENT)
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
- # ── Agent ──
186
+ def select_provider(provider_name)
187
+ new_provider = Brute.provider_for(provider_name)
188
+ return nil unless new_provider
194
189
 
195
- def ensure_session!
196
- return if @session
197
- @session = Brute::Session.new(id: @options[:session_id])
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
- def ensure_agent!
201
- return if @agent
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
- ensure_session!
203
+ provider = Brute.provider
204
+ return [] unless provider
204
205
 
205
- # Shell-mode agents swap the provider to Shell with the right interpreter.
206
- if (shell_model = SHELL_AGENTS[@current_agent])
207
- activate_shell_agent!(shell_model)
208
- else
209
- restore_llm_provider!
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
- @agent = Brute.agent(
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 cycle_agent(direction = :forward)
259
- step = direction == :backward ? -1 : 1
260
- idx = (AGENTS.index(@current_agent) + step) % AGENTS.size
261
- @current_agent = AGENTS[idx]
262
- reset_agent!
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
- if @agent
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
- @selected_model = model_id
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
- new_provider = Brute.provider_for(provider_name)
384
- if new_provider
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 = repl.instance_variable_get(:@model_name)
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 = repl.instance_variable_get(:@provider_name)
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::LOGO.chomp.colorize(DIM)
403
+ puts BruteCLI::MONIKER.chomp.colorize(DIM)
742
404
  puts separator
743
405
  puts "Version #{Brute::VERSION}".colorize(DIM)
744
- if @session
745
- session_dir = File.join(Dir.home, ".brute", "sessions", @session.id)
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) + @session.id.colorize(ACCENT)
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