brute_cli 0.1.2 → 0.1.3

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.
@@ -1,20 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'logger'
4
- require 'io/console'
5
- require 'reline'
6
- require 'tty-spinner'
7
- require 'brute_cli/styles'
3
+ require "logger"
4
+ require "io/console"
5
+ require "json"
6
+ require "pp"
7
+ require "reline"
8
+ require "tty-spinner"
9
+ require "brute_cli/styles"
10
+ require "brute_cli/question_screen"
8
11
 
9
12
  module BruteCLI
10
13
  class REPL
14
+ AGENTS = %w[build plan].freeze
15
+
11
16
  def initialize(options = {})
12
17
  @options = options
18
+ @current_agent = AGENTS.first
13
19
  @agent = nil
14
20
  @session = nil
15
- @width = detect_width
16
- @content_buf = +''
21
+ @selected_model = nil # user-chosen model override (nil = provider default)
22
+ @models_cache = nil # cached model list from provider API
23
+ @width = TTY::Screen.width
24
+ @content_buf = +""
25
+ @streamer = StreamFormatter.new(width: @width)
17
26
  @spinner = nil
27
+ @mu = Mutex.new
18
28
  end
19
29
 
20
30
  def run_once(prompt)
@@ -23,6 +33,7 @@ module BruteCLI
23
33
  end
24
34
 
25
35
  def run_interactive
36
+ ensure_session!
26
37
  print_banner
27
38
  resolve_provider_info
28
39
  setup_reline
@@ -33,12 +44,18 @@ module BruteCLI
33
44
  next if result.empty?
34
45
  break if %w[exit quit].include?(result)
35
46
 
47
+ # Slash command dispatch
48
+ if Commands.match?(result)
49
+ action = handle_command(result)
50
+ break if action == :exit
51
+ next
52
+ end
53
+
36
54
  ensure_agent!
37
55
  execute(result)
38
- $stdout.puts
39
56
  end
40
57
  rescue Interrupt
41
- $stdout.puts
58
+ puts
42
59
  end
43
60
 
44
61
  private
@@ -46,25 +63,74 @@ module BruteCLI
46
63
  # ── Reline ──
47
64
 
48
65
  def setup_reline
49
- subtitle = build_subtitle
66
+ Reline.autocompletion = true
67
+ Reline.completion_append_character = " "
68
+ Reline.completion_proc = method(:complete_input)
50
69
 
51
70
  Reline.prompt_proc = proc { |lines|
71
+ prompt_text = current_prompt
72
+ continuation = ">".rjust(prompt_text.length)
52
73
  lines.map.with_index do |_, i|
53
- i == 0 ? Styles::PROMPT.render('>') + ' ' : Styles::DIM_TEXT.render('.') + ' '
74
+ i == 0 ? prompt_text.colorize(ACCENT_BOLD) + " " : continuation.colorize(DIM) + " "
54
75
  end
55
76
  }
56
77
 
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)
78
+ # Rebind Tab (^I = byte 9) to cycle agents when the buffer is empty,
79
+ # otherwise fall through to normal completion. We define a custom method
80
+ # on the singleton LineEditor instance and point the emacs keymap at it.
81
+ repl = self
82
+ Reline.line_editor.define_singleton_method(:cycle_or_complete) do |key|
83
+ if current_line.empty?
84
+ repl.send(:cycle_agent)
85
+ # Reline caches prompt_list based on (whole_lines, mode_string).
86
+ # Since neither changed, the cache returns the stale prompt.
87
+ # Clear it so the next rerender re-evaluates prompt_proc.
88
+ @cache.delete(:prompt_list)
89
+ @cache.delete(:wrapped_prompt_and_input_lines)
90
+ else
91
+ complete(key)
92
+ end
93
+ end
94
+ Reline.core.config.add_default_key_binding_by_keymap(:emacs, [9], :cycle_or_complete)
95
+ end
96
+
97
+ # Reline completion callback.
98
+ # - File paths: typing "./" launches fzf, injects selected path into the buffer.
99
+ # Backspacing back to "./" does NOT re-trigger.
100
+ # - Slash commands: "/" at the beginning of a line.
101
+ def complete_input(target, preposing = "", _postposing = "")
102
+ prev = @last_completion_target
103
+ @last_completion_target = target
104
+
105
+ # ./ triggers fzf file picker (only on forward typing, not backspace)
106
+ if target == "./"
107
+ backspacing = prev&.start_with?("./") && prev.length > target.length
108
+ unless backspacing
109
+ path = fzf_pick_file
110
+ if path
111
+ cursor = Reline.point
112
+ Reline.delete_text(cursor - target.bytesize, target.bytesize)
113
+ Reline.point = cursor - target.bytesize
114
+ Reline.insert_text(path)
115
+ end
116
+ # Invalidate Reline's render cache so it fully redraws prompt + buffer
117
+ Reline.line_editor.send(:clear_rendered_screen_cache)
118
+ Reline.redisplay
119
+ end
120
+ return []
121
+ end
122
+
123
+ # Slash commands at start of line
124
+ if (preposing.nil? || preposing.strip.empty?) && target.start_with?("/")
125
+ return Commands.names.select { |name| name.start_with?(target) }
126
+ end
127
+
128
+ []
64
129
  end
