tng 0.3.8 → 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.
@@ -8,29 +8,42 @@ module Tng
8
8
  class DirectGeneration
9
9
  include ExtractMethods
10
10
 
11
- def initialize(pastel, testng, http_client, params, show_post_generation_menu_proc)
11
+ def initialize(pastel, testng, http_client, params, show_post_generation_menu_proc, go_ui)
12
12
  @pastel = pastel
13
13
  @testng = testng
14
14
  @http_client = http_client
15
15
  @params = params
16
16
  @show_post_generation_menu = show_post_generation_menu_proc
17
+ @go_ui = go_ui
18
+ end
19
+
20
+ # ... (rest of methods)
21
+
22
+ def display_audit_results(result)
23
+ audit_results = result[:audit_results]
24
+ return unless audit_results
25
+
26
+ # Use Go UI to display rich audit results
27
+ # "issues" type handles both issues and behaviours in the unified view
28
+ @go_ui.show_audit_results(audit_results, "issues")
17
29
  end
18
30
 
19
31
  def run
20
- file_path, method_name = @params[:file], @params[:method]
32
+ file_path = @params[:file]
33
+ method_name = @params[:method]
21
34
 
22
35
  unless file_path && method_name
23
- puts @pastel.red("❌ Both file and method parameters are required")
24
- puts @pastel.yellow("Usage: bundle exec tng app/controllers/users_controller.rb index")
25
- puts @pastel.yellow(" or: bundle exec tng --file=users_controller.rb --method=index")
26
- puts @pastel.yellow(" or: bundle exec tng f=users_controller.rb m=index")
36
+ @go_ui.display_error(@pastel.red("❌ Both file and method parameters are required"))
37
+ @go_ui.display_warning(@pastel.yellow("Usage: bundle exec tng app/controllers/users_controller.rb index"))
38
+ @go_ui.display_warning(@pastel.yellow(" or: bundle exec tng --file=users_controller.rb --method=index"))
39
+ @go_ui.display_warning(@pastel.yellow(" or: bundle exec tng f=users_controller.rb m=index"))
27
40
  return
28
41
  end
29
42
 
30
43
  resolved_path = FileTypeDetector.resolve_file_path(file_path)
31
44
 
32
45
  unless resolved_path && File.exist?(resolved_path)
33
- puts @pastel.red("❌ File not found: #{file_path}")
46
+ @go_ui.display_error(@pastel.red("❌ File not found: #{file_path}"))
34
47
  suggest_similar_files(file_path)
35
48
  return
36
49
  end
@@ -42,15 +55,14 @@ module Tng
42
55
  private
43
56
 
44
57
  def suggest_similar_files(file_path)
45
- base_name = File.basename(file_path, '.rb')
46
- puts @pastel.yellow("💡 Did you mean one of these?")
58
+ base_name = File.basename(file_path, ".rb")
47
59
 
48
60
  similar_files = find_similar_files(base_name)
49
61
 
50
62
  if similar_files.empty?
51
- puts @pastel.dim(" No similar files found")
63
+ @go_ui.display_info(@pastel.dim(" No similar files found"))
52
64
  else
53
- similar_files.first(5).each { |file| puts @pastel.dim(" #{file}") }
65
+ @go_ui.display_list(@pastel.yellow("💡 Did you mean one of these?"), similar_files.first(5))
54
66
  end
55
67
  end
56
68
 
@@ -61,8 +73,8 @@ module Tng
61
73
  %w[app/controllers app/models app/services app/service].each do |dir|
62
74
  next unless Dir.exist?(File.join(rails_root, dir))
63
75
 
