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,726 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Binocs
4
+ module TUI
5
+ class App
6
+ DEFAULT_REFRESH_INTERVAL = 2 # seconds
7
+
8
+ attr_reader :running
9
+
10
+ def initialize(options = {})
11
+ @running = false
12
+ @mode = :list # :list, :detail, :help, :filter, :search, :agents, :agent_output, :spirit_animal
13
+ @last_refresh = Time.now
14
+ @search_buffer = ''
15
+ @refresh_interval = options[:refresh_interval] || DEFAULT_REFRESH_INTERVAL
16
+ @agents_window = nil
17
+ @agent_output_window = nil
18
+ @spirit_animal_window = nil
19
+ @last_key = nil # Track last key for combo detection
20
+ end
21
+
22
+ def run
23
+ setup_curses
24
+ create_windows
25
+ load_data
26
+
27
+ @running = true
28
+ main_loop
29
+ ensure
30
+ cleanup
31
+ end
32
+
33
+ private
34
+
35
+ def setup_curses
36
+ Curses.init_screen
37
+ Curses.start_color
38
+ Curses.use_default_colors
39
+ Curses.cbreak
40
+ Curses.noecho
41
+ Curses.curs_set(0) # Hide cursor
42
+ Curses.stdscr.keypad(true)
43
+ Curses.stdscr.timeout = 100 # Non-blocking getch with 100ms timeout
44
+
45
+ Colors.init
46
+ Curses.refresh # Required before creating windows for colors to work
47
+ end
48
+
49
+ def create_windows
50
+ recalculate_layout
51
+ end
52
+
53
+ def recalculate_layout
54
+ height = Curses.lines
55
+ width = Curses.cols
56
+
57
+ # Close overlay windows
58
+ @help_window&.close
59
+ @help_window = nil
60
+ @filter_window&.close
61
+ @filter_window = nil
62
+ @agents_window&.close
63
+ @agents_window = nil
64
+ @agent_output_window&.close
65
+ @agent_output_window = nil
66
+ @spirit_animal_window&.close
67
+ @spirit_animal_window = nil
68
+
69
+ # Determine if we need split screen (detail view active)
70
+ showing_detail = @mode == :detail ||
71
+ (@previous_mode == :detail && (@mode == :help || @mode == :filter || @mode == :spirit_animal))
72
+
73
+ # Preserve list window state before potential recreation
74
+ preserved_state = nil
75
+ if @list_window
76
+ preserved_state = {
77
+ selected_index: @list_window.selected_index,
78
+ scroll_offset: @list_window.scroll_offset,
79
+ filters: @list_window.filters.dup,
80
+ search_query: @list_window.search_query
81
+ }
82
+ end
83
+
84
+ # Recreate main windows if dimensions changed or mode changed
85
+ if showing_detail
86
+ list_width = [width / 3, 40].max
87
+ detail_width = width - list_width
88
+
89
+ if @list_window.nil? || @list_window.width != list_width || @list_window.height != height
90
+ @list_window&.close
91
+ @list_window = RequestList.new(
92
+ height: height,
93
+ width: list_width,
94
+ top: 0,
95
+ left: 0
96
+ )
97
+ restore_list_state(preserved_state)
98
+ end
99
+
100
+ if @detail_window.nil? || @detail_window.width != detail_width || @detail_window.height != height
101
+ @detail_window&.close
102
+ @detail_window = RequestDetail.new(
103
+ height: height,
104
+ width: detail_width,
105
+ top: 0,
106
+ left: list_width
107
+ )
108
+ end
109
+ else
110
+ # Full width list
111
+ @detail_window&.close
112
+ @detail_window = nil
113
+
114
+ if @list_window.nil? || @list_window.width != width || @list_window.height != height
115
+ @list_window&.close
116
+ @list_window = RequestList.new(
117
+ height: height,
118
+ width: width,
119
+ top: 0,
120
+ left: 0
121
+ )
122
+ restore_list_state(preserved_state)
123
+ end
124
+ end
125
+
126
+ # Help overlay (centered)
127
+ if @mode == :help
128
+ help_height = [32, height - 4].min
129
+ help_width = [65, width - 4].min
130
+ @help_window = HelpScreen.new(
131
+ height: help_height,
132
+ width: help_width,
133
+ top: (height - help_height) / 2,
134
+ left: (width - help_width) / 2
135
+ )
136
+ end
137
+
138
+ # Filter menu (centered overlay)
139
+ if @mode == :filter
140
+ filter_height = [20, height - 4].min
141
+ filter_width = [40, width / 2].min
142
+ @filter_window = FilterMenu.new(
143
+ height: filter_height,
144
+ width: filter_width,
145
+ top: (height - filter_height) / 2,
146
+ left: (width - filter_width) / 2
147
+ )
148
+ @filter_window.set_filters(@list_window.filters)
149
+ end
150
+
151
+ # Agents list (full screen)
152
+ if @mode == :agents
153
+ @agents_window = AgentsList.new(
154
+ height: height,
155
+ width: width,
156
+ top: 0,
157
+ left: 0
158
+ )
159
+ @agents_window.load_agents
160
+ end
161
+
162
+ # Agent output viewer (full screen)
163
+ if @mode == :agent_output
164
+ @agent_output_window = AgentOutput.new(
165
+ height: height,
166
+ width: width,
167
+ top: 0,
168
+ left: 0
169
+ )
170
+ end
171
+
172
+ # Spirit animal overlay (centered popup)
173
+ if @mode == :spirit_animal
174
+ spirit_height = [25, height - 4].min
175
+ spirit_width = [50, width - 4].min
176
+ @spirit_animal_window = SpiritAnimal.new(
177
+ height: spirit_height,
178
+ width: spirit_width,
179
+ top: (height - spirit_height) / 2,
180
+ left: (width - spirit_width) / 2
181
+ )
182
+ end
183
+ end
184
+
185
+ def load_data
186
+ @list_window.load_requests
187
+ end
188
+
189
+ def main_loop
190
+ while @running
191
+ handle_resize if Curses.cols != @last_cols || Curses.lines != @last_lines
192
+ @last_cols = Curses.cols
193
+ @last_lines = Curses.lines
194
+
195
+ draw
196
+ handle_input
197
+
198
+ # Auto-refresh in list mode
199
+ if @mode == :list && Time.now - @last_refresh > @refresh_interval
200
+ load_data
201
+ @last_refresh = Time.now
202
+ end
203
+
204
+ # Auto-refresh agents list and output
205
+ if @mode == :agents && Time.now - @last_refresh > @refresh_interval
206
+ @agents_window&.load_agents
207
+ @last_refresh = Time.now
208
+ end
209
+
210
+ if @mode == :agent_output && Time.now - @last_refresh > 1 # Faster refresh for output
211
+ @agent_output_window&.load_output
212
+ @last_refresh = Time.now
213
+ end
214
+
215
+ # Auto-refresh Agent tab when agent is running
216
+ if @mode == :detail && @detail_window&.agent_tab?
217
+ agent = @detail_window.current_agent
218
+ if agent&.running? && Time.now - @last_refresh > 1
219
+ @detail_window.build_content
220
+ @last_refresh = Time.now
221
+ end
222
+ end
223
+ end
224
+ end
225
+
226
+ def handle_resize
227
+ Curses.clear
228
+ Curses.refresh
229
+ recalculate_layout
230
+ load_data if @list_window
231
+ end
232
+
233
+ def draw
234
+ # Use noutrefresh for all windows, then doupdate once to reduce flicker
235
+ case @mode
236
+ when :list
237
+ @list_window.draw
238
+ @list_window.noutrefresh
239
+ when :detail
240
+ @list_window.draw
241
+ @list_window.noutrefresh
242
+ @detail_window.draw
243
+ @detail_window.noutrefresh
244
+ when :help
245
+ @list_window.draw
246
+ @list_window.noutrefresh
247
+ @detail_window&.draw
248
+ @detail_window&.noutrefresh
249
+ @help_window.draw
250
+ @help_window.noutrefresh
251
+ when :filter
252
+ @list_window.draw
253
+ @list_window.noutrefresh
254
+ @detail_window&.draw
255
+ @detail_window&.noutrefresh
256
+ @filter_window.draw
257
+ @filter_window.noutrefresh
258
+ when :search
259
+ @list_window.draw
260
+ @list_window.noutrefresh
261
+ draw_search_bar
262
+ when :agents
263
+ @agents_window.draw
264
+ @agents_window.noutrefresh
265
+ when :agent_output
266
+ @agent_output_window.draw
267
+ @agent_output_window.noutrefresh
268
+ when :spirit_animal
269
+ @list_window.draw
270
+ @list_window.noutrefresh
271
+ @detail_window&.draw
272
+ @detail_window&.noutrefresh
273
+ @spirit_animal_window.draw
274
+ @spirit_animal_window.noutrefresh
275
+ end
276
+ Curses.doupdate
277
+ end
278
+
279
+ def draw_search_bar
280
+ width = Curses.cols
281
+ y = Curses.lines - 1
282
+
283
+ Curses.attron(Curses.color_pair(Colors::SEARCH)) do
284
+ Curses.setpos(y, 0)
285
+ Curses.addstr(' ' * width)
286
+ Curses.setpos(y, 0)
287
+ Curses.addstr("/#{@search_buffer}")
288
+ end
289
+ Curses.curs_set(1) # Show cursor
290
+ Curses.setpos(y, @search_buffer.length + 1)
291
+ Curses.refresh
292
+ end
293
+
294
+ def handle_input
295
+ key = Curses.getch
296
+ return unless key
297
+
298
+ case @mode
299
+ when :list then handle_list_input(key)
300
+ when :detail then handle_detail_input(key)
301
+ when :help then handle_help_input(key)
302
+ when :filter then handle_filter_input(key)
303
+ when :search then handle_search_input(key)
304
+ when :agents then handle_agents_input(key)
305
+ when :agent_output then handle_agent_output_input(key)
306
+ when :spirit_animal then handle_spirit_animal_input(key)
307
+ end
308
+ end
309
+
310
+ def handle_list_input(key)
311
+ case key
312
+ when 'q', 'Q'
313
+ @running = false
314
+ when 'j', Curses::KEY_DOWN
315
+ @list_window.move_down
316
+ when 'k', Curses::KEY_UP
317
+ @list_window.move_up
318
+ when 'g', Curses::KEY_HOME
319
+ @list_window.go_to_top
320
+ when 'G', Curses::KEY_END
321
+ @list_window.go_to_bottom
322
+ when Curses::KEY_NPAGE, 4, 14 # Ctrl+D, Ctrl+N
323
+ @list_window.page_down
324
+ when Curses::KEY_PPAGE, 21, 16 # Ctrl+U, Ctrl+P
325
+ @list_window.page_up
326
+ when Curses::KEY_ENTER, 10, 13, 'l'
327
+ enter_detail_mode
328
+ when '/'
329
+ enter_search_mode
330
+ when 'f'
331
+ @previous_mode = :list
332
+ @mode = :filter
333
+ recalculate_layout
334
+ when 'c'
335
+ @list_window.clear_filters
336
+ @last_refresh = Time.now
337
+ when 'r'
338
+ load_data
339
+ @last_refresh = Time.now
340
+ when '?'
341
+ @previous_mode = :list
342
+ @mode = :help
343
+ recalculate_layout
344
+ when 'd'
345
+ delete_selected_request
346
+ when 'D'
347
+ delete_all_requests
348
+ when 'a'
349
+ enter_agents_mode
350
+ end
351
+ end
352
+
353
+ def handle_detail_input(key)
354
+ # First, let the Agent tab handle its own input
355
+ if @detail_window&.agent_tab?
356
+ handled = @detail_window.handle_agent_key(key)
357
+ if handled
358
+ # Refresh content if agent tab handled the input
359
+ @detail_window.build_content if @detail_window.agent_tab?
360
+ return
361
+ end
362
+
363
+ # If agent input or worktree input is active, don't process other keys
364
+ if @detail_window.agent_input_active || @detail_window.agent_worktree_input_active
365
+ return # Don't process other keys while typing
366
+ end
367
+ end
368
+
369
+ case key
370
+ when 'q', 'Q'
371
+ @running = false
372
+ when 'h', Curses::KEY_LEFT
373
+ exit_detail_mode
374
+ when 27 # Esc
375
+ exit_detail_mode
376
+ when 'j', Curses::KEY_DOWN
377
+ @detail_window.scroll_down
378
+ when 'k', Curses::KEY_UP
379
+ @detail_window.scroll_up
380
+ when Curses::KEY_NPAGE, 4, 14 # Ctrl+D, Ctrl+N
381
+ @detail_window.page_down
382
+ when Curses::KEY_PPAGE, 21, 16 # Ctrl+U, Ctrl+P
383
+ @detail_window.page_up
384
+ when 9, ']', 'L' # Tab, ], L - next tab
385
+ @detail_window.next_tab
386
+ update_cursor_visibility
387
+ when 353, '[', 'H' # Shift+Tab, [, H - prev tab
388
+ @detail_window.prev_tab
389
+ update_cursor_visibility
390
+ when '1' then @detail_window.go_to_tab(0) # Overview
391
+ when '2' then @detail_window.go_to_tab(1) # Params
392
+ when '3' then @detail_window.go_to_tab(2) # Headers
393
+ when '4' then @detail_window.go_to_tab(3) # Body
394
+ when '5' then @detail_window.go_to_tab(4) # Response
395
+ when '6' then @detail_window.go_to_tab(5) # Logs
396
+ when '7' then @detail_window.go_to_tab(6) # Exception
397
+ when '8' then @detail_window.go_to_tab(7) # Swagger
398
+ when '9', 'a'
399
+ @detail_window.go_to_tab(8) # Agent
400
+ @detail_window.agent_input_active = true
401
+ Curses.curs_set(1)
402
+ when 'o', 'O'
403
+ open_swagger_in_browser
404
+ when 'c'
405
+ copy_tab_content
406
+ when 'n', 'J' # Next request (n or Shift+J)
407
+ @list_window.move_down
408
+ update_detail_request
409
+ when 'p', 'K' # Previous request (p or Shift+K)
410
+ @list_window.move_up
411
+ update_detail_request
412
+ when '?'
413
+ @previous_mode = :detail
414
+ @mode = :help
415
+ recalculate_layout
416
+ when 'f'
417
+ @previous_mode = :detail
418
+ @mode = :filter
419
+ recalculate_layout
420
+ when 's'
421
+ # Easter egg: spacebar + s shows spirit animal
422
+ if @last_key == ' ' || @last_key == 32
423
+ show_spirit_animal
424
+ end
425
+ when ' ', 32 # spacebar
426
+ # Just track it for the combo
427
+ end
428
+
429
+ # Track last key for combos (spacebar + s)
430
+ @last_key = key
431
+ end
432
+
433
+ def show_spirit_animal
434
+ return unless @list_window.selected_request
435
+
436
+ @previous_mode = :detail
437
+ @mode = :spirit_animal
438
+ recalculate_layout
439
+ @spirit_animal_window.set_request(@list_window.selected_request)
440
+ end
441
+
442
+ def handle_spirit_animal_input(key)
443
+ # Any key closes the spirit animal popup
444
+ @mode = @previous_mode || :detail
445
+ recalculate_layout
446
+ end
447
+
448
+ def copy_tab_content
449
+ return unless @detail_window
450
+
451
+ text = @detail_window.content_as_text
452
+ if @detail_window.copy_to_clipboard(text)
453
+ # Brief flash to indicate copy succeeded - could show a message
454
+ end
455
+ end
456
+
457
+ def update_cursor_visibility
458
+ if @detail_window&.agent_tab? && (@detail_window.agent_input_active || @detail_window.agent_worktree_input_active)
459
+ Curses.curs_set(1)
460
+ else
461
+ Curses.curs_set(0)
462
+ end
463
+ end
464
+
465
+ def handle_help_input(key)
466
+ case key
467
+ when '?', 27, 'q', Curses::KEY_ENTER, 10, 13, 'h' # Esc or ? or q or Enter or h
468
+ @mode = @previous_mode || :list
469
+ recalculate_layout
470
+ end
471
+ end
472
+
473
+ def handle_filter_input(key)
474
+ case key
475
+ when 27, 'q' # Esc or q
476
+ if @filter_window.back
477
+ apply_filters
478
+ @mode = @previous_mode || :list
479
+ recalculate_layout
480
+ end
481
+ when 'j', Curses::KEY_DOWN
482
+ @filter_window.move_down
483
+ when 'k', Curses::KEY_UP
484
+ @filter_window.move_up
485
+ when Curses::KEY_ENTER, 10, 13
486
+ @filter_window.select
487
+ when 'c'
488
+ @list_window.clear_filters
489
+ @filter_window.set_filters({})
490
+ @last_refresh = Time.now
491
+ # Close filter menu and return to previous view
492
+ @mode = @previous_mode || :list
493
+ recalculate_layout
494
+ when 'f' # Toggle filter menu off
495
+ apply_filters
496
+ @mode = @previous_mode || :list
497
+ recalculate_layout
498
+ end
499
+ end
500
+
501
+ def handle_search_input(key)
502
+ case key
503
+ when 27 # Esc
504
+ exit_search_mode(apply: false)
505
+ when Curses::KEY_ENTER, 10, 13
506
+ exit_search_mode(apply: true)
507
+ when Curses::KEY_BACKSPACE, 127, 8
508
+ @search_buffer = @search_buffer[0..-2]
509
+ when String
510
+ @search_buffer += key if key.length == 1 && key.ord >= 32
511
+ when Integer
512
+ @search_buffer += key.chr if key >= 32 && key < 127
513
+ end
514
+ end
515
+
516
+ def handle_agents_input(key)
517
+ case key
518
+ when 'q', 27 # q or Esc
519
+ exit_agents_mode
520
+ when 'j', Curses::KEY_DOWN
521
+ @agents_window.move_down
522
+ when 'k', Curses::KEY_UP
523
+ @agents_window.move_up
524
+ when 'g', Curses::KEY_HOME
525
+ @agents_window.go_to_top
526
+ when 'G', Curses::KEY_END
527
+ @agents_window.go_to_bottom
528
+ when Curses::KEY_ENTER, 10, 13
529
+ view_agent_request
530
+ when 'l'
531
+ view_agent_output
532
+ when 'd'
533
+ delete_selected_agent
534
+ when 'o'
535
+ open_agent_worktree
536
+ when 'r'
537
+ @agents_window.load_agents
538
+ when '?'
539
+ @previous_mode = :agents
540
+ @mode = :help
541
+ recalculate_layout
542
+ end
543
+ end
544
+
545
+ def handle_agent_output_input(key)
546
+ case key
547
+ when 'q', 27 # q or Esc
548
+ exit_agent_output_mode
549
+ when 'j', Curses::KEY_DOWN
550
+ @agent_output_window.scroll_down
551
+ when 'k', Curses::KEY_UP
552
+ @agent_output_window.scroll_up
553
+ when Curses::KEY_NPAGE, 4, 14 # Ctrl+D, Ctrl+N
554
+ @agent_output_window.page_down
555
+ when Curses::KEY_PPAGE, 21, 16 # Ctrl+U, Ctrl+P
556
+ @agent_output_window.page_up
557
+ when 'g', Curses::KEY_HOME
558
+ @agent_output_window.go_to_top
559
+ when 'G', Curses::KEY_END
560
+ @agent_output_window.go_to_bottom
561
+ when 'r'
562
+ @agent_output_window.load_output
563
+ end
564
+ end
565
+
566
+ def enter_detail_mode
567
+ return unless @list_window.selected_request
568
+
569
+ @mode = :detail
570
+ recalculate_layout
571
+ @list_window.load_requests
572
+ @detail_window.set_request(@list_window.selected_request)
573
+ end
574
+
575
+ def exit_detail_mode
576
+ @mode = :list
577
+ @detail_window = nil
578
+ recalculate_layout
579
+ @list_window.load_requests
580
+ end
581
+
582
+ def update_detail_request
583
+ @detail_window.set_request(@list_window.selected_request, reset_tab: false) if @list_window.selected_request
584
+ end
585
+
586
+ def enter_search_mode
587
+ @mode = :search
588
+ @search_buffer = @list_window.search_query || ''
589
+ end
590
+
591
+ def exit_search_mode(apply:)
592
+ Curses.curs_set(0) # Hide cursor
593
+ if apply
594
+ @list_window.set_search(@search_buffer)
595
+ @last_refresh = Time.now
596
+ end
597
+ @mode = :list
598
+ @search_buffer = ''
599
+ end
600
+
601
+ def enter_filter_mode
602
+ @mode = :filter
603
+ recalculate_layout
604
+ end
605
+
606
+ def apply_filters
607
+ @filter_window.selected_filters.each do |key, value|
608
+ @list_window.set_filter(key, value)
609
+ end
610
+ @last_refresh = Time.now
611
+ end
612
+
613
+ def delete_selected_request
614
+ request = @list_window.selected_request
615
+ return unless request
616
+
617
+ request.destroy
618
+ load_data
619
+ end
620
+
621
+ def delete_all_requests
622
+ # Show confirmation? For now, just delete all matching current filters
623
+ Binocs::Request.delete_all
624
+ load_data
625
+ end
626
+
627
+ def open_swagger_in_browser
628
+ return unless @detail_window&.swagger_operation
629
+
630
+ url = Binocs::Swagger::PathMatcher.build_swagger_ui_url(@detail_window.swagger_operation)
631
+ return unless url
632
+
633
+ # Open URL in default browser
634
+ if RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/
635
+ system("start", url)
636
+ elsif RbConfig::CONFIG['host_os'] =~ /darwin/
637
+ system("open", url)
638
+ else
639
+ system("xdg-open", url)
640
+ end
641
+ end
642
+
643
+ # Agent mode methods
644
+ def enter_agents_mode
645
+ @previous_mode = @mode
646
+ @mode = :agents
647
+ recalculate_layout
648
+ end
649
+
650
+ def exit_agents_mode
651
+ @mode = @previous_mode || :list
652
+ @previous_mode = nil
653
+ recalculate_layout
654
+ end
655
+
656
+ def view_agent_request
657
+ agent = @agents_window&.selected_agent
658
+ return unless agent
659
+
660
+ # Find the request in the list and select it
661
+ request = Binocs::Request.find_by(id: agent.request_id)
662
+ return unless request
663
+
664
+ # Find the index of this request in the current list
665
+ @list_window.load_requests
666
+ request_index = @list_window.requests.find_index { |r| r.id == request.id }
667
+
668
+ if request_index
669
+ @list_window.selected_index = request_index
670
+
671
+ # Enter detail mode and go to Agent tab with input active
672
+ @mode = :detail
673
+ recalculate_layout
674
+ @list_window.load_requests
675
+ @detail_window.set_request(request)
676
+ @detail_window.go_to_tab(8) # Agent tab
677
+ @detail_window.agent_input_active = true
678
+ Curses.curs_set(1)
679
+ end
680
+ end
681
+
682
+ def view_agent_output
683
+ agent = @agents_window&.selected_agent
684
+ return unless agent
685
+
686
+ @previous_mode = :agents
687
+ @mode = :agent_output
688
+ recalculate_layout
689
+ @agent_output_window.set_agent(agent)
690
+ end
691
+
692
+ def exit_agent_output_mode
693
+ @mode = :agents
694
+ recalculate_layout
695
+ end
696
+
697
+ def delete_selected_agent
698
+ agent = @agents_window&.selected_agent
699
+ return unless agent
700
+
701
+ Binocs::AgentManager.cleanup(agent)
702
+ @agents_window.load_agents
703
+ end
704
+
705
+ def open_agent_worktree
706
+ agent = @agents_window&.selected_agent
707
+ return unless agent
708
+
709
+ Binocs::AgentManager.open_worktree(agent)
710
+ end
711
+
712
+ def restore_list_state(state)
713
+ return unless state && @list_window
714
+
715
+ @list_window.selected_index = state[:selected_index]
716
+ @list_window.scroll_offset = state[:scroll_offset]
717
+ @list_window.instance_variable_set(:@filters, state[:filters])
718
+ @list_window.instance_variable_set(:@search_query, state[:search_query])
719
+ end
720
+
721
+ def cleanup
722
+ Curses.close_screen
723
+ end
724
+ end
725
+ end
726
+ end