65
130
 
66
131
  def read_prompt
67
- input = Reline.readmultiline(Styles::PROMPT.render('>') + ' ', true) { |t| !t.rstrip.end_with?('\\') }
132
+ print_model_line
133
+ input = Reline.readmultiline(current_prompt.colorize(ACCENT_BOLD) + " ", true) { |t| !t.rstrip.end_with?("\\") }
68
134
  return nil if input.nil?
69
135
 
70
136
  input.gsub(/\\\n/, "\n").strip
@@ -73,65 +139,271 @@ module BruteCLI
73
139
  # ── Provider ──
74
140
 
75
141
  def resolve_provider_info
76
- provider = begin
77
- Brute.provider
78
- rescue StandardError
79
- nil
80
- end
142
+ provider = Brute.provider rescue nil
81
143
  @provider_name = provider&.name&.to_s
82
- @model_name = provider&.default_model&.to_s
144
+ @model_name = @selected_model || provider&.default_model&.to_s
83
145
  end
84
146
 
85
147
  def model_short
86
- @model_name&.sub(/^claude-/, '')&.sub(/-\d{8}$/, '') || @model_name
148
+ @model_name&.sub(/^claude-/, "")&.sub(/-\d{8}$/, "") || @model_name
87
149
  end
88
150
 
89
151
  def build_subtitle
90
152
  parts = []
91
153
  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(' · '))
154
+ parts << stat_span("agent", @current_agent)
155
+ parts.join(" · ".colorize(DIM))
94
156
  end
95
157
 
96
158
  def stat_span(label, value)
97
- Styles::DIM_TEXT.render("#{label} ") + Styles::STAT_VALUE.render(value)
159
+ "#{label} ".colorize(DIM) + value.to_s.colorize(ACCENT)
98
160
  end
99
161
 
100
162
  # ── Agent ──
101
163
 
164
+ def ensure_session!
165
+ return if @session
166
+ @session = Brute::Session.new(id: @options[:session_id])
167
+ end
168
+
102
169
  def ensure_agent!
103
170
  return if @agent
104
171
 
105
- @session = Brute::Session.new(id: @options[:session_id])
172
+ ensure_session!
106
173
  @agent = Brute.agent(
107
174
  cwd: @options[:cwd] || Dir.pwd,
175
+ model: @selected_model,
176
+ agent_name: @current_agent,
108
177
  session: @session,
109
178
  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)
179
+ on_content: method(:on_content),
180
+ on_reasoning: method(:on_reasoning),
181
+ on_tool_call: method(:on_tool_call),
182
+ on_tool_result: method(:on_tool_result),
183
+ # on_question: disabled until bubbletea terminal integration is fixed
114
184
  )
115
185
  @session.restore(@agent.context) if @options[:session_id]
116
186
  end
117
187
 