64
- Dir.glob(File.join(rails_root, dir, '**', "*#{base_name}*.rb")).each do |file|
65
- similar_files << file.gsub(/^#{Regexp.escape(rails_root)}\//, '')
76
+ Dir.glob(File.join(rails_root, dir, "**", "*#{base_name}*.rb")).each do |file|
77
+ similar_files << file.gsub(%r{^#{Regexp.escape(rails_root)}/}, "")
66
78
  end
67
79
  end
68
80
 
@@ -74,27 +86,34 @@ module Tng
74
86
  methods = extract_methods_for_type(file_object, type)
75
87
 
76
88
  if methods.empty?
77
- puts @pastel.yellow("⚠️ No methods found in #{file_object[:name]}")
89
+ @go_ui.display_warning(@pastel.yellow("⚠️ No methods found in #{file_object[:name]}"))
78
90
  return
79
91
  end
80
92
 
81
93
  method_info = methods.find { |m| m[:name].downcase == method_name.downcase }
82
94
 
83
95
  unless method_info
84
- puts @pastel.red("❌ Method '#{method_name}' not found in #{file_object[:name]}")
85
- puts @pastel.yellow("Available methods:")
86
- methods.first(10).each { |m| puts @pastel.dim(" • #{m[:name]}") }
96
+ @go_ui.display_error(@pastel.red("❌ Method '#{method_name}' not found in #{file_object[:name]}"))
97
+ @go_ui.display_list(@pastel.yellow("Available methods:"), methods.first(10).map { |m| m[:name] })
87
98
  return
88
99
  end
89
100
 
90
- puts @pastel.bright_white("🎯 Generating test for #{file_object[:name]}##{method_info[:name]}...")
101
+ @go_ui.display_info(@pastel.bright_white("🎯 #{@params[:audit] ? "Auditing" : "Generating test for"} #{file_object[:name]}##{method_info[:name]}..."))
91
102
 
92
- result = generate_test_result(file_object, method_info, type)
103
+ result = @go_ui.show_progress("Processing...") do |progress|
104
+ generate_test_result(file_object, method_info, type, progress)
105
+ end
93
106
 
94
- if result && result[:file_path]
107
+ if result&.dig(:error)
108
+ @go_ui.display_error(@pastel.red("❌ #{@params[:audit] ? "Audit" : "Test generation"} failed: #{result[:message]}"))
109
+ elsif @params[:audit]
110
+ # Audit mode - display results inline
111
+ display_audit_results(result)
112
+ elsif result && result[:file_path]
113
+ # Test generation mode - show post-generation menu
95
114
  @show_post_generation_menu.call(result)
96
115
  else
97
- puts @pastel.red("❌ Failed to generate test")
116
+ @go_ui.display_error(@pastel.red("❌ Failed to generate test"))
98
117
  end
99
118
  end
100
119
 
@@ -107,14 +126,25 @@ module Tng
107
126
  end
108
127
  end
109
128
 
110
- def generate_test_result(file_object, method_info, type)
129
+ def generate_test_result(file_object, method_info, type, progress)
111
130
  generator = Tng::Services::TestGenerator.new(@http_client)
112
131
 
113
- case type
114
- when "controller" then generator.run_for_controller_method(file_object, method_info)
115
- when "model" then generator.run_for_model_method(file_object, method_info)
116
- when "service" then generator.run_for_service_method(file_object, method_info)
117
- else generator.run_for_other_method(file_object, method_info)
132
+ if @params[:audit]
133
+ # Audit mode - return issues and behaviours
134
+ case type
135
+ when "controller" then generator.run_audit_for_controller_method(file_object, method_info, progress: progress)
136
+ when "model" then generator.run_audit_for_model_method(file_object, method_info, progress: progress)
137
+ when "service" then generator.run_audit_for_service_method(file_object, method_info, progress: progress)
138
+ else generator.run_audit_for_other_method(file_object, method_info, progress: progress)
139
+ end
140
+ else
141
+ # Test generation mode
142
+ case type
143
+ when "controller" then generator.run_for_controller_method(file_object, method_info, progress: progress)
144
+ when "model" then generator.run_for_model_method(file_object, method_info, progress: progress)
145
+ when "service" then generator.run_for_service_method(file_object, method_info, progress: progress)
146
+ else generator.run_for_other_method(file_object, method_info, progress: progress)
147
+ end
118
148
  end
119
149
  end
120
150
  end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "repair_service"
4
+
5
+ module Tng
6
+ module Services
7
+ class FixOrchestrator
8
+ MAX_ITERATIONS = 3
9
+
10
+ def initialize(pastel, http_client)
11
+ @pastel = pastel
12
+ @repair_service = RepairService.new(http_client)
13
+ end
14
+
15
+ def run(file_path)
16
+ unless File.exist?(file_path)
17
+ puts @pastel.red("❌ File not found: #{file_path}")
18
+ return
19
+ end
20
+
21
+ puts @pastel.bright_white("🔧 Starting Auto-Fix loop for #{file_path}...")
22
+
23
+ all_healthy = false
24
+ MAX_ITERATIONS.times do |i|
25
+ puts @pastel.dim("\n--- Iteration #{i + 1} ---")
26
+
27
+ # 1. Syntax Check
28
+ status = Tng::Utils.validate_ruby_syntax(file_path)
29
+ unless status[:success]
30
+ puts @pastel.yellow("⚠️ Syntax error detected!")
31
+ apply_fix(file_path, :syntax, status[:output])
32
+ next
33
+ end
34
+ puts @pastel.green("✓ Syntax OK")
35
+
36
+ # 2. Rubocop Check
37
+ status = Tng::Utils.validate_rubocop(file_path)
38
+ unless status[:success]
39
+ puts @pastel.yellow("⚠️ Rubocop violations detected!")
40
+ apply_fix(file_path, :lint, status[:output])
41
+ next
42
+ end
43
+ puts @pastel.green("✓ Rubocop OK")
44
+
45
+ # 3. Test Check (Optional/Proactive)
46
+ status = Tng::Utils.run_tests(file_path)
47
+ unless status[:success]
48
+ puts @pastel.yellow("⚠️ Test failures detected!")
49
+ apply_fix(file_path, :runtime, status[:output])
50
+ next
51
+ end
52
+ puts @pastel.green("✓ Tests Passed")
53
+
54
+ all_healthy = true
55
+ break
56
+ end
57
+
58
+ if all_healthy
59
+ puts @pastel.bright_green("\n🎉 All checks passed! File is healthy.")
60
+ else
61
+ puts @pastel.red("\n❌ Max iterations reached. File still has issues.")
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def apply_fix(file_path, error_type, error_message)
68
+ puts @pastel.blue("🧠 Calling LLM to fix #{error_type}...")
69
+
70
+ context = {
71
+ app_config: Tng::Services::UserAppConfig.config_with_source,
72
+ test_gems: Tng::Utils.has_gem?("rspec") ? ["rspec"] : ["minitest"] # Basic for now
73
+ }
74
+
75
+ result = @repair_service.repair_file(file_path, error_type, error_message, context)
76
+
77
+ if result[:error]
78
+ puts @pastel.red("❌ Repair failed: #{result[:message]}")
79
+ return
80
+ end
81
+
82
+ if result["file_content"]
83
+ File.write(file_path, result["file_content"])
84
+ puts @pastel.green("✅ Fix applied: #{result["applied_fixes"]&.join(", ")}")
85
+ puts @pastel.dim("📝 Explanation: #{result["explanation"]}")
86
+ else
87
+ puts @pastel.yellow("⚠️ LLM did not return file content.")
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+ require "json"
5
+
6
+ module Tng
7
+ module Services
8
+ class RepairService
9
+ REPAIR_PATH = "cli/tng_rails/contents/repair"
10
+
11
+ def initialize(http_client)
12
+ @http_client = http_client
13
+ end
14
+
15
+ def repair_file(file_path, error_type, error_message, context_info = {})
16
+ file_content = File.read(file_path)
17
+
18
+ payload = {
19
+ file_path: file_path,
20
+ file_content: file_content,
21
+ error_type: error_type,
22
+ error_message: error_message,
23
+ context_info: context_info
24
+ }
25
+
26
+ # Compress payload
27
+ json_payload = payload.to_json
28
+ compressed_payload = Zlib::Deflate.deflate(json_payload)
29
+
30
+ response = @http_client.post_binary(REPAIR_PATH, compressed_payload)
31
+
32
+ return { error: :network_error, message: "Network error" } if response.is_a?(HTTPX::ErrorResponse)
33
+ return { error: :auth_failed, message: "Auth failed" } if [401, 403].include?(response.status)
34
+
35
+ begin
36
+ data = JSON.parse(response.body)
37
+ if data["error"]
38
+ { error: :server_error, message: data["error"] }
39
+ else
40
+ data["result"] # This contains { file_content, explanation, applied_fixes }
41
+ end
42
+ rescue JSON::ParserError => e
43
+ { error: :parse_error, message: "Failed to parse repair response: #{e.message}" }
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -36,6 +36,70 @@ module Tng
36
36
  generate_test_for_type(other_file, method_info, :other, progress: progress)
37
37
  end
38
38
 
39
+ # Audit mode - async, polls for completion
40
+ def run_audit_for_controller_method(controller, method_info, progress: nil)
41
+ run_audit_for_type(controller, method_info, :controller, progress: progress)
42
+ end
43
+
44
+ def run_audit_for_model_method(model, method_info, progress: nil)
45
+ run_audit_for_type(model, method_info, :model, progress: progress)
46
+ end
47
+
48
+ def run_audit_for_service_method(service, method_info, progress: nil)
49
+ run_audit_for_type(service, method_info, :service, progress: progress)
50
+ end
51
+
52
+ def run_audit_for_other_method(other_file, method_info, progress: nil)
53
+ run_audit_for_type(other_file, method_info, :other, progress: progress)
54
+ end
55
+
56
+ def run_audit_for_type(file_object, method_info, type, progress: nil)
57
+ response = send_audit_request_for_type(file_object, method_info, type)
58
+ return { error: :network_error, message: "Network error" } unless response
59
+
60
+ if response.is_a?(HTTPX::ErrorResponse)
61
+ return { error: :network_error, message: response.error&.message || "Network error" }
62
+ end
63
+
64
+ return { error: :auth_failed, message: "Invalid or missing API key" } if response.status == 401
65
+ return { error: :auth_failed, message: "API key expired" } if response.status == 403
66
+
67
+ begin
68
+ job_data = JSON.parse(response.body)
69
+ return { error: :server_error, message: job_data["error"] } if job_data["error"]
70
+
71
+ job_id = job_data["job_id"]
72
+ return { error: :server_error, message: "No job_id returned" } unless job_id
73
+
74
+ # Poll for completion (similar to test generation)
75
+ result = poll_for_completion(job_id, progress: progress)
76
+ return { error: :timeout, message: "Audit timed out" } unless result
77
+
78
+ # Audit results come back as the result directly (not wrapped)
79
+ # Wrap in audit_results key for save_audit_file compatibility
80
+ { audit_results: result }
81
+ rescue JSON::ParserError => e
82
+ { error: :parse_error, message: "Failed to parse audit response: #{e.message}" }
83
+ end
84
+ end
85
+
86
+ def send_audit_request_for_type(file_object, method_info, type)
87
+ config = request_config
88
+ name = file_object[:name] || File.basename(file_object[:path], ".rb")
89
+
90
+ # Pass audit_mode: true as 8th parameter
91
+ case type
92
+ when :controller
93
+ Tng.send_request_for_controller(name, file_object[:path], method_info, *config, true)
94
+ when :model
95
+ Tng.send_request_for_model(name, file_object[:path], method_info, *config, true)
96
+ when :service
97
+ Tng.send_request_for_service(name, file_object[:path], method_info, *config, true)
98
+ when :other
99
+ Tng.send_request_for_other(name, file_object[:path], method_info, *config, true)
100
+ end
101
+ end
102
+
39
103
  def generate_test_for_type(file_object, method_info, type, progress: nil)
40
104
  start_time = Time.now
41
105
 
@@ -77,15 +141,16 @@ module Tng
77
141
  config = request_config
78
142
  name = file_object[:name] || File.basename(file_object[:path], ".rb")
79
143
 
144
+ # Pass audit_mode: false as 8th parameter for normal test generation
80
145
  case type
81
146
  when :controller
82
- Tng.send_request_for_controller(name, file_object[:path], method_info, *config)
147
+ Tng.send_request_for_controller(name, file_object[:path], method_info, *config, false)
83
148
  when :model
84
- Tng.send_request_for_model(name, file_object[:path], method_info, *config)
149
+ Tng.send_request_for_model(name, file_object[:path], method_info, *config, false)
85
150
  when :service
86
- Tng.send_request_for_service(name, file_object[:path], method_info, *config)
151
+ Tng.send_request_for_service(name, file_object[:path], method_info, *config, false)
87
152
  when :other
88
- Tng.send_request_for_other(name, file_object[:path], method_info, *config)
153
+ Tng.send_request_for_other(name, file_object[:path], method_info, *config, false)
89
154
  end
90
155
  end
91
156
 
@@ -98,6 +163,7 @@ module Tng
98
163
  ]
99
164
  end
100
165
 
166
+ # Modified method signature and added interrupt handler
101
167
  def poll_for_completion(job_id, progress: nil)
102
168
  start_time = Time.current
103
169
  attempts = 0
@@ -229,10 +295,26 @@ module Tng
229
295
  next
230
296
  end
231
297
  rescue JSON::ParserError => e
232
- debug_log("Failed to parse response status: #{e.message}") if debug_enabled?
298
+ debug_log("JSON parse error: #{e.message}") if debug_enabled?
233
299
  next
234
300
  end
235
301
  end
302
+ rescue Interrupt
303
+ # User pressed Ctrl+C - exit gracefully
304
+ system("clear") || system("cls")
305
+ puts "\n\n#{TTY::Box.frame(
306
+ "Generation cancelled by user",
307
+ padding: 1,
308
+ align: :center,
309
+ border: :thick,
310
+ style: {
311
+ fg: :yellow,
312
+ border: {
313
+ fg: :yellow
314
+ }
315
+ }
316
+ )}"
317
+ exit(0)
236
318
  end
