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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7faec8b9ffa2108882a1deb9e4eeb1a5747ce41ed8d6afb15414817de129fcca
|
|
4
|
+
data.tar.gz: 0ffc0437131b4244123eed92bb9850062af2f785ff7dcaa873ed8a4d6ace1310
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 60607a8e7d25dc568a9e29b267985f524bd7f3d5f7b9f791e5a2c51641813f40dfb0f0e5f8a3cd0a43c1d216d33f13782460ceed40f30b5520fafc1b333fd572
|
|
7
|
+
data.tar.gz: a89c64d5621373a349f1f8d27d5ab8d8f2add3f409e69d9caaad43d201171db22ae9b7db2a00cae8aa485a5613466251d791765331067ec5301ff4e92fe0d487
|
data/exe/brute
CHANGED
|
@@ -18,43 +18,28 @@ end.parse!
|
|
|
18
18
|
|
|
19
19
|
BruteCLI.apply_theme!(options[:theme]) if options[:theme]
|
|
20
20
|
|
|
21
|
-
# ── List sessions ──
|
|
22
|
-
|
|
23
21
|
if options[:list]
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
id_style = Lipgloss::Style.new.foreground(BruteCLI::Styles::CYAN)
|
|
30
|
-
time_style = Lipgloss::Style.new.foreground(BruteCLI::Styles::DIM)
|
|
31
|
-
|
|
32
|
-
sessions.each do |s|
|
|
33
|
-
id = id_style.render(s[:id][0..7])
|
|
34
|
-
title = title_style.render(s[:title] || "(untitled)")
|
|
35
|
-
time = time_style.render(s[:saved_at].to_s)
|
|
36
|
-
puts " #{id} #{title} #{time}"
|
|
22
|
+
Brute::Session.list.tap do |sessions|
|
|
23
|
+
if sessions.empty?
|
|
24
|
+
puts "No saved sessions."
|
|
25
|
+
else
|
|
26
|
+
puts "Not implemented"
|
|
37
27
|
end
|
|
38
28
|
end
|
|
29
|
+
|
|
39
30
|
exit
|
|
40
31
|
end
|
|
41
32
|
|
|
42
|
-
# ── Collect prompt ──
|
|
43
|
-
|
|
44
|
-
prompt = ARGV.join(" ")
|
|
45
|
-
prompt = $stdin.read.strip if prompt.empty? && !$stdin.tty?
|
|
46
|
-
|
|
47
|
-
# ── Run ──
|
|
48
|
-
|
|
49
|
-
repl = BruteCLI::REPL.new(options)
|
|
50
|
-
|
|
51
33
|
begin
|
|
34
|
+
prompt = ARGV.join(" ")
|
|
35
|
+
prompt = $stdin.read.strip if prompt.empty? && !$stdin.tty?
|
|
36
|
+
|
|
52
37
|
if prompt.empty?
|
|
53
38
|
# Interactive mode
|
|
54
|
-
|
|
39
|
+
BruteCLI::REPL.new(options).run
|
|
55
40
|
else
|
|
56
41
|
# Single prompt mode
|
|
57
|
-
|
|
42
|
+
BruteCLI::Execution.new(options).run(prompt)
|
|
58
43
|
puts
|
|
59
44
|
end
|
|
60
45
|
rescue Interrupt
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "brute_cli/styles"
|
|
5
|
+
require "brute_cli/emoji"
|
|
6
|
+
|
|
7
|
+
module BruteCLI
|
|
8
|
+
module BufferOutput
|
|
9
|
+
# Renderable error badge with pretty-printed message.
|
|
10
|
+
#
|
|
11
|
+
# puts BufferOutput::Error.new(err)
|
|
12
|
+
# # => "✖ ERROR"
|
|
13
|
+
# # => "\"Something went wrong\""
|
|
14
|
+
#
|
|
15
|
+
class Error
|
|
16
|
+
def initialize(err)
|
|
17
|
+
@err = err
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def to_s
|
|
21
|
+
header = "#{Emoji::CROSS} #{"ERROR".colorize(ERROR_BG)}"
|
|
22
|
+
parsed = JSON.parse(@err.message) rescue @err.message
|
|
23
|
+
"#{header}\n#{parsed.pretty_inspect.chomp}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "brute_cli/styles"
|
|
4
|
+
|
|
5
|
+
module BruteCLI
|
|
6
|
+
module BufferOutput
|
|
7
|
+
# Renderable provider / model / agent status line.
|
|
8
|
+
#
|
|
9
|
+
# puts BufferOutput::ModelLine.new(
|
|
10
|
+
# provider_name: "anthropic",
|
|
11
|
+
# model_short: "3.5-sonnet",
|
|
12
|
+
# current_agent: "build"
|
|
13
|
+
# )
|
|
14
|
+
# # => "anthropic 3.5-sonnet · agent build"
|
|
15
|
+
#
|
|
16
|
+
class ModelLine
|
|
17
|
+
def initialize(provider_name:, model_short:, current_agent:)
|
|
18
|
+
@provider_name = provider_name
|
|
19
|
+
@model_short = model_short
|
|
20
|
+
@current_agent = current_agent
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_s
|
|
24
|
+
parts = []
|
|
25
|
+
parts << stat_span(@provider_name, @model_short) if @provider_name && @model_short
|
|
26
|
+
parts << stat_span("agent", @current_agent)
|
|
27
|
+
parts.join(" \u00b7 ".colorize(DIM))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def stat_span(label, value)
|
|
33
|
+
"#{label} ".colorize(DIM) + value.to_s.colorize(ACCENT)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "brute_cli/styles"
|
|
4
|
+
|
|
5
|
+
module BruteCLI
|
|
6
|
+
module BufferOutput
|
|
7
|
+
# Renderable horizontal rule for terminal output.
|
|
8
|
+
#
|
|
9
|
+
# puts BufferOutput::Separator.new(width: 80)
|
|
10
|
+
# puts BufferOutput::Separator.new(width: 80, thick: true)
|
|
11
|
+
#
|
|
12
|
+
class Separator
|
|
13
|
+
def initialize(width:, thick: false)
|
|
14
|
+
@width = width
|
|
15
|
+
@thick = thick
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_s
|
|
19
|
+
char = @thick ? "\u2550" : "\u2500"
|
|
20
|
+
(char * [@width, 40].max).colorize(DIM)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "brute_cli/styles"
|
|
4
|
+
|
|
5
|
+
module BruteCLI
|
|
6
|
+
module BufferOutput
|
|
7
|
+
# Renderable token / timing / tool-call metrics line.
|
|
8
|
+
#
|
|
9
|
+
# puts BufferOutput::StatsBar.new(metadata, width: 80)
|
|
10
|
+
# # => "tokens 150 | in 100 | out 50 | time 45.5s | tools 5"
|
|
11
|
+
#
|
|
12
|
+
class StatsBar
|
|
13
|
+
def initialize(metadata, width:)
|
|
14
|
+
@metadata = metadata
|
|
15
|
+
@width = width
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_s
|
|
19
|
+
tokens = @metadata[:tokens] || {}
|
|
20
|
+
timing = @metadata[:timing] || {}
|
|
21
|
+
tool_calls = @metadata[:tool_calls] || 0
|
|
22
|
+
sep = " | ".colorize(DIM)
|
|
23
|
+
parts = []
|
|
24
|
+
parts << stat_span("tokens", (tokens[:total] || 0).to_s)
|
|
25
|
+
parts << stat_span("in", (tokens[:total_input] || 0).to_s)
|
|
26
|
+
parts << stat_span("out", (tokens[:total_output] || 0).to_s)
|
|
27
|
+
parts << stat_span("time", format_time(timing[:total_elapsed] || 0))
|
|
28
|
+
parts << stat_span("tools", tool_calls.to_s) if tool_calls > 0
|
|
29
|
+
parts.join(sep)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def stat_span(label, value)
|
|
35
|
+
"#{label} ".colorize(DIM) + value.to_s.colorize(ACCENT)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def format_time(s)
|
|
39
|
+
s < 60 ? "#{s.round(1)}s" : "#{(s / 60).floor}m#{(s % 60).round(1)}s"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "brute_cli/buffer_output/separator"
|
|
4
|
+
require "brute_cli/buffer_output/stats_bar"
|
|
5
|
+
require "brute_cli/buffer_output/error"
|
|
6
|
+
require "brute_cli/buffer_output/model_line"
|
|
7
|
+
|
|
8
|
+
module BruteCLI
|
|
9
|
+
module BufferOutput
|
|
10
|
+
end
|
|
11
|
+
end
|
data/lib/brute_cli/emoji.rb
CHANGED
|
@@ -4,30 +4,33 @@ require 'gemoji'
|
|
|
4
4
|
|
|
5
5
|
module BruteCLI
|
|
6
6
|
module Emoji
|
|
7
|
-
def self
|
|
7
|
+
def self.💩(name)
|
|
8
8
|
::Emoji.find_by_alias(name)&.raw || ''
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
11
|
+
# brutal mate...
|
|
12
|
+
|
|
13
|
+
EYES = 💩 'eyes'
|
|
14
|
+
PENCIL = 💩 'pencil2'
|
|
15
|
+
PAGE = 💩 'page_facing_up'
|
|
16
|
+
COMPUTER = 💩 'computer'
|
|
17
|
+
SPARKLES = 💩 'sparkles'
|
|
18
|
+
GLOBE = 💩 'globe_with_meridians'
|
|
19
|
+
WASTEBASKET = 💩 'wastebasket'
|
|
20
|
+
REWIND = 💩 'rewind'
|
|
21
|
+
DIAMOND = 💩 'diamond_shape_with_a_dot_inside'
|
|
22
|
+
GEAR = 💩 'gear'
|
|
23
|
+
MAG = 💩 'mag'
|
|
24
|
+
HAMMER = 💩 'hammer_and_wrench'
|
|
25
|
+
PACKAGE = 💩 'package'
|
|
26
|
+
CLIPBOARD = 💩 'clipboard'
|
|
27
|
+
CHECK = 💩 'white_check_mark'
|
|
28
|
+
CROSS = 💩 'x'
|
|
29
|
+
WRITING = 💩 'writing_hand'
|
|
30
|
+
ROBOT = 💩 'robot'
|
|
31
|
+
FOLDER = 💩 'file_folder'
|
|
32
|
+
SQUARE = 💩 'white_large_square'
|
|
33
|
+
ARROWS = 💩 'arrows_counterclockwise'
|
|
34
|
+
SMOKE = 💩 'dash'
|
|
32
35
|
end
|
|
33
36
|
end
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
require "io/console"
|
|
5
|
+
require "json"
|
|
6
|
+
require "pp"
|
|
7
|
+
require "brute_cli/styles"
|
|
8
|
+
require "brute_cli/phase"
|
|
9
|
+
require "brute_cli/tool_output"
|
|
10
|
+
|
|
11
|
+
module BruteCLI
|
|
12
|
+
# Execution encapsulates running a single prompt (or repeated prompts) against
|
|
13
|
+
# an agent, streaming output to the terminal. It owns the agent lifecycle,
|
|
14
|
+
# streaming callbacks, spinner, and stats rendering.
|
|
15
|
+
#
|
|
16
|
+
# Use directly for non-interactive (pipe / single-prompt) mode:
|
|
17
|
+
#
|
|
18
|
+
# BruteCLI::Execution.new(options).run(prompt)
|
|
19
|
+
#
|
|
20
|
+
# Or let BruteCLI::REPL wrap it for interactive use.
|
|
21
|
+
class Execution
|
|
22
|
+
AGENTS = %w[build plan bash ruby python nix].freeze
|
|
23
|
+
|
|
24
|
+
SAVE_CURSOR = "\e7"
|
|
25
|
+
RESTORE_CURSOR = "\e8"
|
|
26
|
+
CLEAR_TO_END = "\e[J"
|
|
27
|
+
|
|
28
|
+
# Shell-mode agents: agent name -> shell interpreter (model name).
|
|
29
|
+
# These agents use the Shell provider instead of the current LLM provider.
|
|
30
|
+
SHELL_AGENTS = {
|
|
31
|
+
"bash" => "bash",
|
|
32
|
+
"ruby" => "ruby",
|
|
33
|
+
"python" => "python",
|
|
34
|
+
"nix" => "nix",
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
attr_reader :current_agent, :provider_name, :model_name
|
|
38
|
+
attr_accessor :agent
|
|
39
|
+
attr_writer :selected_model, :current_agent
|
|
40
|
+
|
|
41
|
+
def initialize(options = {})
|
|
42
|
+
@options = options
|
|
43
|
+
@current_agent = AGENTS.first
|
|
44
|
+
@agent = nil
|
|
45
|
+
@session = nil
|
|
46
|
+
@selected_model = nil # user-chosen model override (nil = provider default)
|
|
47
|
+
@width = TTY::Screen.width
|
|
48
|
+
@streamer = StreamFormatter.new(width: @width)
|
|
49
|
+
spinner_class = options[:spinner] || BruteCLI.config.spinner
|
|
50
|
+
@spinner = spinner_class.new
|
|
51
|
+
@last_output = nil # :separator, :content, or :tool — used to deduplicate separators
|
|
52
|
+
@current_phase = nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Run a single prompt against the agent. This is the primary public API.
|
|
56
|
+
def run(prompt)
|
|
57
|
+
ensure_agent!
|
|
58
|
+
execute(prompt)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def execute(prompt)
|
|
62
|
+
@current_phase = nil
|
|
63
|
+
@streamer.reset
|
|
64
|
+
@last_output = nil
|
|
65
|
+
|
|
66
|
+
start_spinner
|
|
67
|
+
|
|
68
|
+
begin
|
|
69
|
+
@agent.run(prompt)
|
|
70
|
+
rescue Interrupt
|
|
71
|
+
stop_spinner
|
|
72
|
+
flush_content
|
|
73
|
+
puts "Aborted.".colorize(DIM)
|
|
74
|
+
print_stats_bar
|
|
75
|
+
return
|
|
76
|
+
rescue => e
|
|
77
|
+
stop_spinner
|
|
78
|
+
flush_content
|
|
79
|
+
print_error(e)
|
|
80
|
+
print_stats_bar
|
|
81
|
+
return
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
stop_spinner
|
|
85
|
+
flush_content
|
|
86
|
+
print_stats_bar
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ── Provider ──
|
|
91
|
+
|
|
92
|
+
def resolve_provider_info
|
|
93
|
+
if (shell_model = SHELL_AGENTS[@current_agent])
|
|
94
|
+
@provider_name = "shell"
|
|
95
|
+
@model_name = shell_model
|
|
96
|
+
else
|
|
97
|
+
provider = Brute.provider rescue nil
|
|
98
|
+
@provider_name = provider&.name&.to_s
|
|
99
|
+
@model_name = @selected_model || provider&.default_model&.to_s
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def model_short
|
|
104
|
+
@model_name&.sub(/^claude-/, "")&.sub(/-\d{8}$/, "") || @model_name
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# ── Agent ──
|
|
108
|
+
|
|
109
|
+
def ensure_session!
|
|
110
|
+
@session ||= Brute::Session.new(id: @options[:session_id])
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def ensure_agent!
|
|
114
|
+
return if @agent
|
|
115
|
+
|
|
116
|
+
ensure_session!
|
|
117
|
+
|
|
118
|
+
@agent = Brute.agent(
|
|
119
|
+
cwd: @options[:cwd] || Dir.pwd,
|
|
120
|
+
model: @selected_model,
|
|
121
|
+
agent_name: @current_agent,
|
|
122
|
+
session: @session,
|
|
123
|
+
logger: Logger.new(File::NULL),
|
|
124
|
+
on_content: method(:on_content),
|
|
125
|
+
on_reasoning: method(:on_reasoning),
|
|
126
|
+
on_tool_call_start: method(:on_tool_call_start),
|
|
127
|
+
on_tool_result: method(:on_tool_result),
|
|
128
|
+
# on_question: disabled until bubbletea terminal integration is fixed
|
|
129
|
+
)
|
|
130
|
+
@session.restore(@agent.context) if @options[:session_id]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def detect_width
|
|
134
|
+
TTY::Screen.width
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
# ── Spinner ──
|
|
140
|
+
|
|
141
|
+
def start_spinner
|
|
142
|
+
stop_spinner
|
|
143
|
+
|
|
144
|
+
unless @last_output == :separator
|
|
145
|
+
puts BufferOutput::Separator.new(width: @width)
|
|
146
|
+
@last_output = :separator
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
@spinner.start
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def stop_spinner
|
|
153
|
+
if @spinner.spinning?
|
|
154
|
+
@spinner.stop
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# ── Callbacks ──
|
|
159
|
+
#
|
|
160
|
+
# All callbacks fire sequentially on the same thread — no
|
|
161
|
+
# synchronization needed. The orchestrator guarantees:
|
|
162
|
+
#
|
|
163
|
+
# on_content* → on_tool_call_start → on_tool_result* → (repeat)
|
|
164
|
+
#
|
|
165
|
+
|
|
166
|
+
def on_content(text)
|
|
167
|
+
stop_spinner
|
|
168
|
+
unless @current_phase.is_a?(Phase::ContentPhase)
|
|
169
|
+
puts BufferOutput::Separator.new(width: @width) unless @last_output == :separator
|
|
170
|
+
@current_phase = Phase::ContentPhase.new(@streamer)
|
|
171
|
+
end
|
|
172
|
+
@current_phase.append(text)
|
|
173
|
+
@last_output = :content
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def on_reasoning(_text); end
|
|
177
|
+
|
|
178
|
+
# Receives the full batch of tool calls for this LLM turn.
|
|
179
|
+
# Renders all tool call headers upfront.
|
|
180
|
+
def on_tool_call_start(calls)
|
|
181
|
+
stop_spinner
|
|
182
|
+
flush_content
|
|
183
|
+
|
|
184
|
+
@current_phase = Phase::ToolPhase.new(calls)
|
|
185
|
+
|
|
186
|
+
puts BufferOutput::Separator.new(width: @width) unless @last_output == :separator
|
|
187
|
+
print SAVE_CURSOR
|
|
188
|
+
render_tool_phase
|
|
189
|
+
@last_output = :tool
|
|
190
|
+
|
|
191
|
+
start_spinner
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Fires once per tool as each completes.
|
|
195
|
+
# Re-renders the entire tool phase block.
|
|
196
|
+
def on_tool_result(name, result)
|
|
197
|
+
stop_spinner
|
|
198
|
+
|
|
199
|
+
if @current_phase.is_a?(Phase::ToolPhase)
|
|
200
|
+
@current_phase.resolve(name, result)
|
|
201
|
+
|
|
202
|
+
print RESTORE_CURSOR
|
|
203
|
+
print CLEAR_TO_END
|
|
204
|
+
render_tool_phase
|
|
205
|
+
@last_output = :tool
|
|
206
|
+
start_spinner
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# TODO: Interactive question forms are disabled while the bubbletea
|
|
211
|
+
# terminal integration is being worked on. For now, auto-select the
|
|
212
|
+
# first option for each question so the agent can continue.
|
|
213
|
+
def on_question(questions, reply_queue)
|
|
214
|
+
answers = questions.map do |q|
|
|
215
|
+
q = q.respond_to?(:transform_keys) ? q.transform_keys(&:to_s) : q
|
|
216
|
+
options = (q["options"] || []).map { |o| o.respond_to?(:transform_keys) ? o.transform_keys(&:to_s) : o }
|
|
217
|
+
first = options.first
|
|
218
|
+
first ? [first["label"].to_s] : []
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
reply_queue.push(answers)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# ── Output ──
|
|
225
|
+
|
|
226
|
+
def flush_content
|
|
227
|
+
if @current_phase.is_a?(Phase::ContentPhase)
|
|
228
|
+
@current_phase.finish
|
|
229
|
+
@last_output = :content unless @current_phase.empty?
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Render every tool call in the current ToolPhase.
|
|
234
|
+
# Resolved calls show header + body + error; pending calls show header only.
|
|
235
|
+
def render_tool_phase
|
|
236
|
+
@current_phase.tool_calls.each do |call|
|
|
237
|
+
puts ToolOutput.for(call, width: @width)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def render_markdown(text)
|
|
242
|
+
BruteCLI::Bat.markdown_mode(text.strip, width: @width)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# ── Stats ──
|
|
246
|
+
|
|
247
|
+
def print_stats_bar
|
|
248
|
+
metadata = @agent&.env&.dig(:metadata) || {}
|
|
249
|
+
puts BufferOutput::Separator.new(width: @width) unless @last_output == :separator
|
|
250
|
+
puts BufferOutput::StatsBar.new(metadata, width: @width)
|
|
251
|
+
puts BufferOutput::Separator.new(width: @width, thick: true)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# ── Error ──
|
|
255
|
+
|
|
256
|
+
def print_error(err)
|
|
257
|
+
puts BufferOutput::Error.new(err)
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BruteCLI
|
|
4
|
+
module Phase
|
|
5
|
+
# Accumulates streamed text tokens. Owns a StreamFormatter for
|
|
6
|
+
# incremental terminal output.
|
|
7
|
+
class ContentPhase
|
|
8
|
+
attr_reader :buf
|
|
9
|
+
|
|
10
|
+
def initialize(streamer)
|
|
11
|
+
@streamer = streamer
|
|
12
|
+
@buf = +""
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def append(text)
|
|
16
|
+
@buf << text
|
|
17
|
+
@streamer << text
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def finish
|
|
21
|
+
return if @buf.strip.empty?
|
|
22
|
+
@streamer.flush
|
|
23
|
+
@buf = +""
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def empty? = @buf.strip.empty?
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BruteCLI
|
|
4
|
+
module Phase
|
|
5
|
+
# Pure data object representing a single tool invocation.
|
|
6
|
+
# Holds name, arguments, and (once resolved) the result.
|
|
7
|
+
class ToolCall
|
|
8
|
+
attr_reader :name, :arguments
|
|
9
|
+
attr_accessor :result
|
|
10
|
+
|
|
11
|
+
def initialize(name:, arguments:)
|
|
12
|
+
@name = name
|
|
13
|
+
@arguments = arguments || {}
|
|
14
|
+
@result = nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def resolved? = !@result.nil?
|
|
18
|
+
def pending? = @result.nil?
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BruteCLI
|
|
4
|
+
module Phase
|
|
5
|
+
# Holds a batch of ToolCall objects for a single LLM turn.
|
|
6
|
+
# The orchestrator fires on_tool_call_start with the full batch,
|
|
7
|
+
# then on_tool_result per-tool as each completes.
|
|
8
|
+
class ToolPhase
|
|
9
|
+
attr_reader :tool_calls
|
|
10
|
+
|
|
11
|
+
def initialize(calls)
|
|
12
|
+
@tool_calls = calls.map do |c|
|
|
13
|
+
ToolCall.new(name: c[:name], arguments: c[:arguments])
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Resolve the first unresolved call matching +name+.
|
|
18
|
+
# Returns the ToolCall, or nil if no match.
|
|
19
|
+
def resolve(name, result)
|
|
20
|
+
call = @tool_calls.find { |c| c.name == name && c.pending? }
|
|
21
|
+
return unless call
|
|
22
|
+
call.result = result
|
|
23
|
+
call
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def finished? = @tool_calls.all?(&:resolved?)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|