binocs 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 (50) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +21 -0
  3. data/README.md +528 -0
  4. data/Rakefile +7 -0
  5. data/app/assets/javascripts/binocs/application.js +105 -0
  6. data/app/assets/stylesheets/binocs/application.css +67 -0
  7. data/app/channels/binocs/application_cable/channel.rb +8 -0
  8. data/app/channels/binocs/application_cable/connection.rb +8 -0
  9. data/app/channels/binocs/requests_channel.rb +13 -0
  10. data/app/controllers/binocs/application_controller.rb +62 -0
  11. data/app/controllers/binocs/requests_controller.rb +69 -0
  12. data/app/helpers/binocs/application_helper.rb +61 -0
  13. data/app/models/binocs/application_record.rb +7 -0
  14. data/app/models/binocs/request.rb +198 -0
  15. data/app/views/binocs/requests/_empty_list.html.erb +9 -0
  16. data/app/views/binocs/requests/_request.html.erb +61 -0
  17. data/app/views/binocs/requests/index.html.erb +115 -0
  18. data/app/views/binocs/requests/show.html.erb +227 -0
  19. data/app/views/layouts/binocs/application.html.erb +109 -0
  20. data/config/importmap.rb +6 -0
  21. data/config/routes.rb +11 -0
  22. data/db/migrate/20240101000000_create_binocs_requests.rb +36 -0
  23. data/exe/binocs +86 -0
  24. data/lib/binocs/agent.rb +153 -0
  25. data/lib/binocs/agent_context.rb +165 -0
  26. data/lib/binocs/agent_manager.rb +302 -0
  27. data/lib/binocs/configuration.rb +65 -0
  28. data/lib/binocs/engine.rb +61 -0
  29. data/lib/binocs/log_subscriber.rb +56 -0
  30. data/lib/binocs/middleware/request_recorder.rb +264 -0
  31. data/lib/binocs/swagger/client.rb +100 -0
  32. data/lib/binocs/swagger/path_matcher.rb +118 -0
  33. data/lib/binocs/tui/agent_output.rb +163 -0
  34. data/lib/binocs/tui/agents_list.rb +195 -0
  35. data/lib/binocs/tui/app.rb +726 -0
  36. data/lib/binocs/tui/colors.rb +115 -0
  37. data/lib/binocs/tui/filter_menu.rb +162 -0
  38. data/lib/binocs/tui/help_screen.rb +93 -0
  39. data/lib/binocs/tui/request_detail.rb +899 -0
  40. data/lib/binocs/tui/request_list.rb +268 -0
  41. data/lib/binocs/tui/spirit_animal.rb +235 -0
  42. data/lib/binocs/tui/window.rb +98 -0
  43. data/lib/binocs/tui.rb +24 -0
  44. data/lib/binocs/version.rb +5 -0
  45. data/lib/binocs.rb +27 -0
  46. data/lib/generators/binocs/install/install_generator.rb +61 -0
  47. data/lib/generators/binocs/install/templates/create_binocs_requests.rb +36 -0
  48. data/lib/generators/binocs/install/templates/initializer.rb +25 -0
  49. data/lib/tasks/binocs_tasks.rake +38 -0
  50. metadata +149 -0