237
319
 
238
320
  def trigger_cleanup(job_id)
@@ -47,14 +47,15 @@ module Tng
47
47
  end
48
48
 
49
49
  # Show test type selection menu
50
+ # @param mode [String] "test" or "audit" (default: "test")
50
51
  # Returns: "controller", "model", "service", "other", "back"
51
- def show_test_type_menu
52
+ def show_test_type_menu(mode = "test")
52
53
  output_file = Tempfile.new(["go_ui_test_type", ".json"])
53
54
  output_path = output_file.path
54
55
  output_file.close
55
56
 
56
57
  begin
57
- system(@binary_path, "rails-test-menu", "--output", output_path)
58
+ system(@binary_path, "rails-test-menu", "--output", output_path, "--mode", mode)
58
59
 
59
60
  return "back" unless File.exist?(output_path) && File.size(output_path) > 0
60
61
 
@@ -92,7 +93,7 @@ module Tng
92
93
  # @param message [String] Spinner message
93
94
  # @yield Block that returns Hash with :success and :message keys
94
95
  # @return [Hash] Result from block
95
- def show_spinner(message, &block)
96
+ def show_spinner(message)
96
97
  control_file = Tempfile.new(["go_ui_spinner", ".json"])
97
98
  control_path = control_file.path
98
99
  control_file.close
