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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +21 -0
- data/README.md +528 -0
- data/Rakefile +7 -0
- data/app/assets/javascripts/binocs/application.js +105 -0
- data/app/assets/stylesheets/binocs/application.css +67 -0
- data/app/channels/binocs/application_cable/channel.rb +8 -0
- data/app/channels/binocs/application_cable/connection.rb +8 -0
- data/app/channels/binocs/requests_channel.rb +13 -0
- data/app/controllers/binocs/application_controller.rb +62 -0
- data/app/controllers/binocs/requests_controller.rb +69 -0
- data/app/helpers/binocs/application_helper.rb +61 -0
- data/app/models/binocs/application_record.rb +7 -0
- data/app/models/binocs/request.rb +198 -0
- data/app/views/binocs/requests/_empty_list.html.erb +9 -0
- data/app/views/binocs/requests/_request.html.erb +61 -0
- data/app/views/binocs/requests/index.html.erb +115 -0
- data/app/views/binocs/requests/show.html.erb +227 -0
- data/app/views/layouts/binocs/application.html.erb +109 -0
- data/config/importmap.rb +6 -0
- data/config/routes.rb +11 -0
- data/db/migrate/20240101000000_create_binocs_requests.rb +36 -0
- data/exe/binocs +86 -0
- data/lib/binocs/agent.rb +153 -0
- data/lib/binocs/agent_context.rb +165 -0
- data/lib/binocs/agent_manager.rb +302 -0
- data/lib/binocs/configuration.rb +65 -0
- data/lib/binocs/engine.rb +61 -0
- data/lib/binocs/log_subscriber.rb +56 -0
- data/lib/binocs/middleware/request_recorder.rb +264 -0
- data/lib/binocs/swagger/client.rb +100 -0
- data/lib/binocs/swagger/path_matcher.rb +118 -0
- data/lib/binocs/tui/agent_output.rb +163 -0
- data/lib/binocs/tui/agents_list.rb +195 -0
- data/lib/binocs/tui/app.rb +726 -0
- data/lib/binocs/tui/colors.rb +115 -0
- data/lib/binocs/tui/filter_menu.rb +162 -0
- data/lib/binocs/tui/help_screen.rb +93 -0
- data/lib/binocs/tui/request_detail.rb +899 -0
- data/lib/binocs/tui/request_list.rb +268 -0
- data/lib/binocs/tui/spirit_animal.rb +235 -0
- data/lib/binocs/tui/window.rb +98 -0
- data/lib/binocs/tui.rb +24 -0
- data/lib/binocs/version.rb +5 -0
- data/lib/binocs.rb +27 -0
- data/lib/generators/binocs/install/install_generator.rb +61 -0
- data/lib/generators/binocs/install/templates/create_binocs_requests.rb +36 -0
- data/lib/generators/binocs/install/templates/initializer.rb +25 -0
- data/lib/tasks/binocs_tasks.rake +38 -0
- 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
|