188
+ # Force the agent to be recreated on next ensure_agent! call.
189
+ # Used after changing provider, model, or agent.
190
+ def reset_agent!
191
+ @agent = nil
192
+ @models_cache = nil
193
+ end
194
+
195
+ def current_prompt
196
+ "%"
197
+ end
198
+
199
+ def cycle_agent
200
+ idx = (AGENTS.index(@current_agent) + 1) % AGENTS.size
201
+ @current_agent = AGENTS[idx]
202
+ reset_agent!
203
+ # Rewrite the model/status line sitting one line above the prompt.
204
+ # Save cursor, move up, clear line, print, restore cursor.
205
+ parts = []
206
+ parts << stat_span(@provider_name, model_short) if @provider_name && model_short
207
+ parts << stat_span("agent", @current_agent)
208
+ line = parts.join(" · ".colorize(DIM))
209
+ $stdout.print "\e[s\e[A\r\e[2K#{line}\e[u"
210
+ $stdout.flush
211
+ end
212
+
213
+ # ── Commands ──
214
+
215
+ # Dispatch a slash command. Returns :exit to break the REPL loop, or nil.
216
+ def handle_command(input)
217
+ entry = Commands.find(input)
218
+ unless entry
219
+ puts "Unknown command: #{input.split(/\s+/).first}".colorize(ERROR_FG)
220
+ puts "Type /help for available commands.".colorize(DIM)
221
+ return nil
222
+ end
223
+
224
+ send(entry.method_name)
225
+ end
226
+
227
+ def cmd_help
228
+ puts separator
229
+ puts "Available commands:".colorize(ACCENT_BOLD)
230
+ puts
231
+ Commands::REGISTRY.each do |entry|
232
+ puts " #{entry.name.ljust(12).colorize(ACCENT)} #{entry.description.colorize(DIM)}"
233
+ end
234
+ puts separator
235
+ nil
236
+ end
237
+
238
+ def cmd_exit
239
+ :exit
240
+ end
241
+
242
+ def cmd_compact
243
+ if @agent
244
+ puts "Compacting conversation...".colorize(DIM)
245
+ # Trigger compaction by sending a hint through the pipeline
246
+ # For now, just report the current token count
247
+ metadata = @agent.env&.dig(:metadata) || {}
248
+ tokens = metadata.dig(:tokens, :total) || 0
249
+ puts "Current token count: #{tokens}".colorize(DIM)
250
+ puts "Manual compaction not yet implemented.".colorize(DIM)
251
+ else
252
+ puts "No active conversation to compact.".colorize(DIM)
253
+ end
254
+ nil
255
+ end
256
+
257
+ def cmd_menu
258
+ menu = build_main_menu
259
+ result = menu.call(:main)
260
+
261
+ case result
262
+ when :exit
263
+ return :exit
264
+ when Array
265
+ action, *args = result
266
+ handle_menu_action(action, *args)
267
+ end
268
+
269
+ nil
270
+ end
271
+
272
+ def cmd_model
273
+ menu = build_main_menu
274
+ result = menu.call(:models)
275
+
276
+ if result.is_a?(Array)
277
+ action, *args = result
278
+ handle_menu_action(action, *args)
279
+ end
280
+
281
+ nil
282
+ end
283
+
284
+ def cmd_provider
285
+ menu = build_main_menu
286
+ result = menu.call(:providers)
287
+
288
+ if result.is_a?(Array)
289
+ action, *args = result
290
+ handle_menu_action(action, *args)
291
+ end
292
+
293
+ nil
294
+ end
295
+
296
+ # Process action tuples returned by the menu system.
297
+ def handle_menu_action(action, *args)
298
+ case action
299
+ when :set_model
300
+ model_id = args.first
301
+ @selected_model = model_id
302
+ reset_agent!
303
+ resolve_provider_info
304
+ puts separator
305
+ puts "Model changed to: #{model_id.colorize(ACCENT)}"
306
+ puts separator
307
+ when :set_provider
308
+ provider_name = args.first
309
+ new_provider = Brute.provider_for(provider_name)
310
+ if new_provider
311
+ Brute.provider = new_provider
312
+ @selected_model = nil
313
+ reset_agent!
314
+ resolve_provider_info
315
+ puts separator
316
+ puts "Provider changed to: #{provider_name.colorize(ACCENT)}"
317
+ puts "Model: #{@model_name.colorize(ACCENT)}"
318
+ puts separator
319
+ else
320
+ puts "Failed to initialize provider: #{provider_name}".colorize(ERROR_FG)
321
+ end
322
+ end
323
+ end
324
+
325
+ # ── Menu Builder ──
326
+
327
+ def build_main_menu
328
+ repl = self
329
+
330
+ FzfMenu.new do
331
+ menu :main, "Brute" do
332
+ choice "Change Model", :models
333
+ choice "Change Provider", :providers
334
+ choice "Help", :help_display
335
+ choice "Exit Menu", nil
336
+ end
337
+
338
+ menu :help_display, "Help" do
339
+ choice "Back", :main
340
+ end
341
+
342
+ # Dynamic: models from the current provider
343
+ menu(:models, "Select Model") do |m|
344
+ models = repl.send(:fetch_models)
345
+ current = repl.instance_variable_get(:@model_name)
346
+
347
+ models.each do |id|
348
+ label = id == current ? "#{id} (current)" : id
349
+ m.choice label, [:set_model, id]
350
+ end
351
+
352
+ m.choice "Back", :main
353
+ end
354
+
355
+ # Dynamic: only providers with configured API keys
356
+ menu(:providers, "Select Provider") do |m|
357
+ configured = Brute.configured_providers
358
+ current = repl.instance_variable_get(:@provider_name)
359
+
360
+ configured.each do |name|
361
+ label = name == current ? "#{name} (current)" : name
362
+ m.choice label, [:set_provider, name]
363
+ end
364
+
365
+ m.choice "Back", :main
366
+ end
367
+ end
368
+ end
369
+
370
+ # Fetch available chat models from the current provider.
371
+ # Results are cached for the session to avoid repeated API calls.
372
+ def fetch_models
373
+ return @models_cache if @models_cache
374
+
375
+ provider = Brute.provider
376
+ return [] unless provider
377
+
378
+ begin
379
+ all = provider.models.all
380
+ @models_cache = all.select(&:chat?).map { |m| m.id.to_s }.sort
381
+ rescue => e
382
+ puts "Failed to fetch models: #{e.message}".colorize(ERROR_FG)
383
+ # Fall back to just the default model
384
+ @models_cache = [provider.default_model.to_s]
385
+ end
386
+
387
+ @models_cache
388
+ end
389
+
118
390
  # ── Execute ──