@@ -0,0 +1,899 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Binocs
4
+ module TUI
5
+ class RequestDetail < Window
6
+ TABS = %w[Overview Params Headers Body Response Logs Exception Swagger Agent].freeze
7
+
8
+ attr_accessor :request, :current_tab, :scroll_offset, :swagger_operation,
9
+ :agent_input, :agent_input_cursor, :agent_input_active,
10
+ :agent_use_worktree, :agent_tool, :agent_worktree_name,
11
+ :agent_worktree_name_cursor, :agent_worktree_input_active
12
+
13
+ def initialize(height:, width:, top:, left:)
14
+ super
15
+ @request = nil
16
+ @current_tab = 0
17
+ @scroll_offset = 0
18
+ @content_lines = []
19
+ @swagger_operation = nil
20
+ reset_agent_state
21
+ end
22
+
23
+ def reset_agent_state
24
+ @agent_input = ''
25
+ @agent_input_cursor = 0
26
+ @agent_input_active = false
27
+ @agent_use_worktree = false
28
+ @agent_worktree_name = ''
29
+ @agent_worktree_name_cursor = 0
30
+ @agent_worktree_input_active = false
31
+ @agent_tool = Binocs.configuration.agent_tool
32
+ end
33
+
34
+ def set_request(request, reset_tab: true)
35
+ @request = request
36
+ @current_tab = 0 if reset_tab
37
+ @scroll_offset = 0
38
+ @swagger_operation = Binocs::Swagger::PathMatcher.find_operation(request) if request
39
+ build_content
40
+ end
41
+
42
+ def next_tab
43
+ @current_tab = (@current_tab + 1) % TABS.length
44
+ @scroll_offset = 0
45
+ build_content
46
+ end
47
+
48
+ def prev_tab
49
+ @current_tab = (@current_tab - 1) % TABS.length
50
+ @scroll_offset = 0
51
+ build_content
52
+ end
53
+
54
+ def go_to_tab(index)
55
+ return if index < 0 || index >= TABS.length
56
+
57
+ @current_tab = index
58
+ @scroll_offset = 0
59
+ build_content
60
+ end
61
+
62
+ def scroll_up
63
+ @scroll_offset = [@scroll_offset - 1, 0].max
64
+ end
65
+
66
+ def scroll_down
67
+ max_scroll = [@content_lines.length - content_height, 0].max
68
+ @scroll_offset = [@scroll_offset + 1, max_scroll].min
69
+ end
70
+
71
+ def page_up
72
+ @scroll_offset = [@scroll_offset - content_height, 0].max
73
+ end
74
+
75
+ def page_down
76
+ max_scroll = [@content_lines.length - content_height, 0].max
77
+ @scroll_offset = [@scroll_offset + content_height, max_scroll].min
78
+ end
79
+
80
+ def draw
81
+ return unless @request
82
+
83
+ clear
84
+ draw_box("Request Detail")
85
+ draw_header
86
+ draw_tabs
87
+ draw_content
88
+ draw_agent_input if agent_tab?
89
+ draw_footer
90
+ draw_agent_cursor if agent_tab? && @agent_input_active
91
+ refresh
92
+ end
93
+
94
+ private
95
+
96
+ def content_height
97
+ base = @height - 7 # Box (2) + header (2) + tabs (1) + footer (2)
98
+ # Reserve space for input area in Agent tab
99
+ agent_tab? ? base - 3 : base
100
+ end
101
+
102
+ def content_width
103
+ @width - 4
104
+ end
105
+
106
+ def draw_header
107
+ y = 1
108
+
109
+ # Method and status
110
+ method = @request.method.to_s
111
+ status = @request.status_code.to_s
112
+
113
+ @win.setpos(y, 2)
114
+ @win.attron(Curses.color_pair(Colors.method_color(@request.method)) | Curses::A_BOLD) do
115
+ @win.addstr(method)
116
+ end
117
+ @win.addstr(' ')
118
+ @win.attron(Curses.color_pair(Colors.status_color(@request.status_code)) | Curses::A_BOLD) do
119
+ @win.addstr(status)
120
+ end
121
+ @win.addstr(' ')
122
+ @win.attron(Curses.color_pair(Colors::NORMAL)) do
123
+ path = truncate(@request.path, @width - method.length - status.length - 10)
124
+ @win.addstr(path)
125
+ end
126
+
127
+ # Second line: duration and time
128
+ y = 2
129
+ info = "#{@request.formatted_duration} • #{@request.ip_address || 'N/A'} • #{@request.created_at&.strftime('%Y-%m-%d %H:%M:%S')}"
130
+ write(y, 2, info, Colors::MUTED, Curses::A_DIM)
131
+ end
132
+
133
+ def draw_tabs
134
+ y = 3
135
+ x = 2
136
+
137
+ # Superscript hotkeys: 1-8 for first 8 tabs, 'a' for Agent
138
+ superscripts = %w[¹ ² ³ ⁴ ⁵ ⁶ ⁷ ⁸ ᵃ]
139
+
140
+ TABS.each_with_index do |tab, i|
141
+ is_selected = i == @current_tab
142
+ hotkey = superscripts[i]
143
+
144
+ # Draw tab name
145
+ if is_selected
146
+ @win.attron(Curses.color_pair(Colors::SELECTED) | Curses::A_BOLD) do
147
+ @win.setpos(y, x)
148
+ @win.addstr(tab)
149
+ end
150
+ else
151
+ @win.attron(Curses.color_pair(Colors::MUTED)) do
152
+ @win.setpos(y, x)
153
+ @win.addstr(tab)
154
+ end
155
+ end
156
+ x += tab.length
157
+
158
+ # Draw superscript hotkey after tab name
159
+ @win.attron(Curses.color_pair(Colors::MUTED) | Curses::A_DIM) do
160
+ @win.setpos(y, x)
161
+ @win.addstr(hotkey)
162
+ end
163
+ x += 2 # superscript + space
164
+ end
165
+
166
+ # Draw separator
167
+ write(4, 1, '─' * (@width - 2), Colors::BORDER)
168
+ end
169
+
170
+ public
171
+
172
+ # Agent tab methods - need to be public for app.rb to call
173
+ def build_content
174
+ @content_lines = []
175
+ return unless @request
176
+
177
+ case TABS[@current_tab]
178
+ when 'Overview' then build_overview
179
+ when 'Params' then build_params
180
+ when 'Headers' then build_headers
181
+ when 'Body' then build_body
182
+ when 'Response' then build_response
183
+ when 'Logs' then build_logs
184
+ when 'Exception' then build_exception
185
+ when 'Swagger' then build_swagger
186
+ when 'Agent' then build_agent
187
+ end
188
+ end
189
+
190
+ def agent_tab?
191
+ TABS[@current_tab] == 'Agent'
192
+ end
193
+
194
+ def current_agent
195
+ return nil unless @request
196
+ Binocs::Agent.for_request(@request.id).first
197
+ end
198
+
199
+ def agent_tool_label
200
+ case @agent_tool
201
+ when :claude_code then 'Claude Code'
202
+ when :opencode then 'OpenCode'
203
+ else @agent_tool.to_s
204
+ end
205
+ end
206
+
207
+ def cycle_agent_tool
208
+ tools = [:claude_code, :opencode]
209
+ current_index = tools.find_index(@agent_tool) || 0
210
+ @agent_tool = tools[(current_index + 1) % tools.length]
211
+ end
212
+
213
+ def toggle_worktree_mode
214
+ if @agent_use_worktree
215
+ # Turning off worktree mode
216
+ @agent_use_worktree = false
217
+ @agent_worktree_name = ''
218
+ @agent_worktree_input_active = false
219
+ else
220
+ # Turning on worktree mode - prompt for name
221
+ timestamp = Time.now.strftime('%m%d-%H%M')
222
+ @agent_worktree_name = "#{timestamp}-fix"
223
+ @agent_worktree_name_cursor = @agent_worktree_name.length
224
+ @agent_worktree_input_active = true
225
+ @agent_input_active = false
226
+ end
227
+ end
228
+
229
+ def confirm_worktree_name
230
+ if @agent_worktree_name.strip.empty?
231
+ # Cancelled - go back to current branch mode
232
+ @agent_use_worktree = false
233
+ else
234
+ @agent_use_worktree = true
235
+ end
236
+ @agent_worktree_input_active = false
237
+ end
238
+
239
+ def cancel_worktree_input
240
+ @agent_use_worktree = false
241
+ @agent_worktree_name = ''
242
+ @agent_worktree_input_active = false
243
+ end
244
+
245
+ def handle_agent_key(key)
246
+ return false unless agent_tab?
247
+
248
+ # If worktree name input is active
249
+ if @agent_worktree_input_active
250
+ case key
251
+ when 27 # Esc - cancel
252
+ cancel_worktree_input
253
+ return true
254
+ when Curses::KEY_ENTER, 10, 13
255
+ confirm_worktree_name
256
+ return true
257
+ when Curses::KEY_BACKSPACE, 127, 8
258
+ if @agent_worktree_name_cursor > 0
259
+ @agent_worktree_name = @agent_worktree_name[0, @agent_worktree_name_cursor - 1] + @agent_worktree_name[@agent_worktree_name_cursor..]
260
+ @agent_worktree_name_cursor -= 1
261
+ end
262
+ return true
263
+ when Curses::KEY_LEFT
264
+ @agent_worktree_name_cursor = [@agent_worktree_name_cursor - 1, 0].max
265
+ return true
266
+ when Curses::KEY_RIGHT
267
+ @agent_worktree_name_cursor = [@agent_worktree_name_cursor + 1, @agent_worktree_name.length].min
268
+ return true
269
+ when String
270
+ # Only allow valid branch name characters
271
+ if key.length == 1 && key =~ /[a-zA-Z0-9\-_]/
272
+ @agent_worktree_name = @agent_worktree_name[0, @agent_worktree_name_cursor] + key + (@agent_worktree_name[@agent_worktree_name_cursor..] || '')
273
+ @agent_worktree_name_cursor += 1
274
+ end
275
+ return true
276
+ when Integer
277
+ if key >= 32 && key < 127
278
+ char = key.chr
279
+ if char =~ /[a-zA-Z0-9\-_]/
280
+ @agent_worktree_name = @agent_worktree_name[0, @agent_worktree_name_cursor] + char + (@agent_worktree_name[@agent_worktree_name_cursor..] || '')
281
+ @agent_worktree_name_cursor += 1
282
+ end
283
+ end
284
+ return true
285
+ end
286
+ return true
287
+ end
288
+
289
+ # If input is active, handle text input first
290
+ if @agent_input_active
291
+ case key
292
+ when 27 # Esc - cancel input
293
+ @agent_input_active = false
294
+ return true
295
+ when Curses::KEY_ENTER, 10, 13
296
+ # Enter submits if we have input
297
+ if @agent_input.strip.length > 0
298
+ submit_agent_prompt
299
+ end
300
+ return true
301
+ when Curses::KEY_BACKSPACE, 127, 8
302
+ if @agent_input_cursor > 0
303
+ @agent_input = @agent_input[0, @agent_input_cursor - 1] + @agent_input[@agent_input_cursor..]
304
+ @agent_input_cursor -= 1
305
+ end
306
+ return true
307
+ when Curses::KEY_LEFT
308
+ @agent_input_cursor = [@agent_input_cursor - 1, 0].max
309
+ return true
310
+ when Curses::KEY_RIGHT
311
+ @agent_input_cursor = [@agent_input_cursor + 1, @agent_input.length].min
312
+ return true
313
+ when Curses::KEY_HOME
314
+ @agent_input_cursor = 0
315
+ return true
316
+ when Curses::KEY_END
317
+ @agent_input_cursor = @agent_input.length
318
+ return true
319
+ when String
320
+ if key.length == 1 && key.ord >= 32
321
+ @agent_input = @agent_input[0, @agent_input_cursor] + key + (@agent_input[@agent_input_cursor..] || '')
322
+ @agent_input_cursor += 1
323
+ end
324
+ return true
325
+ when Integer
326
+ if key >= 32 && key < 127
327
+ char = key.chr
328
+ @agent_input = @agent_input[0, @agent_input_cursor] + char + (@agent_input[@agent_input_cursor..] || '')
329
+ @agent_input_cursor += 1
330
+ end
331
+ return true
332
+ end
333
+ return true # Consume all keys when input is active
334
+ end
335
+
336
+ # Not in input mode - handle commands
337
+ case key
338
+ when 'i', Curses::KEY_ENTER, 10, 13
339
+ @agent_input_active = true
340
+ return true
341
+ when 't', 'T'
342
+ cycle_agent_tool
343
+ return true
344
+ when 'w', 'W'
345
+ toggle_worktree_mode
346
+ return true
347
+ when 's', 'S'
348
+ stop_current_agent
349
+ return true
350
+ end
351
+
352
+ false
353
+ end
354
+
355
+ def submit_agent_prompt
356
+ return if @agent_input.strip.empty?
357
+ return unless @request
358
+
359
+ prompt = @agent_input.strip
360
+ @agent_input = ''
361
+ @agent_input_cursor = 0
362
+ @agent_input_active = false
363
+
364
+ # Check if there's an existing agent for this request
365
+ existing_agent = current_agent
366
+
367
+ if existing_agent && !existing_agent.running?
368
+ # Continue in same directory with new prompt
369
+ Binocs::AgentManager.continue_session(
370
+ agent: existing_agent,
371
+ prompt: prompt,
372
+ tool: @agent_tool
373
+ )
374
+ else
375
+ # Launch new agent
376
+ Binocs::AgentManager.launch(
377
+ request: @request,
378
+ prompt: prompt,
379
+ tool: @agent_tool,
380
+ use_worktree: @agent_use_worktree,
381
+ branch_name: @agent_use_worktree ? @agent_worktree_name : nil
382
+ )
383
+ end
384
+
385
+ build_content
386
+ end
387
+
388
+ def stop_current_agent
389
+ agent = current_agent
390
+ return unless agent&.running?
391
+
392
+ agent.stop!
393
+ build_content
394
+ end
395
+
396
+ def build_overview
397
+ add_section('Request Information')
398
+ add_field('Method', @request.method)
399
+ add_field('Path', @request.path)
400
+ add_field('Full URL', @request.full_url)
401
+ add_field('Controller', @request.controller_name || 'N/A')
402
+ add_field('Action', @request.action_name || 'N/A')
403
+ add_blank
404
+
405
+ add_section('Response')
406
+ add_field('Status', @request.status_code.to_s)
407
+ add_field('Duration', @request.formatted_duration)
408
+ add_field('Memory Delta', @request.formatted_memory_delta)
409
+ add_blank
410
+
411
+ add_section('Client')
412
+ add_field('IP Address', @request.ip_address || 'N/A')
413
+ add_field('Session ID', @request.session_id || 'N/A')
414
+ add_blank
415
+
416
+ add_section('Timing')
417
+ add_field('Created At', @request.created_at&.strftime('%Y-%m-%d %H:%M:%S.%L'))
418
+
419
+ if @request.has_exception?
420
+ add_blank
421
+ add_line('!! HAS EXCEPTION - See Exception tab', Colors::ERROR)
422
+ end
423
+ end
424
+
425
+ def build_params
426
+ params = @request.params
427
+ if params.present? && params.any?
428
+ add_section('Request Parameters')
429
+ format_hash(params)
430
+ else
431
+ add_line('No parameters', Colors::MUTED)
432
+ end
433
+ end
434
+
435
+ def build_headers
436
+ add_section('Request Headers')
437
+ req_headers = @request.request_headers
438
+ if req_headers.present? && req_headers.any?
439
+ format_hash(req_headers)
440
+ else
441
+ add_line('No request headers', Colors::MUTED)
442
+ end
443
+
444
+ add_blank
445
+ add_section('Response Headers')
446
+ res_headers = @request.response_headers
447
+ if res_headers.present? && res_headers.any?
448
+ format_hash(res_headers)
449
+ else
450
+ add_line('No response headers', Colors::MUTED)
451
+ end
452
+ end
453
+
454
+ def build_body
455
+ body = @request.request_body
456
+ if body.present?
457
+ add_section('Request Body')
458
+ format_body(body)
459
+ else
460
+ add_line('No request body', Colors::MUTED)
461
+ end
462
+ end
463
+
464
+ def build_response
465
+ body = @request.response_body
466
+ if body.present?
467
+ add_section('Response Body')
468
+ format_body(body)
469
+ else
470
+ add_line('No response body captured', Colors::MUTED)
471
+ end
472
+ end
473
+
474
+ def build_logs
475
+ logs = @request.logs
476
+ if logs.present? && logs.any?
477
+ logs.each_with_index do |log, i|
478
+ add_section("Log Entry #{i + 1} - #{log['type']&.upcase}")
479
+ add_field('Timestamp', log['timestamp'])
480
+
481
+ case log['type']
482
+ when 'controller'
483
+ add_field('Controller', "#{log['controller']}##{log['action']}")
484
+ add_field('Format', log['format'])
485
+ add_field('View Runtime', "#{log['view_runtime']}ms") if log['view_runtime']
486
+ add_field('DB Runtime', "#{log['db_runtime']}ms") if log['db_runtime']
487
+ add_field('Duration', "#{log['duration']}ms")
488
+ when 'redirect'
489
+ add_field('Location', log['location'])
490
+ add_field('Status', log['status'])
491
+ else
492
+ log.each do |key, value|
493
+ next if key == 'timestamp' || key == 'type'
494
+ add_field(key.to_s.titleize, value.to_s)
495
+ end
496
+ end
497
+ add_blank
498
+ end
499
+ else
500
+ add_line('No logs captured', Colors::MUTED)
501
+ end
502
+ end
503
+
504
+ def build_exception
505
+ exc = @request.exception
506
+ if exc.present?
507
+ add_section('Exception Details')
508
+ add_field('Class', exc['class'], Colors::ERROR)
509
+ add_blank
510
+ add_field('Message', exc['message'], Colors::ERROR)
511
+ add_blank
512
+
513
+ if exc['backtrace'].present?
514
+ add_section('Backtrace')
515
+ exc['backtrace'].each do |line|
516
+ add_line(line, Colors::MUTED)
517
+ end
518
+ end
519
+ else
520
+ add_line('No exception', Colors::STATUS_SUCCESS)
521
+ end
522
+ end
523
+
524
+ def build_swagger
525
+ unless @swagger_operation
526
+ add_line('No matching Swagger operation found', Colors::MUTED)
527
+ add_blank
528
+ add_line("Request: #{@request.method} #{@request.path}", Colors::MUTED)
529
+ add_blank
530
+ add_line('Ensure swagger_spec_url is configured correctly.', Colors::MUTED)
531
+ return
532
+ end
533
+
534
+ op = @swagger_operation
535
+
536
+ add_section('Operation')
537
+ add_field('Operation ID', op[:operation_id] || 'N/A')
538
+ add_field('Spec Path', op[:spec_path])
539
+ add_field('Method', op[:method].upcase)
540
+ add_field('Tags', op[:tags].join(', ')) if op[:tags].any?
541
+ add_field('Deprecated', 'Yes', Colors::ERROR) if op[:deprecated]
542
+ add_blank
543
+
544
+ if op[:summary].present?
545
+ add_section('Summary')
546
+ add_line(op[:summary], Colors::NORMAL)
547
+ add_blank
548
+ end
549
+
550
+ if op[:description].present?
551
+ add_section('Description')
552
+ op[:description].each_line do |line|
553
+ add_line(line.chomp, Colors::NORMAL)
554
+ end
555
+ add_blank
556
+ end
557
+
558
+ if op[:parameters].any?
559
+ add_section('Parameters')
560
+ op[:parameters].each do |param|
561
+ location = param['in']
562
+ name = param['name']
563
+ required = param['required'] ? '*' : ''
564
+ param_type = param.dig('schema', 'type') || 'any'
565
+ add_field("#{location}:#{name}#{required}", param_type)
566
+ if param['description'].present?
567
+ add_line(" #{param['description']}", Colors::MUTED)
568
+ end
569
+ end
570
+ add_blank
571
+ end
572
+
573
+ if op[:request_body].present?
574
+ add_section('Request Body')
575
+ content = op[:request_body]['content']
576
+ if content
577
+ content.each do |media_type, schema_info|
578
+ add_field('Content-Type', media_type)
579
+ if schema_info['schema']
580
+ format_schema(schema_info['schema'], 1)
581
+ end
582
+ end
583
+ end
584
+ add_blank
585
+ end
586
+
587
+ if op[:responses].any?
588
+ add_section('Responses')
589
+ op[:responses].each do |status, response_info|
590
+ description = response_info['description'] || ''
591
+ color = status.to_s.start_with?('2') ? Colors::STATUS_SUCCESS :
592
+ status.to_s.start_with?('4') ? Colors::STATUS_CLIENT_ERROR :
593
+ status.to_s.start_with?('5') ? Colors::STATUS_SERVER_ERROR : Colors::NORMAL
594
+ add_field(status.to_s, description, color)
595
+ end
596
+ add_blank
597
+ end
598
+
599
+ add_line("Press 'o' to open in browser", Colors::KEY_HINT)
600
+ end
601
+
602
+ def build_agent
603
+ agent = current_agent
604
+ agents_for_request = Binocs::Agent.for_request(@request.id)
605
+
606
+ # Settings section
607
+ add_section('Settings')
608
+ add_field('Tool', agent_tool_label)
609
+ if @agent_use_worktree && @agent_worktree_name.present?
610
+ add_field('Mode', "Worktree: agent/#{@agent_worktree_name}")
611
+ else
612
+ add_field('Mode', 'Current Branch')
613
+ end
614
+ add_blank
615
+ add_line("Press 't' to change tool, 'w' to #{@agent_use_worktree ? 'disable' : 'enable'} worktree mode", Colors::KEY_HINT)
616
+ add_blank
617
+
618
+ if agent
619
+ # Status section
620
+ add_section('Agent Status')
621
+ status_color = case agent.status
622
+ when :running then Colors::STATUS_SUCCESS
623
+ when :completed then Colors::HEADER
624
+ when :failed, :stopped then Colors::ERROR
625
+ else Colors::MUTED
626
+ end
627
+ add_field('Status', agent.status.to_s.upcase, status_color)
628
+ add_field('Tool', agent.tool_command)
629
+ add_field('Duration', agent.duration)
630
+ add_field('Branch', agent.branch_name) if agent.branch_name
631
+ add_field('Worktree', agent.worktree_path) if agent.worktree_path
632
+ add_blank
633
+
634
+ if agent.running?
635
+ add_line("Press 's' to stop the agent", Colors::KEY_HINT)
636
+ add_blank
637
+ end
638
+
639
+ # Show agent prompt
640
+ if agent.prompt.present?
641
+ add_section('Prompt')
642
+ agent.prompt.each_line do |line|
643
+ add_line(line.chomp, Colors::NORMAL)
644
+ end
645
+ add_blank
646
+ end
647
+
648
+ # Show recent output
649
+ add_section('Output (last 30 lines)')
650
+ output = agent.output_tail(30)
651
+ if output.present?
652
+ output.each_line do |line|
653
+ add_line(line.chomp, Colors::MUTED)
654
+ end
655
+ else
656
+ add_line('No output yet...', Colors::MUTED)
657
+ end
658
+ else
659
+ # No agent yet - show context preview
660
+ add_section('Context (will be sent to agent)')
661
+ method_str = @request.respond_to?(:read_attribute) ? @request.read_attribute(:method) : @request.method
662
+ add_line("#{method_str} #{@request.path} -> #{@request.status_code}", Colors::NORMAL)
663
+ if @request.controller_name
664
+ add_line("#{@request.controller_name}##{@request.action_name}", Colors::MUTED)
665
+ end
666
+ if @request.has_exception?
667
+ add_line("Exception: #{@request.exception&.dig('class')}", Colors::ERROR)
668
+ end
669
+ add_blank
670
+ add_line("Press 'i' or Enter to compose a prompt for the AI agent", Colors::KEY_HINT)
671
+ end
672
+
673
+ # Show history of agents if more than current
674
+ if agents_for_request.length > 1
675
+ add_blank
676
+ add_section("Agent History (#{agents_for_request.length} total)")
677
+ agents_for_request.each do |a|
678
+ status_indicator = case a.status
679
+ when :running then '●'
680
+ when :completed then '✓'
681
+ when :failed then '✗'
682
+ else '○'
683
+ end
684
+ add_line("#{status_indicator} #{a.short_prompt(40)} (#{a.duration})", Colors::MUTED)
685
+ end
686
+ end
687
+ end
688
+
689
+ def content_as_text
690
+ # Convert content_lines to plain text for copying
691
+ lines = []
692
+ @content_lines.each do |line|
693
+ case line[:type]
694
+ when :section
695
+ lines << ""
696
+ lines << "── #{line[:text]} ──"
697
+ when :field
698
+ lines << "#{line[:label]}: #{line[:value]}"
699
+ when :line
700
+ lines << line[:text]
701
+ when :blank
702
+ lines << ""
703
+ end
704
+ end
705
+ lines.join("\n")
706
+ end
707
+
708
+ def copy_to_clipboard(text)
709
+ # Try different clipboard commands based on platform
710
+ if RbConfig::CONFIG['host_os'] =~ /darwin/
711
+ IO.popen('pbcopy', 'w') { |io| io.write(text) }
712
+ true
713
+ elsif system('which xclip > /dev/null 2>&1')
714
+ IO.popen('xclip -selection clipboard', 'w') { |io| io.write(text) }
715
+ true
716
+ elsif system('which xsel > /dev/null 2>&1')
717
+ IO.popen('xsel --clipboard --input', 'w') { |io| io.write(text) }
718
+ true
719
+ else
720
+ false
721
+ end
722
+ rescue
723
+ false
724
+ end
725
+
726
+ private
727
+
728
+ # Helper methods for content building
729
+ def format_schema(schema, indent = 0)
730
+ prefix = ' ' * indent
731
+ if schema['type'] == 'object' && schema['properties']
732
+ schema['properties'].each do |prop_name, prop_schema|
733
+ prop_type = prop_schema['type'] || 'any'
734
+ required_mark = schema['required']&.include?(prop_name) ? '*' : ''
735
+ add_line("#{prefix}#{prop_name}#{required_mark}: #{prop_type}", Colors::NORMAL)
736
+ end
737
+ elsif schema['type'] == 'array' && schema['items']
738
+ add_line("#{prefix}array of:", Colors::NORMAL)
739
+ format_schema(schema['items'], indent + 1)
740
+ elsif schema['$ref']
741
+ ref_name = schema['$ref'].split('/').last
742
+ add_line("#{prefix}$ref: #{ref_name}", Colors::MUTED)
743
+ else
744
+ add_line("#{prefix}type: #{schema['type'] || 'any'}", Colors::NORMAL)
745
+ end
746
+ end
747
+
748
+ def add_section(title)
749
+ @content_lines << { type: :section, text: title }
750
+ end
751
+
752
+ def add_field(label, value, color = nil)
753
+ @content_lines << { type: :field, label: label, value: value.to_s, color: color }
754
+ end
755
+
756
+ def add_line(text, color = Colors::NORMAL)
757
+ @content_lines << { type: :line, text: text, color: color }
758
+ end
759
+
760
+ def add_blank
761
+ @content_lines << { type: :blank }
762
+ end
763
+
764
+ def format_hash(hash, indent = 0)
765
+ hash.each do |key, value|
766
+ if value.is_a?(Hash)
767
+ add_line(" " * indent + "#{key}:", Colors::HEADER)
768
+ format_hash(value, indent + 1)
769
+ elsif value.is_a?(Array)
770
+ add_line(" " * indent + "#{key}: [#{value.length} items]", Colors::HEADER)
771
+ value.each_with_index do |item, i|
772
+ if item.is_a?(Hash)
773
+ add_line(" " * (indent + 1) + "[#{i}]:", Colors::MUTED)
774
+ format_hash(item, indent + 2)
775
+ else
776
+ add_line(" " * (indent + 1) + "- #{item}", Colors::NORMAL)
777
+ end
778
+ end
779
+ else
780
+ add_field(" " * indent + key.to_s, value.to_s)
781
+ end
782
+ end
783
+ end
784
+
785
+ def format_body(body)
786
+ begin
787
+ parsed = JSON.parse(body)
788
+ formatted = JSON.pretty_generate(parsed)
789
+ formatted.each_line do |line|
790
+ add_line(line.chomp, Colors::NORMAL)
791
+ end
792
+ rescue JSON::ParserError
793
+ body.each_line do |line|
794
+ add_line(line.chomp, Colors::NORMAL)
795
+ end
796
+ end
797
+ end
798
+
799
+ def draw_content
800
+ start_y = 5
801
+ visible = content_height
802
+
803
+ visible.times do |i|
804
+ line_index = @scroll_offset + i
805
+ break if line_index >= @content_lines.length
806
+
807
+ line = @content_lines[line_index]
808
+ y = start_y + i
809
+
810
+ case line[:type]
811
+ when :section
812
+ write(y, 2, "── #{line[:text]} ", Colors::HEADER, Curses::A_BOLD)
813
+ when :field
814
+ label = "#{line[:label]}: "
815
+ write(y, 2, label, Colors::MUTED, Curses::A_DIM)
816
+ color = line[:color] || Colors::NORMAL
817
+ write(y, 2 + label.length, truncate(line[:value], content_width - label.length), color)
818
+ when :line
819
+ write(y, 2, truncate(line[:text], content_width), line[:color] || Colors::NORMAL)
820
+ when :blank
821
+ # Nothing to draw
822
+ end
823
+ end
824
+ end
825
+
826
+ def draw_footer
827
+ y = @height - 2
828
+ write(y - 1, 1, '─' * (@width - 2), Colors::BORDER)
829
+
830
+ # Scroll indicator
831
+ if @content_lines.length > content_height
832
+ scroll_info = "Line #{@scroll_offset + 1}-#{[@scroll_offset + content_height, @content_lines.length].min} of #{@content_lines.length}"
833
+ write(y, 2, scroll_info, Colors::MUTED, Curses::A_DIM)
834
+ end
835
+
836
+ # Key hints - context sensitive for Agent tab
837
+ if agent_tab?
838
+ hints = @agent_input_active ? "Enter:send Esc:cancel" : "i:input t:tool w:worktree s:stop"
839
+ else
840
+ hints = "Tab:switch j/k:scroll h/Esc:back"
841
+ end
842
+ write(y, @width - hints.length - 2, hints, Colors::KEY_HINT, Curses::A_DIM)
843
+ end
844
+
845
+ def draw_agent_input
846
+ # Draw input area above footer
847
+ input_y = @height - 5
848
+ write(input_y, 1, '─' * (@width - 2), Colors::BORDER)
849
+
850
+ label_y = input_y + 1
851
+ if @agent_worktree_input_active
852
+ # Worktree name input mode
853
+ write(label_y, 2, "Worktree name: agent/", Colors::HEADER, Curses::A_BOLD)
854
+ write(label_y, 23, @agent_worktree_name, Colors::NORMAL)
855
+ # Show explanation on next line
856
+ write(label_y + 1, 2, "All changes will be isolated in this worktree. Enter to confirm, Esc to cancel.", Colors::MUTED, Curses::A_DIM)
857
+ elsif @agent_input_active
858
+ write(label_y, 2, "Prompt: ", Colors::HEADER, Curses::A_BOLD)
859
+ # Draw input text
860
+ display_input = @agent_input.length > content_width - 10 ?
861
+ @agent_input[-content_width + 10..] : @agent_input
862
+ write(label_y, 10, display_input, Colors::NORMAL)
863
+ else
864
+ agent = current_agent
865
+ if agent&.running?
866
+ write(label_y, 2, "Agent is running... Press 's' to stop", Colors::STATUS_SUCCESS)
867
+ else
868
+ write(label_y, 2, "Press 'i' or Enter to start composing a prompt", Colors::MUTED)
869
+ end
870
+ end
871
+ end
872
+
873
+ def draw_agent_cursor
874
+ if @agent_worktree_input_active
875
+ Curses.curs_set(1)
876
+ cursor_y = @height - 4
877
+ cursor_x = 23 + @agent_worktree_name_cursor
878
+ @win.setpos(cursor_y, [cursor_x, content_width].min)
879
+ elsif @agent_input_active
880
+ Curses.curs_set(1)
881
+ cursor_y = @height - 4
882
+ # Calculate cursor x position accounting for scrolling
883
+ visible_start = [@agent_input.length - (content_width - 10), 0].max
884
+ cursor_x = 10 + (@agent_input_cursor - visible_start)
885
+ cursor_x = [cursor_x, content_width].min
886
+ @win.setpos(cursor_y, cursor_x)
887
+ end
888
+ end
889
+
890
+ def truncate(str, max_length)
891
+ str = str.to_s
892
+ return str if str.length <= max_length
893
+ return str if max_length < 4
894
+
895
+ "#{str[0, max_length - 3]}..."
896
+ end
897
+ end
898
+ end
899
+ end