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