@@ -126,7 +127,7 @@ module Tng
126
127
  # @param title [String] Progress title
127
128
  # @yield [ProgressUpdater] Block receives updater and returns Hash with :message and :result
128
129
  # @return [Hash] Result from block
129
- def show_progress(title, &block)
130
+ def show_progress(title)
130
131
  control_file = Tempfile.new(["go_ui_progress", ".json"])
131
132
  control_path = control_file.path
132
133
  control_file.close
@@ -239,6 +240,28 @@ module Tng
239
240
  puts "Test results error: #{e.message}"
240
241
  end
241
242
 
243
+ # Show audit results
244
+ # @param audit_result [Hash] Full audit result with items, method_name, class_name, method_source_with_lines
245
+ # @param audit_type [String] "issues" or "behaviours"
246
+ def show_audit_results(audit_result, _audit_type)
247
+ # Always use the unified audit-results command which handles both issues and behaviours
248
+ command = "audit-results"
249
+
250
+ # Send full result object via temp file to avoid CLI argument limits
251
+ data_json = JSON.generate(audit_result)
252
+
253
+ input_file = Tempfile.new(["go_ui_audit", ".json"])
254
+ input_path = input_file.path
255
+ File.write(input_path, data_json)
256
+ input_file.close
257
+
258
+ system(@binary_path, command, "--file", input_path)
259
+ rescue StandardError => e
260
+ puts "Audit results error: #{e.message}"
261
+ ensure
262
+ File.unlink(input_path) if input_path && File.exist?(input_path)
263
+ end
264
+
242
265
  # Show authentication error