119
391
 
120
392
  def execute(prompt)
121
- @content_buf = +''
393
+ @content_buf = +""
394
+ @streamer.reset
122
395
 
123
- print_model_line
124
- start_spinner('Thinking...')
396
+ start_spinner("Thinking...")
125
397
 
126
398
  begin
127
399
  @agent.run(prompt)
128
400
  rescue Interrupt
129
401
  stop_spinner
130
402
  flush_content
131
- styled_puts Styles::DIM_TEXT.render(' Aborted.')
403
+ puts "Aborted.".colorize(DIM)
132
404
  print_stats_bar
133
405
  return
134
- rescue StandardError => e
406
+ rescue => e
135
407
  stop_spinner
136
408
  flush_content
137
409
  print_error(e)
@@ -147,22 +419,22 @@ module BruteCLI
147
419
  def print_model_line
148
420
  parts = []
149
421
  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(' · '))
422
+ parts << stat_span("agent", @current_agent)
423
+ puts parts.join(" · ".colorize(DIM))
152
424
  end
153
425
 
154
426
  # ── Spinner ──
155
427
 
156
428
  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"
429
+ "\e[38;2;255;56;96m", "\e[38;2;255;165;0m",
430
+ "\e[38;2;255;220;0m", "\e[38;2;0;219;68m",
431
+ "\e[38;2;0;186;255m", "\e[38;2;107;80;255m",
432
+ "\e[38;2;255;96;255m",
161
433
  ].freeze
162
434
  RESET = "\e[0m"
163
435
 
164
436
  def nyan_frames
165
- bar = '' * 12
437
+ bar = "" * 12
166
438
  bar.length.times.map do |offset|
167
439
  bar.chars.map.with_index { |c, i| RAINBOW[(i + offset) % RAINBOW.length] + c }.join + RESET
168
440
  end
@@ -170,186 +442,186 @@ module BruteCLI
170
442
 
171
443
  def start_spinner(label)
