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, Table, TableState, Wrap,
7    },
8    Frame,
9};
10
11use crate::app::{App, AppMode, InputMode, TransportType};
12
13// Helper function to format JSON with syntax highlighting and 2-space indentation
14fn format_json_with_highlighting(json_value: &serde_json::Value) -> Vec<Line<'static>> {
15    // Use the standard pretty formatter
16    let json_str = match serde_json::to_string_pretty(json_value) {
17        Ok(s) => s,
18        Err(_) => return vec![Line::from("Failed to format JSON")],
19    };
20
21    let mut lines = Vec::new();
22
23    for (line_num, line) in json_str.lines().enumerate() {
24        // Limit total lines to prevent UI issues
25        if line_num > 1000 {
26            lines.push(Line::from(Span::styled(
27                "... (content truncated)",
28                Style::default().fg(Color::Gray),
29            )));
30            break;
31        }
32
33        // Don't trim the line - work with it as-is to preserve indentation
34        let mut spans = Vec::new();
35        let mut chars = line.chars().peekable();
36        let mut current_token = String::new();
37
38        while let Some(ch) = chars.next() {
39            match ch {
40                '"' => {
41                    // Flush any accumulated token (including spaces)
42                    if !current_token.is_empty() {
43                        spans.push(Span::raw(current_token.clone()));
44                        current_token.clear();
45                    }
46
47                    // Collect the entire string
48                    let mut string_content = String::from("\"");
49                    for string_ch in chars.by_ref() {
50                        string_content.push(string_ch);
51                        if string_ch == '"' && !string_content.ends_with("\\\"") {
52                            break;
53                        }
54                    }
55
56                    // Check if this is a key (followed by colon)
57                    let peek_chars = chars.clone();
58                    let mut found_colon = false;
59                    for peek_ch in peek_chars {
60                        if peek_ch == ':' {
61                            found_colon = true;
62                            break;
63                        } else if !peek_ch.is_whitespace() {
64                            break;
65                        }
66                    }
67
68                    if found_colon {
69                        // This is a key
70                        spans.push(Span::styled(
71                            string_content,
72                            Style::default()
73                                .fg(Color::Cyan)
74                                .add_modifier(Modifier::BOLD),
75                        ));
76                    } else {
77                        // This is a string value
78                        spans.push(Span::styled(
79                            string_content,
80                            Style::default().fg(Color::Green),
81                        ));
82                    }
83                }
84                ':' => {
85                    if !current_token.is_empty() {
86                        spans.push(Span::raw(current_token.clone()));
87                        current_token.clear();
88                    }
89                    spans.push(Span::styled(":", Style::default().fg(Color::White)));
90                }
91                ',' => {
92                    if !current_token.is_empty() {
93                        spans.push(Span::raw(current_token.clone()));
94                        current_token.clear();
95                    }
96                    spans.push(Span::styled(",", Style::default().fg(Color::White)));
97                }
98                '{' | '}' | '[' | ']' => {
99                    if !current_token.is_empty() {
100                        spans.push(Span::raw(current_token.clone()));
101                        current_token.clear();
102                    }
103                    spans.push(Span::styled(
104                        ch.to_string(),
105                        Style::default()
106                            .fg(Color::Yellow)
107                            .add_modifier(Modifier::BOLD),
108                    ));
109                }
110                _ => {
111                    // Accumulate all other characters including spaces
112                    current_token.push(ch);
113                }
114            }
115        }
116
117        // Handle any remaining token (including trailing spaces)
118        if !current_token.is_empty() {
119            let trimmed_token = current_token.trim();
120            if trimmed_token == "true" || trimmed_token == "false" {
121                spans.push(Span::styled(
122                    current_token,
123                    Style::default().fg(Color::Magenta),
124                ));
125            } else if trimmed_token == "null" {
126                spans.push(Span::styled(current_token, Style::default().fg(Color::Red)));
127            } else if trimmed_token.parse::<f64>().is_ok() {
128                spans.push(Span::styled(
129                    current_token,
130                    Style::default().fg(Color::Blue),
131                ));
132            } else {
133                // This includes spaces and other whitespace - preserve as-is
134                spans.push(Span::raw(current_token));
135            }
136        }
137
138        lines.push(Line::from(spans));
139    }
140
141    lines
142}
143
144pub fn draw(f: &mut Frame, app: &App) {
145    let chunks = Layout::default()
146        .direction(Direction::Vertical)
147        .constraints([
148            Constraint::Length(3), // Header
149            Constraint::Min(10),   // Main content
150            Constraint::Length(3), // Footer
151        ])
152        .split(f.size());
153
154    draw_header(f, chunks[0], app);
155
156    // Choose layout based on app mode
157    match app.app_mode {
158        AppMode::Normal => {
159            draw_main_content(f, chunks[1], app);
160        }
161        AppMode::Paused | AppMode::Intercepting => {
162            draw_intercept_content(f, chunks[1], app);
163        }
164    }
165
166    draw_footer(f, chunks[2], app);
167
168    // Draw input dialogs
169    if app.input_mode == InputMode::EditingTarget {
170        draw_input_dialog(f, app);
171    }
172}
173
174fn draw_header(f: &mut Frame, area: Rect, app: &App) {
175    let status = if app.is_running { "RUNNING" } else { "STOPPED" };
176    let status_color = if app.is_running {
177        Color::Green
178    } else {
179        Color::Red
180    };
181
182    let mode_text = match app.app_mode {
183        AppMode::Normal => String::new(),
184        AppMode::Paused => " | Mode: PAUSED".to_string(),
185        AppMode::Intercepting => format!(
186            " | Mode: INTERCEPTING ({} pending)",
187            app.pending_requests.len()
188        ),
189    };
190    let mode_color = match app.app_mode {
191        AppMode::Normal => Color::White,
192        AppMode::Paused => Color::Yellow,
193        AppMode::Intercepting => Color::Red,
194    };
195
196    let header_text = vec![Line::from(vec![
197        Span::raw("JSON-RPC Debugger | Status: "),
198        Span::styled(
199            status,
200            Style::default()
201                .fg(status_color)
202                .add_modifier(Modifier::BOLD),
203        ),
204        Span::raw(format!(
205            " | Port: {} | Target: {}",
206            app.proxy_config.listen_port, app.proxy_config.target_url
207        )),
208        Span::styled(
209            mode_text,
210            Style::default().fg(mode_color).add_modifier(Modifier::BOLD),
211        ),
212    ])];
213
214    let header =
215        Paragraph::new(header_text).block(Block::default().borders(Borders::ALL).title("Status"));
216
217    f.render_widget(header, area);
218}
219
220fn draw_main_content(f: &mut Frame, area: Rect, app: &App) {
221    let chunks = Layout::default()
222        .direction(Direction::Horizontal)
223        .constraints([
224            Constraint::Percentage(50), // Message list
225            Constraint::Percentage(50), // Message details
226        ])
227        .split(area);
228
229    draw_message_list(f, chunks[0], app);
230    draw_message_details(f, chunks[1], app);
231}
232
233fn draw_message_list(f: &mut Frame, area: Rect, app: &App) {
234    if app.exchanges.is_empty() {
235        let empty_message = if app.is_running {
236            format!(
237                "Proxy is running on port {}. Waiting for JSON-RPC requests...",
238                app.proxy_config.listen_port
239            )
240        } else {
241            "Press 's' to start the proxy and begin capturing messages".to_string()
242        };
243
244        let paragraph = Paragraph::new(empty_message.as_str())
245            .block(Block::default().borders(Borders::ALL).title("JSON-RPC"))
246            .style(Style::default().fg(Color::Gray))
247            .wrap(Wrap { trim: true });
248
249        f.render_widget(paragraph, area);
250        return;
251    }
252
253    // Create table headers
254    let header = Row::new(vec![
255        Cell::from("Status"),
256        Cell::from("Transport"),
257        Cell::from("Method"),
258        Cell::from("ID"),
259        Cell::from("Duration"),
260    ])
261    .style(Style::default().add_modifier(Modifier::BOLD))
262    .height(1);
263
264    // Create table rows
265    let rows: Vec<Row> = app
266        .exchanges
267        .iter()
268        .enumerate()
269        .map(|(i, exchange)| {
270            let transport_symbol = match exchange.transport {
271                TransportType::Http => "HTTP",
272                TransportType::WebSocket => "WS",
273            };
274
275            let method = exchange.method.as_deref().unwrap_or("unknown");
276            let id = exchange
277                .id
278                .as_ref()
279                .map(|v| match v {
280                    serde_json::Value::String(s) => s.clone(),
281                    serde_json::Value::Number(n) => n.to_string(),
282                    _ => v.to_string(),
283                })
284                .unwrap_or_else(|| "null".to_string());
285
286            // Determine status
287            let (status_symbol, status_color) = if exchange.response.is_none() {
288                ("⏳ Pending", Color::Yellow)
289            } else if let Some(response) = &exchange.response {
290                if response.error.is_some() {
291                    ("✗ Error", Color::Red)
292                } else {
293                    ("✓ Success", Color::Green)
294                }
295            } else {
296                ("? Unknown", Color::Gray)
297            };
298
299            // Calculate duration if we have both request and response
300            let duration_text =
301                if let (Some(request), Some(response)) = (&exchange.request, &exchange.response) {
302                    match response.timestamp.duration_since(request.timestamp) {
303                        Ok(duration) => {
304                            let millis = duration.as_millis();
305                            if millis < 1000 {
306                                format!("{}ms", millis)
307                            } else {
308                                format!("{:.2}s", duration.as_secs_f64())
309                            }
310                        }
311                        Err(_) => "-".to_string(),
312                    }
313                } else {
314                    "-".to_string()
315                };
316
317            let style = if i == app.selected_exchange {
318                Style::default()
319                    .bg(Color::Cyan)
320                    .fg(Color::Black)
321                    .add_modifier(Modifier::BOLD)
322            } else {
323                Style::default()
324            };
325
326            Row::new(vec![
327                Cell::from(status_symbol).style(Style::default().fg(status_color)),
328                Cell::from(transport_symbol).style(Style::default().fg(Color::Blue)),
329                Cell::from(method).style(Style::default().fg(Color::Red)),
330                Cell::from(id).style(Style::default().fg(Color::Gray)),
331                Cell::from(duration_text).style(Style::default().fg(Color::Magenta)),
332            ])
333            .style(style)
334            .height(1)
335        })
336        .collect();
337
338    let table = Table::new(
339        rows,
340        [
341            Constraint::Length(12), // Status
342            Constraint::Length(9),  // Transport
343            Constraint::Min(15),    // Method (flexible)
344            Constraint::Length(12), // ID
345            Constraint::Length(10), // Duration
346        ],
347    )
348    .header(header)
349    .block(Block::default().borders(Borders::ALL).title("JSON-RPC"))
350    .highlight_style(
351        Style::default()
352            .bg(Color::Cyan)
353            .fg(Color::Black)
354            .add_modifier(Modifier::BOLD),
355    )
356    .highlight_symbol("→ ");
357
358    let mut table_state = TableState::default();
359    table_state.select(Some(app.selected_exchange));
360    f.render_stateful_widget(table, area, &mut table_state);
361}
362
363fn draw_message_details(f: &mut Frame, area: Rect, app: &App) {
364    let content = if let Some(exchange) = app.get_selected_exchange() {
365        let mut lines = Vec::new();
366
367        // Basic exchange info
368        lines.push(Line::from(vec![
369            Span::styled("Transport: ", Style::default().add_modifier(Modifier::BOLD)),
370            Span::raw(format!("{:?}", exchange.transport)),
371        ]));
372
373        if let Some(method) = &exchange.method {
374            lines.push(Line::from(vec![
375                Span::styled("Method: ", Style::default().add_modifier(Modifier::BOLD)),
376                Span::raw(method.clone()),
377            ]));
378        }
379
380        if let Some(id) = &exchange.id {
381            lines.push(Line::from(vec![
382                Span::styled("ID: ", Style::default().add_modifier(Modifier::BOLD)),
383                Span::raw(id.to_string()),
384            ]));
385        }
386
387        // Request details
388        if let Some(request) = &exchange.request {
389            lines.push(Line::from(""));
390            lines.push(Line::from(Span::styled(
391                "REQUEST:",
392                Style::default()
393                    .add_modifier(Modifier::BOLD)
394                    .fg(Color::Green),
395            )));
396
397            // Show HTTP headers if available
398            if let Some(headers) = &request.headers {
399                lines.push(Line::from(""));
400                lines.push(Line::from("HTTP Headers:"));
401                for (key, value) in headers {
402                    lines.push(Line::from(format!("  {}: {}", key, value)));
403                }
404            }
405
406            // Build and show the complete JSON-RPC request object
407            lines.push(Line::from(""));
408            lines.push(Line::from(Span::styled(
409                "JSON-RPC Request:",
410                Style::default()
411                    .fg(Color::Green)
412                    .add_modifier(Modifier::BOLD),
413            )));
414            lines.push(Line::from(""));
415            let mut request_json = serde_json::Map::new();
416            request_json.insert(
417                "jsonrpc".to_string(),
418                serde_json::Value::String("2.0".to_string()),
419            );
420
421            if let Some(id) = &request.id {
422                request_json.insert("id".to_string(), id.clone());
423            }
424            if let Some(method) = &request.method {
425                request_json.insert(
426                    "method".to_string(),
427                    serde_json::Value::String(method.clone()),
428                );
429            }
430            if let Some(params) = &request.params {
431                request_json.insert("params".to_string(), params.clone());
432            }
433
434            let request_json_value = serde_json::Value::Object(request_json);
435            let request_json_lines = format_json_with_highlighting(&request_json_value);
436            for line in request_json_lines {
437                lines.push(line);
438            }
439        }
440
441        // Response details
442        if let Some(response) = &exchange.response {
443            lines.push(Line::from(""));
444            lines.push(Line::from(Span::styled(
445                "RESPONSE:",
446                Style::default()
447                    .add_modifier(Modifier::BOLD)
448                    .fg(Color::Blue),
449            )));
450
451            // Show HTTP headers if available
452            if let Some(headers) = &response.headers {
453                lines.push(Line::from(""));
454                lines.push(Line::from("HTTP Headers:"));
455                for (key, value) in headers {
456                    lines.push(Line::from(format!("  {}: {}", key, value)));
457                }
458            }
459
460            // Build and show the complete JSON-RPC response object
461            lines.push(Line::from(""));
462            lines.push(Line::from(Span::styled(
463                "JSON-RPC Response:",
464                Style::default()
465                    .fg(Color::Blue)
466                    .add_modifier(Modifier::BOLD),
467            )));
468            lines.push(Line::from(""));
469            let mut response_json = serde_json::Map::new();
470            response_json.insert(
471                "jsonrpc".to_string(),
472                serde_json::Value::String("2.0".to_string()),
473            );
474
475            if let Some(id) = &response.id {
476                response_json.insert("id".to_string(), id.clone());
477            }
478            if let Some(result) = &response.result {
479                response_json.insert("result".to_string(), result.clone());
480            }
481            if let Some(error) = &response.error {
482                response_json.insert("error".to_string(), error.clone());
483            }
484
485            let response_json_value = serde_json::Value::Object(response_json);
486            let response_json_lines = format_json_with_highlighting(&response_json_value);
487            for line in response_json_lines {
488                lines.push(line);
489            }
490        } else {
491            lines.push(Line::from(""));
492            lines.push(Line::from(Span::styled(
493                "RESPONSE: Pending...",
494                Style::default()
495                    .add_modifier(Modifier::BOLD)
496                    .fg(Color::Yellow),
497            )));
498        }
499
500        lines
501    } else {
502        vec![Line::from("No request selected")]
503    };
504
505    // Calculate visible area for scrolling
506    let inner_area = area.inner(&Margin {
507        vertical: 1,
508        horizontal: 1,
509    });
510    let visible_lines = inner_area.height as usize;
511    let total_lines = content.len();
512
513    // Apply scrolling offset
514    let start_line = app.details_scroll;
515    let end_line = std::cmp::min(start_line + visible_lines, total_lines);
516    let visible_content = if start_line < total_lines {
517        content[start_line..end_line].to_vec()
518    } else {
519        vec![]
520    };
521
522    // Create title with scroll indicator
523    let scroll_info = if total_lines > visible_lines {
524        let progress =
525            ((app.details_scroll as f32 / (total_lines - visible_lines) as f32) * 100.0) as u8;
526        format!("Details ({}% - vim: j/k/d/u/G/g)", progress)
527    } else {
528        "Details".to_string()
529    };
530
531    let details = Paragraph::new(visible_content)
532        .block(Block::default().borders(Borders::ALL).title(scroll_info))
533        .wrap(Wrap { trim: false });
534
535    f.render_widget(details, area);
536}
537
538fn draw_footer(f: &mut Frame, area: Rect, app: &App) {
539    let mut footer_spans = vec![
540        Span::styled(
541            "q",
542            Style::default()
543                .fg(Color::Yellow)
544                .add_modifier(Modifier::BOLD),
545        ),
546        Span::raw(" quit | "),
547        Span::styled(
548            "↑↓",
549            Style::default()
550                .fg(Color::Yellow)
551                .add_modifier(Modifier::BOLD),
552        ),
553        Span::raw("/"),
554        Span::styled(
555            "^n/^p",
556            Style::default()
557                .fg(Color::Yellow)
558                .add_modifier(Modifier::BOLD),
559        ),
560        Span::raw(" navigate | "),
561        Span::styled(
562            "j/k/d/u/G/g",
563            Style::default()
564                .fg(Color::Yellow)
565                .add_modifier(Modifier::BOLD),
566        ),
567        Span::raw(" scroll details | "),
568        Span::styled(
569            "s",
570            Style::default()
571                .fg(Color::Yellow)
572                .add_modifier(Modifier::BOLD),
573        ),
574        Span::raw(" start/stop proxy | "),
575        Span::styled(
576            "t",
577            Style::default()
578                .fg(Color::Yellow)
579                .add_modifier(Modifier::BOLD),
580        ),
581        Span::raw(" edit target | "),
582        Span::styled(
583            "p",
584            Style::default()
585                .fg(Color::Yellow)
586                .add_modifier(Modifier::BOLD),
587        ),
588        Span::raw(" pause"),
589    ];
590
591    // Show context-specific controls
592    match app.app_mode {
593        AppMode::Paused | AppMode::Intercepting => {
594            footer_spans.extend(vec![
595                Span::raw(" | "),
596                Span::styled(
597                    "a/e/h/c/b/r",
598                    Style::default()
599                        .fg(Color::Yellow)
600                        .add_modifier(Modifier::BOLD),
601                ),
602                Span::raw(" allow/edit/headers/complete/block/resume"),
603            ]);
604        }
605        AppMode::Normal => {
606            footer_spans.extend(vec![
607                Span::raw(" | "),
608                Span::styled(
609                    "c",
610                    Style::default()
611                        .fg(Color::Yellow)
612                        .add_modifier(Modifier::BOLD),
613                ),
614                Span::raw(" create request"),
615            ]);
616        }
617    }
618
619    let footer_text = vec![Line::from(footer_spans)];
620
621    let footer =
622        Paragraph::new(footer_text).block(Block::default().borders(Borders::ALL).title("Controls"));
623
624    f.render_widget(footer, area);
625}
626
627fn draw_input_dialog(f: &mut Frame, app: &App) {
628    let area = f.size();
629
630    // Create a centered popup
631    let popup_area = Rect {
632        x: area.width / 4,
633        y: area.height / 2 - 3,
634        width: area.width / 2,
635        height: 7,
636    };
637
638    // Clear the entire screen first
639    f.render_widget(Clear, area);
640
641    // Render a black background
642    let background = Block::default().style(Style::default().bg(Color::Black));
643    f.render_widget(background, area);
644
645    // Clear the popup area specifically
646    f.render_widget(Clear, popup_area);
647
648    let input_text = vec![
649        Line::from(""),
650        Line::from(vec![
651            Span::raw("Target URL: "),
652            Span::styled(&app.input_buffer, Style::default().fg(Color::Green)),
653        ]),
654        Line::from(""),
655        Line::from(Span::styled(
656            "Press Enter to confirm, Esc to cancel",
657            Style::default().fg(Color::Gray),
658        )),
659        Line::from(""),
660    ];
661
662    let input_dialog = Paragraph::new(input_text)
663        .block(
664            Block::default()
665                .borders(Borders::ALL)
666                .title("Edit Target URL")
667                .style(Style::default().fg(Color::White).bg(Color::DarkGray)),
668        )
669        .wrap(Wrap { trim: true });
670
671    f.render_widget(input_dialog, popup_area);
672}
673
674fn draw_intercept_content(f: &mut Frame, area: Rect, app: &App) {
675    let chunks = Layout::default()
676        .direction(Direction::Horizontal)
677        .constraints([
678            Constraint::Percentage(50), // Pending requests list
679            Constraint::Percentage(50), // Request details/editor
680        ])
681        .split(area);
682
683    draw_pending_requests(f, chunks[0], app);
684    draw_request_details(f, chunks[1], app);
685}
686
687fn draw_pending_requests(f: &mut Frame, area: Rect, app: &App) {
688    if app.pending_requests.is_empty() {
689        let mode_text = match app.app_mode {
690            AppMode::Paused => "Pause mode active. New requests will be intercepted.",
691            _ => "No pending requests.",
692        };
693
694        let paragraph = Paragraph::new(mode_text)
695            .block(
696                Block::default()
697                    .borders(Borders::ALL)
698                    .title("Pending Requests"),
699            )
700            .style(Style::default().fg(Color::Yellow))
701            .wrap(Wrap { trim: true });
702
703        f.render_widget(paragraph, area);
704        return;
705    }
706
707    let requests: Vec<ListItem> = app
708        .pending_requests
709        .iter()
710        .enumerate()
711        .map(|(i, pending)| {
712            let method = pending
713                .original_request
714                .method
715                .as_deref()
716                .unwrap_or("unknown");
717            let id = pending
718                .original_request
719                .id
720                .as_ref()
721                .map(|v| v.to_string())
722                .unwrap_or_else(|| "null".to_string());
723
724            let style = if i == app.selected_pending {
725                Style::default()
726                    .bg(Color::Cyan)
727                    .fg(Color::Black)
728                    .add_modifier(Modifier::BOLD)
729            } else {
730                Style::default()
731            };
732
733            // Show different icon if request has been modified
734            let (icon, icon_color) =
735                if pending.modified_request.is_some() || pending.modified_headers.is_some() {
736                    ("✏ ", Color::Blue) // Modified
737                } else {
738                    ("⏸ ", Color::Red) // Paused/Intercepted
739                };
740
741            let mut modification_labels = Vec::new();
742            if pending.modified_request.is_some() {
743                modification_labels.push("BODY");
744            }
745            if pending.modified_headers.is_some() {
746                modification_labels.push("HEADERS");
747            }
748            let modification_text = if !modification_labels.is_empty() {
749                format!(" [{}]", modification_labels.join("+"))
750            } else {
751                String::new()
752            };
753
754            ListItem::new(Line::from(vec![
755                Span::styled(icon, Style::default().fg(icon_color)),
756                Span::styled(format!("{} ", method), Style::default().fg(Color::Red)),
757                Span::styled(format!("(id: {})", id), Style::default().fg(Color::Gray)),
758                if !modification_text.is_empty() {
759                    Span::styled(
760                        modification_text,
761                        Style::default()
762                            .fg(Color::Blue)
763                            .add_modifier(Modifier::BOLD),
764                    )
765                } else {
766                    Span::raw("")
767                },
768            ]))
769            .style(style)
770        })
771        .collect();
772
773    let requests_list = List::new(requests)
774        .block(
775            Block::default()
776                .borders(Borders::ALL)
777                .title(format!("Pending Requests ({})", app.pending_requests.len())),
778        )
779        .highlight_style(
780            Style::default()
781                .bg(Color::Cyan)
782                .fg(Color::Black)
783                .add_modifier(Modifier::BOLD),
784        );
785
786    f.render_widget(requests_list, area);
787}
788
789fn draw_request_details(f: &mut Frame, area: Rect, app: &App) {
790    let content = if let Some(pending) = app.get_selected_pending() {
791        let mut lines = Vec::new();
792
793        if pending.modified_request.is_some() || pending.modified_headers.is_some() {
794            lines.push(Line::from(Span::styled(
795                "MODIFIED REQUEST:",
796                Style::default()
797                    .add_modifier(Modifier::BOLD)
798                    .fg(Color::Blue),
799            )));
800        } else {
801            lines.push(Line::from(Span::styled(
802                "INTERCEPTED REQUEST:",
803                Style::default().add_modifier(Modifier::BOLD).fg(Color::Red),
804            )));
805        }
806        lines.push(Line::from(""));
807
808        // Show headers section
809        lines.push(Line::from(Span::styled(
810            "HTTP Headers:",
811            Style::default()
812                .add_modifier(Modifier::BOLD)
813                .fg(Color::Green),
814        )));
815        let headers_to_show = pending
816            .modified_headers
817            .as_ref()
818            .or(pending.original_request.headers.as_ref());
819
820        if let Some(headers) = headers_to_show {
821            for (key, value) in headers {
822                lines.push(Line::from(format!("  {}: {}", key, value)));
823            }
824            if pending.modified_headers.is_some() {
825                lines.push(Line::from(Span::styled(
826                    "  [Headers have been modified]",
827                    Style::default()
828                        .fg(Color::Blue)
829                        .add_modifier(Modifier::ITALIC),
830                )));
831            }
832        } else {
833            lines.push(Line::from("  No headers"));
834        }
835        lines.push(Line::from(""));
836
837        // Show JSON-RPC body section
838        lines.push(Line::from(Span::styled(
839            "JSON-RPC Request:",
840            Style::default()
841                .add_modifier(Modifier::BOLD)
842                .fg(Color::Green),
843        )));
844
845        // Show the modified request if available, otherwise show original
846        let json_to_show = if let Some(ref modified_json) = pending.modified_request {
847            if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(modified_json) {
848                parsed
849            } else {
850                // Fallback to original if modified JSON is invalid
851                let mut request_json = serde_json::Map::new();
852                request_json.insert(
853                    "jsonrpc".to_string(),
854                    serde_json::Value::String("2.0".to_string()),
855                );
856
857                if let Some(id) = &pending.original_request.id {
858                    request_json.insert("id".to_string(), id.clone());
859                }
860                if let Some(method) = &pending.original_request.method {
861                    request_json.insert(
862                        "method".to_string(),
863                        serde_json::Value::String(method.clone()),
864                    );
865                }
866                if let Some(params) = &pending.original_request.params {
867                    request_json.insert("params".to_string(), params.clone());
868                }
869
870                serde_json::Value::Object(request_json)
871            }
872        } else {
873            // Show original request
874            let mut request_json = serde_json::Map::new();
875            request_json.insert(
876                "jsonrpc".to_string(),
877                serde_json::Value::String("2.0".to_string()),
878            );
879
880            if let Some(id) = &pending.original_request.id {
881                request_json.insert("id".to_string(), id.clone());
882            }
883            if let Some(method) = &pending.original_request.method {
884                request_json.insert(
885                    "method".to_string(),
886                    serde_json::Value::String(method.clone()),
887                );
888            }
889            if let Some(params) = &pending.original_request.params {
890                request_json.insert("params".to_string(), params.clone());
891            }
892
893            serde_json::Value::Object(request_json)
894        };
895
896        let request_json_lines = format_json_with_highlighting(&json_to_show);
897        for line in request_json_lines {
898            lines.push(line);
899        }
900
901        lines.push(Line::from(""));
902        lines.push(Line::from(Span::styled(
903            "Actions:",
904            Style::default().add_modifier(Modifier::BOLD),
905        )));
906        lines.push(Line::from("• Press 'a' to Allow request"));
907        lines.push(Line::from("• Press 'e' to Edit request body"));
908        lines.push(Line::from("• Press 'h' to Edit headers"));
909        lines.push(Line::from("• Press 'c' to Complete with custom response"));
910        lines.push(Line::from("• Press 'b' to Block request"));
911        lines.push(Line::from("• Press 'r' to Resume all requests"));
912
913        lines
914    } else {
915        vec![Line::from("No request selected")]
916    };
917
918    // Calculate visible area for scrolling
919    let inner_area = area.inner(&Margin {
920        vertical: 1,
921        horizontal: 1,
922    });
923    let visible_lines = inner_area.height as usize;
924    let total_lines = content.len();
925
926    // Apply scrolling offset
927    let start_line = app.intercept_details_scroll;
928    let end_line = std::cmp::min(start_line + visible_lines, total_lines);
929    let visible_content = if start_line < total_lines {
930        content[start_line..end_line].to_vec()
931    } else {
932        vec![]
933    };
934
935    // Create title with scroll indicator
936    let scroll_info = if total_lines > visible_lines {
937        let progress = ((app.intercept_details_scroll as f32
938            / (total_lines - visible_lines) as f32)
939            * 100.0) as u8;
940        format!("Request Details ({}% - vim: j/k/d/u/G/g)", progress)
941    } else {
942        "Request Details".to_string()
943    };
944
945    let details = Paragraph::new(visible_content)
946        .block(Block::default().borders(Borders::ALL).title(scroll_info))
947        .wrap(Wrap { trim: false });
948
949    f.render_widget(details, area);
950}