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,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Binocs
4
+ module TUI
5
+ module Colors
6
+ # Color pair constants
7
+ NORMAL = 1
8
+ HEADER = 2
9
+ SELECTED = 3
10
+ METHOD_GET = 4
11
+ METHOD_POST = 5
12
+ METHOD_PUT = 6
13
+ METHOD_DELETE = 7
14
+ STATUS_SUCCESS = 8
15
+ STATUS_REDIRECT = 9
16
+ STATUS_CLIENT_ERROR = 10
17
+ STATUS_SERVER_ERROR = 11
18
+ MUTED = 12
19
+ ERROR = 13
20
+ BORDER = 14
21
+ TITLE = 15
22
+ KEY_HINT = 16
23
+ SEARCH = 17
24
+
25
+ # Pill-style colors (with backgrounds)
26
+ METHOD_GET_PILL = 18
27
+ METHOD_POST_PILL = 19
28
+ METHOD_PUT_PILL = 20
29
+ METHOD_DELETE_PILL = 21
30
+ STATUS_SUCCESS_PILL = 22
31
+ STATUS_REDIRECT_PILL = 23
32
+ STATUS_CLIENT_ERROR_PILL = 24
33
+ STATUS_SERVER_ERROR_PILL = 25
34
+
35
+ def self.init
36
+ # Note: Curses.start_color and use_default_colors are called in App#setup_curses
37
+ # Define color pairs (foreground, background)
38
+ # -1 means default/transparent background
39
+ # Colors adjusted for terminal compatibility (BLUE shows as teal in some themes)
40
+ Curses.init_pair(NORMAL, Curses::COLOR_WHITE, -1)
41
+ Curses.init_pair(HEADER, Curses::COLOR_YELLOW, -1)
42
+ Curses.init_pair(SELECTED, Curses::COLOR_BLACK, Curses::COLOR_WHITE)
43
+ Curses.init_pair(METHOD_GET, Curses::COLOR_BLUE, -1) # teal in your theme
44
+ Curses.init_pair(METHOD_POST, Curses::COLOR_YELLOW, -1)
45
+ Curses.init_pair(METHOD_PUT, Curses::COLOR_MAGENTA, -1)
46
+ Curses.init_pair(METHOD_DELETE, Curses::COLOR_RED, -1)
47
+ Curses.init_pair(STATUS_SUCCESS, Curses::COLOR_BLUE, -1) # teal = success
48
+ Curses.init_pair(STATUS_REDIRECT, Curses::COLOR_YELLOW, -1)
49
+ Curses.init_pair(STATUS_CLIENT_ERROR, Curses::COLOR_MAGENTA, -1)
50
+ Curses.init_pair(STATUS_SERVER_ERROR, Curses::COLOR_RED, -1)
51
+ Curses.init_pair(MUTED, Curses::COLOR_WHITE, -1) # Will use dim attribute
52
+ Curses.init_pair(ERROR, Curses::COLOR_RED, -1)
53
+ Curses.init_pair(BORDER, Curses::COLOR_BLUE, -1)
54
+ Curses.init_pair(TITLE, Curses::COLOR_YELLOW, -1)
55
+ Curses.init_pair(KEY_HINT, Curses::COLOR_YELLOW, -1)
56
+ Curses.init_pair(SEARCH, Curses::COLOR_BLACK, Curses::COLOR_YELLOW)
57
+
58
+ # Pill-style pairs (text on colored background)
59
+ # Using colors with best terminal compatibility
60
+ Curses.init_pair(METHOD_GET_PILL, Curses::COLOR_WHITE, Curses::COLOR_BLUE)
61
+ Curses.init_pair(METHOD_POST_PILL, Curses::COLOR_BLACK, Curses::COLOR_YELLOW)
62
+ Curses.init_pair(METHOD_PUT_PILL, Curses::COLOR_WHITE, Curses::COLOR_MAGENTA)
63
+ Curses.init_pair(METHOD_DELETE_PILL, Curses::COLOR_WHITE, Curses::COLOR_RED)
64
+ Curses.init_pair(STATUS_SUCCESS_PILL, Curses::COLOR_WHITE, Curses::COLOR_BLUE)
65
+ Curses.init_pair(STATUS_REDIRECT_PILL, Curses::COLOR_BLACK, Curses::COLOR_YELLOW)
66
+ Curses.init_pair(STATUS_CLIENT_ERROR_PILL, Curses::COLOR_WHITE, Curses::COLOR_MAGENTA)
67
+ Curses.init_pair(STATUS_SERVER_ERROR_PILL, Curses::COLOR_WHITE, Curses::COLOR_RED)
68
+ end
69
+
70
+ def self.method_color(method)
71
+ case method.to_s.upcase
72
+ when 'GET' then METHOD_GET
73
+ when 'POST' then METHOD_POST
74
+ when 'PUT', 'PATCH' then METHOD_PUT
75
+ when 'DELETE' then METHOD_DELETE
76
+ else NORMAL
77
+ end
78
+ end
79
+
80
+ def self.status_color(status)
81
+ return MUTED if status.nil?
82
+
83
+ case status
84
+ when 200..299 then STATUS_SUCCESS
85
+ when 300..399 then STATUS_REDIRECT
86
+ when 400..499 then STATUS_CLIENT_ERROR
87
+ when 500..599 then STATUS_SERVER_ERROR
88
+ else NORMAL
89
+ end
90
+ end
91
+
92
+ def self.method_pill_color(method)
93
+ case method.to_s.upcase
94
+ when 'GET' then METHOD_GET_PILL
95
+ when 'POST' then METHOD_POST_PILL
96
+ when 'PUT', 'PATCH' then METHOD_PUT_PILL
97
+ when 'DELETE' then METHOD_DELETE_PILL
98
+ else NORMAL
99
+ end
100
+ end
101
+
102
+ def self.status_pill_color(status)
103
+ return MUTED if status.nil?
104
+
105
+ case status
106
+ when 200..299 then STATUS_SUCCESS_PILL
107
+ when 300..399 then STATUS_REDIRECT_PILL
108
+ when 400..499 then STATUS_CLIENT_ERROR_PILL
109
+ when 500..599 then STATUS_SERVER_ERROR_PILL
110
+ else NORMAL
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Binocs
4
+ module TUI
5
+ class FilterMenu < Window
6
+ FILTERS = [
7
+ { key: :method, label: 'HTTP Method', options: %w[GET POST PUT PATCH DELETE] },
8
+ { key: :status, label: 'Status Code', options: %w[2xx 3xx 4xx 5xx] },
9
+ { key: :has_exception, label: 'Has Exception', options: [true, false] },
10
+ ].freeze
11
+
12
+ attr_reader :selected_filters
13
+
14
+ def initialize(height:, width:, top:, left:)
15
+ super
16
+ @selected_index = 0
17
+ @selected_filters = {}
18
+ @expanded_filter = nil
19
+ @option_index = 0
20
+ end
21
+
22
+ def set_filters(filters)
23
+ @selected_filters = filters.dup
24
+ end
25
+
26
+ def move_up
27
+ if @expanded_filter
28
+ @option_index = [@option_index - 1, 0].max
29
+ else
30
+ @selected_index = [@selected_index - 1, 0].max
31
+ end
32
+ end
33
+
34
+ def move_down
35
+ if @expanded_filter
36
+ max = current_filter[:options].length
37
+ @option_index = [@option_index + 1, max].min # +1 for "Clear" option
38
+ else
39
+ @selected_index = [@selected_index + 1, FILTERS.length - 1].min
40
+ end
41
+ end
42
+
43
+ def select
44
+ if @expanded_filter
45
+ filter = current_filter
46
+ if @option_index >= filter[:options].length
47
+ # Clear option selected
48
+ @selected_filters.delete(filter[:key])
49
+ else
50
+ value = filter[:options][@option_index]
51
+ @selected_filters[filter[:key]] = value
52
+ end
53
+ @expanded_filter = nil
54
+ @option_index = 0
55
+ else
56
+ @expanded_filter = @selected_index
57
+ @option_index = 0
58
+ end
59
+ end
60
+
61
+ def back
62
+ if @expanded_filter
63
+ @expanded_filter = nil
64
+ @option_index = 0
65
+ false
66
+ else
67
+ true
68
+ end
69
+ end
70
+
71
+ def draw
72
+ clear
73
+ draw_box('Filters')
74
+
75
+ y = 2
76
+
77
+ FILTERS.each_with_index do |filter, i|
78
+ is_selected = i == @selected_index && @expanded_filter.nil?
79
+ is_expanded = i == @expanded_filter
80
+
81
+ # Draw filter label
82
+ label = "#{filter[:label]}: "
83
+ current_value = @selected_filters[filter[:key]]
84
+ value_text = current_value ? current_value.to_s : 'Any'
85
+
86
+ if is_selected
87
+ @win.attron(Curses.color_pair(Colors::SELECTED)) do
88
+ @win.setpos(y, 2)
89
+ @win.addstr(' ' * (@width - 4))
90
+ end
91
+ write(y, 3, label, Colors::SELECTED, Curses::A_BOLD)
92
+ write(y, 3 + label.length, value_text, Colors::SELECTED)
93
+ write(y, @width - 5, '▶', Colors::SELECTED)
94
+ else
95
+ write(y, 3, label, Colors::MUTED)
96
+ color = current_value ? Colors::HEADER : Colors::MUTED
97
+ write(y, 3 + label.length, value_text, color)
98
+ end
99
+
100
+ y += 1
101
+
102
+ # Draw expanded options
103
+ if is_expanded
104
+ filter[:options].each_with_index do |option, oi|
105
+ is_option_selected = oi == @option_index
106
+ is_current = @selected_filters[filter[:key]] == option
107
+
108
+ if is_option_selected
109
+ @win.attron(Curses.color_pair(Colors::SELECTED)) do
110
+ @win.setpos(y, 4)
111
+ @win.addstr(' ' * (@width - 8))
112
+ end
113
+ write(y, 5, option.to_s, Colors::SELECTED)
114
+ write(y, @width - 7, '✓', Colors::SELECTED) if is_current
115
+ else
116
+ color = is_current ? Colors::STATUS_SUCCESS : Colors::NORMAL
117
+ write(y, 5, option.to_s, color)
118
+ write(y, @width - 7, '✓', Colors::STATUS_SUCCESS) if is_current
119
+ end
120
+ y += 1
121
+ end
122
+
123
+ # Clear option
124
+ is_clear_selected = @option_index >= filter[:options].length
125
+ if is_clear_selected
126
+ @win.attron(Curses.color_pair(Colors::SELECTED)) do
127
+ @win.setpos(y, 4)
128
+ @win.addstr(' ' * (@width - 8))
129
+ end
130
+ write(y, 5, '(Clear)', Colors::SELECTED)
131
+ else
132
+ write(y, 5, '(Clear)', Colors::MUTED, Curses::A_DIM)
133
+ end
134
+ y += 1
135
+ end
136
+
137
+ y += 1
138
+ break if y >= @height - 4
139
+ end
140
+
141
+ # Footer
142
+ draw_footer
143
+
144
+ refresh
145
+ end
146
+
147
+ private
148
+
149
+ def current_filter
150
+ FILTERS[@expanded_filter || @selected_index]
151
+ end
152
+
153
+ def draw_footer
154
+ y = @height - 2
155
+ write(y - 1, 1, '─' * (@width - 2), Colors::BORDER)
156
+
157
+ hints = @expanded_filter ? 'Enter:select Esc:back' : 'Enter:expand c:clear all Esc:close'
158
+ write(y, @width - hints.length - 2, hints, Colors::KEY_HINT, Curses::A_DIM)
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Binocs
4
+ module TUI
5
+ class HelpScreen < Window
6
+ KEYBINDINGS = [
7
+ ['Navigation', [
8
+ ['j / ↓', 'Move down / Scroll down'],
9
+ ['k / ↑', 'Move up / Scroll up'],
10
+ ['g / Home', 'Go to top'],
11
+ ['G / End', 'Go to bottom'],
12
+ ['Ctrl+d/n / PgDn', 'Page down'],
13
+ ['Ctrl+u/p / PgUp', 'Page up'],
14
+ ]],
15
+ ['Actions', [
16
+ ['Enter / l', 'View request details'],
17
+ ['h / Esc', 'Go back / Close'],
18
+ ['n / J', 'Next request (detail view)'],
19
+ ['p / K', 'Prev request (detail view)'],
20
+ ['d', 'Delete request'],
21
+ ['D', 'Delete all requests'],
22
+ ['a', 'View all agents (from list)'],
23
+ ]],
24
+ ['Tabs (Detail View)', [
25
+ ['Tab / ] / L', 'Next tab'],
26
+ ['Shift+Tab / [ / H', 'Previous tab'],
27
+ ['1-8', 'Jump to tab by number'],
28
+ ['a', 'Agent tab + start input'],
29
+ ['c', 'Copy tab content to clipboard'],
30
+ ['o', 'Open Swagger docs in browser'],
31
+ ]],
32
+ ['Agent Tab', [
33
+ ['i / Enter', 'Start composing prompt'],
34
+ ['j / k', 'Scroll output up/down'],
35
+ ['t', 'Change AI tool (Claude/OpenCode)'],
36
+ ['w', 'Toggle worktree mode'],
37
+ ['s', 'Stop running agent'],
38
+ ['Esc', 'Cancel input'],
39
+ ]],
40
+ ['Agents View', [
41
+ ['Enter', 'Go to request Agent tab'],
42
+ ['l', 'View raw log output'],
43
+ ['d', 'Delete/cleanup agent'],
44
+ ['o', 'Open worktree folder'],
45
+ ['r', 'Refresh agents list'],
46
+ ]],
47
+ ['Filtering', [
48
+ ['/', 'Search by path'],
49
+ ['f', 'Open filter menu'],
50
+ ['c', 'Clear all filters'],
51
+ ]],
52
+ ['Other', [
53
+ ['r', 'Refresh list'],
54
+ ['?', 'Toggle this help'],
55
+ ['Space s', 'Spirit animal (detail view)'],
56
+ ['q', 'Quit'],
57
+ ]],
58
+ ].freeze
59
+
60
+ def draw
61
+ clear
62
+ draw_box('Help - Keybindings')
63
+
64
+ y = 2
65
+ KEYBINDINGS.each do |section_name, bindings|
66
+ # Section header
67
+ write(y, 3, "── #{section_name} ", Colors::HEADER, Curses::A_BOLD)
68
+ y += 1
69
+
70
+ bindings.each do |key, description|
71
+ # Key
72
+ @win.attron(Curses.color_pair(Colors::KEY_HINT) | Curses::A_BOLD) do
73
+ @win.setpos(y, 4)
74
+ @win.addstr(key.ljust(16))
75
+ end
76
+
77
+ # Description
78
+ write(y, 21, description, Colors::NORMAL)
79
+ y += 1
80
+ end
81
+
82
+ y += 1
83
+ break if y >= @height - 3
84
+ end
85
+
86
+ # Footer
87
+ write(@height - 2, 3, 'Press ? or Esc to close', Colors::MUTED, Curses::A_DIM)
88
+
89
+ refresh
90
+ end
91
+ end
92
+ end
93
+ end