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.
Files changed (104) hide show
  1. checksums.yaml +7 -0
  2. data/bin/rspec-agents +24 -0
  3. data/lib/async_workers/channel_config.rb +34 -0
  4. data/lib/async_workers/doc/process_manager_design.md +512 -0
  5. data/lib/async_workers/errors.rb +21 -0
  6. data/lib/async_workers/managed_process.rb +284 -0
  7. data/lib/async_workers/output_stream.rb +86 -0
  8. data/lib/async_workers/rpc_channel.rb +159 -0
  9. data/lib/async_workers/transport/base.rb +57 -0
  10. data/lib/async_workers/transport/stdio_transport.rb +91 -0
  11. data/lib/async_workers/transport/unix_socket_transport.rb +112 -0
  12. data/lib/async_workers/worker_group.rb +175 -0
  13. data/lib/async_workers.rb +17 -0
  14. data/lib/rspec/agents/agent_response.rb +61 -0
  15. data/lib/rspec/agents/agents/base.rb +123 -0
  16. data/lib/rspec/agents/cli.rb +342 -0
  17. data/lib/rspec/agents/conversation.rb +308 -0
  18. data/lib/rspec/agents/criterion.rb +237 -0
  19. data/lib/rspec/agents/doc/2026_01_22_observer-system-design.md +757 -0
  20. data/lib/rspec/agents/doc/2026_01_23_parallel_spec_runner-design.md +1060 -0
  21. data/lib/rspec/agents/doc/2026_01_27_event_serialization-design.md +294 -0
  22. data/lib/rspec/agents/doc/2026_01_27_experiment_aggregation_design.md +831 -0
  23. data/lib/rspec/agents/doc/2026_01_29_rspec-agents-studio-design.md +1332 -0
  24. data/lib/rspec/agents/doc/2026_01_29_testing-framework-design.md +1037 -0
  25. data/lib/rspec/agents/doc/2026_02_04-parallel-runner-ui.md +537 -0
  26. data/lib/rspec/agents/doc/2026_02_05_html_renderer_extensions.md +708 -0
  27. data/lib/rspec/agents/doc/scenario_guide.md +289 -0
  28. data/lib/rspec/agents/dsl/agent_proxy.rb +141 -0
  29. data/lib/rspec/agents/dsl/criterion_definition.rb +78 -0
  30. data/lib/rspec/agents/dsl/graph_builder.rb +38 -0
  31. data/lib/rspec/agents/dsl/runner_factory.rb +52 -0
  32. data/lib/rspec/agents/dsl/scenario_set_dsl.rb +166 -0
  33. data/lib/rspec/agents/dsl/test_context.rb +223 -0
  34. data/lib/rspec/agents/dsl/user_proxy.rb +71 -0
  35. data/lib/rspec/agents/dsl.rb +398 -0
  36. data/lib/rspec/agents/evaluation_result.rb +44 -0
  37. data/lib/rspec/agents/event_bus.rb +78 -0
  38. data/lib/rspec/agents/events.rb +141 -0
  39. data/lib/rspec/agents/isolated_event_bus.rb +86 -0
  40. data/lib/rspec/agents/judge.rb +244 -0
  41. data/lib/rspec/agents/llm/anthropic.rb +143 -0
  42. data/lib/rspec/agents/llm/base.rb +64 -0
  43. data/lib/rspec/agents/llm/mock.rb +181 -0
  44. data/lib/rspec/agents/llm/response.rb +52 -0
  45. data/lib/rspec/agents/matchers.rb +554 -0
  46. data/lib/rspec/agents/message.rb +81 -0
  47. data/lib/rspec/agents/metadata.rb +120 -0
  48. data/lib/rspec/agents/observers/base.rb +70 -0
  49. data/lib/rspec/agents/observers/parallel_terminal_observer.rb +151 -0
  50. data/lib/rspec/agents/observers/rpc_notify_observer.rb +43 -0
  51. data/lib/rspec/agents/observers/terminal_observer.rb +103 -0
  52. data/lib/rspec/agents/parallel/controller.rb +284 -0
  53. data/lib/rspec/agents/parallel/example_discovery.rb +153 -0
  54. data/lib/rspec/agents/parallel/partitioner.rb +31 -0
  55. data/lib/rspec/agents/parallel/run_result.rb +22 -0
  56. data/lib/rspec/agents/parallel/ui/interactive_ui.rb +605 -0
  57. data/lib/rspec/agents/parallel/ui/interleaved_ui.rb +139 -0
  58. data/lib/rspec/agents/parallel/ui/output_adapter.rb +127 -0
  59. data/lib/rspec/agents/parallel/ui/quiet_ui.rb +100 -0
  60. data/lib/rspec/agents/parallel/ui/ui_factory.rb +53 -0
  61. data/lib/rspec/agents/parallel/ui/ui_mode.rb +101 -0
  62. data/lib/rspec/agents/prompt_builders/base.rb +113 -0
  63. data/lib/rspec/agents/prompt_builders/criterion_evaluation.rb +136 -0
  64. data/lib/rspec/agents/prompt_builders/goal_achievement_evaluation.rb +142 -0
  65. data/lib/rspec/agents/prompt_builders/grounding_evaluation.rb +172 -0
  66. data/lib/rspec/agents/prompt_builders/intent_evaluation.rb +111 -0
  67. data/lib/rspec/agents/prompt_builders/topic_classification.rb +105 -0
  68. data/lib/rspec/agents/prompt_builders/user_simulation.rb +131 -0
  69. data/lib/rspec/agents/runners/headless_runner.rb +272 -0
  70. data/lib/rspec/agents/runners/parallel_terminal_runner.rb +220 -0
  71. data/lib/rspec/agents/runners/terminal_runner.rb +186 -0
  72. data/lib/rspec/agents/runners/user_simulator.rb +261 -0
  73. data/lib/rspec/agents/scenario.rb +133 -0
  74. data/lib/rspec/agents/scenario_loader.rb +145 -0
  75. data/lib/rspec/agents/serialization/conversation_renderer.rb +161 -0
  76. data/lib/rspec/agents/serialization/extension.rb +199 -0
  77. data/lib/rspec/agents/serialization/extensions/core_extension.rb +66 -0
  78. data/lib/rspec/agents/serialization/presenters.rb +281 -0
  79. data/lib/rspec/agents/serialization/run_data_aggregator.rb +197 -0
  80. data/lib/rspec/agents/serialization/run_data_builder.rb +189 -0
  81. data/lib/rspec/agents/serialization/templates/_alpine.min.js +5 -0
  82. data/lib/rspec/agents/serialization/templates/_base_components.css +196 -0
  83. data/lib/rspec/agents/serialization/templates/_base_components.js +46 -0
  84. data/lib/rspec/agents/serialization/templates/_conversation_fragment.html.haml +34 -0
  85. data/lib/rspec/agents/serialization/templates/_metadata_default.html.haml +17 -0
  86. data/lib/rspec/agents/serialization/templates/_scripts.js +89 -0
  87. data/lib/rspec/agents/serialization/templates/_styles.css +1211 -0
  88. data/lib/rspec/agents/serialization/templates/conversation_document.html.haml +29 -0
  89. data/lib/rspec/agents/serialization/templates/test_suite.html.haml +238 -0
  90. data/lib/rspec/agents/serialization/test_suite_renderer.rb +207 -0
  91. data/lib/rspec/agents/serialization.rb +374 -0
  92. data/lib/rspec/agents/simulator_config.rb +336 -0
  93. data/lib/rspec/agents/spec_executor.rb +494 -0
  94. data/lib/rspec/agents/stable_example_id.rb +147 -0
  95. data/lib/rspec/agents/templates/user_simulation.erb +9 -0
  96. data/lib/rspec/agents/tool_call.rb +53 -0
  97. data/lib/rspec/agents/topic.rb +307 -0
  98. data/lib/rspec/agents/topic_graph.rb +236 -0
  99. data/lib/rspec/agents/triggers.rb +122 -0
  100. data/lib/rspec/agents/turn.rb +63 -0
  101. data/lib/rspec/agents/turn_executor.rb +91 -0
  102. data/lib/rspec/agents/version.rb +7 -0
  103. data/lib/rspec/agents.rb +145 -0
  104. 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