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,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
|