172
444
  stop_spinner
445
+ puts separator
173
446
  @spinner = TTY::Spinner.new(
174
- " :spinner #{label}",
447
+ ":spinner #{label}",
175
448
  frames: nyan_frames,
176
449
  interval: 8,
177
- output: $stdout
450
+ output: $stdout,
451
+ clear: true,
178
452
  )
179
453
  @spinner.auto_spin
180
454
  end
181
455
 
182
456
  def stop_spinner
183
- return unless @spinner
184
-
185
- @spinner.stop('') if @spinner.spinning?
186
- @spinner = nil
457
+ if @spinner
458
+ if @spinner.spinning?
459
+ @spinner.stop
460
+ end
461
+ @spinner = nil
462
+ end
187
463
  end
188
464
 
189
465
  # ── Callbacks ──
190
466
 
191
467
  def on_content(text)
192
- stop_spinner
193
- @content_buf << text
468
+ @mu.synchronize do
469
+ stop_spinner
470
+ @content_buf << text
471
+ @streamer << text
472
+ end
194
473
  end
195
474
 
196
475
  def on_reasoning(_text); end
197
476
 
198
- INLINE_TOOLS = %w[read fs_search todo_read todo_write fetch].freeze
199
477
  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
478
+ "read" => Emoji::EYES, "patch" => Emoji::HAMMER, "write" => Emoji::WRITING,
479
+ "shell" => Emoji::COMPUTER, "fs_search" => Emoji::MAG, "fetch" => Emoji::GLOBE,
480
+ "todo_read" => Emoji::CLIPBOARD, "todo_write" => Emoji::CLIPBOARD,
481
+ "remove" => Emoji::WASTEBASKET, "undo" => Emoji::REWIND, "delegate" => Emoji::ROBOT,
482
+ "question" => Emoji::DIAMOND,
483
+ }.freeze
484
+
485
+ TODO_STATUS = {
486
+ "pending" => Emoji::SQUARE,
487
+ "in_progress" => Emoji::ARROWS,
488
+ "completed" => Emoji::CHECK,
489
+ "cancelled" => Emoji::CROSS,
204
490
  }.freeze
205
491
 
206
492
  def on_tool_call(name, args)
207
- stop_spinner
208
- flush_content
209
- @pending_tool = { name: name, args: args }
493
+ @mu.synchronize do
494
+ stop_spinner
495
+ flush_content
496
+ @pending_tool = { name: name, args: args }
497
+ end
210
498
  end
211
499
 
212
500
  def on_tool_result(name, result)
213
- stop_spinner
214
- tool = @pending_tool || { name: name, args: {} }
501
+ @mu.synchronize do
502
+ stop_spinner
503
+ tool = @pending_tool || { name: name, args: {} }
215
504
 
216
- if INLINE_TOOLS.include?(tool[:name])
217
- print_inline_tool(tool, result)
218
- else
219
- print_block_tool(tool, result)
505
+ puts separator
506
+ print_tool_result(tool, result)
507
+
508
+ @pending_tool = nil
509
+ start_spinner("Thinking...")
220
510
  end
511
+ end
221
512
 
222
- @pending_tool = nil
223
- start_spinner('Thinking...')
513
+ # TODO: Interactive question forms are disabled while the bubbletea
514
+ # terminal integration is being worked on. For now, auto-select the
515
+ # first option for each question so the agent can continue.
516
+ def on_question(questions, reply_queue)
517
+ answers = questions.map do |q|
518
+ q = q.respond_to?(:transform_keys) ? q.transform_keys(&:to_s) : q
519
+ options = (q["options"] || []).map { |o| o.respond_to?(:transform_keys) ? o.transform_keys(&:to_s) : o }
520
+ first = options.first
521
+ first ? [first["label"].to_s] : []
522
+ end
523
+
524
+ reply_queue.push(answers)
224
525
  end
225
526
 
226
527
  # ── Output ──
227
528
 
228
529
  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 = +''
530
+ unless @content_buf.strip.empty?
531
+ @streamer.flush
532
+ @content_buf = +""
533
+ end
236
534
  end
237
535
 
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
536
+ def render_markdown(text)
537
+ BruteCLI::Bat.markdown_mode(text.strip, width: @width)
243
538
  end
