rspec-agents 0.1.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 +7 -0
- data/bin/rspec-agents +24 -0
- data/lib/async_workers/channel_config.rb +34 -0
- data/lib/async_workers/doc/process_manager_design.md +512 -0
- data/lib/async_workers/errors.rb +21 -0
- data/lib/async_workers/managed_process.rb +284 -0
- data/lib/async_workers/output_stream.rb +86 -0
- data/lib/async_workers/rpc_channel.rb +159 -0
- data/lib/async_workers/transport/base.rb +57 -0
- data/lib/async_workers/transport/stdio_transport.rb +91 -0
- data/lib/async_workers/transport/unix_socket_transport.rb +112 -0
- data/lib/async_workers/worker_group.rb +175 -0
- data/lib/async_workers.rb +17 -0
- data/lib/rspec/agents/agent_response.rb +61 -0
- data/lib/rspec/agents/agents/base.rb +123 -0
- data/lib/rspec/agents/cli.rb +342 -0
- data/lib/rspec/agents/conversation.rb +308 -0
- data/lib/rspec/agents/criterion.rb +237 -0
- data/lib/rspec/agents/doc/2026_01_22_observer-system-design.md +757 -0
- data/lib/rspec/agents/doc/2026_01_23_parallel_spec_runner-design.md +1060 -0
- data/lib/rspec/agents/doc/2026_01_27_event_serialization-design.md +294 -0
- data/lib/rspec/agents/doc/2026_01_27_experiment_aggregation_design.md +831 -0
- data/lib/rspec/agents/doc/2026_01_29_rspec-agents-studio-design.md +1332 -0
- data/lib/rspec/agents/doc/2026_01_29_testing-framework-design.md +1037 -0
- data/lib/rspec/agents/doc/2026_02_04-parallel-runner-ui.md +537 -0
- data/lib/rspec/agents/doc/2026_02_05_html_renderer_extensions.md +708 -0
- data/lib/rspec/agents/doc/scenario_guide.md +289 -0
- data/lib/rspec/agents/dsl/agent_proxy.rb +141 -0
- data/lib/rspec/agents/dsl/criterion_definition.rb +78 -0
- data/lib/rspec/agents/dsl/graph_builder.rb +38 -0
- data/lib/rspec/agents/dsl/runner_factory.rb +52 -0
- data/lib/rspec/agents/dsl/scenario_set_dsl.rb +166 -0
- data/lib/rspec/agents/dsl/test_context.rb +223 -0
- data/lib/rspec/agents/dsl/user_proxy.rb +71 -0
- data/lib/rspec/agents/dsl.rb +398 -0
- data/lib/rspec/agents/evaluation_result.rb +44 -0
- data/lib/rspec/agents/event_bus.rb +78 -0
- data/lib/rspec/agents/events.rb +141 -0
- data/lib/rspec/agents/isolated_event_bus.rb +86 -0
- data/lib/rspec/agents/judge.rb +244 -0
- data/lib/rspec/agents/llm/anthropic.rb +143 -0
- data/lib/rspec/agents/llm/base.rb +64 -0
- data/lib/rspec/agents/llm/mock.rb +181 -0
- data/lib/rspec/agents/llm/response.rb +52 -0
- data/lib/rspec/agents/matchers.rb +554 -0
- data/lib/rspec/agents/message.rb +81 -0
- data/lib/rspec/agents/metadata.rb +120 -0
- data/lib/rspec/agents/observers/base.rb +70 -0
- data/lib/rspec/agents/observers/parallel_terminal_observer.rb +151 -0
- data/lib/rspec/agents/observers/rpc_notify_observer.rb +43 -0
- data/lib/rspec/agents/observers/terminal_observer.rb +103 -0
- data/lib/rspec/agents/parallel/controller.rb +284 -0
- data/lib/rspec/agents/parallel/example_discovery.rb +153 -0
- data/lib/rspec/agents/parallel/partitioner.rb +31 -0
- data/lib/rspec/agents/parallel/run_result.rb +22 -0
- data/lib/rspec/agents/parallel/ui/interactive_ui.rb +605 -0
- data/lib/rspec/agents/parallel/ui/interleaved_ui.rb +139 -0
- data/lib/rspec/agents/parallel/ui/output_adapter.rb +127 -0
- data/lib/rspec/agents/parallel/ui/quiet_ui.rb +100 -0
- data/lib/rspec/agents/parallel/ui/ui_factory.rb +53 -0
- data/lib/rspec/agents/parallel/ui/ui_mode.rb +101 -0
- data/lib/rspec/agents/prompt_builders/base.rb +113 -0
- data/lib/rspec/agents/prompt_builders/criterion_evaluation.rb +136 -0
- data/lib/rspec/agents/prompt_builders/goal_achievement_evaluation.rb +142 -0
- data/lib/rspec/agents/prompt_builders/grounding_evaluation.rb +172 -0
- data/lib/rspec/agents/prompt_builders/intent_evaluation.rb +111 -0
- data/lib/rspec/agents/prompt_builders/topic_classification.rb +105 -0
- data/lib/rspec/agents/prompt_builders/user_simulation.rb +131 -0
- data/lib/rspec/agents/runners/headless_runner.rb +272 -0
- data/lib/rspec/agents/runners/parallel_terminal_runner.rb +220 -0
- data/lib/rspec/agents/runners/terminal_runner.rb +186 -0
- data/lib/rspec/agents/runners/user_simulator.rb +261 -0
- data/lib/rspec/agents/scenario.rb +133 -0
- data/lib/rspec/agents/scenario_loader.rb +145 -0
- data/lib/rspec/agents/serialization/conversation_renderer.rb +161 -0
- data/lib/rspec/agents/serialization/extension.rb +199 -0
- data/lib/rspec/agents/serialization/extensions/core_extension.rb +66 -0
- data/lib/rspec/agents/serialization/presenters.rb +281 -0
- data/lib/rspec/agents/serialization/run_data_aggregator.rb +197 -0
- data/lib/rspec/agents/serialization/run_data_builder.rb +189 -0
- data/lib/rspec/agents/serialization/templates/_alpine.min.js +5 -0
- data/lib/rspec/agents/serialization/templates/_base_components.css +196 -0
- data/lib/rspec/agents/serialization/templates/_base_components.js +46 -0
- data/lib/rspec/agents/serialization/templates/_conversation_fragment.html.haml +34 -0
- data/lib/rspec/agents/serialization/templates/_metadata_default.html.haml +17 -0
- data/lib/rspec/agents/serialization/templates/_scripts.js +89 -0
- data/lib/rspec/agents/serialization/templates/_styles.css +1211 -0
- data/lib/rspec/agents/serialization/templates/conversation_document.html.haml +29 -0
- data/lib/rspec/agents/serialization/templates/test_suite.html.haml +238 -0
- data/lib/rspec/agents/serialization/test_suite_renderer.rb +207 -0
- data/lib/rspec/agents/serialization.rb +374 -0
- data/lib/rspec/agents/simulator_config.rb +336 -0
- data/lib/rspec/agents/spec_executor.rb +494 -0
- data/lib/rspec/agents/stable_example_id.rb +147 -0
- data/lib/rspec/agents/templates/user_simulation.erb +9 -0
- data/lib/rspec/agents/tool_call.rb +53 -0
- data/lib/rspec/agents/topic.rb +307 -0
- data/lib/rspec/agents/topic_graph.rb +236 -0
- data/lib/rspec/agents/triggers.rb +122 -0
- data/lib/rspec/agents/turn.rb +63 -0
- data/lib/rspec/agents/turn_executor.rb +91 -0
- data/lib/rspec/agents/version.rb +7 -0
- data/lib/rspec/agents.rb +145 -0
- metadata +242 -0
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
require_relative "output_adapter"
|
|
5
|
+
|
|
6
|
+
module RSpec
|
|
7
|
+
module Agents
|
|
8
|
+
module Parallel
|
|
9
|
+
module UI
|
|
10
|
+
# Full-screen terminal UI with progress tracking and per-worker conversation views.
|
|
11
|
+
# Features tabbed interface, progress bar, keyboard navigation, and auto-follow.
|
|
12
|
+
#
|
|
13
|
+
# Layout:
|
|
14
|
+
# +-- Progress Header ------------------------------------------+
|
|
15
|
+
# | Tab Bar: [1 *] 2 o 3 v 4 x [f]ollow on |
|
|
16
|
+
# +-- Content Pane (scrollable) --------------------------------+
|
|
17
|
+
# | User: ... |
|
|
18
|
+
# | Agent: ... |
|
|
19
|
+
# +-- Help Bar -------------------------------------------------+
|
|
20
|
+
#
|
|
21
|
+
class InteractiveUI < OutputAdapter
|
|
22
|
+
# Spinner animation frames (cycles at ~200ms)
|
|
23
|
+
SPINNER_FRAMES = ["\u25d0", "\u25d3", "\u25d1", "\u25d2"].freeze
|
|
24
|
+
|
|
25
|
+
# Box drawing characters
|
|
26
|
+
BOX = {
|
|
27
|
+
top_left: "\u256d",
|
|
28
|
+
top_right: "\u256e",
|
|
29
|
+
bottom_left: "\u2570",
|
|
30
|
+
bottom_right: "\u256f",
|
|
31
|
+
horizontal: "\u2500",
|
|
32
|
+
vertical: "\u2502",
|
|
33
|
+
content_top_left: "\u250c",
|
|
34
|
+
content_top_right: "\u2510",
|
|
35
|
+
content_bottom_left: "\u2514",
|
|
36
|
+
content_bottom_right: "\u2518"
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
# Status symbols
|
|
40
|
+
STATUS = {
|
|
41
|
+
running: SPINNER_FRAMES[0],
|
|
42
|
+
idle: "\u25cb",
|
|
43
|
+
passed: "\u2713",
|
|
44
|
+
failed: "\u2717",
|
|
45
|
+
pending: "\u23f8"
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
attr_reader :failures
|
|
49
|
+
|
|
50
|
+
# Maximum lines to keep in per-worker buffer
|
|
51
|
+
BUFFER_SIZE = 500
|
|
52
|
+
|
|
53
|
+
# Scroll margin (lines from edge before scrolling)
|
|
54
|
+
SCROLL_MARGIN = 3
|
|
55
|
+
|
|
56
|
+
def initialize(output: $stdout, color: true)
|
|
57
|
+
super
|
|
58
|
+
@failures = []
|
|
59
|
+
@worker_count = 0
|
|
60
|
+
@example_count = 0
|
|
61
|
+
@completed = 0
|
|
62
|
+
@failure_count = 0
|
|
63
|
+
|
|
64
|
+
# Worker state
|
|
65
|
+
@worker_status = {} # worker_index => :running, :idle, :passed, :failed, :pending
|
|
66
|
+
@worker_examples = {} # worker_index => current example description
|
|
67
|
+
@worker_buffers = Hash.new { |h, k| h[k] = [] } # worker_index => [lines]
|
|
68
|
+
@worker_finished = {} # worker_index => example_count finished
|
|
69
|
+
|
|
70
|
+
# UI state
|
|
71
|
+
@selected_worker = 0
|
|
72
|
+
@scroll_offset = 0
|
|
73
|
+
@follow_mode = false
|
|
74
|
+
@auto_rotate = false
|
|
75
|
+
@last_switch_time = Time.now
|
|
76
|
+
@spinner_index = 0
|
|
77
|
+
@last_spinner_time = Time.now
|
|
78
|
+
|
|
79
|
+
# Terminal state
|
|
80
|
+
@running = false
|
|
81
|
+
@input_thread = nil
|
|
82
|
+
@render_mutex = Mutex.new
|
|
83
|
+
@cols = 80
|
|
84
|
+
@rows = 24
|
|
85
|
+
@run_start_time = nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def on_run_started(worker_count:, example_count:)
|
|
89
|
+
@worker_count = worker_count
|
|
90
|
+
@example_count = example_count
|
|
91
|
+
@run_start_time = Time.now
|
|
92
|
+
|
|
93
|
+
# Initialize worker state
|
|
94
|
+
worker_count.times do |i|
|
|
95
|
+
@worker_status[i] = :idle
|
|
96
|
+
@worker_examples[i] = nil
|
|
97
|
+
@worker_buffers[i] = []
|
|
98
|
+
@worker_finished[i] = 0
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
@running = true
|
|
102
|
+
update_terminal_size
|
|
103
|
+
enter_alternate_screen
|
|
104
|
+
hide_cursor
|
|
105
|
+
render
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def on_run_finished(results:)
|
|
109
|
+
@running = false
|
|
110
|
+
show_cursor
|
|
111
|
+
exit_alternate_screen
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def start_input_handling
|
|
115
|
+
@input_thread = Thread.new { input_loop }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def stop_input_handling
|
|
119
|
+
@running = false
|
|
120
|
+
@input_thread&.kill
|
|
121
|
+
@input_thread = nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def cleanup
|
|
125
|
+
stop_input_handling
|
|
126
|
+
show_cursor
|
|
127
|
+
exit_alternate_screen
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def on_example_started(worker:, event:)
|
|
131
|
+
desc = event.description || event.full_description
|
|
132
|
+
|
|
133
|
+
synchronized do
|
|
134
|
+
@worker_status[worker] = :running
|
|
135
|
+
@worker_examples[worker] = desc
|
|
136
|
+
append_to_buffer(worker, "#{colorize("\u25cb", :white)} #{desc}...")
|
|
137
|
+
|
|
138
|
+
maybe_switch_to_worker(worker)
|
|
139
|
+
render
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def on_example_finished(worker:, event:)
|
|
144
|
+
synchronized do
|
|
145
|
+
@completed += 1
|
|
146
|
+
|
|
147
|
+
case event
|
|
148
|
+
when Events::ExamplePassed
|
|
149
|
+
@worker_status[worker] = :passed
|
|
150
|
+
duration = format_duration(event.duration)
|
|
151
|
+
append_to_buffer(worker, "#{colorize("\u2713", :green)} #{event.description || event.full_description} #{colorize("(#{duration})", :dim)}")
|
|
152
|
+
|
|
153
|
+
when Events::ExampleFailed
|
|
154
|
+
@failures << event
|
|
155
|
+
@failure_count += 1
|
|
156
|
+
@worker_status[worker] = :failed
|
|
157
|
+
append_to_buffer(worker, "#{colorize("\u2717", :red)} #{event.description || event.full_description}")
|
|
158
|
+
if event.message
|
|
159
|
+
event.message.to_s.lines.first(3).each do |line|
|
|
160
|
+
append_to_buffer(worker, " #{colorize(line.chomp, :red)}")
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
when Events::ExamplePending
|
|
165
|
+
@worker_status[worker] = :pending
|
|
166
|
+
message = event.message ? " (#{event.message})" : ""
|
|
167
|
+
append_to_buffer(worker, "#{colorize("\u23f8", :yellow)} #{event.description || event.full_description}#{message}")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
@worker_examples[worker] = nil
|
|
171
|
+
@worker_finished[worker] = (@worker_finished[worker] || 0) + 1
|
|
172
|
+
|
|
173
|
+
maybe_switch_to_worker(worker)
|
|
174
|
+
render
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def on_conversation_event(worker:, event:)
|
|
179
|
+
synchronized do
|
|
180
|
+
case event
|
|
181
|
+
when Events::UserMessage
|
|
182
|
+
text = truncate(event.text, @cols - 20)
|
|
183
|
+
append_to_buffer(worker, " #{colorize("User:", :dim)} #{text}")
|
|
184
|
+
|
|
185
|
+
when Events::AgentResponse
|
|
186
|
+
text = truncate(event.text, @cols - 20)
|
|
187
|
+
append_to_buffer(worker, " #{colorize("Agent:", :dim)} #{text}")
|
|
188
|
+
|
|
189
|
+
when Events::ToolCallCompleted
|
|
190
|
+
args_str = format_tool_args(event.arguments)
|
|
191
|
+
append_to_buffer(worker, " #{colorize("\u2192", :magenta)} #{colorize(event.tool_name.to_s, :magenta)}#{args_str}")
|
|
192
|
+
|
|
193
|
+
when Events::TopicChanged
|
|
194
|
+
return unless event.from_topic
|
|
195
|
+
append_to_buffer(worker, " #{colorize("Topic:", :yellow)} #{event.from_topic} \u2192 #{event.to_topic}")
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
maybe_switch_to_worker(worker)
|
|
199
|
+
render
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def on_group_event(worker:, event:)
|
|
204
|
+
synchronized do
|
|
205
|
+
case event
|
|
206
|
+
when Events::GroupStarted
|
|
207
|
+
append_to_buffer(worker, "#{colorize("\u25bc", :blue)} #{event.description}")
|
|
208
|
+
render
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def on_progress(completed:, total:, failures:)
|
|
214
|
+
@completed = completed
|
|
215
|
+
@failure_count = failures
|
|
216
|
+
|
|
217
|
+
synchronized do
|
|
218
|
+
update_spinner
|
|
219
|
+
render
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
private
|
|
224
|
+
|
|
225
|
+
# =======================================================================
|
|
226
|
+
# Buffer Management
|
|
227
|
+
# =======================================================================
|
|
228
|
+
|
|
229
|
+
def append_to_buffer(worker, line)
|
|
230
|
+
buffer = @worker_buffers[worker]
|
|
231
|
+
buffer << line
|
|
232
|
+
buffer.shift while buffer.size > BUFFER_SIZE
|
|
233
|
+
|
|
234
|
+
# Auto-scroll to bottom if in follow mode
|
|
235
|
+
if @follow_mode && worker == @selected_worker
|
|
236
|
+
@scroll_offset = [0, buffer.size - content_height].max
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# =======================================================================
|
|
241
|
+
# Input Handling
|
|
242
|
+
# =======================================================================
|
|
243
|
+
|
|
244
|
+
def input_loop
|
|
245
|
+
while @running
|
|
246
|
+
handle_input if IO.select([$stdin], nil, nil, 0.1)
|
|
247
|
+
update_spinner
|
|
248
|
+
render
|
|
249
|
+
end
|
|
250
|
+
rescue
|
|
251
|
+
# Silently ignore input errors during shutdown
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def handle_input
|
|
255
|
+
char = $stdin.getch
|
|
256
|
+
|
|
257
|
+
case char
|
|
258
|
+
when "q"
|
|
259
|
+
@running = false
|
|
260
|
+
when "1".."9"
|
|
261
|
+
worker = char.to_i - 1
|
|
262
|
+
select_worker(worker) if worker < @worker_count
|
|
263
|
+
when "h", "\e[D" # left arrow
|
|
264
|
+
select_worker((@selected_worker - 1) % @worker_count)
|
|
265
|
+
when "l", "\e[C" # right arrow
|
|
266
|
+
select_worker((@selected_worker + 1) % @worker_count)
|
|
267
|
+
when "j", "\e[B" # down arrow
|
|
268
|
+
scroll_down
|
|
269
|
+
when "k", "\e[A" # up arrow
|
|
270
|
+
scroll_up
|
|
271
|
+
when "f"
|
|
272
|
+
@follow_mode = !@follow_mode
|
|
273
|
+
@auto_rotate = false if @follow_mode
|
|
274
|
+
when "a"
|
|
275
|
+
@auto_rotate = !@auto_rotate
|
|
276
|
+
@follow_mode = false if @auto_rotate
|
|
277
|
+
when "g"
|
|
278
|
+
scroll_to_top
|
|
279
|
+
when "G"
|
|
280
|
+
scroll_to_bottom
|
|
281
|
+
when "\e" # Escape sequence
|
|
282
|
+
handle_escape_sequence
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
render
|
|
286
|
+
rescue Errno::EIO
|
|
287
|
+
# Terminal disconnected
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def handle_escape_sequence
|
|
291
|
+
return unless IO.select([$stdin], nil, nil, 0.05)
|
|
292
|
+
seq = String.new
|
|
293
|
+
2.times do
|
|
294
|
+
break unless IO.select([$stdin], nil, nil, 0.01)
|
|
295
|
+
seq << $stdin.getch
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
case seq
|
|
299
|
+
when "[A" then scroll_up
|
|
300
|
+
when "[B" then scroll_down
|
|
301
|
+
when "[C" then select_worker((@selected_worker + 1) % @worker_count)
|
|
302
|
+
when "[D" then select_worker((@selected_worker - 1) % @worker_count)
|
|
303
|
+
when "[5" then page_up # Page Up
|
|
304
|
+
when "[6" then page_down # Page Down
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def select_worker(index)
|
|
309
|
+
@selected_worker = index
|
|
310
|
+
@scroll_offset = [0, @worker_buffers[@selected_worker].size - content_height].max
|
|
311
|
+
@last_switch_time = Time.now
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def scroll_up
|
|
315
|
+
@scroll_offset = [@scroll_offset - 1, 0].max
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def scroll_down
|
|
319
|
+
max = [@worker_buffers[@selected_worker].size - content_height, 0].max
|
|
320
|
+
@scroll_offset = [@scroll_offset + 1, max].min
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def page_up
|
|
324
|
+
@scroll_offset = [@scroll_offset - content_height, 0].max
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def page_down
|
|
328
|
+
max = [@worker_buffers[@selected_worker].size - content_height, 0].max
|
|
329
|
+
@scroll_offset = [@scroll_offset + content_height, max].min
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def scroll_to_top
|
|
333
|
+
@scroll_offset = 0
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def scroll_to_bottom
|
|
337
|
+
@scroll_offset = [@worker_buffers[@selected_worker].size - content_height, 0].max
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def maybe_switch_to_worker(worker)
|
|
341
|
+
return unless @follow_mode
|
|
342
|
+
return if Time.now - @last_switch_time < 3 # Don't switch if manually switched recently
|
|
343
|
+
return if @scroll_offset < [@worker_buffers[@selected_worker].size - content_height, 0].max # User scrolled up
|
|
344
|
+
|
|
345
|
+
@selected_worker = worker
|
|
346
|
+
@scroll_offset = [@worker_buffers[worker].size - content_height, 0].max
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# =======================================================================
|
|
350
|
+
# Rendering
|
|
351
|
+
# =======================================================================
|
|
352
|
+
|
|
353
|
+
def render
|
|
354
|
+
@render_mutex.synchronize do
|
|
355
|
+
update_terminal_size
|
|
356
|
+
|
|
357
|
+
output = StringIO.new(String.new)
|
|
358
|
+
output << move_cursor(1, 1)
|
|
359
|
+
output << clear_screen
|
|
360
|
+
|
|
361
|
+
render_progress_header(output)
|
|
362
|
+
render_tab_bar(output)
|
|
363
|
+
render_content_pane(output)
|
|
364
|
+
render_help_bar(output)
|
|
365
|
+
|
|
366
|
+
@output.print output.string
|
|
367
|
+
@output.flush
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def render_progress_header(output)
|
|
372
|
+
# Progress bar width (leave room for text)
|
|
373
|
+
bar_width = @cols - 50
|
|
374
|
+
bar_width = [bar_width, 20].max
|
|
375
|
+
|
|
376
|
+
progress_ratio = @example_count > 0 ? @completed.to_f / @example_count : 0
|
|
377
|
+
filled = (progress_ratio * bar_width).round
|
|
378
|
+
empty = bar_width - filled
|
|
379
|
+
|
|
380
|
+
bar = colorize("\u2588" * filled, :cyan) + colorize("\u2591" * empty, :dim)
|
|
381
|
+
|
|
382
|
+
status_text = @running ? "Running specs" : "Completed"
|
|
383
|
+
@run_start_time ? format_duration(Time.now - @run_start_time) : ""
|
|
384
|
+
|
|
385
|
+
# Build header line
|
|
386
|
+
header = "#{colorize("\u26a1", :cyan)} #{status_text} [#{bar}] #{@completed}/#{@example_count}"
|
|
387
|
+
header += " #{colorize("#{@failure_count} \u2717", :red)}" if @failure_count > 0
|
|
388
|
+
header += " #{colorize("#{@completed - @failure_count} \u2713", :green)}" if @completed > 0
|
|
389
|
+
header += " #{@worker_count} workers"
|
|
390
|
+
|
|
391
|
+
# Draw boxed header
|
|
392
|
+
output << BOX[:top_left] + BOX[:horizontal] * (@cols - 2) + BOX[:top_right] + "\n"
|
|
393
|
+
output << BOX[:vertical] + " " + pad_or_truncate(header, @cols - 4) + " " + BOX[:vertical] + "\n"
|
|
394
|
+
output << BOX[:bottom_left] + BOX[:horizontal] * (@cols - 2) + BOX[:bottom_right] + "\n"
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def render_tab_bar(output)
|
|
398
|
+
tabs = []
|
|
399
|
+
@worker_count.times do |i|
|
|
400
|
+
status = @worker_status[i]
|
|
401
|
+
symbol = status == :running ? spinner_char : STATUS[status]
|
|
402
|
+
color = status_color(status)
|
|
403
|
+
|
|
404
|
+
if i == @selected_worker
|
|
405
|
+
tabs << "[#{colorize("#{i + 1}", :bold)} #{colorize(symbol, color)}]"
|
|
406
|
+
else
|
|
407
|
+
tabs << " #{i + 1} #{colorize(symbol, color)} "
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
mode_indicator = if @follow_mode
|
|
412
|
+
"[#{colorize("f", :bold)}]ollow on"
|
|
413
|
+
elsif @auto_rotate
|
|
414
|
+
"[#{colorize("a", :bold)}]uto-rotate"
|
|
415
|
+
else
|
|
416
|
+
"[#{colorize("f", :dim)}]ollow off"
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
tab_line = tabs.join(" ")
|
|
420
|
+
padding = @cols - visible_length(tab_line) - visible_length(mode_indicator) - 2
|
|
421
|
+
padding = [padding, 1].max
|
|
422
|
+
|
|
423
|
+
output << "\n " << tab_line << " " * padding << mode_indicator << "\n\n"
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def render_content_pane(output)
|
|
427
|
+
worker = @selected_worker
|
|
428
|
+
example = @worker_examples[worker]
|
|
429
|
+
buffer = @worker_buffers[worker]
|
|
430
|
+
finished = @worker_finished[worker] || 0
|
|
431
|
+
|
|
432
|
+
# Header
|
|
433
|
+
header = "Worker #{worker + 1}"
|
|
434
|
+
header += ": #{example}" if example
|
|
435
|
+
header = truncate(header, @cols - 6)
|
|
436
|
+
|
|
437
|
+
output << BOX[:content_top_left] + BOX[:horizontal] + " " + header + " "
|
|
438
|
+
output << BOX[:horizontal] * (@cols - visible_length(header) - 6) + BOX[:content_top_right] + "\n"
|
|
439
|
+
|
|
440
|
+
# Content
|
|
441
|
+
height = content_height
|
|
442
|
+
visible_lines = buffer[@scroll_offset, height] || []
|
|
443
|
+
|
|
444
|
+
if visible_lines.empty?
|
|
445
|
+
# Show waiting message
|
|
446
|
+
empty_lines = (height - 3) / 2
|
|
447
|
+
empty_lines.times { output << BOX[:vertical] + " " * (@cols - 2) + BOX[:vertical] + "\n" }
|
|
448
|
+
|
|
449
|
+
if finished > 0
|
|
450
|
+
msg = "\u2713 Worker finished (#{finished} example#{"s" unless finished == 1})"
|
|
451
|
+
output << BOX[:vertical] + center_text(colorize(msg, :green), @cols - 2) + BOX[:vertical] + "\n"
|
|
452
|
+
else
|
|
453
|
+
msg = "Waiting for next example..."
|
|
454
|
+
output << BOX[:vertical] + center_text(colorize(msg, :dim), @cols - 2) + BOX[:vertical] + "\n"
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
remaining = height - empty_lines - 1
|
|
458
|
+
remaining.times { output << BOX[:vertical] + " " * (@cols - 2) + BOX[:vertical] + "\n" }
|
|
459
|
+
else
|
|
460
|
+
visible_lines.each do |line|
|
|
461
|
+
truncated = truncate_line(line, @cols - 4)
|
|
462
|
+
padding = @cols - 4 - visible_length(truncated)
|
|
463
|
+
output << BOX[:vertical] + " " + truncated + " " * [padding, 0].max + " " + BOX[:vertical] + "\n"
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# Pad remaining lines
|
|
467
|
+
(height - visible_lines.size).times do
|
|
468
|
+
output << BOX[:vertical] + " " * (@cols - 2) + BOX[:vertical] + "\n"
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
output << BOX[:content_bottom_left] + BOX[:horizontal] * (@cols - 2) + BOX[:content_bottom_right] + "\n"
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def render_help_bar(output)
|
|
476
|
+
help = " 1-#{@worker_count} switch \u00b7 \u2190\u2192 prev/next \u00b7 \u2191\u2193 scroll \u00b7 f follow \u00b7 a auto-rotate \u00b7 q quit"
|
|
477
|
+
output << colorize(truncate(help, @cols), :dim)
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# =======================================================================
|
|
481
|
+
# Terminal Control
|
|
482
|
+
# =======================================================================
|
|
483
|
+
|
|
484
|
+
def update_terminal_size
|
|
485
|
+
size = IO.console&.winsize rescue nil
|
|
486
|
+
if size && size[0] > 0
|
|
487
|
+
@rows = size[0]
|
|
488
|
+
@cols = size[1]
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def content_height
|
|
493
|
+
# Total height minus: header(3) + blank(1) + tabs(2) + blank(1) + content border(2) + help(1)
|
|
494
|
+
[@rows - 10, 5].max
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def enter_alternate_screen
|
|
498
|
+
@output.print "\e[?1049h"
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def exit_alternate_screen
|
|
502
|
+
@output.print "\e[?1049l"
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def hide_cursor
|
|
506
|
+
@output.print "\e[?25l"
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def show_cursor
|
|
510
|
+
@output.print "\e[?25h"
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
def move_cursor(row, col)
|
|
514
|
+
"\e[#{row};#{col}H"
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def clear_screen
|
|
518
|
+
"\e[2J"
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# =======================================================================
|
|
522
|
+
# Helpers
|
|
523
|
+
# =======================================================================
|
|
524
|
+
|
|
525
|
+
def update_spinner
|
|
526
|
+
now = Time.now
|
|
527
|
+
if now - @last_spinner_time >= 0.2
|
|
528
|
+
@spinner_index = (@spinner_index + 1) % SPINNER_FRAMES.size
|
|
529
|
+
@last_spinner_time = now
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def spinner_char
|
|
534
|
+
SPINNER_FRAMES[@spinner_index]
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def status_color(status)
|
|
538
|
+
case status
|
|
539
|
+
when :running then :cyan
|
|
540
|
+
when :passed then :green
|
|
541
|
+
when :failed then :red
|
|
542
|
+
when :pending then :yellow
|
|
543
|
+
else :white
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def pad_or_truncate(text, width)
|
|
548
|
+
visible = visible_length(text)
|
|
549
|
+
if visible > width
|
|
550
|
+
truncate_line(text, width)
|
|
551
|
+
else
|
|
552
|
+
text + " " * (width - visible)
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def center_text(text, width)
|
|
557
|
+
visible = visible_length(text)
|
|
558
|
+
return truncate_line(text, width) if visible > width
|
|
559
|
+
|
|
560
|
+
padding = (width - visible) / 2
|
|
561
|
+
" " * padding + text + " " * (width - visible - padding)
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def truncate_line(line, max_width)
|
|
565
|
+
return "" unless line
|
|
566
|
+
visible = 0
|
|
567
|
+
result = String.new
|
|
568
|
+
in_escape = false
|
|
569
|
+
|
|
570
|
+
line.each_char do |char|
|
|
571
|
+
if char == "\e"
|
|
572
|
+
in_escape = true
|
|
573
|
+
result << char
|
|
574
|
+
elsif in_escape
|
|
575
|
+
result << char
|
|
576
|
+
in_escape = false if char =~ /[a-zA-Z]/
|
|
577
|
+
else
|
|
578
|
+
break if visible >= max_width - 3 && visible_length(line) > max_width
|
|
579
|
+
result << char
|
|
580
|
+
visible += 1
|
|
581
|
+
end
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
if visible_length(line) > max_width
|
|
585
|
+
result + "..."
|
|
586
|
+
else
|
|
587
|
+
result
|
|
588
|
+
end
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def visible_length(text)
|
|
592
|
+
# Remove ANSI escape sequences to get visible length
|
|
593
|
+
text.to_s.gsub(/\e\[[0-9;]*[a-zA-Z]/, "").length
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
def format_tool_args(arguments)
|
|
597
|
+
return "" if arguments.nil? || arguments.empty?
|
|
598
|
+
args_preview = arguments.map { |k, v| "#{k}: #{truncate(v.to_s, 20)}" }.join(", ")
|
|
599
|
+
colorize("(#{truncate(args_preview, 40)})", :dim)
|
|
600
|
+
end
|
|
601
|
+
end
|
|
602
|
+
end
|
|
603
|
+
end
|
|
604
|
+
end
|
|
605
|
+
end
|