n2b 0.5.1 → 0.7.1

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.
@@ -0,0 +1,694 @@
1
+ require 'shellwords'
2
+ require 'rbconfig'
3
+
4
+ module N2B
5
+ class MergeCLI < Base
6
+ COLOR_RED = "\e[31m"
7
+ COLOR_GREEN = "\e[32m"
8
+ COLOR_YELLOW= "\e[33m"
9
+ COLOR_BLUE = "\e[34m"
10
+ COLOR_GRAY = "\e[90m"
11
+ COLOR_RESET = "\e[0m"
12
+
13
+ def self.run(args)
14
+ new(args).execute
15
+ end
16
+
17
+ def initialize(args)
18
+ @args = args
19
+ @options = parse_options
20
+ @file_path = @args.shift
21
+ end
22
+
23
+ def execute
24
+ if @file_path.nil?
25
+ show_usage_and_unresolved
26
+ exit 1
27
+ end
28
+
29
+ unless File.exist?(@file_path)
30
+ puts "File not found: #{@file_path}"
31
+ exit 1
32
+ end
33
+
34
+ config = get_config(reconfigure: false, advanced_flow: false)
35
+
36
+ parser = MergeConflictParser.new(context_lines: @options[:context_lines])
37
+ blocks = parser.parse(@file_path)
38
+ if blocks.empty?
39
+ puts "No merge conflicts found."
40
+ return
41
+ end
42
+
43
+ lines = File.readlines(@file_path, chomp: true)
44
+ log_entries = []
45
+ aborted = false
46
+
47
+ blocks.reverse_each do |block|
48
+ result = resolve_block(block, config, lines.join("\n"))
49
+ log_entries << result.merge({
50
+ base_content: block.base_content,
51
+ incoming_content: block.incoming_content,
52
+ base_label: block.base_label,
53
+ incoming_label: block.incoming_label
54
+ })
55
+ if result[:abort]
56
+ aborted = true
57
+ break
58
+ elsif result[:accepted]
59
+ replacement = result[:merged_code].to_s.split("\n")
60
+ lines[(block.start_line-1)...block.end_line] = replacement
61
+ end
62
+ end
63
+
64
+ unless aborted
65
+ File.write(@file_path, lines.join("\n") + "\n")
66
+
67
+ # Show summary
68
+ accepted_count = log_entries.count { |entry| entry[:accepted] }
69
+ skipped_count = log_entries.count { |entry| !entry[:accepted] && !entry[:abort] }
70
+
71
+ puts "\n#{COLOR_BLUE}📊 Resolution Summary:#{COLOR_RESET}"
72
+ puts "#{COLOR_GREEN}✅ Accepted: #{accepted_count}#{COLOR_RESET}"
73
+ puts "#{COLOR_YELLOW}⏭️ Skipped: #{skipped_count}#{COLOR_RESET}" if skipped_count > 0
74
+
75
+ # Only auto-mark as resolved if ALL conflicts were accepted (none skipped)
76
+ if accepted_count > 0 && skipped_count == 0
77
+ mark_file_as_resolved(@file_path)
78
+ puts "#{COLOR_GREEN}🎉 All conflicts resolved! File marked as resolved in VCS.#{COLOR_RESET}"
79
+ elsif accepted_count > 0 && skipped_count > 0
80
+ puts "#{COLOR_YELLOW}⚠️ Some conflicts were skipped - file NOT marked as resolved#{COLOR_RESET}"
81
+ puts "#{COLOR_GRAY}💡 Resolve remaining conflicts or manually mark: hg resolve --mark #{@file_path}#{COLOR_RESET}"
82
+ else
83
+ puts "#{COLOR_YELLOW}⚠️ No conflicts were accepted - file NOT marked as resolved#{COLOR_RESET}"
84
+ end
85
+ else
86
+ puts "\n#{COLOR_YELLOW}⚠️ Resolution aborted - no changes made#{COLOR_RESET}"
87
+ end
88
+
89
+ if config['merge_log_enabled'] && log_entries.any?
90
+ dir = '.n2b_merge_log'
91
+ FileUtils.mkdir_p(dir)
92
+ timestamp = Time.now.strftime('%Y-%m-%d-%H%M%S')
93
+ log_path = File.join(dir, "#{timestamp}.json")
94
+ File.write(log_path, JSON.pretty_generate({file: @file_path, timestamp: Time.now, entries: log_entries}))
95
+ puts "#{COLOR_GRAY}📝 Merge log saved to #{log_path}#{COLOR_RESET}"
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ def parse_options
102
+ options = { context_lines: MergeConflictParser::DEFAULT_CONTEXT_LINES }
103
+ parser = OptionParser.new do |opts|
104
+ opts.banner = 'Usage: n2b-diff FILE [options]'
105
+ opts.on('--context N', Integer, 'Context lines (default: 10)') { |v| options[:context_lines] = v }
106
+ opts.on('-h', '--help', 'Show this help') { puts opts; exit }
107
+ end
108
+ parser.parse!(@args)
109
+ options
110
+ end
111
+
112
+ def resolve_block(block, config, full_file_content)
113
+ comment = nil
114
+
115
+ # Display file and line information
116
+ puts "\n#{COLOR_BLUE}📁 File: #{@file_path}#{COLOR_RESET}"
117
+ puts "#{COLOR_BLUE}📍 Lines: #{block.start_line}-#{block.end_line} (#{block.base_label} ↔ #{block.incoming_label})#{COLOR_RESET}"
118
+ puts "#{COLOR_GRAY}💡 You can check this conflict in your editor at the specified line numbers#{COLOR_RESET}\n"
119
+
120
+ puts "#{COLOR_YELLOW}🤖 AI is analyzing the conflict...#{COLOR_RESET}"
121
+ suggestion = request_merge_with_spinner(block, config, comment, full_file_content)
122
+ puts "#{COLOR_GREEN}✅ Initial suggestion ready!#{COLOR_RESET}\n"
123
+
124
+ loop do
125
+ print_conflict(block)
126
+ print_suggestion(suggestion)
127
+ print "#{COLOR_YELLOW}Accept [y], Skip [n], Comment [c], Edit [e], Abort [a] (explicit choice required): #{COLOR_RESET}"
128
+ choice = $stdin.gets&.strip&.downcase
129
+
130
+ case choice
131
+ when 'y'
132
+ return {accepted: true, merged_code: suggestion['merged_code'], reason: suggestion['reason'], comment: comment}
133
+ when 'n'
134
+ return {accepted: false, merged_code: suggestion['merged_code'], reason: suggestion['reason'], comment: comment}
135
+ when 'c'
136
+ puts 'Enter comment (end with blank line):'
137
+ comment = read_multiline_input
138
+ puts "#{COLOR_YELLOW}🤖 AI is analyzing your comment and generating new suggestion...#{COLOR_RESET}"
139
+ # Re-read file content in case it was edited previously
140
+ fresh_file_content = File.read(@file_path)
141
+ suggestion = request_merge_with_spinner(block, config, comment, fresh_file_content)
142
+ puts "#{COLOR_GREEN}✅ New suggestion ready!#{COLOR_RESET}\n"
143
+ when 'e'
144
+ edit_result = handle_editor_workflow(block, config, full_file_content)
145
+ if edit_result[:resolved]
146
+ return {accepted: true, merged_code: edit_result[:merged_code], reason: edit_result[:reason], comment: comment}
147
+ elsif edit_result[:updated_content]
148
+ # File was changed but conflict not resolved, update content for future LLM calls
149
+ full_file_content = edit_result[:updated_content]
150
+ end
151
+ # Continue the loop with potentially updated content
152
+ when 'a'
153
+ return {abort: true, merged_code: suggestion['merged_code'], reason: suggestion['reason'], comment: comment}
154
+ when '', nil
155
+ puts "#{COLOR_RED}Please enter a valid choice: y/n/c/e/a#{COLOR_RESET}"
156
+ else
157
+ puts "#{COLOR_RED}Invalid option. Please enter: y (accept), n (skip), c (comment), e (edit), or a (abort)#{COLOR_RESET}"
158
+ end
159
+ end
160
+ end
161
+
162
+ def request_merge(block, config, comment, full_file_content)
163
+ prompt = build_merge_prompt(block, comment, full_file_content)
164
+ json_str = call_llm_for_merge(prompt, config)
165
+
166
+ begin
167
+ parsed = JSON.parse(extract_json(json_str))
168
+
169
+ # Validate the response structure
170
+ unless parsed.is_a?(Hash) && parsed.key?('merged_code') && parsed.key?('reason')
171
+ raise JSON::ParserError, "Response missing required keys 'merged_code' and 'reason'"
172
+ end
173
+
174
+ parsed
175
+ rescue JSON::ParserError => e
176
+ # First try automatic JSON repair
177
+ puts "#{COLOR_YELLOW}⚠️ Invalid JSON detected, attempting automatic repair...#{COLOR_RESET}"
178
+ repaired_response = attempt_json_repair(json_str, config)
179
+
180
+ if repaired_response
181
+ puts "#{COLOR_GREEN}✅ JSON repair successful!#{COLOR_RESET}"
182
+ return repaired_response
183
+ else
184
+ puts "#{COLOR_RED}❌ JSON repair failed#{COLOR_RESET}"
185
+ handle_invalid_llm_response(json_str, e, block)
186
+ end
187
+ end
188
+ end
189
+
190
+ def request_merge_with_spinner(block, config, comment, full_file_content)
191
+ spinner_chars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
192
+ spinner_thread = Thread.new do
193
+ i = 0
194
+ while true
195
+ print "\r#{COLOR_BLUE}#{spinner_chars[i % spinner_chars.length]} Processing...#{COLOR_RESET}"
196
+ $stdout.flush
197
+ sleep(0.1)
198
+ i += 1
199
+ end
200
+ end
201
+
202
+ begin
203
+ result = request_merge(block, config, comment, full_file_content)
204
+ spinner_thread.kill
205
+ print "\r#{' ' * 20}\r" # Clear the spinner line
206
+ result
207
+ rescue => e
208
+ spinner_thread.kill
209
+ print "\r#{' ' * 20}\r" # Clear the spinner line
210
+ { 'merged_code' => '', 'reason' => "Error: #{e.message}" }
211
+ end
212
+ end
213
+
214
+ def build_merge_prompt(block, comment, full_file_content)
215
+ config = get_config(reconfigure: false, advanced_flow: false)
216
+ template_path = resolve_template_path('merge_conflict_prompt', config)
217
+ template = File.read(template_path)
218
+
219
+ user_comment_text = comment && !comment.empty? ? "User comment: #{comment}" : ""
220
+
221
+ template.gsub('{full_file_content}', full_file_content.to_s)
222
+ .gsub('{context_before}', block.context_before.to_s)
223
+ .gsub('{base_label}', block.base_label.to_s)
224
+ .gsub('{base_content}', block.base_content.to_s)
225
+ .gsub('{incoming_content}', block.incoming_content.to_s)
226
+ .gsub('{incoming_label}', block.incoming_label.to_s)
227
+ .gsub('{context_after}', block.context_after.to_s)
228
+ .gsub('{user_comment}', user_comment_text)
229
+ end
230
+
231
+ def call_llm_for_merge(prompt, config)
232
+ llm_service_name = config['llm']
233
+ llm = case llm_service_name
234
+ when 'openai'
235
+ N2M::Llm::OpenAi.new(config)
236
+ when 'claude'
237
+ N2M::Llm::Claude.new(config)
238
+ when 'gemini'
239
+ N2M::Llm::Gemini.new(config)
240
+ when 'openrouter'
241
+ N2M::Llm::OpenRouter.new(config)
242
+ when 'ollama'
243
+ N2M::Llm::Ollama.new(config)
244
+ else
245
+ raise N2B::Error, "Unsupported LLM service: #{llm_service_name}"
246
+ end
247
+ llm.analyze_code_diff(prompt)
248
+ rescue N2B::LlmApiError => e
249
+ puts "\n#{COLOR_RED}❌ LLM API Error#{COLOR_RESET}"
250
+ puts "#{COLOR_YELLOW}Failed to communicate with the AI service.#{COLOR_RESET}"
251
+ puts "#{COLOR_GRAY}Error: #{e.message}#{COLOR_RESET}"
252
+
253
+ # Check for common error types and provide specific guidance
254
+ if e.message.include?('model') || e.message.include?('Model') || e.message.include?('invalid') || e.message.include?('not found')
255
+ puts "\n#{COLOR_BLUE}💡 This looks like a model configuration issue.#{COLOR_RESET}"
256
+ puts "#{COLOR_YELLOW}Run 'n2b -c' to reconfigure your model settings.#{COLOR_RESET}"
257
+ elsif e.message.include?('auth') || e.message.include?('unauthorized') || e.message.include?('401')
258
+ puts "\n#{COLOR_BLUE}💡 This looks like an authentication issue.#{COLOR_RESET}"
259
+ puts "#{COLOR_YELLOW}Check your API key configuration with 'n2b -c'.#{COLOR_RESET}"
260
+ elsif e.message.include?('timeout') || e.message.include?('network')
261
+ puts "\n#{COLOR_BLUE}💡 This looks like a network issue.#{COLOR_RESET}"
262
+ puts "#{COLOR_YELLOW}Check your internet connection and try again.#{COLOR_RESET}"
263
+ end
264
+
265
+ '{"merged_code":"","reason":"LLM API error: ' + e.message.gsub('"', '\\"') + '"}'
266
+ end
267
+
268
+ def extract_json(response)
269
+ JSON.parse(response)
270
+ response
271
+ rescue JSON::ParserError
272
+ start = response.index('{')
273
+ stop = response.rindex('}')
274
+ return response unless start && stop
275
+ response[start..stop]
276
+ end
277
+
278
+ def handle_invalid_llm_response(raw_response, error, block)
279
+ puts "\n#{COLOR_RED}❌ Invalid LLM Response Error#{COLOR_RESET}"
280
+ puts "#{COLOR_YELLOW}The AI returned an invalid response that couldn't be parsed.#{COLOR_RESET}"
281
+ puts "#{COLOR_YELLOW}Automatic JSON repair was attempted but failed.#{COLOR_RESET}"
282
+ puts "#{COLOR_GRAY}Error: #{error.message}#{COLOR_RESET}"
283
+
284
+ # Save problematic response for debugging
285
+ save_debug_response(raw_response, error)
286
+
287
+ # Show truncated raw response for debugging
288
+ truncated_response = raw_response.length > 200 ? "#{raw_response[0..200]}..." : raw_response
289
+ puts "\n#{COLOR_GRAY}Raw response (truncated):#{COLOR_RESET}"
290
+ puts "#{COLOR_GRAY}#{truncated_response}#{COLOR_RESET}"
291
+
292
+ puts "\n#{COLOR_BLUE}What would you like to do?#{COLOR_RESET}"
293
+ puts "#{COLOR_GREEN}[r]#{COLOR_RESET} Retry with the same prompt"
294
+ puts "#{COLOR_YELLOW}[c]#{COLOR_RESET} Add a comment to guide the AI better"
295
+ puts "#{COLOR_BLUE}[m]#{COLOR_RESET} Manually choose one side of the conflict"
296
+ puts "#{COLOR_RED}[s]#{COLOR_RESET} Skip this conflict"
297
+ puts "#{COLOR_RED}[a]#{COLOR_RESET} Abort entirely"
298
+
299
+ loop do
300
+ print "#{COLOR_YELLOW}Choose action [r/c/m/s/a]: #{COLOR_RESET}"
301
+ choice = $stdin.gets&.strip&.downcase
302
+
303
+ case choice
304
+ when 'r'
305
+ puts "#{COLOR_YELLOW}🔄 Retrying with same prompt...#{COLOR_RESET}"
306
+ return retry_llm_request(block)
307
+ when 'c'
308
+ puts "#{COLOR_YELLOW}💬 Please provide guidance for the AI:#{COLOR_RESET}"
309
+ comment = read_multiline_input
310
+ return retry_llm_request_with_comment(block, comment)
311
+ when 'm'
312
+ return handle_manual_choice(block)
313
+ when 's'
314
+ puts "#{COLOR_YELLOW}⏭️ Skipping this conflict#{COLOR_RESET}"
315
+ return { 'merged_code' => '', 'reason' => 'Skipped due to invalid LLM response' }
316
+ when 'a'
317
+ puts "#{COLOR_RED}🛑 Aborting merge resolution#{COLOR_RESET}"
318
+ return { 'merged_code' => '', 'reason' => 'Aborted due to invalid LLM response', 'abort' => true }
319
+ when '', nil
320
+ puts "#{COLOR_RED}Please enter a valid choice: r/c/m/s/a#{COLOR_RESET}"
321
+ else
322
+ puts "#{COLOR_RED}Invalid option. Please enter: r (retry), c (comment), m (manual), s (skip), or a (abort)#{COLOR_RESET}"
323
+ end
324
+ end
325
+ end
326
+
327
+ def retry_llm_request(block)
328
+ config = get_config(reconfigure: false, advanced_flow: false)
329
+ # Always re-read file content in case it was edited
330
+ full_file_content = File.read(@file_path)
331
+
332
+ puts "#{COLOR_YELLOW}🤖 Retrying AI analysis...#{COLOR_RESET}"
333
+ suggestion = request_merge_with_spinner(block, config, nil, full_file_content)
334
+ puts "#{COLOR_GREEN}✅ Retry successful!#{COLOR_RESET}"
335
+ suggestion
336
+ rescue => e
337
+ puts "#{COLOR_RED}❌ Retry failed: #{e.message}#{COLOR_RESET}"
338
+ { 'merged_code' => '', 'reason' => 'Retry failed due to persistent LLM error' }
339
+ end
340
+
341
+ def retry_llm_request_with_comment(block, comment)
342
+ config = get_config(reconfigure: false, advanced_flow: false)
343
+ # Always re-read file content in case it was edited
344
+ full_file_content = File.read(@file_path)
345
+
346
+ puts "#{COLOR_YELLOW}🤖 Retrying with your guidance...#{COLOR_RESET}"
347
+ suggestion = request_merge_with_spinner(block, config, comment, full_file_content)
348
+ puts "#{COLOR_GREEN}✅ Retry with comment successful!#{COLOR_RESET}"
349
+ suggestion
350
+ rescue => e
351
+ puts "#{COLOR_RED}❌ Retry with comment failed: #{e.message}#{COLOR_RESET}"
352
+ { 'merged_code' => '', 'reason' => 'Retry with comment failed due to persistent LLM error' }
353
+ end
354
+
355
+ def handle_manual_choice(block)
356
+ puts "\n#{COLOR_BLUE}Manual conflict resolution:#{COLOR_RESET}"
357
+ puts "#{COLOR_RED}[1] Choose HEAD version (#{block.base_label})#{COLOR_RESET}"
358
+ puts "#{COLOR_RED}#{block.base_content}#{COLOR_RESET}"
359
+ puts ""
360
+ puts "#{COLOR_GREEN}[2] Choose incoming version (#{block.incoming_label})#{COLOR_RESET}"
361
+ puts "#{COLOR_GREEN}#{block.incoming_content}#{COLOR_RESET}"
362
+ puts ""
363
+ puts "#{COLOR_YELLOW}[3] Skip this conflict#{COLOR_RESET}"
364
+
365
+ loop do
366
+ print "#{COLOR_YELLOW}Choose version [1/2/3]: #{COLOR_RESET}"
367
+ choice = $stdin.gets&.strip
368
+
369
+ case choice
370
+ when '1'
371
+ puts "#{COLOR_GREEN}✅ Selected HEAD version#{COLOR_RESET}"
372
+ return {
373
+ 'merged_code' => block.base_content,
374
+ 'reason' => "Manually selected HEAD version (#{block.base_label}) due to LLM error"
375
+ }
376
+ when '2'
377
+ puts "#{COLOR_GREEN}✅ Selected incoming version#{COLOR_RESET}"
378
+ return {
379
+ 'merged_code' => block.incoming_content,
380
+ 'reason' => "Manually selected incoming version (#{block.incoming_label}) due to LLM error"
381
+ }
382
+ when '3'
383
+ puts "#{COLOR_YELLOW}⏭️ Skipping conflict#{COLOR_RESET}"
384
+ return { 'merged_code' => '', 'reason' => 'Manually skipped due to LLM error' }
385
+ when '', nil
386
+ puts "#{COLOR_RED}Please enter 1, 2, or 3#{COLOR_RESET}"
387
+ else
388
+ puts "#{COLOR_RED}Invalid choice. Please enter 1 (HEAD), 2 (incoming), or 3 (skip)#{COLOR_RESET}"
389
+ end
390
+ end
391
+ end
392
+
393
+ def read_multiline_input
394
+ lines = []
395
+ puts "#{COLOR_GRAY}(Type your comment, then press Enter on an empty line to finish)#{COLOR_RESET}"
396
+ while (line = $stdin.gets)
397
+ line = line.chomp
398
+ break if line.empty?
399
+ lines << line
400
+ end
401
+ comment = lines.join("\n")
402
+ if comment.empty?
403
+ puts "#{COLOR_YELLOW}No comment entered.#{COLOR_RESET}"
404
+ else
405
+ puts "#{COLOR_GREEN}Comment received: #{comment.length} characters#{COLOR_RESET}"
406
+ end
407
+ comment
408
+ end
409
+
410
+ def print_conflict(block)
411
+ puts "#{COLOR_RED}<<<<<<< #{block.base_label} (lines #{block.start_line}-#{block.end_line})#{COLOR_RESET}"
412
+ puts "#{COLOR_RED}#{block.base_content}#{COLOR_RESET}"
413
+ puts "#{COLOR_YELLOW}=======#{COLOR_RESET}"
414
+ puts "#{COLOR_GREEN}#{block.incoming_content}#{COLOR_RESET}"
415
+ puts "#{COLOR_YELLOW}>>>>>>> #{block.incoming_label}#{COLOR_RESET}"
416
+ end
417
+
418
+ def print_suggestion(sug)
419
+ puts "#{COLOR_BLUE}--- Suggestion ---#{COLOR_RESET}"
420
+ puts "#{COLOR_BLUE}#{sug['merged_code']}#{COLOR_RESET}"
421
+ puts "#{COLOR_GRAY}Reason: #{sug['reason']}#{COLOR_RESET}"
422
+ end
423
+
424
+ def resolve_template_path(template_key, config)
425
+ user_path = config.dig('templates', template_key) if config.is_a?(Hash)
426
+ return user_path if user_path && File.exist?(user_path)
427
+
428
+ File.expand_path(File.join(__dir__, 'templates', "#{template_key}.txt"))
429
+ end
430
+
431
+ def mark_file_as_resolved(file_path)
432
+ # Detect VCS and mark file as resolved
433
+ if File.exist?('.hg')
434
+ result = execute_vcs_command_with_timeout("hg resolve --mark #{Shellwords.escape(file_path)}", 10)
435
+ if result[:success]
436
+ puts "#{COLOR_GREEN}✅ Marked #{file_path} as resolved in Mercurial#{COLOR_RESET}"
437
+ else
438
+ puts "#{COLOR_YELLOW}⚠️ Could not mark #{file_path} as resolved in Mercurial#{COLOR_RESET}"
439
+ puts "#{COLOR_GRAY}Error: #{result[:error]}#{COLOR_RESET}" if result[:error]
440
+ puts "#{COLOR_GRAY}💡 You can manually mark it with: hg resolve --mark #{file_path}#{COLOR_RESET}"
441
+ end
442
+ elsif File.exist?('.git')
443
+ result = execute_vcs_command_with_timeout("git add #{Shellwords.escape(file_path)}", 10)
444
+ if result[:success]
445
+ puts "#{COLOR_GREEN}✅ Added #{file_path} to Git staging area#{COLOR_RESET}"
446
+ else
447
+ puts "#{COLOR_YELLOW}⚠️ Could not add #{file_path} to Git staging area#{COLOR_RESET}"
448
+ puts "#{COLOR_GRAY}Error: #{result[:error]}#{COLOR_RESET}" if result[:error]
449
+ puts "#{COLOR_GRAY}💡 You can manually add it with: git add #{file_path}#{COLOR_RESET}"
450
+ end
451
+ else
452
+ puts "#{COLOR_BLUE}ℹ️ No VCS detected - file saved but not marked as resolved#{COLOR_RESET}"
453
+ end
454
+ end
455
+
456
+ def execute_vcs_command_with_timeout(command, timeout_seconds)
457
+ require 'open3'
458
+
459
+ begin
460
+ # Use Open3.popen3 with manual timeout handling to avoid thread issues
461
+ stdin, stdout, stderr, wait_thr = Open3.popen3(command)
462
+ stdin.close
463
+
464
+ # Wait for the process with timeout
465
+ if wait_thr.join(timeout_seconds)
466
+ # Process completed within timeout
467
+ stdout_content = stdout.read
468
+ stderr_content = stderr.read
469
+ status = wait_thr.value
470
+
471
+ stdout.close
472
+ stderr.close
473
+
474
+ if status.success?
475
+ { success: true, stdout: stdout_content, stderr: stderr_content }
476
+ else
477
+ { success: false, error: "Command failed: #{stderr_content.strip}", stdout: stdout_content, stderr: stderr_content }
478
+ end
479
+ else
480
+ # Process timed out, kill it
481
+ Process.kill('TERM', wait_thr.pid) rescue nil
482
+ sleep(0.1)
483
+ Process.kill('KILL', wait_thr.pid) rescue nil
484
+
485
+ stdout.close rescue nil
486
+ stderr.close rescue nil
487
+ wait_thr.join rescue nil
488
+
489
+ { success: false, error: "Command timed out after #{timeout_seconds} seconds" }
490
+ end
491
+ rescue => e
492
+ { success: false, error: "Unexpected error: #{e.message}" }
493
+ end
494
+ end
495
+
496
+ def show_usage_and_unresolved
497
+ puts "Usage: n2b-diff FILE [--context N]"
498
+ puts ""
499
+
500
+ # Show unresolved conflicts if in a VCS repository
501
+ if File.exist?('.hg')
502
+ puts "#{COLOR_BLUE}📋 Unresolved conflicts in Mercurial:#{COLOR_RESET}"
503
+ result = execute_vcs_command_with_timeout("hg resolve --list", 5)
504
+
505
+ if result[:success]
506
+ unresolved_files = result[:stdout].lines.select { |line| line.start_with?('U ') }
507
+
508
+ if unresolved_files.any?
509
+ unresolved_files.each do |line|
510
+ file = line.strip.sub(/^U /, '')
511
+ puts " #{COLOR_RED}❌ #{file}#{COLOR_RESET}"
512
+ end
513
+ puts ""
514
+ puts "#{COLOR_YELLOW}💡 Use: n2b-diff <filename> to resolve conflicts#{COLOR_RESET}"
515
+ else
516
+ puts " #{COLOR_GREEN}✅ No unresolved conflicts#{COLOR_RESET}"
517
+ end
518
+ else
519
+ puts " #{COLOR_YELLOW}⚠️ Could not check Mercurial status: #{result[:error]}#{COLOR_RESET}"
520
+ end
521
+ elsif File.exist?('.git')
522
+ puts "#{COLOR_BLUE}📋 Unresolved conflicts in Git:#{COLOR_RESET}"
523
+ result = execute_vcs_command_with_timeout("git diff --name-only --diff-filter=U", 5)
524
+
525
+ if result[:success]
526
+ unresolved_files = result[:stdout].lines
527
+
528
+ if unresolved_files.any?
529
+ unresolved_files.each do |file|
530
+ puts " #{COLOR_RED}❌ #{file.strip}#{COLOR_RESET}"
531
+ end
532
+ puts ""
533
+ puts "#{COLOR_YELLOW}💡 Use: n2b-diff <filename> to resolve conflicts#{COLOR_RESET}"
534
+ else
535
+ puts " #{COLOR_GREEN}✅ No unresolved conflicts#{COLOR_RESET}"
536
+ end
537
+ else
538
+ puts " #{COLOR_YELLOW}⚠️ Could not check Git status: #{result[:error]}#{COLOR_RESET}"
539
+ end
540
+ end
541
+ end
542
+
543
+ def attempt_json_repair(malformed_response, config)
544
+ repair_prompt = build_json_repair_prompt(malformed_response)
545
+
546
+ begin
547
+ puts "#{COLOR_BLUE}🔧 Asking AI to fix the JSON...#{COLOR_RESET}"
548
+ repaired_json_str = call_llm_for_merge(repair_prompt, config)
549
+
550
+ # Try to parse the repaired response
551
+ parsed = JSON.parse(extract_json(repaired_json_str))
552
+
553
+ # Validate the repaired response structure
554
+ if parsed.is_a?(Hash) && parsed.key?('merged_code') && parsed.key?('reason')
555
+ return parsed
556
+ else
557
+ puts "#{COLOR_YELLOW}⚠️ Repaired JSON missing required keys#{COLOR_RESET}"
558
+ return nil
559
+ end
560
+ rescue JSON::ParserError => e
561
+ puts "#{COLOR_YELLOW}⚠️ JSON repair attempt also returned invalid JSON#{COLOR_RESET}"
562
+ return nil
563
+ rescue => e
564
+ puts "#{COLOR_YELLOW}⚠️ JSON repair failed: #{e.message}#{COLOR_RESET}"
565
+ return nil
566
+ end
567
+ end
568
+
569
+ def build_json_repair_prompt(malformed_response)
570
+ <<~PROMPT
571
+ The following response was supposed to be valid JSON with keys "merged_code" and "reason", but it has formatting issues. Please fix it and return ONLY the corrected JSON:
572
+
573
+ Original response:
574
+ #{malformed_response}
575
+
576
+ Requirements:
577
+ - Must be valid JSON
578
+ - Must have "merged_code" key with the code content
579
+ - Must have "reason" key with explanation
580
+ - Return ONLY the JSON, no other text
581
+
582
+ Fixed JSON:
583
+ PROMPT
584
+ end
585
+
586
+ def handle_editor_workflow(block, config, full_file_content)
587
+ original_content = File.read(@file_path)
588
+
589
+ puts "#{COLOR_BLUE}🔧 Opening #{@file_path} in editor...#{COLOR_RESET}"
590
+ open_file_in_editor(@file_path)
591
+ puts "#{COLOR_BLUE}📁 Editor closed. Checking for changes...#{COLOR_RESET}"
592
+
593
+ current_content = File.read(@file_path)
594
+
595
+ if file_changed?(original_content, current_content)
596
+ puts "#{COLOR_YELLOW}📝 File has been modified.#{COLOR_RESET}"
597
+ print "#{COLOR_YELLOW}Did you resolve this conflict yourself? [y/n]: #{COLOR_RESET}"
598
+ response = $stdin.gets&.strip&.downcase
599
+
600
+ if response == 'y'
601
+ puts "#{COLOR_GREEN}✅ Conflict marked as resolved by user#{COLOR_RESET}"
602
+ return {
603
+ resolved: true,
604
+ merged_code: "user_resolved",
605
+ reason: "User resolved conflict manually in editor"
606
+ }
607
+ else
608
+ puts "#{COLOR_BLUE}🔄 Continuing with AI assistance...#{COLOR_RESET}"
609
+ return {
610
+ resolved: false,
611
+ updated_content: current_content
612
+ }
613
+ end
614
+ else
615
+ puts "#{COLOR_GRAY}📋 No changes detected. Continuing...#{COLOR_RESET}"
616
+ return {resolved: false, updated_content: nil}
617
+ end
618
+ end
619
+
620
+ def detect_editor
621
+ ENV['EDITOR'] || ENV['VISUAL'] || detect_system_editor
622
+ end
623
+
624
+ def detect_system_editor
625
+ case RbConfig::CONFIG['host_os']
626
+ when /darwin|mac os/
627
+ 'open'
628
+ when /linux/
629
+ 'nano'
630
+ when /mswin|mingw/
631
+ 'notepad'
632
+ else
633
+ 'vi'
634
+ end
635
+ end
636
+
637
+ def open_file_in_editor(file_path)
638
+ editor = detect_editor
639
+
640
+ begin
641
+ case editor
642
+ when 'open'
643
+ # macOS: open with default application (non-blocking)
644
+ result = execute_vcs_command_with_timeout("open #{Shellwords.escape(file_path)}", 5)
645
+ unless result[:success]
646
+ puts "#{COLOR_YELLOW}⚠️ Could not open with 'open' command: #{result[:error]}#{COLOR_RESET}"
647
+ puts "#{COLOR_BLUE}💡 Please open #{file_path} manually in your editor#{COLOR_RESET}"
648
+ end
649
+ else
650
+ # Other editors: open directly (blocking)
651
+ puts "#{COLOR_BLUE}🔧 Opening with #{editor}...#{COLOR_RESET}"
652
+ system("#{editor} #{Shellwords.escape(file_path)}")
653
+ end
654
+ rescue => e
655
+ puts "#{COLOR_RED}❌ Failed to open editor: #{e.message}#{COLOR_RESET}"
656
+ puts "#{COLOR_YELLOW}💡 Try setting EDITOR environment variable to your preferred editor#{COLOR_RESET}"
657
+ end
658
+ end
659
+
660
+ def file_changed?(original_content, current_content)
661
+ original_content != current_content
662
+ end
663
+
664
+ def save_debug_response(raw_response, error)
665
+ begin
666
+ debug_dir = '.n2b_debug'
667
+ FileUtils.mkdir_p(debug_dir)
668
+ timestamp = Time.now.strftime('%Y-%m-%d-%H%M%S')
669
+ debug_file = File.join(debug_dir, "invalid_response_#{timestamp}.txt")
670
+
671
+ debug_content = <<~DEBUG
672
+ N2B Debug: Invalid LLM Response
673
+ ===============================
674
+ Timestamp: #{Time.now}
675
+ File: #{@file_path}
676
+ Error: #{error.message}
677
+ Error Class: #{error.class}
678
+
679
+ Raw LLM Response:
680
+ -----------------
681
+ #{raw_response}
682
+
683
+ End of Response
684
+ ===============
685
+ DEBUG
686
+
687
+ File.write(debug_file, debug_content)
688
+ puts "#{COLOR_GRAY}🐛 Debug info saved to #{debug_file}#{COLOR_RESET}"
689
+ rescue => e
690
+ puts "#{COLOR_GRAY}⚠️ Could not save debug info: #{e.message}#{COLOR_RESET}"
691
+ end
692
+ end
693
+ end
694
+ end