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.
- checksums.yaml +4 -4
- data/exe/brute +3 -0
- data/lib/brute_cli/bat.rb +67 -0
- data/lib/brute_cli/commands.rb +38 -0
- data/lib/brute_cli/emoji.rb +2 -0
- data/lib/brute_cli/fzf_menu.rb +150 -0
- data/lib/brute_cli/question_screen.rb +334 -0
- data/lib/brute_cli/repl.rb +496 -194
- data/lib/brute_cli/stream_formatter.rb +114 -0
- data/lib/brute_cli/styles.rb +36 -35
- data/lib/brute_cli/version.rb +1 -1
- data/lib/brute_cli.rb +18 -5
- metadata +94 -5
data/lib/brute_cli/repl.rb
CHANGED
|
@@ -1,20 +1,30 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
require
|
|
6
|
-
require
|
|
7
|
-
require
|
|
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
|
-
@
|
|
16
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
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 ?
|
|
74
|
+
i == 0 ? prompt_text.colorize(ACCENT_BOLD) + " " : continuation.colorize(DIM) + " "
|
|
54
75
|
end
|
|
55
76
|
}
|
|
56
77
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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 =
|
|
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-/,
|
|
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(
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
111
|
-
on_reasoning:
|
|
112
|
-
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
|
-
|
|
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
|
-
|
|
403
|
+
puts "Aborted.".colorize(DIM)
|
|
132
404
|
print_stats_bar
|
|
133
405
|
return
|
|
134
|
-
rescue
|
|
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(
|
|
151
|
-
|
|
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",
|
|
158
|
-
"\e[38;2;255;220;0m",
|
|
159
|
-
"\e[38;2;0;186;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 =
|
|
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
|
-
"
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
214
|
-
|
|
501
|
+
@mu.synchronize do
|
|
502
|
+
stop_spinner
|
|
503
|
+
tool = @pending_tool || { name: name, args: {} }
|
|
215
504
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
239
|
-
width
|
|
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
|
|
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
|
-
|
|
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[
|
|
269
|
-
|
|
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[
|
|
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) + [
|
|
276
|
-
|
|
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
|
-
#
|
|
567
|
+
# Error (only on failure)
|
|
280
568
|
if error_result?(result)
|
|
281
569
|
msg = error_message(result)
|
|
282
|
-
msg = msg[0..70] +
|
|
283
|
-
|
|
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
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
|
298
|
-
|
|
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
|
-
|
|
595
|
+
def error_result?(result)
|
|
596
|
+
result.is_a?(Hash) && (result[:error] || result["error"])
|
|
301
597
|
end
|
|
302
598
|
|
|
303
|
-
def
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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 <<
|
|
364
|
-
parts <<
|
|
365
|
-
parts <<
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
391
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
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
|
-
|
|
414
|
-
cols || 80
|
|
415
|
-
rescue StandardError
|
|
416
|
-
80
|
|
718
|
+
TTY::Screen.width
|
|
417
719
|
end
|
|
418
720
|
end
|
|
419
721
|
end
|