243
266
  # @param message [String] Error message to display
244
267
  def show_auth_error(message = "Authentication failed")
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Tng
6
+ module UI
7
+ # UI Session that outputs JSON events to STDOUT for machine consumption
8
+ class JsonSession
9
+ def initialize
10
+ # Ensure stdout is sync so events are emitted immediately
11
+ $stdout.sync = true
12
+ @running = false
13
+ end
14
+
15
+ def start
16
+ @running = true
17
+ emit_event("started", { message: "TNG JSON Session Started" })
18
+ end
19
+
20
+ def stop
21
+ @running = false
22
+ emit_event("stopped", { message: "TNG JSON Session Stopped" })
23
+ end
24
+
25
+ def display_error(message)
26
+ emit_event("error", { message: strip_colors(message) })
27
+ end
28
+
29
+ def display_warning(message)
30
+ emit_event("warning", { message: strip_colors(message) })
31
+ end
32
+
33
+ def display_info(message)
34
+ emit_event("info", { message: strip_colors(message) })
35
+ end
36
+
37
+ def display_list(title, items)
38
+ emit_event("list", { title: strip_colors(title), items: items })
39
+ end
40
+
41
+ def running?
42
+ @running
43
+ end
44
+
45
+ # ------------------------------------------------------------------
46
+ # Progress & Spinners
47
+ # ------------------------------------------------------------------
48
+
49
+ def show_spinner(message)
50
+ emit_event("progress", { message: message, status: "running" })
51
+
52
+ result = yield
53
+
54
+ status = result&.dig(:success) ? "success" : "error"
55
+ emit_event("progress", {
56
+ message: result&.dig(:message) || "Done",
57
+ status: status
58
+ })
59
+
60
+ result
61
+ rescue StandardError => e
62
+ emit_event("error", { message: e.message })
63
+ raise
64
+ end
65
+
66
+ def show_progress(title)
67
+ emit_event("progress_start", { title: title })
68
+
69
+ reporter = JsonProgressReporter.new
70
+ result = yield(reporter)
71
+
72
+ if result&.dig(:error)
73
+ # Error handled by reporter or caller
74
+ else
75
+ emit_event("progress_complete", {
76
+ message: result&.dig(:message) || "Done",
77
+ result: result
78
+ })
79
+ end
80
+
81
+ result
82
+ rescue StandardError => e
83
+ emit_event("error", { message: e.message })
84
+ raise
85
+ end
86
+
87
+ # ------------------------------------------------------------------
88
+ # Results Display
89
+ # ------------------------------------------------------------------
90
+
91
+ def show_audit_results(audit_result, _audit_type)
92
+ emit_event("result", audit_result)
93
+ end
94
+
95
+ def show_test_results(title, passed, failed, errors, total, results = [])
96
+ emit_event("test_results", {
97
+ title: title,
98
+ passed: passed,
99
+ failed: failed,
100
+ errors: errors,
101
+ total: total,
102
+ results: results
103
+ })
104
+ end
105
+
106
+ # ------------------------------------------------------------------
107
+ # Errors & Warnings
108
+ # ------------------------------------------------------------------
109
+
110
+ def show_auth_error(message = "Authentication failed")
111
+ emit_event("auth_error", { message: message })
112
+ end
113
+
114
+ def show_config_error(missing)
115
+ emit_event("config_error", { missing: missing })
116
+ end
117
+
118
+ def show_config_missing(missing_items)
119
+ emit_event("config_missing", { missing: missing_items })
120
+ end
121
+
122
+ def show_system_status(status)
123
+ emit_event("system_status", status)
124
+ end
125
+
126
+ # ------------------------------------------------------------------
127
+ # Stubs for Interactive Methods (Not supported in JSON mode)
128
+ # ------------------------------------------------------------------
129
+
130
+ def show_menu
131
+ # JSON mode is non-interactive usually, but if called, we just exit
132
+ "exit"
133
+ end
134
+
135
+ def show_test_type_menu(_mode = "test")
136
+ "back"
137
+ end
138
+
139
+ def show_list_view(_title, _items)
140
+ "back"
141
+ end
142
+
143
+ def show_post_generation_menu(_file_path, _run_command)
144
+ "back"
145
+ end
146
+
147
+ def show_auth_warning(missing_items = "")
148
+ emit_event("auth_warning", { missing: missing_items })
149
+ # In headless mode, we probably can't prompt, so we might default to cancel
150
+ # or we could make this configurable. For now, cancel to be safe.
151
+ "cancel"
152
+ end
153
+
154
+ def show_banner(_version); end
155
+ def show_goodbye; end
156
+ def show_post_install(_version); end
157
+ def show_help(_version); end
158
+ def show_stats(_stats); end
159
+ def show_clipboard_success(_cmd); end
160
+
161
+ def show_no_items(type)
162
+ emit_event("no_items", { type: type })
163
+ end
164
+
165
+ private
166
+
167
+ def strip_colors(str)
168
+ str.gsub(/\e\[\d+(;\d+)*m/, "")
169
+ end
170
+
171
+ def emit_event(type, data = {})
172
+ puts JSON.generate({ type: type }.merge(data))
173
+ end
174
+ end
175
+
176
+ class JsonProgressReporter
177
+ def initialize
178
+ @step = 0
179
+ end
180
+
181
+ def update(message, percent = nil, step_increment: true, explicit_step: nil)
182
+ step_idx = explicit_step || @step
183
+
184
+ payload = { type: "progress_update", message: message }
185
+ payload[:percent] = percent if percent
186
+ payload[:step] = step_idx
187
+
188
+ puts JSON.generate(payload)
189
+
190
+ @step += 1 if step_increment && explicit_step.nil?
191
+ end
192
+
193
+ def error(message)
194
+ puts JSON.generate({ type: "error", message: message })
195
+ end
196
+ end
197
+ end
198
+ end