244
539
 
245
- def print_inline_tool(tool, result)
540
+ def print_tool_result(tool, result)
246
541
  icon = TOOL_ICONS[tool[:name].to_s] || Emoji::GEAR
247
542
  name = tool[:name].to_s
248
- summary = tool_summary(tool) || ''
543
+ summary = tool_summary(tool) || ""
249
544
 
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?
545
+ puts "#{icon} #{name.colorize(ACCENT_BG)} #{summary}"
266
546
 
267
547
  # Diff
268
- diff = result.is_a?(Hash) && (result[:diff] || result['diff'])
269
- body_lines.concat(format_diff_lines(diff)) if diff && !diff.strip.empty?
548
+ diff = result.is_a?(Hash) && (result[:diff] || result["diff"])
549
+ if diff && !diff.strip.empty?
550
+ print BruteCLI::Bat.diff_mode(diff, width: @width)
551
+ end
270
552
 
271
553
  # Shell output
272
- stdout = result.is_a?(Hash) && (result[:stdout] || result['stdout'])
554
+ stdout = result.is_a?(Hash) && (result[:stdout] || result["stdout"])
273
555
  if stdout && !stdout.strip.empty?
274
556
  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) })
557
+ lines = lines.first(15) + ["... (truncated)"] if lines.size > 15
558
+ lines.each { |l| puts l.colorize(DIM) }
559
+ end
560
+
561
+ # Todos
562
+ todos = extract_todos(tool, result)
563
+ if todos && !todos.empty?
564
+ format_todos(todos).each { |line| puts line }
277
565
  end
278
566
 
279
- # Status
567
+ # Error (only on failure)
280
568
  if error_result?(result)
281
569
  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')
570
+ msg = msg[0..70] + "..." if msg.length > 70
571
+ puts "#{"FAILED".colorize(ERROR_BG)} #{msg.colorize(DIM)}"
286
572
  end
287
-
288
- styled_puts render_titled_frame(title, body_lines)
289
573
  end
290
574
 
291
- def error_result?(result)
292
- return false unless result.is_a?(Hash)
293
-
294
- result[:error] || result['error']
575
+ def extract_todos(tool, result)
576
+ name = tool[:name].to_s
577
+ if name == "todo_write"
578
+ args = tool[:args]
579
+ args = args.is_a?(Hash) ? (args[:todos] || args["todos"]) : nil
580
+ elsif name == "todo_read"
581
+ result.is_a?(Hash) ? (result[:todos] || result["todos"]) : nil
582
+ end
295
583
  end
296
584
 
297
- def error_message(result)
298
- return '' unless result.is_a?(Hash)
585
+ def format_todos(todos)
586
+ todos.map do |t|
587
+ t = t.transform_keys(&:to_s) if t.is_a?(Hash)
588
+ status = t["status"].to_s
589
+ icon = TODO_STATUS[status] || Emoji::SQUARE
590
+ content = t["content"] || t["id"] || "?"
591
+ " #{icon} #{content}"
592
+ end
593
+ end
299
594
 
300
- (result[:message] || result['message'] || result[:error] || result['error']).to_s
595
+ def error_result?(result)
596
+ result.is_a?(Hash) && (result[:error] || result["error"])
301
597
  end
302
598
 
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}\"")
599
+ def error_message(result)
600
+ if result.is_a?(Hash)
601
+ (
602
+ result[:message] ||
603
+ result["message"] ||
604
+ result[:error] ||
605
+ result["error"]
606
+ ).to_s
318
607
  else
319
- ''
608
+ ""
320
609
  end
321
610
  end
322
611
 
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
612
+ def tool_summary(tool)
613
+ args = tool[:args]
614
+ return "" unless args.is_a?(Hash) && !args.empty?
334
615
 
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
616
+ path = args["file_path"] || args[:file_path]
617
+ cmd = args["command"] || args[:command]
618
+ pattern = args["pattern"] || args[:pattern]
340
619
 
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('│')
620
+ if path then path.to_s.colorize(DIM)
621
+ elsif cmd then cmd.to_s[0..60].colorize(DIM)
622
+ elsif pattern then "\"#{pattern}\"".colorize(DIM)
623
+ else ""
346
624
  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
