jsonrpc_debugger/
ui.rs

1use ratatui::{
2    layout::{Constraint, Direction, Layout, Margin, Rect},
3    style::{Color, Modifier, Style},
4    text::{Line, Span},
5    widgets::{
6        Block, Borders, Cell, Clear, List, ListItem, Paragraph, Row, Scrollbar,
7        ScrollbarOrientation, ScrollbarState, Table, TableState, Wrap,
8    },
9    Frame,
10};
11
12use crate::app::{App, AppMode, Focus, InputMode, JsonRpcExchange, TransportType};
13
14// Helper function to format JSON with syntax highlighting and 2-space indentation
15fn format_json_with_highlighting(json_value: &serde_json::Value) -> Vec<Line<'static>> {
16    // Use the standard pretty formatter
17    let json_str = match serde_json::to_string_pretty(json_value) {
18        Ok(s) => s,
19        Err(_) => return vec![Line::from("Failed to format JSON")],
20    };
21
22    let mut lines = Vec::new();
23
24    for (line_num, line) in json_str.lines().enumerate() {
25        // Limit total lines to prevent UI issues
26        if line_num > 1000 {
27            lines.push(Line::from(Span::styled(
28                "... (content truncated)",
29                Style::default().fg(Color::Gray),
30            )));
31            break;
32        }
33
34        // Don't trim the line - work with it as-is to preserve indentation
35        let mut spans = Vec::new();
36        let mut chars = line.chars().peekable();
37        let mut current_token = String::new();
38
39        while let Some(ch) = chars.next() {
40            match ch {
41                '"' => {
42                    // Flush any accumulated token (including spaces)
43                    if !current_token.is_empty() {
44                        spans.push(Span::raw(current_token.clone()));
45                        current_token.clear();
46                    }
47
48                    // Collect the entire string
49                    let mut string_content = String::from("\"");
50                    for string_ch in chars.by_ref() {
51                        string_content.push(string_ch);
52                        if string_ch == '"' && !string_content.ends_with("\\\"") {
53                            break;
54                        }
55                    }
56
57                    // Check if this is a key (followed by colon)
58                    let peek_chars = chars.clone();
59                    let mut found_colon = false;
60                    for peek_ch in peek_chars {
61                        if peek_ch == ':' {
62                            found_colon = true;
63                            break;
64                        } else if !peek_ch.is_whitespace() {
65                            break;
66                        }
67                    }
68
69                    if found_colon {
70                        // This is a key
71                        spans.push(Span::styled(
72                            string_content,
73                            Style::default()
74                                .fg(Color::Cyan)
75                                .add_modifier(Modifier::BOLD),
76                        ));
77                    } else {
78                        // This is a string value
79                        spans.push(Span::styled(
80                            string_content,
81                            Style::default().fg(Color::Green),
82                        ));
83                    }
84                }
85                ':' => {
86                    if !current_token.is_empty() {
87                        spans.push(Span::raw(current_token.clone()));
88                        current_token.clear();
89                    }
90                    spans.push(Span::styled(":", Style::default().fg(Color::White)));
91                }
92                ',' => {
93                    if !current_token.is_empty() {
94                        spans.push(Span::raw(current_token.clone()));
95                        current_token.clear();
96                    }
97                    spans.push(Span::styled(",", Style::default().fg(Color::White)));
98                }
99                '{' | '}' | '[' | ']' => {
100                    if !current_token.is_empty() {
101                        spans.push(Span::raw(current_token.clone()));
102                        current_token.clear();
103                    }
104                    spans.push(Span::styled(
105                        ch.to_string(),
106                        Style::default()
107                            .fg(Color::Yellow)
108                            .add_modifier(Modifier::BOLD),
109                    ));
110                }
111                _ => {
112                    // Accumulate all other characters including spaces
113                    current_token.push(ch);
114                }
115            }
116        }
117
118        // Handle any remaining token (including trailing spaces)
119        if !current_token.is_empty() {
120            let trimmed_token = current_token.trim();
121            if trimmed_token == "true" || trimmed_token == "false" {
122                spans.push(Span::styled(
123                    current_token,
124                    Style::default().fg(Color::Magenta),
125                ));
126            } else if trimmed_token == "null" {
127                spans.push(Span::styled(current_token, Style::default().fg(Color::Red)));
128            } else if trimmed_token.parse::<f64>().is_ok() {
129                spans.push(Span::styled(
130                    current_token,
131                    Style::default().fg(Color::Blue),
132                ));
133            } else {
134                // This includes spaces and other whitespace - preserve as-is
135                spans.push(Span::raw(current_token));
136            }
137        }
138
139        lines.push(Line::from(spans));
140    }
141
142    lines
143}
144
145fn build_tab_line(
146    labels: &'static [&'static str],
147    selected: usize,
148    is_active: bool,
149    is_enabled: bool,
150) -> Line<'static> {
151    let mut spans = Vec::new();
152
153    for (index, label) in labels.iter().enumerate() {
154        let is_selected = index == selected;
155
156        if is_selected {
157            // Active tab - use a more prominent style like modern tab designs
158            let mut style = Style::default();
159            if is_enabled {
160                style = style
161                    .fg(Color::Black)
162                    .bg(if is_active { Color::Cyan } else { Color::White })
163                    .add_modifier(Modifier::BOLD);
164            } else {
165                style = style.fg(Color::DarkGray).bg(Color::DarkGray);
166            }
167
168            spans.push(Span::styled(format!(" {} ", *label), style));
169        } else if is_enabled {
170            // Inactive tab - subtle background
171            let style = Style::default()
172                .fg(if is_active { Color::White } else { Color::Gray })
173                .bg(Color::DarkGray);
174            spans.push(Span::styled(format!(" {} ", *label), style));
175        } else {
176            // Disabled tab
177            let style = Style::default().fg(Color::DarkGray);
178            spans.push(Span::styled(format!(" {} ", *label), style));
179        }
180
181        // Add separator between tabs
182        if index < labels.len() - 1 {
183            spans.push(Span::raw(""));
184        }
185    }
186
187    Line::from(spans)
188}
189
190pub fn draw(f: &mut Frame, app: &App) {
191    // Calculate footer height dynamically
192    let keybinds = get_keybinds_for_mode(app);
193    let available_width = f.size().width as usize;
194    let line_spans = arrange_keybinds_responsive(keybinds, available_width);
195    let footer_height = (line_spans.len() + 2).max(3); // +2 for borders, minimum 3
196
197    let chunks = Layout::default()
198        .direction(Direction::Vertical)
199        .constraints([
200            Constraint::Length(5),                    // Header
201            Constraint::Min(10),                      // Main content
202            Constraint::Length(footer_height as u16), // Dynamic footer height
203            Constraint::Length(1),                    // Input dialog
204        ])
205        .split(f.size());
206
207    draw_header(f, chunks[0], app);
208
209    // Choose layout based on app mode
210    match app.app_mode {
211        AppMode::Normal => {
212            draw_main_content(f, chunks[1], app);
213        }
214        AppMode::Paused | AppMode::Intercepting => {
215            draw_intercept_content(f, chunks[1], app);
216        }
217    }
218
219    draw_footer(f, chunks[2], app);
220
221    // Draw modal input dialogs (target editing now inline at top)
222    if app.input_mode == InputMode::FilteringRequests {
223        draw_input_dialog(f, app, "Filter Requests", "Filter");
224    }
225}
226
227fn draw_header(f: &mut Frame, area: Rect, app: &App) {
228    let header_chunks = Layout::default()
229        .direction(Direction::Horizontal)
230        .constraints([Constraint::Percentage(65), Constraint::Percentage(35)])
231        .split(area);
232
233    draw_request_header(f, header_chunks[0], app);
234    draw_status_header(f, header_chunks[1], app);
235}
236
237fn draw_request_header(f: &mut Frame, area: Rect, app: &App) {
238    let transport_label = match app.proxy_config.transport {
239        TransportType::Http => "HTTP",
240        TransportType::WebSocket => "WebSocket",
241    };
242
243    let transport_style = Style::default()
244        .fg(Color::Black)
245        .bg(Color::Rgb(210, 160, 255))
246        .add_modifier(Modifier::BOLD);
247
248    let dropdown_style = Style::default()
249        .fg(Color::Black)
250        .bg(Color::Rgb(170, 120, 235))
251        .add_modifier(Modifier::BOLD);
252
253    let target_bg = if app.input_mode == InputMode::EditingTarget {
254        Color::Rgb(80, 56, 140)
255    } else {
256        Color::Rgb(48, 36, 96)
257    };
258
259    let target_style = Style::default()
260        .fg(Color::White)
261        .bg(target_bg)
262        .add_modifier(Modifier::BOLD);
263
264    let target_text = if app.input_mode == InputMode::EditingTarget {
265        if app.input_buffer.is_empty() {
266            "Enter target URL".to_string()
267        } else {
268            app.input_buffer.clone()
269        }
270    } else if app.proxy_config.target_url.is_empty() {
271        "Press t to set target".to_string()
272    } else {
273        app.proxy_config.target_url.clone()
274    };
275
276    let mut spans = vec![
277        Span::styled(format!(" {} ", transport_label), transport_style),
278        Span::styled(" ▾ ", dropdown_style),
279        Span::raw(" "),
280        Span::styled(format!(" {} ", target_text), target_style),
281    ];
282
283    if app.input_mode == InputMode::EditingTarget {
284        spans.push(Span::styled("█", target_style));
285    }
286
287    spans.push(Span::raw("  "));
288
289    let filter_bg = if app.input_mode == InputMode::FilteringRequests {
290        Color::Rgb(80, 56, 140)
291    } else {
292        Color::Rgb(48, 36, 96)
293    };
294
295    let filter_style = Style::default()
296        .fg(if app.filter_text.is_empty() {
297            Color::Rgb(180, 170, 210)
298        } else {
299            Color::White
300        })
301        .bg(filter_bg)
302        .add_modifier(Modifier::BOLD);
303
304    let filter_text = if app.filter_text.is_empty() {
305        "Filter (press /)".to_string()
306    } else {
307        format!("Filter: {}", app.filter_text)
308    };
309
310    spans.push(Span::styled(format!(" {} ", filter_text), filter_style));
311
312    if app.input_mode == InputMode::FilteringRequests {
313        spans.push(Span::styled("█", filter_style));
314    }
315
316    let block = Block::default().borders(Borders::ALL).title(Span::styled(
317        "Request",
318        Style::default().fg(Color::LightMagenta),
319    ));
320
321    let paragraph = Paragraph::new(Line::from(spans))
322        .block(block)
323        .wrap(Wrap { trim: true });
324
325    f.render_widget(paragraph, area);
326}
327
328fn draw_status_header(f: &mut Frame, area: Rect, app: &App) {
329    let status_focus = matches!(app.focus, Focus::StatusHeader);
330
331    let inactive_fg = Color::Rgb(180, 170, 210);
332
333    let mut running_style = if app.is_running {
334        Style::default()
335            .fg(Color::Black)
336            .bg(Color::Green)
337            .add_modifier(Modifier::BOLD)
338    } else {
339        Style::default().fg(inactive_fg).bg(Color::Rgb(60, 60, 60))
340    };
341
342    let mut stopped_style = if app.is_running {
343        Style::default().fg(inactive_fg).bg(Color::Rgb(60, 60, 60))
344    } else {
345        Style::default()
346            .fg(Color::White)
347            .bg(Color::Rgb(120, 35, 52))
348            .add_modifier(Modifier::BOLD)
349    };
350
351    if status_focus {
352        if app.is_running {
353            running_style = running_style.add_modifier(Modifier::UNDERLINED);
354        } else {
355            stopped_style = stopped_style.add_modifier(Modifier::UNDERLINED);
356        }
357    }
358
359    let mode_text = match app.app_mode {
360        AppMode::Normal => "Normal".to_string(),
361        AppMode::Paused => "Paused".to_string(),
362        AppMode::Intercepting => format!("Intercepting ({})", app.pending_requests.len()),
363    };
364
365    let mode_color = match app.app_mode {
366        AppMode::Normal => Color::Gray,
367        AppMode::Paused => Color::Yellow,
368        AppMode::Intercepting => Color::Red,
369    };
370
371    let mut lines = Vec::new();
372
373    let tab_spans = vec![
374        Span::styled(" RUNNING ", running_style),
375        Span::styled(" STOPPED ", stopped_style),
376    ];
377    lines.push(Line::from(tab_spans));
378
379    let label_style = Style::default()
380        .fg(Color::Gray)
381        .add_modifier(Modifier::BOLD);
382
383    let info_line = Line::from(vec![
384        Span::styled("Port:", label_style),
385        Span::raw(format!(" {}", app.proxy_config.listen_port)),
386        Span::raw("    "),
387        Span::styled("Mode:", label_style),
388        Span::styled(format!(" {}", mode_text), Style::default().fg(mode_color)),
389    ]);
390    lines.push(info_line);
391
392    if app.input_mode == InputMode::EditingTarget {
393        lines.push(Line::from(Span::styled(
394            "Editing target (Enter to save, Esc to cancel)",
395            Style::default().fg(Color::Yellow),
396        )));
397    }
398
399    let mut block = Block::default().borders(Borders::ALL).title(Span::styled(
400        "Status",
401        Style::default().fg(Color::LightMagenta),
402    ));
403
404    if status_focus {
405        block = block.border_style(
406            Style::default()
407                .fg(Color::Yellow)
408                .add_modifier(Modifier::BOLD),
409        );
410    } else {
411        block = block.border_style(Style::default().fg(Color::DarkGray));
412    }
413
414    let paragraph = Paragraph::new(lines)
415        .block(block)
416        .wrap(Wrap { trim: false });
417
418    f.render_widget(paragraph, area);
419}
420
421fn draw_main_content(f: &mut Frame, area: Rect, app: &App) {
422    let chunks = Layout::default()
423        .direction(Direction::Horizontal)
424        .constraints([
425            Constraint::Percentage(50), // Message list
426            Constraint::Percentage(50), // Details area
427        ])
428        .split(area);
429
430    draw_message_list(f, chunks[0], app);
431    draw_details_split(f, chunks[1], app);
432}
433
434fn draw_message_list(f: &mut Frame, area: Rect, app: &App) {
435    let filtered: Vec<(usize, &JsonRpcExchange)> = app
436        .exchanges
437        .iter()
438        .enumerate()
439        .filter(|(_, exchange)| {
440            if app.filter_text.is_empty() {
441                true
442            } else {
443                exchange
444                    .method
445                    .as_deref()
446                    .unwrap_or("")
447                    .contains(&app.filter_text)
448            }
449        })
450        .collect();
451
452    if filtered.is_empty() {
453        let empty_message = if app.is_running {
454            format!(
455                "Proxy is running on port {}. Waiting for requests...",
456                app.proxy_config.listen_port
457            )
458        } else {
459            "Press 's' to start the proxy and begin capturing messages".to_string()
460        };
461
462        let mut block = Block::default().borders(Borders::ALL).title("Requests");
463        if matches!(app.focus, Focus::MessageList) {
464            block = block.border_style(
465                Style::default()
466                    .fg(Color::Yellow)
467                    .add_modifier(Modifier::BOLD),
468            );
469        } else {
470            block = block.border_style(Style::default().fg(Color::DarkGray));
471        }
472
473        let paragraph = Paragraph::new(empty_message.as_str())
474            .block(block)
475            .style(Style::default().fg(Color::Gray))
476            .wrap(Wrap { trim: true });
477
478        f.render_widget(paragraph, area);
479        return;
480    }
481
482    let selected_position = filtered
483        .iter()
484        .position(|(index, _)| *index == app.selected_exchange)
485        .unwrap_or(0);
486
487    let highlight_style = if matches!(app.focus, Focus::MessageList) {
488        Style::default()
489            .bg(Color::Cyan)
490            .fg(Color::Black)
491            .add_modifier(Modifier::BOLD)
492    } else {
493        Style::default().fg(Color::White)
494    };
495
496    let header = Row::new(vec![
497        Cell::from("Status"),
498        Cell::from("Transport"),
499        Cell::from("Method"),
500        Cell::from("ID"),
501        Cell::from("Duration"),
502    ])
503    .style(Style::default().add_modifier(Modifier::BOLD))
504    .height(1);
505
506    let rows: Vec<Row> = filtered
507        .iter()
508        .map(|(_, exchange)| {
509            let transport_symbol = match exchange.transport {
510                TransportType::Http => "HTTP",
511                TransportType::WebSocket => "WS",
512            };
513
514            let method = exchange.method.as_deref().unwrap_or("unknown");
515            let id = exchange
516                .id
517                .as_ref()
518                .map(|v| match v {
519                    serde_json::Value::String(s) => s.clone(),
520                    serde_json::Value::Number(n) => n.to_string(),
521                    _ => v.to_string(),
522                })
523                .unwrap_or_else(|| "null".to_string());
524
525            let (status_symbol, status_color) = if exchange.response.is_none() {
526                ("⏳ Pending", Color::Yellow)
527            } else if let Some(response) = &exchange.response {
528                if response.error.is_some() {
529                    ("✗ Error", Color::Red)
530                } else {
531                    ("✓ Success", Color::Green)
532                }
533            } else {
534                ("? Unknown", Color::Gray)
535            };
536
537            let duration_text =
538                if let (Some(request), Some(response)) = (&exchange.request, &exchange.response) {
539                    match response.timestamp.duration_since(request.timestamp) {
540                        Ok(duration) => {
541                            let millis = duration.as_millis();
542                            if millis < 1000 {
543                                format!("{}ms", millis)
544                            } else {
545                                format!("{:.2}s", duration.as_secs_f64())
546                            }
547                        }
548                        Err(_) => "-".to_string(),
549                    }
550                } else {
551                    "-".to_string()
552                };
553
554            Row::new(vec![
555                Cell::from(status_symbol).style(Style::default().fg(status_color)),
556                Cell::from(transport_symbol).style(Style::default().fg(Color::Blue)),
557                Cell::from(method).style(Style::default().fg(Color::Red)),
558                Cell::from(id).style(Style::default().fg(Color::Gray)),
559                Cell::from(duration_text).style(Style::default().fg(Color::Magenta)),
560            ])
561            .height(1)
562        })
563        .collect();
564
565    let mut table_block = Block::default().borders(Borders::ALL).title("Requests");
566    if matches!(app.focus, Focus::MessageList) {
567        table_block = table_block.border_style(
568            Style::default()
569                .fg(Color::Yellow)
570                .add_modifier(Modifier::BOLD),
571        );
572    } else {
573        table_block = table_block.border_style(Style::default().fg(Color::DarkGray));
574    }
575
576    let table = Table::new(
577        rows,
578        [
579            Constraint::Length(12), // Status
580            Constraint::Length(9),  // Transport
581            Constraint::Min(15),    // Method (flexible)
582            Constraint::Length(12), // ID
583            Constraint::Length(10), // Duration
584        ],
585    )
586    .header(header)
587    .block(table_block)
588    .highlight_style(highlight_style)
589    .highlight_symbol("  ");
590
591    let mut table_state = TableState::default();
592    table_state.select(Some(selected_position));
593    f.render_stateful_widget(table, area, &mut table_state);
594
595    if filtered.len() > 1 {
596        let mut scrollbar_state = ScrollbarState::new(filtered.len()).position(selected_position);
597
598        let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
599            .begin_symbol(None)
600            .end_symbol(None)
601            .track_symbol(None)
602            .thumb_symbol("▐");
603
604        f.render_stateful_widget(
605            scrollbar,
606            area.inner(&Margin {
607                vertical: 1,
608                horizontal: 0,
609            }),
610            &mut scrollbar_state,
611        );
612    }
613}
614
615fn draw_request_details(f: &mut Frame, area: Rect, app: &App) {
616    let content = if let Some(exchange) = app.get_selected_exchange() {
617        let mut lines = Vec::new();
618
619        // Basic exchange info
620        lines.push(Line::from(vec![
621            Span::styled("Transport: ", Style::default().add_modifier(Modifier::BOLD)),
622            Span::raw(format!("{:?}", exchange.transport)),
623        ]));
624
625        if let Some(method) = &exchange.method {
626            lines.push(Line::from(vec![
627                Span::styled("Method: ", Style::default().add_modifier(Modifier::BOLD)),
628                Span::raw(method.clone()),
629            ]));
630        }
631
632        if let Some(id) = &exchange.id {
633            lines.push(Line::from(vec![
634                Span::styled("ID: ", Style::default().add_modifier(Modifier::BOLD)),
635                Span::raw(id.to_string()),
636            ]));
637        }
638
639        // Request section with tabs
640        lines.push(Line::from(""));
641        lines.push(Line::from(Span::styled(
642            "REQUEST:",
643            Style::default()
644                .add_modifier(Modifier::BOLD)
645                .fg(Color::Green),
646        )));
647        lines.push(build_tab_line(
648            &["Headers", "Body"],
649            app.request_tab,
650            matches!(app.focus, Focus::RequestSection),
651            exchange.request.is_some(),
652        ));
653
654        if let Some(request) = &exchange.request {
655            if app.request_tab == 0 {
656                // Show headers regardless of focus state
657                lines.push(Line::from(""));
658                match &request.headers {
659                    Some(headers) if !headers.is_empty() => {
660                        for (key, value) in headers {
661                            lines.push(Line::from(format!("  {}: {}", key, value)));
662                        }
663                    }
664                    Some(_) => {
665                        lines.push(Line::from("  No headers"));
666                    }
667                    None => {
668                        lines.push(Line::from("  No headers captured"));
669                    }
670                }
671            } else {
672                // Show body regardless of focus state
673                lines.push(Line::from(""));
674                let mut request_json = serde_json::Map::new();
675                request_json.insert(
676                    "jsonrpc".to_string(),
677                    serde_json::Value::String("2.0".to_string()),
678                );
679
680                if let Some(id) = &request.id {
681                    request_json.insert("id".to_string(), id.clone());
682                }
683                if let Some(method) = &request.method {
684                    request_json.insert(
685                        "method".to_string(),
686                        serde_json::Value::String(method.clone()),
687                    );
688                }
689                if let Some(params) = &request.params {
690                    request_json.insert("params".to_string(), params.clone());
691                }
692
693                let request_json_value = serde_json::Value::Object(request_json);
694                let request_json_lines = format_json_with_highlighting(&request_json_value);
695                for line in request_json_lines {
696                    lines.push(line);
697                }
698            }
699        } else {
700            lines.push(Line::from(""));
701            lines.push(Line::from("Request not captured yet"));
702        }
703
704        lines
705    } else {
706        vec![Line::from("No request selected")]
707    };
708
709    // Calculate visible area for scrolling
710    let inner_area = area.inner(&Margin {
711        vertical: 1,
712        horizontal: 1,
713    });
714    let visible_lines = inner_area.height as usize;
715    let total_lines = content.len();
716
717    // Apply scrolling offset
718    let start_line = app.request_details_scroll;
719    let end_line = std::cmp::min(start_line + visible_lines, total_lines);
720    let visible_content = if start_line < total_lines {
721        content[start_line..end_line].to_vec()
722    } else {
723        vec![]
724    };
725
726    // Create title with scroll indicator
727    let base_title = "Request Details";
728
729    let scroll_info = if total_lines > visible_lines {
730        let progress = ((app.request_details_scroll as f32 / (total_lines - visible_lines) as f32)
731            * 100.0) as u8;
732        format!("{} ({}% - vim: j/k/d/u/G/g)", base_title, progress)
733    } else {
734        base_title.to_string()
735    };
736
737    let details_block = if matches!(app.focus, Focus::RequestSection) {
738        Block::default()
739            .borders(Borders::ALL)
740            .title(scroll_info)
741            .border_style(
742                Style::default()
743                    .fg(Color::Yellow)
744                    .add_modifier(Modifier::BOLD),
745            )
746    } else {
747        Block::default().borders(Borders::ALL).title(scroll_info)
748    };
749
750    let details = Paragraph::new(visible_content)
751        .block(details_block)
752        .wrap(Wrap { trim: false });
753
754    f.render_widget(details, area);
755
756    if total_lines > visible_lines {
757        let mut scrollbar_state =
758            ScrollbarState::new(total_lines).position(app.request_details_scroll);
759
760        let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
761            .begin_symbol(None)
762            .end_symbol(None)
763            .track_symbol(None)
764            .thumb_symbol("▐");
765
766        f.render_stateful_widget(
767            scrollbar,
768            area.inner(&Margin {
769                vertical: 1,
770                horizontal: 0,
771            }),
772            &mut scrollbar_state,
773        );
774    }
775}
776
777fn draw_details_split(f: &mut Frame, area: Rect, app: &App) {
778    let chunks = Layout::default()
779        .direction(Direction::Vertical)
780        .constraints([
781            Constraint::Percentage(50), // Request details
782            Constraint::Percentage(50), // Response details
783        ])
784        .split(area);
785
786    draw_request_details(f, chunks[0], app);
787    draw_response_details(f, chunks[1], app);
788}
789
790fn draw_response_details(f: &mut Frame, area: Rect, app: &App) {
791    let content = if let Some(exchange) = app.get_selected_exchange() {
792        let mut lines = Vec::new();
793
794        // Response section with tabs
795        lines.push(Line::from(Span::styled(
796            "RESPONSE:",
797            Style::default()
798                .add_modifier(Modifier::BOLD)
799                .fg(Color::Blue),
800        )));
801        lines.push(build_tab_line(
802            &["Headers", "Body"],
803            app.response_tab,
804            matches!(app.focus, Focus::ResponseSection),
805            exchange.response.is_some(),
806        ));
807
808        if let Some(response) = &exchange.response {
809            if app.response_tab == 0 {
810                // Show headers regardless of focus state
811                lines.push(Line::from(""));
812                match &response.headers {
813                    Some(headers) if !headers.is_empty() => {
814                        for (key, value) in headers {
815                            lines.push(Line::from(format!("  {}: {}", key, value)));
816                        }
817                    }
818                    Some(_) => {
819                        lines.push(Line::from("  No headers"));
820                    }
821                    None => {
822                        lines.push(Line::from("  No headers captured"));
823                    }
824                }
825            } else {
826                // Show body regardless of focus state
827                lines.push(Line::from(""));
828                let mut response_json = serde_json::Map::new();
829                response_json.insert(
830                    "jsonrpc".to_string(),
831                    serde_json::Value::String("2.0".to_string()),
832                );
833
834                if let Some(id) = &response.id {
835                    response_json.insert("id".to_string(), id.clone());
836                }
837                if let Some(result) = &response.result {
838                    response_json.insert("result".to_string(), result.clone());
839                }
840                if let Some(error) = &response.error {
841                    response_json.insert("error".to_string(), error.clone());
842                }
843
844                let response_json_value = serde_json::Value::Object(response_json);
845                let response_json_lines = format_json_with_highlighting(&response_json_value);
846                for line in response_json_lines {
847                    lines.push(line);
848                }
849            }
850        } else {
851            lines.push(Line::from(""));
852            lines.push(Line::from(Span::styled(
853                "Response pending...",
854                Style::default().fg(Color::Yellow),
855            )));
856        }
857
858        lines
859    } else {
860        vec![Line::from("No request selected")]
861    };
862
863    // Calculate visible area for scrolling
864    let inner_area = area.inner(&Margin {
865        vertical: 1,
866        horizontal: 1,
867    });
868    let visible_lines = inner_area.height as usize;
869    let total_lines = content.len();
870
871    // Apply scrolling offset
872    let start_line = app.response_details_scroll;
873    let end_line = std::cmp::min(start_line + visible_lines, total_lines);
874    let visible_content = if start_line < total_lines {
875        content[start_line..end_line].to_vec()
876    } else {
877        vec![]
878    };
879
880    // Create title with scroll indicator
881    let base_title = "Response Details";
882
883    let scroll_info = if total_lines > visible_lines {
884        let progress = ((app.response_details_scroll as f32 / (total_lines - visible_lines) as f32)
885            * 100.0) as u8;
886        format!("{} ({}% - vim: j/k/d/u/G/g)", base_title, progress)
887    } else {
888        base_title.to_string()
889    };
890
891    let details_block = if matches!(app.focus, Focus::ResponseSection) {
892        Block::default()
893            .borders(Borders::ALL)
894            .title(scroll_info)
895            .border_style(
896                Style::default()
897                    .fg(Color::Yellow)
898                    .add_modifier(Modifier::BOLD),
899            )
900    } else {
901        Block::default().borders(Borders::ALL).title(scroll_info)
902    };
903
904    let details = Paragraph::new(visible_content)
905        .block(details_block)
906        .wrap(Wrap { trim: false });
907
908    f.render_widget(details, area);
909
910    if total_lines > visible_lines {
911        let mut scrollbar_state =
912            ScrollbarState::new(total_lines).position(app.response_details_scroll);
913
914        let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
915            .begin_symbol(None)
916            .end_symbol(None)
917            .track_symbol(None)
918            .thumb_symbol("▐");
919
920        f.render_stateful_widget(
921            scrollbar,
922            area.inner(&Margin {
923                vertical: 1,
924                horizontal: 0,
925            }),
926            &mut scrollbar_state,
927        );
928    }
929}
930
931// Helper struct to represent a keybind with its display information
932#[derive(Clone)]
933struct KeybindInfo {
934    key: String,
935    description: String,
936    priority: u8, // Lower number = higher priority
937}
938
939impl KeybindInfo {
940    fn new(key: &str, description: &str, priority: u8) -> Self {
941        Self {
942            key: key.to_string(),
943            description: description.to_string(),
944            priority,
945        }
946    }
947
948    // Calculate the display width of this keybind (key + description + separators)
949    fn display_width(&self) -> usize {
950        self.key.len() + 1 + self.description.len() + 3 // " | " separator
951    }
952
953    // Convert to spans for rendering
954    fn to_spans(&self) -> Vec<Span<'static>> {
955        vec![
956            Span::styled(
957                self.key.clone(),
958                Style::default()
959                    .fg(Color::Yellow)
960                    .add_modifier(Modifier::BOLD),
961            ),
962            Span::raw(format!(" {} | ", self.description)),
963        ]
964    }
965}
966
967fn get_keybinds_for_mode(app: &App) -> Vec<KeybindInfo> {
968    let mut keybinds = vec![
969        // Essential keybinds (priority 1)
970        KeybindInfo::new("q", "quit", 1),
971        KeybindInfo::new("↑↓", "navigate", 1),
972        KeybindInfo::new("s", "start/stop proxy", 1),
973        // Navigation keybinds (priority 2)
974        KeybindInfo::new("Tab/Shift+Tab", "navigate", 2),
975        KeybindInfo::new("^n/^p", "navigate", 2),
976        KeybindInfo::new("t", "edit target", 2),
977        KeybindInfo::new("/", "filter", 2),
978        KeybindInfo::new("p", "pause", 2),
979        // Advanced keybinds (priority 3)
980        KeybindInfo::new("j/k/d/u/G/g", "scroll details", 3),
981        KeybindInfo::new("h/l", "navigate tabs", 3),
982    ];
983
984    // Add context-specific keybinds (priority 4)
985    match app.app_mode {
986        AppMode::Paused | AppMode::Intercepting => {
987            // Only show intercept controls if there are pending requests
988            if !app.pending_requests.is_empty() {
989                keybinds.extend(vec![
990                    KeybindInfo::new("a", "allow", 4),
991                    KeybindInfo::new("e", "edit", 4),
992                    KeybindInfo::new("h", "headers", 4),
993                    KeybindInfo::new("c", "complete", 4),
994                    KeybindInfo::new("b", "block", 4),
995                    KeybindInfo::new("r", "resume", 4),
996                ]);
997            }
998        }
999        AppMode::Normal => {
1000            keybinds.push(KeybindInfo::new("c", "create request", 4));
1001        }
1002    }
1003
1004    keybinds
1005}
1006
1007fn arrange_keybinds_responsive(
1008    keybinds: Vec<KeybindInfo>,
1009    available_width: usize,
1010) -> Vec<Vec<Span<'static>>> {
1011    let mut lines = Vec::new();
1012    let mut current_line_spans = Vec::new();
1013    let mut current_line_width = 0;
1014
1015    // Account for border padding (2 chars for left/right borders)
1016    let usable_width = available_width.saturating_sub(4);
1017
1018    // Sort keybinds by priority
1019    let mut sorted_keybinds = keybinds;
1020    sorted_keybinds.sort_by_key(|k| k.priority);
1021
1022    for (i, keybind) in sorted_keybinds.iter().enumerate() {
1023        let keybind_width = keybind.display_width();
1024        let is_last = i == sorted_keybinds.len() - 1;
1025
1026        // Check if this keybind fits on the current line
1027        let width_needed = if is_last {
1028            keybind_width - 3 // Remove " | " from last item
1029        } else {
1030            keybind_width
1031        };
1032
1033        if current_line_width + width_needed <= usable_width || current_line_spans.is_empty() {
1034            // Add to current line
1035            let mut spans = keybind.to_spans();
1036            if is_last {
1037                // Remove the trailing " | " from the last keybind
1038                if let Some(last_span) = spans.last_mut() {
1039                    if let Some(content) = last_span.content.strip_suffix(" | ") {
1040                        *last_span = Span::raw(content.to_string());
1041                    }
1042                }
1043            }
1044            current_line_spans.extend(spans);
1045            current_line_width += width_needed;
1046        } else {
1047            // Start a new line
1048            // Remove trailing " | " from the last span of the current line
1049            if let Some(last_span) = current_line_spans.last_mut() {
1050                if let Some(content) = last_span.content.strip_suffix(" | ") {
1051                    *last_span = Span::raw(content.to_string());
1052                }
1053            }
1054
1055            lines.push(current_line_spans);
1056            current_line_spans = keybind.to_spans();
1057            current_line_width = keybind_width;
1058
1059            // If this is the last keybind, remove trailing separator
1060            if is_last {
1061                if let Some(last_span) = current_line_spans.last_mut() {
1062                    if let Some(content) = last_span.content.strip_suffix(" | ") {
1063                        *last_span = Span::raw(content.to_string());
1064                    }
1065                }
1066            }
1067        }
1068    }
1069
1070    // Add the last line if it has content
1071    if !current_line_spans.is_empty() {
1072        lines.push(current_line_spans);
1073    }
1074
1075    lines
1076}
1077
1078fn draw_footer(f: &mut Frame, area: Rect, app: &App) {
1079    let keybinds = get_keybinds_for_mode(app);
1080    let available_width = area.width as usize;
1081
1082    let line_spans = arrange_keybinds_responsive(keybinds, available_width);
1083
1084    // Convert spans to Lines
1085    let footer_text: Vec<Line> = line_spans.into_iter().map(Line::from).collect();
1086
1087    let footer =
1088        Paragraph::new(footer_text).block(Block::default().borders(Borders::ALL).title("Controls"));
1089
1090    f.render_widget(footer, area);
1091}
1092
1093fn draw_input_dialog(f: &mut Frame, app: &App, title: &str, label: &str) {
1094    let area = f.size();
1095
1096    // Create a centered popup
1097    let popup_area = Rect {
1098        x: area.width / 4,
1099        y: area.height / 2 - 3,
1100        width: area.width / 2,
1101        height: 7,
1102    };
1103
1104    // Clear the entire screen first
1105    f.render_widget(Clear, area);
1106
1107    // Render a black background
1108    let background = Block::default().style(Style::default().bg(Color::Black));
1109    f.render_widget(background, area);
1110
1111    // Clear the popup area specifically
1112    f.render_widget(Clear, popup_area);
1113
1114    let input_text = vec![
1115        Line::from(""),
1116        Line::from(vec![
1117            Span::raw(format!("{}: ", label)),
1118            Span::styled(&app.input_buffer, Style::default().fg(Color::Green)),
1119        ]),
1120        Line::from(""),
1121        Line::from(Span::styled(
1122            "Press Enter to confirm, Esc to cancel",
1123            Style::default().fg(Color::Gray),
1124        )),
1125        Line::from(""),
1126    ];
1127
1128    let input_dialog = Paragraph::new(input_text)
1129        .block(
1130            Block::default()
1131                .borders(Borders::ALL)
1132                .title(title)
1133                .style(Style::default().fg(Color::White).bg(Color::DarkGray)),
1134        )
1135        .wrap(Wrap { trim: true });
1136
1137    f.render_widget(input_dialog, popup_area);
1138}
1139
1140fn draw_intercept_content(f: &mut Frame, area: Rect, app: &App) {
1141    let chunks = Layout::default()
1142        .direction(Direction::Horizontal)
1143        .constraints([
1144            Constraint::Percentage(50), // Pending requests list
1145            Constraint::Percentage(50), // Request details/editor
1146        ])
1147        .split(area);
1148
1149    draw_pending_requests(f, chunks[0], app);
1150    draw_intercept_request_details(f, chunks[1], app);
1151}
1152
1153fn draw_pending_requests(f: &mut Frame, area: Rect, app: &App) {
1154    if app.pending_requests.is_empty() {
1155        let mode_text = match app.app_mode {
1156            AppMode::Paused => "Pause mode active. New requests will be intercepted.",
1157            _ => "No pending requests.",
1158        };
1159
1160        let paragraph = Paragraph::new(mode_text)
1161            .block(
1162                Block::default()
1163                    .borders(Borders::ALL)
1164                    .title("Pending Requests"),
1165            )
1166            .style(Style::default().fg(Color::Yellow))
1167            .wrap(Wrap { trim: true });
1168
1169        f.render_widget(paragraph, area);
1170        return;
1171    }
1172
1173    let requests: Vec<ListItem> = app
1174        .pending_requests
1175        .iter()
1176        .enumerate()
1177        .filter(|(_, pending)| {
1178            if app.filter_text.is_empty() {
1179                true
1180            } else {
1181                // Filter pending requests by method name (same as main list)
1182                pending
1183                    .original_request
1184                    .method
1185                    .as_deref()
1186                    .unwrap_or("")
1187                    .contains(&app.filter_text)
1188            }
1189        })
1190        .map(|(i, pending)| {
1191            let method = pending
1192                .original_request
1193                .method
1194                .as_deref()
1195                .unwrap_or("unknown");
1196            let id = pending
1197                .original_request
1198                .id
1199                .as_ref()
1200                .map(|v| v.to_string())
1201                .unwrap_or_else(|| "null".to_string());
1202
1203            let style = if i == app.selected_pending {
1204                Style::default()
1205                    .bg(Color::Cyan)
1206                    .fg(Color::Black)
1207                    .add_modifier(Modifier::BOLD)
1208            } else {
1209                Style::default()
1210            };
1211
1212            // Show different icon if request has been modified
1213            let (icon, icon_color) =
1214                if pending.modified_request.is_some() || pending.modified_headers.is_some() {
1215                    ("✏ ", Color::Blue) // Modified
1216                } else {
1217                    ("⏸ ", Color::Red) // Paused/Intercepted
1218                };
1219
1220            let mut modification_labels = Vec::new();
1221            if pending.modified_request.is_some() {
1222                modification_labels.push("BODY");
1223            }
1224            if pending.modified_headers.is_some() {
1225                modification_labels.push("HEADERS");
1226            }
1227            let modification_text = if !modification_labels.is_empty() {
1228                format!(" [{}]", modification_labels.join("+"))
1229            } else {
1230                String::new()
1231            };
1232
1233            ListItem::new(Line::from(vec![
1234                Span::styled(icon, Style::default().fg(icon_color)),
1235                Span::styled(format!("{} ", method), Style::default().fg(Color::Red)),
1236                Span::styled(format!("(id: {})", id), Style::default().fg(Color::Gray)),
1237                if !modification_text.is_empty() {
1238                    Span::styled(
1239                        modification_text,
1240                        Style::default()
1241                            .fg(Color::Blue)
1242                            .add_modifier(Modifier::BOLD),
1243                    )
1244                } else {
1245                    Span::raw("")
1246                },
1247            ]))
1248            .style(style)
1249        })
1250        .collect();
1251
1252    let pending_block = if matches!(app.app_mode, AppMode::Paused | AppMode::Intercepting) {
1253        Block::default()
1254            .borders(Borders::ALL)
1255            .title(format!("Pending Requests ({})", app.pending_requests.len()))
1256            .border_style(
1257                Style::default()
1258                    .fg(Color::Yellow)
1259                    .add_modifier(Modifier::BOLD),
1260            )
1261    } else {
1262        Block::default()
1263            .borders(Borders::ALL)
1264            .title(format!("Pending Requests ({})", app.pending_requests.len()))
1265    };
1266
1267    let requests_list = List::new(requests).block(pending_block).highlight_style(
1268        Style::default()
1269            .bg(Color::Cyan)
1270            .fg(Color::Black)
1271            .add_modifier(Modifier::BOLD),
1272    );
1273
1274    f.render_widget(requests_list, area);
1275}
1276
1277fn draw_intercept_request_details(f: &mut Frame, area: Rect, app: &App) {
1278    let content = if let Some(pending) = app.get_selected_pending() {
1279        let mut lines = Vec::new();
1280
1281        if pending.modified_request.is_some() || pending.modified_headers.is_some() {
1282            lines.push(Line::from(Span::styled(
1283                "MODIFIED REQUEST:",
1284                Style::default()
1285                    .add_modifier(Modifier::BOLD)
1286                    .fg(Color::Blue),
1287            )));
1288        } else {
1289            lines.push(Line::from(Span::styled(
1290                "INTERCEPTED REQUEST:",
1291                Style::default().add_modifier(Modifier::BOLD).fg(Color::Red),
1292            )));
1293        }
1294        lines.push(Line::from(""));
1295
1296        // Show headers section
1297        lines.push(Line::from(Span::styled(
1298            "HTTP Headers:",
1299            Style::default()
1300                .add_modifier(Modifier::BOLD)
1301                .fg(Color::Green),
1302        )));
1303        let headers_to_show = pending
1304            .modified_headers
1305            .as_ref()
1306            .or(pending.original_request.headers.as_ref());
1307
1308        if let Some(headers) = headers_to_show {
1309            for (key, value) in headers {
1310                lines.push(Line::from(format!("  {}: {}", key, value)));
1311            }
1312            if pending.modified_headers.is_some() {
1313                lines.push(Line::from(Span::styled(
1314                    "  [Headers have been modified]",
1315                    Style::default()
1316                        .fg(Color::Blue)
1317                        .add_modifier(Modifier::ITALIC),
1318                )));
1319            }
1320        } else {
1321            lines.push(Line::from("  No headers"));
1322        }
1323        lines.push(Line::from(""));
1324
1325        // Show JSON-RPC body section
1326        lines.push(Line::from(Span::styled(
1327            "JSON-RPC Request:",
1328            Style::default()
1329                .add_modifier(Modifier::BOLD)
1330                .fg(Color::Green),
1331        )));
1332
1333        // Show the modified request if available, otherwise show original
1334        let json_to_show = if let Some(ref modified_json) = pending.modified_request {
1335            if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(modified_json) {
1336                parsed
1337            } else {
1338                // Fallback to original if modified JSON is invalid
1339                let mut request_json = serde_json::Map::new();
1340                request_json.insert(
1341                    "jsonrpc".to_string(),
1342                    serde_json::Value::String("2.0".to_string()),
1343                );
1344
1345                if let Some(id) = &pending.original_request.id {
1346                    request_json.insert("id".to_string(), id.clone());
1347                }
1348                if let Some(method) = &pending.original_request.method {
1349                    request_json.insert(
1350                        "method".to_string(),
1351                        serde_json::Value::String(method.clone()),
1352                    );
1353                }
1354                if let Some(params) = &pending.original_request.params {
1355                    request_json.insert("params".to_string(), params.clone());
1356                }
1357
1358                serde_json::Value::Object(request_json)
1359            }
1360        } else {
1361            // Show original request
1362            let mut request_json = serde_json::Map::new();
1363            request_json.insert(
1364                "jsonrpc".to_string(),
1365                serde_json::Value::String("2.0".to_string()),
1366            );
1367
1368            if let Some(id) = &pending.original_request.id {
1369                request_json.insert("id".to_string(), id.clone());
1370            }
1371            if let Some(method) = &pending.original_request.method {
1372                request_json.insert(
1373                    "method".to_string(),
1374                    serde_json::Value::String(method.clone()),
1375                );
1376            }
1377            if let Some(params) = &pending.original_request.params {
1378                request_json.insert("params".to_string(), params.clone());
1379            }
1380
1381            serde_json::Value::Object(request_json)
1382        };
1383
1384        let request_json_lines = format_json_with_highlighting(&json_to_show);
1385        for line in request_json_lines {
1386            lines.push(line);
1387        }
1388
1389        lines.push(Line::from(""));
1390        lines.push(Line::from(Span::styled(
1391            "Actions:",
1392            Style::default().add_modifier(Modifier::BOLD),
1393        )));
1394        lines.push(Line::from("• Press 'a' to Allow request"));
1395        lines.push(Line::from("• Press 'e' to Edit request body"));
1396        lines.push(Line::from("• Press 'h' to Edit headers"));
1397        lines.push(Line::from("• Press 'c' to Complete with custom response"));
1398        lines.push(Line::from("• Press 'b' to Block request"));
1399        lines.push(Line::from("• Press 'r' to Resume all requests"));
1400
1401        lines
1402    } else {
1403        vec![Line::from("No request selected")]
1404    };
1405
1406    // Calculate visible area for scrolling
1407    let inner_area = area.inner(&Margin {
1408        vertical: 1,
1409        horizontal: 1,
1410    });
1411    let visible_lines = inner_area.height as usize;
1412    let total_lines = content.len();
1413
1414    // Apply scrolling offset
1415    let start_line = app.intercept_details_scroll;
1416    let end_line = std::cmp::min(start_line + visible_lines, total_lines);
1417    let visible_content = if start_line < total_lines {
1418        content[start_line..end_line].to_vec()
1419    } else {
1420        vec![]
1421    };
1422
1423    // Create title with scroll indicator
1424    let scroll_info = if total_lines > visible_lines {
1425        let progress = ((app.intercept_details_scroll as f32
1426            / (total_lines - visible_lines) as f32)
1427            * 100.0) as u8;
1428        format!("Request Details ({}% - vim: j/k/d/u/G/g)", progress)
1429    } else {
1430        "Request Details".to_string()
1431    };
1432
1433    let details_block = if matches!(app.app_mode, AppMode::Paused | AppMode::Intercepting) {
1434        Block::default()
1435            .borders(Borders::ALL)
1436            .title(scroll_info)
1437            .border_style(
1438                Style::default()
1439                    .fg(Color::Yellow)
1440                    .add_modifier(Modifier::BOLD),
1441            )
1442    } else {
1443        Block::default().borders(Borders::ALL).title(scroll_info)
1444    };
1445
1446    let details = Paragraph::new(visible_content)
1447        .block(details_block)
1448        .wrap(Wrap { trim: false });
1449
1450    f.render_widget(details, area);
1451
1452    if total_lines > visible_lines {
1453        let mut scrollbar_state =
1454            ScrollbarState::new(total_lines).position(app.intercept_details_scroll);
1455
1456        let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
1457            .begin_symbol(None)
1458            .end_symbol(None)
1459            .track_symbol(None)
1460            .thumb_symbol("▐");
1461
1462        f.render_stateful_widget(
1463            scrollbar,
1464            area.inner(&Margin {
1465                vertical: 1,
1466                horizontal: 0,
1467            }),
1468            &mut scrollbar_state,
1469        );
1470    }
1471}