625
  end
354
626
 
355
627
  # ── Stats ──
@@ -359,25 +631,17 @@ module BruteCLI
359
631
  tokens = metadata[:tokens] || {}
360
632
  timing = metadata[:timing] || {}
361
633
  tool_calls = metadata[:tool_calls] || 0
634
+ sep = " | ".colorize(DIM)
362
635
  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)"
636
+ parts << stat_span("tokens", (tokens[:total] || 0).to_s)
637
+ parts << stat_span("in", (tokens[:total_input] || 0).to_s)
638
+ parts << stat_span("out", (tokens[:total_output] || 0).to_s)
639
+ parts << stat_span("time", format_time(timing[:total_elapsed] || 0))
640
+ parts << stat_span("tools", tool_calls.to_s) if tool_calls > 0
641
+ puts
642
+ puts separator
643
+ puts parts.join(sep)
644
+ puts thick_separator
381
645
  end
382
646
 
383
647
  def format_time(s)
@@ -387,33 +651,71 @@ module BruteCLI
387
651
  # ── Error ──
388
652
 
389
653
  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
654
+ puts "#{Emoji::CROSS} #{"ERROR".colorize(ERROR_BG)}"
655
+ parsed = JSON.parse(err.message) rescue err.message
656
+ pp parsed
392
657
  end
393
658
 
394
659
  # ── UI ──
395
660
 
396
661
  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
662
+ puts separator
663
+ puts BruteCLI::LOGO.chomp.colorize(ACCENT)
664
+ puts separator
665
+ puts "Version #{Brute::VERSION}".colorize(DIM)
666
+ if @session
667
+ session_dir = File.join(Dir.home, ".brute", "sessions", @session.id)
668
+ puts separator
669
+ puts "session_id: ".colorize(DIM) + @session.id.colorize(ACCENT)
670
+ puts "session_log: ".colorize(DIM) + session_dir.colorize(ACCENT)
671
+ end
672
+ check_dependencies
673
+ puts separator
674
+ puts "Type /help for available commands.".colorize(DIM)
675
+ puts separator
401
676
  end
402
677
 
403
- def styled_puts(text)
404
- $stdout.puts text
405
- $stdout.flush
678
+ def check_dependencies
679
+ missing = []
680
+ missing << ["bat", "https://github.com/sharkdp/bat#installation", "diff syntax highlighting"] unless BruteCLI::Bat.available?
681
+ missing << ["fzf", "https://github.com/junegunn/fzf#installation", "interactive selection"] unless fzf_on_path?
682
+ return if missing.empty?
683
+
684
+ puts separator
685
+ missing.each do |name, url, purpose|
686
+ $stderr.puts " #{name} not found — recommended for #{purpose}.\n Install: #{url} ".colorize(background: :red, color: :white)
687
+ end
688
+ end
689
+
690
+ def fzf_on_path?
691
+ ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, "fzf")) }
692
+ end
693
+
694
+ # Launch fzf inline and return the selected path as ./relative, or nil.
695
+ def fzf_pick_file
696
+ return nil unless fzf_on_path?
697
+
698
+ selected = `git ls-files --cached --others --exclude-standard 2>/dev/null | fzf --prompt='Select File › ' --height=~20 --reverse --no-info`
699
+ return nil unless $?.success?
700
+
701
+ selected = selected.strip
702
+ return nil if selected.empty?
703
+
704
+ selected.start_with?("/", "./", "../") ? selected : "./#{selected}"
705
+ rescue Errno::ENOENT
706
+ nil
406
707
  end
407
708
 
408
709
  def separator
409
- Styles::SEPARATOR.render('' * [@width, 40].max)
710
+ ("" * [@width, 40].max).colorize(ACCENT)
711
+ end
712
+
713
+ def thick_separator
714
+ ("═" * [@width, 40].max).colorize(ACCENT)
410
715
  end
411
716
 
412
717
  def detect_width
413
- _rows, cols = IO.console&.winsize
414
- cols || 80
415
- rescue StandardError
416
- 80
718
+ TTY::Screen.width
417
719
  end
418
720
  end
419
721
  end